diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 6898067f99..84a334716a 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -84,6 +84,9 @@ jobs: env: PATTERNS: | apps/backend/** + apps/web/lib/settings-discovery/contract.generated.json + apps/web/lib/settings-discovery/profile-contract.generated.json + scripts/settings-contract-ci.test.mjs # check-make-shells also lints the root Makefile's $(shell ...) # probes, so a change there has to be able to trip it. Makefile @@ -178,6 +181,10 @@ jobs: - name: Build run: go build -v ./cmd/kandev + - name: Verify settings catalog contract + working-directory: ${{ github.workspace }} + run: node --test scripts/settings-contract-ci.test.mjs + - name: Check required-store SQL portability run: go run ./cmd/sqlguard ./internal diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 1dad80d027..ded873fdc3 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -87,6 +87,7 @@ jobs: apps/prettier.config.mjs apps/.prettierignore apps/web/** + scripts/settings-contract-ci.test.mjs apps/packages/** apps/pnpm-lock.yaml apps/backend/go.mod @@ -168,6 +169,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify settings catalog contract + working-directory: ${{ github.workspace }} + run: node --test scripts/settings-contract-ci.test.mjs + - name: Regenerate OSS licenses manifest run: pnpm --filter @kandev/web licenses:gen diff --git a/apps/backend/cmd/settings-catalog/main.go b/apps/backend/cmd/settings-catalog/main.go new file mode 100644 index 0000000000..79499f7d1e --- /dev/null +++ b/apps/backend/cmd/settings-catalog/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + agentcatalog "github.com/kandev/kandev/internal/agent/settings/catalog" + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/settingscatalog" + userdto "github.com/kandev/kandev/internal/user/dto" +) + +const schemaVersion = "settings-catalog.v1" + +type snapshot struct { + SchemaVersion string `json:"schema_version"` + GeneratedBy string `json:"generated_by"` + Domains []settingscatalog.DomainDescriptor `json:"domains"` + MutableFields map[string][]string `json:"mutable_fields"` +} + +func main() { + check := flag.Bool("check", false, "check generated snapshots without writing them") + flag.Parse() + + root, err := moduleRoot() + if err != nil { + fatal(err) + } + all, profile, err := snapshots() + if err != nil { + fatal(err) + } + outputs := map[string][]byte{ + filepath.Join(root, "../web/lib/settings-discovery/contract.generated.json"): all, + filepath.Join(root, "../web/lib/settings-discovery/profile-contract.generated.json"): profile, + } + for path, expected := range outputs { + if *check { + actual, readErr := os.ReadFile(path) + if readErr != nil { + fatal(fmt.Errorf("read %s: %w", path, readErr)) + } + if !bytes.Equal(actual, expected) { + fatal(fmt.Errorf("generated snapshot is stale: %s", path)) + } + continue + } + if err := os.WriteFile(path, expected, 0o644); err != nil { + fatal(fmt.Errorf("write %s: %w", path, err)) + } + } +} + +func snapshots() ([]byte, []byte, error) { + profileDomains := agentcatalog.Descriptors() + profileTypes := make(map[string]struct{}, len(profileDomains)) + for _, domain := range profileDomains { + profileTypes[domain.ResourceType] = struct{}{} + } + allDomains := make([]settingscatalog.DomainDescriptor, 0) + for _, domain := range settingscatalog.DefaultDomainDescriptors() { + if _, exists := profileTypes[domain.ResourceType]; exists { + continue + } + allDomains = append(allDomains, domain) + } + allDomains = append(allDomains, profileDomains...) + allRegistry, err := settingscatalog.NewRegistry(allDomains) + if err != nil { + return nil, nil, err + } + profileRegistry, err := settingscatalog.NewRegistry(profileDomains) + if err != nil { + return nil, nil, err + } + mutableFields := mutableFieldInventory() + all, err := marshalSnapshot(allRegistry, mutableFields) + if err != nil { + return nil, nil, err + } + profile, err := marshalSnapshot(profileRegistry, mutableFields) + if err != nil { + return nil, nil, err + } + return all, profile, nil +} + +func marshalSnapshot(registry *settingscatalog.Registry, mutableFields map[string][]string) ([]byte, error) { + domains := registry.Domains() + sort.Slice(domains, func(i, j int) bool { return domains[i].ResourceType < domains[j].ResourceType }) + payload, err := json.MarshalIndent(snapshot{ + SchemaVersion: schemaVersion, + GeneratedBy: "cmd/settings-catalog", + Domains: domains, + MutableFields: mutableFields, + }, "", " ") + if err != nil { + return nil, err + } + return append(payload, '\n'), nil +} + +func mutableFieldInventory() map[string][]string { + userFields := jsonFieldPaths(reflect.TypeFor[userdto.UpdateUserSettingsRequest](), nil) + // sidebar_task_colors is the public patch alias. The DTO stores its + // normalized form as sidebar_task_color_patch before calling the service. + userFields = append(userFields, "sidebar_task_colors") + sort.Strings(userFields) + return map[string][]string{ + "agent_profile": jsonFieldPaths(reflect.TypeFor[agentsettingsdto.ProfileUpdateRequest](), map[string]struct{}{ + "id": {}, "force": {}, + }), + "user_settings": userFields, + } +} + +func jsonFieldPaths(valueType reflect.Type, excluded map[string]struct{}) []string { + if valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } + paths := make([]string, 0, valueType.NumField()) + for index := 0; index < valueType.NumField(); index++ { + field := valueType.Field(index) + jsonName := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonName == "" || jsonName == "-" { + continue + } + if _, skip := excluded[jsonName]; skip { + continue + } + paths = append(paths, jsonName) + } + sort.Strings(paths) + return paths +} + +func moduleRoot() (string, error) { + workingDirectory, err := os.Getwd() + if err != nil { + return "", err + } + for current := workingDirectory; ; current = filepath.Dir(current) { + if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { + return current, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("go.mod not found above %s", workingDirectory) + } + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/apps/backend/config/prompts/config-context.md b/apps/backend/config/prompts/config-context.md index 8cf20935d5..0d99a2b573 100644 --- a/apps/backend/config/prompts/config-context.md +++ b/apps/backend/config/prompts/config-context.md @@ -22,7 +22,7 @@ WORKFLOW TOOLS: AGENT TOOLS: - list_agents_kandev: List all configured agents and their profiles. - update_agent_kandev: Update agent settings. Required: agent_id. Optional: supports_mcp, mcp_config_path. -- create_agent_profile_kandev: Create a new agent profile. Required: agent_id, name, model. Optional: auto_approve. +- create_agent_profile_kandev: Create a new agent profile. Required: agent_id, name. Optional: model, auto_approve, settings. Use describe_setting_kandev for the current profile schema before supplying settings. - delete_agent_profile_kandev: Delete an agent profile. Required: profile_id. EXECUTOR PROFILE TOOLS: @@ -46,6 +46,27 @@ Saved prompt names are case-sensitive. Surrounding whitespace is ignored. Use the list result to discover names before reading a prompt. These tools are read-only and do not create, update, delete, or expand saved prompts. +SETTINGS TOOLS: +- search_settings_kandev: Search setting definitions by metadata. This does not read saved values. +- describe_setting_kandev: Describe one setting's schema, target rules, authority, and choices. +- list_settings_resources_kandev: List authorized exact targets for a resource type. +- get_settings_kandev: Read saved values for one exact target. Sensitive values are redacted or returned as references. +- update_settings_kandev: Update declared settings for one exact target. Changes are validated by the owning domain. + +When a user asks to inspect or change a setting, use this sequence: +1. Search with search_settings_kandev when you need to find the setting key. +2. Describe the setting with describe_setting_kandev before reading or writing it. +3. List exact targets with list_settings_resources_kandev when the setting needs a resource ID or workspace ID. +4. Read current values with get_settings_kandev before changing a saved value. +5. Update one exact target with update_settings_kandev using the declared field paths and schema. + +Do not guess resource IDs, workspace IDs, setting keys, or field paths. A target +must match the setting's declared resource type. Use the separate +agent_profile_mcp target and MCP config tools for profile MCP documents. Use +existing lifecycle tools for resource creation, deletion, reordering, and +other explicit actions. Do not request or write credential values through +settings tools; use the existing interactive integration flow. + TASK TOOLS: - list_tasks_kandev: List all tasks in a workflow. Required: workflow_id. - move_task_kandev: Move a task to a different workflow step. Required: task_id, workflow_step_id. @@ -61,6 +82,8 @@ EXAMPLE REQUESTS the user might ask: - "Add a 'Code Review' step to my workflow" - "Create a new agent profile for Claude with auto-approve enabled" - "Show me the current workflow steps" +- "Show me the current terminal font size" +- "Change the default agent profile for this workspace" - "Update the MCP servers for the default agent profile" - "Create a new executor profile for Docker with a prepare script" - "Move all completed tasks to the 'Done' column" diff --git a/apps/backend/internal/agent/mcpconfig/service.go b/apps/backend/internal/agent/mcpconfig/service.go index 0a844eb62e..30a9a70400 100644 --- a/apps/backend/internal/agent/mcpconfig/service.go +++ b/apps/backend/internal/agent/mcpconfig/service.go @@ -16,6 +16,17 @@ type Repository interface { UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error } +// atomicConfigPatcher is implemented by stores that can update selected MCP +// columns without reading and rewriting the complete document first. +type atomicConfigPatcher interface { + UpdateAgentProfileMcpConfigPatch( + context.Context, + string, + *bool, + *map[string]interface{}, + ) (*models.AgentProfileMcpConfig, error) +} + var ( ErrAgentNotFound = errors.New("agent not found") ErrAgentProfileNotFound = errors.New("agent profile not found") @@ -65,6 +76,7 @@ func (s *Service) GetConfigByProfileID(ctx context.Context, profileID string) (* ProfileName: profile.Name, AgentID: agent.ID, AgentName: agent.Name, + WorkspaceID: profile.WorkspaceID, Enabled: false, Servers: map[string]ServerDef{}, Meta: map[string]any{}, @@ -78,6 +90,7 @@ func (s *Service) GetConfigByProfileID(ctx context.Context, profileID string) (* ProfileName: profile.Name, AgentID: agent.ID, AgentName: agent.Name, + WorkspaceID: profile.WorkspaceID, Enabled: config.Enabled, Servers: castServerDefs(config.Servers), Meta: config.Meta, @@ -128,12 +141,81 @@ func (s *Service) UpsertConfigByProfileID(ctx context.Context, profileID string, ProfileName: profile.Name, AgentID: agent.ID, AgentName: agent.Name, + WorkspaceID: profile.WorkspaceID, Enabled: record.Enabled, Servers: config.Servers, Meta: config.Meta, }, nil } +// PatchConfigByProfileID applies a partial MCP document update atomically at +// the storage boundary. Stores without the atomic extension fail explicitly; +// they must not fall back to a read/merge/full-write sequence. +// +//nolint:cyclop // Validation and error translation stay at this service boundary. +func (s *Service) PatchConfigByProfileID(ctx context.Context, profileID string, patch ConfigPatch) (*ProfileConfig, error) { + profile, err := s.repo.GetAgentProfile(ctx, profileID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentProfileNotFound + } + return nil, err + } + if profile == nil { + return nil, ErrAgentProfileNotFound + } + + agent, err := s.repo.GetAgent(ctx, profile.AgentID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentNotFound + } + return nil, err + } + if agent == nil { + return nil, ErrAgentNotFound + } + if !agent.SupportsMCP { + return nil, ErrAgentMcpUnsupported + } + if patch.Enabled == nil && patch.Servers == nil { + return nil, errors.New("MCP config patch is empty") + } + patcher, ok := s.repo.(atomicConfigPatcher) + if !ok { + return nil, errors.New("atomic MCP config updates are unavailable") + } + + var servers *map[string]interface{} + if patch.Servers != nil { + converted := castServerInterfaces(*patch.Servers) + servers = &converted + } + config, err := patcher.UpdateAgentProfileMcpConfigPatch(ctx, profile.ID, patch.Enabled, servers) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentProfileNotFound + } + if errors.Is(err, ErrAgentMcpUnsupported) { + return nil, ErrAgentMcpUnsupported + } + return nil, err + } + if config == nil { + return nil, errors.New("atomic MCP config update returned no config") + } + return &ProfileConfig{ + ProfileID: profile.ID, + ProfileName: profile.Name, + AgentID: agent.ID, + AgentName: agent.Name, + WorkspaceID: profile.WorkspaceID, + Enabled: config.Enabled, + Servers: castServerDefs(config.Servers), + Meta: config.Meta, + }, nil +} + func castServerDefs(raw map[string]interface{}) map[string]ServerDef { if raw == nil { return map[string]ServerDef{} diff --git a/apps/backend/internal/agent/mcpconfig/types.go b/apps/backend/internal/agent/mcpconfig/types.go index 9b0454f693..297316f947 100644 --- a/apps/backend/internal/agent/mcpconfig/types.go +++ b/apps/backend/internal/agent/mcpconfig/types.go @@ -34,11 +34,19 @@ type ProfileConfig struct { ProfileName string `json:"profile_name,omitempty"` AgentID string `json:"agent_id,omitempty"` AgentName string `json:"agent_name,omitempty"` + WorkspaceID string `json:"-"` Enabled bool `json:"enabled"` Servers map[string]ServerDef `json:"servers"` Meta map[string]any `json:"meta,omitempty"` } +// ConfigPatch contains only the MCP document fields that can be changed by a +// partial settings update. A nil pointer means that the field is preserved. +type ConfigPatch struct { + Enabled *bool + Servers *map[string]ServerDef +} + type ResolvedServer struct { Name string Type ServerType diff --git a/apps/backend/internal/agent/settings/catalog/catalog.go b/apps/backend/internal/agent/settings/catalog/catalog.go new file mode 100644 index 0000000000..fb62004866 --- /dev/null +++ b/apps/backend/internal/agent/settings/catalog/catalog.go @@ -0,0 +1,89 @@ +// Package catalog adapts the agent-settings domain contracts to the shared +// platform settings catalog. +package catalog + +import ( + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/settingscatalog" +) + +func ProfileDescriptor() settingscatalog.DomainDescriptor { + fields := make([]settingscatalog.FieldDescriptor, 0, len(dto.ProfileContractFields())) + for _, contract := range dto.ProfileContractFields() { + fields = append(fields, settingscatalog.FieldDescriptor{ + Key: "agent_profile." + contract.Path, + FieldPath: contract.Path, + Label: contract.Path, + Description: contract.Description, + JSONType: contract.JSONType, + Support: settingscatalog.SupportSupported, + Classification: settingscatalog.ClassificationWritable, + Owner: "agent-settings", + Writable: true, + Validator: "agent-settings.profile", + Authority: "org.config.manage", + Sensitive: contract.Sensitive, + Replacement: contract.Replacement, + ChangeTiming: "future_sessions", + SettingsHref: "/settings/agents", + }) + } + return settingscatalog.DomainDescriptor{ + Domain: "profiles", + ResourceType: "agent_profile", + Label: "Agent profiles", + Description: "Saved agent profile settings used for future sessions.", + Owner: "agent-settings", + Scope: settingscatalog.ScopeResource, + Target: settingscatalog.TargetRules{Scope: settingscatalog.ScopeResource, RequiresResourceID: true, AllowsWorkspaceID: true}, + SettingsHref: "/settings/agents", + Operations: []settingscatalog.OperationDescriptor{{ + Name: "update", + Binding: "agent-settings.profile.update", + Authority: "org.config.manage", + }}, + Fields: fields, + } +} + +func MCPDescriptor() settingscatalog.DomainDescriptor { + return settingscatalog.DomainDescriptor{ + Domain: "profiles", + ResourceType: "agent_profile_mcp", + Label: "Profile MCP documents", + Description: "Separate MCP server settings for a saved agent profile.", + Owner: "agent-settings", + Scope: settingscatalog.ScopeResource, + Target: settingscatalog.TargetRules{Scope: settingscatalog.ScopeResource, RequiresResourceID: true}, + SettingsHref: "/settings/agents", + Operations: []settingscatalog.OperationDescriptor{{ + Name: "update", + Binding: "agent-settings.profile-mcp.update", + Authority: "org.config.manage", + }}, + Fields: []settingscatalog.FieldDescriptor{ + { + Key: "agent_profile_mcp.enabled", FieldPath: "enabled", Label: "MCP enabled", + Description: "Enable the profile MCP document.", JSONType: "boolean", + Support: settingscatalog.SupportSupported, Classification: settingscatalog.ClassificationWritable, + Owner: "agent-settings", Writable: true, Validator: "agent-settings.profile-mcp", Authority: "org.config.manage", + }, + { + Key: "agent_profile_mcp.servers", FieldPath: "servers", Label: "MCP servers", + Description: "Complete replacement map of validated MCP servers.", JSONType: "object", + Support: settingscatalog.SupportSupported, Classification: settingscatalog.ClassificationWritable, + Owner: "agent-settings", Writable: true, Validator: "agent-settings.profile-mcp", Authority: "org.config.manage", Sensitive: true, Replacement: true, + }, + { + Key: "agent_profile_mcp.meta", FieldPath: "meta", Label: "MCP metadata", + Description: "Preserved metadata from the MCP document.", JSONType: "object", + Support: settingscatalog.SupportReadOnly, Classification: settingscatalog.ClassificationComputed, + Owner: "agent-settings", Sensitive: true, + }, + }, + } +} + +func Descriptors() []settingscatalog.DomainDescriptor { + return []settingscatalog.DomainDescriptor{ProfileDescriptor(), MCPDescriptor()} +} diff --git a/apps/backend/internal/agent/settings/catalog/catalog_test.go b/apps/backend/internal/agent/settings/catalog/catalog_test.go new file mode 100644 index 0000000000..6d637ef442 --- /dev/null +++ b/apps/backend/internal/agent/settings/catalog/catalog_test.go @@ -0,0 +1,35 @@ +package catalog + +import ( + "testing" + + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +func TestProfileDescriptorCoversSharedContract(t *testing.T) { + descriptor := ProfileDescriptor() + seen := make(map[string]struct{}, len(descriptor.Fields)) + for _, field := range descriptor.Fields { + seen[field.FieldPath] = struct{}{} + } + for _, field := range dto.ProfileContractFields() { + if _, ok := seen[field.Path]; !ok { + t.Errorf("profile contract field %q is absent from catalog", field.Path) + } + } + if descriptor.ResourceType != "agent_profile" || descriptor.Target.RequiresResourceID == false { + t.Fatalf("invalid profile target descriptor: %#v", descriptor) + } +} + +func TestProfileDescriptorDoesNotMakeComputedFieldsWritable(t *testing.T) { + descriptor := ProfileDescriptor() + for _, field := range descriptor.Fields { + if field.Writable && (field.Validator == "" || field.Authority == "") { + t.Fatalf("writable field %q has no validator or authority", field.Key) + } + } + if len(MCPDescriptor().Fields) != 3 { + t.Fatalf("MCP field count = %d, want enabled, servers, and preserved metadata", len(MCPDescriptor().Fields)) + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index e8be45f2da..be5ceeb3b2 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -24,6 +24,11 @@ type UpdateAgentProfileMcpConfigRequest struct { Meta map[string]any } +type UpdateAgentProfileMcpConfigPatchRequest struct { + Enabled *bool + Servers *map[string]mcpconfig.ServerDef +} + func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*dto.AgentProfileMcpConfigDTO, error) { config, err := c.mcpService.GetConfigByProfileID(ctx, profileID) if err != nil { @@ -36,10 +41,11 @@ func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID str return nil, err } return &dto.AgentProfileMcpConfigDTO{ - ProfileID: config.ProfileID, - Enabled: config.Enabled, - Servers: config.Servers, - Meta: config.Meta, + ProfileID: config.ProfileID, + WorkspaceID: config.WorkspaceID, + Enabled: config.Enabled, + Servers: config.Servers, + Meta: config.Meta, }, nil } @@ -59,10 +65,34 @@ func (c *Controller) UpdateAgentProfileMcpConfig(ctx context.Context, profileID return nil, err } return &dto.AgentProfileMcpConfigDTO{ - ProfileID: config.ProfileID, - Enabled: config.Enabled, - Servers: config.Servers, - Meta: config.Meta, + ProfileID: config.ProfileID, + WorkspaceID: config.WorkspaceID, + Enabled: config.Enabled, + Servers: config.Servers, + Meta: config.Meta, + }, nil +} + +func (c *Controller) UpdateAgentProfileMcpConfigPatch(ctx context.Context, profileID string, req UpdateAgentProfileMcpConfigPatchRequest) (*dto.AgentProfileMcpConfigDTO, error) { + config, err := c.mcpService.PatchConfigByProfileID(ctx, profileID, mcpconfig.ConfigPatch{ + Enabled: req.Enabled, + Servers: req.Servers, + }) + if err != nil { + if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) { + return nil, ErrAgentProfileNotFound + } + if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) { + return nil, ErrAgentMcpUnsupported + } + return nil, err + } + return &dto.AgentProfileMcpConfigDTO{ + ProfileID: config.ProfileID, + WorkspaceID: config.WorkspaceID, + Enabled: config.Enabled, + Servers: config.Servers, + Meta: config.Meta, }, nil } diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 963ff70ca2..6954c5294e 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -275,10 +275,11 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa } type UpdateAgentRequest struct { - ID string - WorkspaceID *string - SupportsMCP *bool - MCPConfigPath *string + ID string + WorkspaceID *string + SupportsMCP *bool + MCPConfigPath *string + MCPConfigPathSet bool } func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) { @@ -292,7 +293,13 @@ func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (* if req.SupportsMCP != nil { agent.SupportsMCP = *req.SupportsMCP } - if req.MCPConfigPath != nil { + if req.MCPConfigPathSet { + if req.MCPConfigPath == nil { + agent.MCPConfigPath = "" + } else { + agent.MCPConfigPath = *req.MCPConfigPath + } + } else if req.MCPConfigPath != nil { agent.MCPConfigPath = *req.MCPConfigPath } if err := c.repo.UpdateAgent(ctx, agent); err != nil { diff --git a/apps/backend/internal/agent/settings/controller/profile_contract.go b/apps/backend/internal/agent/settings/controller/profile_contract.go new file mode 100644 index 0000000000..fe644e137e --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/profile_contract.go @@ -0,0 +1,49 @@ +package controller + +import "github.com/kandev/kandev/internal/agent/settings/dto" + +// CreateProfileRequestFromDTO is the single transport-to-domain conversion for +// profile creation. It intentionally copies values without applying defaults; +// CreateProfile owns those defaults. +func CreateProfileRequestFromDTO(request dto.ProfileCreateRequest) CreateProfileRequest { + return CreateProfileRequest{ + AgentID: request.AgentID, + Name: request.Name, + Model: request.Model, + FallbackModel: request.FallbackModel, + AutoFallback: request.AutoFallback, + Mode: request.Mode, + ConfigOptions: request.ConfigOptions, + AllowIndexing: request.AllowIndexing, + AutoApprove: request.AutoApprove, + CLIPassthrough: request.CLIPassthrough, + CLIFlags: request.CLIFlags, + EnvVars: request.EnvVars, + CommandPrefix: request.CommandPrefix, + Dynamic: request.Dynamic, + } +} + +// UpdateProfileRequestFromDTO is the single transport-to-domain conversion +// for partial profile updates. Pointer fields preserve omission and explicit +// empty values all the way to the controller. +func UpdateProfileRequestFromDTO(request dto.ProfileUpdateRequest) UpdateProfileRequest { + return UpdateProfileRequest{ + ID: request.ID, + Name: request.Name, + Model: request.Model, + FallbackModel: request.FallbackModel, + AutoFallback: request.AutoFallback, + Mode: request.Mode, + ConfigOptions: request.ConfigOptions, + AllowIndexing: request.AllowIndexing, + AutoApprove: request.AutoApprove, + CLIPassthrough: request.CLIPassthrough, + Enabled: request.Enabled, + CLIFlags: request.CLIFlags, + EnvVars: request.EnvVars, + CommandPrefix: request.CommandPrefix, + Dynamic: request.Dynamic, + Force: request.Force, + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_contract_test.go b/apps/backend/internal/agent/settings/controller/profile_contract_test.go new file mode 100644 index 0000000000..053f382065 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/profile_contract_test.go @@ -0,0 +1,41 @@ +package controller + +import ( + "testing" + + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +func TestProfileContractConversionsPreserveExplicitValues(t *testing.T) { + model := "" + autoApprove := false + flags := []dto.CLIFlagDTO{} + request := dto.ProfileUpdateRequest{ + ID: "profile-1", + Model: &model, + AutoApprove: &autoApprove, + CLIFlags: &flags, + Force: true, + } + converted := UpdateProfileRequestFromDTO(request) + if converted.ID != request.ID || converted.Model == nil || *converted.Model != "" || + converted.AutoApprove == nil || *converted.AutoApprove || converted.CLIFlags == nil || + len(*converted.CLIFlags) != 0 || !converted.Force { + t.Fatalf("converted update = %#v, explicit values were not preserved", converted) + } +} + +func TestProfileCreateContractConversionCopiesAllFields(t *testing.T) { + request := dto.ProfileCreateRequest{ + AgentID: "agent-1", Name: "Profile", Model: "model", FallbackModel: "fallback", + AutoFallback: true, Mode: "plan", ConfigOptions: map[string]string{"x": "y"}, + AllowIndexing: true, AutoApprove: true, CLIPassthrough: true, + CLIFlags: []dto.CLIFlagDTO{{Flag: "--verbose"}}, EnvVars: []dto.ProfileEnvVarDTO{{Key: "X", Value: "Y"}}, + CommandPrefix: "prefix", + } + converted := CreateProfileRequestFromDTO(request) + if converted.AgentID != request.AgentID || converted.Name != request.Name || converted.CommandPrefix != request.CommandPrefix || + len(converted.CLIFlags) != 1 || len(converted.EnvVars) != 1 || !converted.AutoFallback || !converted.CLIPassthrough { + t.Fatalf("converted create = %#v, fields were not copied", converted) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 032a106cea..96f48f825f 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -426,10 +426,11 @@ type ListAgentUpdateJobsResponse struct { } type AgentProfileMcpConfigDTO struct { - ProfileID string `json:"profile_id"` - Enabled bool `json:"enabled"` - Servers map[string]mcpconfig.ServerDef `json:"servers"` - Meta map[string]any `json:"meta,omitempty"` + ProfileID string `json:"profile_id"` + WorkspaceID string `json:"-"` + Enabled bool `json:"enabled"` + Servers map[string]mcpconfig.ServerDef `json:"servers"` + Meta map[string]any `json:"meta,omitempty"` } // CommandPreviewRequest is the request body for previewing the agent CLI command diff --git a/apps/backend/internal/agent/settings/dto/profile_contract.go b/apps/backend/internal/agent/settings/dto/profile_contract.go new file mode 100644 index 0000000000..ddf766b784 --- /dev/null +++ b/apps/backend/internal/agent/settings/dto/profile_contract.go @@ -0,0 +1,97 @@ +package dto + +import ( + "fmt" + "strings" +) + +// ProfileCreateRequest is the shared wire contract for HTTP and MCP profile +// creation. Value fields retain the existing create defaults; omitted optional +// fields are left at their domain defaults. +type ProfileCreateRequest struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + Model string `json:"model,omitempty"` + FallbackModel string `json:"fallback_model,omitempty"` + AutoFallback bool `json:"auto_fallback,omitempty"` + Mode string `json:"mode,omitempty"` + ConfigOptions map[string]string `json:"config_options,omitempty"` + AllowIndexing bool `json:"allow_indexing,omitempty"` + AutoApprove bool `json:"auto_approve,omitempty"` + CLIPassthrough bool `json:"cli_passthrough,omitempty"` + CLIFlags []CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix string `json:"command_prefix,omitempty"` + Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` +} + +// ProfileUpdateRequest is the shared partial wire contract. A nil pointer +// means that the caller omitted the field. A non-nil pointer preserves an +// explicit false, empty string, empty map, or empty list. +type ProfileUpdateRequest struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + FallbackModel *string `json:"fallback_model,omitempty"` + AutoFallback *bool `json:"auto_fallback,omitempty"` + Mode *string `json:"mode,omitempty"` + ConfigOptions *map[string]string `json:"config_options,omitempty"` + AllowIndexing *bool `json:"allow_indexing,omitempty"` + AutoApprove *bool `json:"auto_approve,omitempty"` + CLIPassthrough *bool `json:"cli_passthrough,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + CLIFlags *[]CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars *[]ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix *string `json:"command_prefix,omitempty"` + Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` + Force bool `json:"force,omitempty"` +} + +func (r ProfileCreateRequest) Validate() error { + if strings.TrimSpace(r.AgentID) == "" { + return fmt.Errorf("agent_id is required") + } + if strings.TrimSpace(r.Name) == "" { + return fmt.Errorf("name is required") + } + return nil +} + +func (r ProfileUpdateRequest) Validate() error { + if strings.TrimSpace(r.ID) == "" { + return fmt.Errorf("id is required") + } + return nil +} + +// ProfileContractField describes one profile setting without exposing the +// controller's internal request type to schema consumers. +type ProfileContractField struct { + Path string `json:"path"` + JSONType string `json:"json_type"` + Support string `json:"support"` + Description string `json:"description"` + Sensitive bool `json:"sensitive,omitempty"` + Replacement bool `json:"replacement,omitempty"` +} + +// ProfileContractFields is intentionally explicit. It is an independent +// contract root used by schema export and coverage checks. +func ProfileContractFields() []ProfileContractField { + return []ProfileContractField{ + {Path: "name", JSONType: "string", Support: "read_write", Description: "Profile display name."}, + {Path: "model", JSONType: "string", Support: "read_write", Description: "Preferred model; empty uses the agent default."}, + {Path: "fallback_model", JSONType: "string", Support: "read_write", Description: "Optional fallback model."}, + {Path: "auto_fallback", JSONType: "boolean", Support: "read_write", Description: "Enable automatic fallback behavior."}, + {Path: "mode", JSONType: "string", Support: "read_write", Description: "Agent operating mode."}, + {Path: "config_options", JSONType: "object", Support: "read_write", Description: "Typed provider configuration options.", Replacement: true}, + {Path: "allow_indexing", JSONType: "boolean", Support: "compatibility", Description: "Legacy indexing permission field."}, + {Path: "auto_approve", JSONType: "boolean", Support: "read_write", Description: "Automatically approve agent permissions."}, + {Path: "cli_passthrough", JSONType: "boolean", Support: "read_write", Description: "Allow CLI passthrough mode."}, + {Path: "enabled", JSONType: "boolean", Support: "read_write", Description: "Allow this profile for new work."}, + {Path: "cli_flags", JSONType: "array", Support: "read_write", Description: "Complete replacement list of CLI flags.", Replacement: true}, + {Path: "env_vars", JSONType: "array", Support: "read_write", Description: "Complete replacement list of environment variables.", Replacement: true, Sensitive: true}, + {Path: "command_prefix", JSONType: "string", Support: "read_write", Description: "Optional launcher command prefix."}, + {Path: "dynamic", JSONType: "object", Support: "read_write", Description: "Versioned dynamic routing document.", Replacement: true}, + } +} diff --git a/apps/backend/internal/agent/settings/dto/profile_contract_test.go b/apps/backend/internal/agent/settings/dto/profile_contract_test.go new file mode 100644 index 0000000000..ffa1570e81 --- /dev/null +++ b/apps/backend/internal/agent/settings/dto/profile_contract_test.go @@ -0,0 +1,53 @@ +package dto + +import ( + "encoding/json" + "testing" +) + +func TestProfileUpdateRequestPreservesOmissionAndExplicitValues(t *testing.T) { + var req ProfileUpdateRequest + if err := json.Unmarshal([]byte(`{"id":"profile-1","auto_approve":false,"name":""}`), &req); err != nil { + t.Fatalf("decode profile update: %v", err) + } + if req.ID != "profile-1" { + t.Fatalf("id = %q, want profile-1", req.ID) + } + if req.AutoApprove == nil || *req.AutoApprove { + t.Fatalf("auto_approve = %#v, want explicit false", req.AutoApprove) + } + if req.Name == nil || *req.Name != "" { + t.Fatalf("name = %#v, want explicit empty string", req.Name) + } + if req.Model != nil { + t.Fatalf("omitted model = %#v, want nil", req.Model) + } +} + +func TestProfileContractFieldsAreUniqueAndClassified(t *testing.T) { + fields := ProfileContractFields() + seen := make(map[string]struct{}, len(fields)) + for _, field := range fields { + if field.Path == "" || field.JSONType == "" || field.Support == "" { + t.Fatalf("incomplete profile field: %#v", field) + } + if _, exists := seen[field.Path]; exists { + t.Fatalf("duplicate profile field %q", field.Path) + } + seen[field.Path] = struct{}{} + } + for _, required := range []string{"name", "model", "fallback_model", "auto_fallback", "mode", "config_options", "cli_flags", "env_vars", "command_prefix", "enabled"} { + if _, ok := seen[required]; !ok { + t.Fatalf("missing profile field %q", required) + } + } +} + +func TestProfileCreateRequestValidatesRequiredIdentity(t *testing.T) { + if err := (ProfileCreateRequest{}).Validate(); err == nil { + t.Fatal("empty profile create request unexpectedly validated") + } + if err := (ProfileCreateRequest{AgentID: "agent-1", Name: "Profile"}).Validate(); err != nil { + t.Fatalf("profile create request with optional model rejected: %v", err) + } +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 20ab9ec501..d9b74cc538 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -204,6 +204,34 @@ func (h *Handlers) httpListAgentUpdateStatuses(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *Handlers) broadcastProfileMCPConfigUpdated(profileID, workspaceID string) { + if h.hub == nil || profileID == "" { + return + } + var scopedWorkspaceID any + if workspaceID != "" { + scopedWorkspaceID = workspaceID + } + notification, err := ws.NewNotification(ws.ActionAgentProfileMCPConfigUpdated, gin.H{ + "profile_id": profileID, + "workspace_id": scopedWorkspaceID, + }) + if err != nil { + return + } + if workspaceID != "" { + workspaceHub, ok := h.hub.(interface { + BroadcastToWorkspaceOrDrop(string, *ws.Message) + }) + if ok { + workspaceHub.BroadcastToWorkspaceOrDrop(workspaceID, notification) + } + return + } + //ws:global profile MCP updates without workspace ownership are global. + h.hub.Broadcast(notification) +} + func requireAgentName(c *gin.Context) (string, bool) { name := strings.TrimSpace(c.Param("agentName")) if name == "" { @@ -564,23 +592,10 @@ func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) { return } c.JSON(http.StatusOK, resp) + h.broadcastProfileMCPConfigUpdated(profileID, resp.WorkspaceID) } -type createProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - FallbackModel string `json:"fallback_model,omitempty"` - AutoFallback bool `json:"auto_fallback"` - Mode string `json:"mode,omitempty"` - ConfigOptions map[string]string `json:"config_options,omitempty"` - AllowIndexing bool `json:"allow_indexing"` - AutoApprove bool `json:"auto_approve"` - CLIPassthrough bool `json:"cli_passthrough"` - CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix string `json:"command_prefix,omitempty"` - Dynamic *dto.DynamicAgentProfileDTO `json:"dynamic,omitempty"` -} +type createProfileRequest = dto.ProfileCreateRequest func (h *Handlers) httpCreateProfile(c *gin.Context) { var body createProfileRequest @@ -588,26 +603,12 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if strings.TrimSpace(body.Name) == "" { + body.AgentID = c.Param("id") + if err := body.Validate(); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"}) return } - resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequest{ - AgentID: c.Param("id"), - Name: body.Name, - Model: body.Model, - FallbackModel: body.FallbackModel, - AutoFallback: body.AutoFallback, - Mode: body.Mode, - ConfigOptions: body.ConfigOptions, - AllowIndexing: body.AllowIndexing, - AutoApprove: body.AutoApprove, - CLIPassthrough: body.CLIPassthrough, - CLIFlags: body.CLIFlags, - EnvVars: body.EnvVars, - CommandPrefix: body.CommandPrefix, - Dynamic: body.Dynamic, - }) + resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequestFromDTO(body)) if err != nil { if errors.Is(err, controller.ErrDynamicAgentRoutingDisabled) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) @@ -632,22 +633,7 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { c.JSON(http.StatusOK, resp) } -type updateProfileRequest struct { - Name *string `json:"name,omitempty"` - Model *string `json:"model,omitempty"` - FallbackModel *string `json:"fallback_model,omitempty"` - AutoFallback *bool `json:"auto_fallback,omitempty"` - Mode *string `json:"mode,omitempty"` - ConfigOptions *map[string]string `json:"config_options,omitempty"` - AllowIndexing *bool `json:"allow_indexing,omitempty"` - AutoApprove *bool `json:"auto_approve,omitempty"` - CLIPassthrough *bool `json:"cli_passthrough,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - CLIFlags *[]dto.CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars *[]dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix *string `json:"command_prefix,omitempty"` - Dynamic *dto.DynamicAgentProfileDTO `json:"dynamic,omitempty"` -} +type updateProfileRequest = dto.ProfileUpdateRequest func (h *Handlers) httpUpdateProfile(c *gin.Context) { var body updateProfileRequest @@ -655,28 +641,17 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } + body.ID = c.Param("id") + body.Force = c.Query("force") == queryTrue + if err := body.Validate(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"}) + return + } if body.Name != nil && strings.TrimSpace(*body.Name) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"}) return } - resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequest{ - ID: c.Param("id"), - Name: body.Name, - Model: body.Model, - FallbackModel: body.FallbackModel, - AutoFallback: body.AutoFallback, - Mode: body.Mode, - ConfigOptions: body.ConfigOptions, - AllowIndexing: body.AllowIndexing, - AutoApprove: body.AutoApprove, - CLIPassthrough: body.CLIPassthrough, - Enabled: body.Enabled, - CLIFlags: body.CLIFlags, - EnvVars: body.EnvVars, - CommandPrefix: body.CommandPrefix, - Dynamic: body.Dynamic, - Force: c.Query("force") == queryTrue, - }) + resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequestFromDTO(body)) if err != nil { if err == controller.ErrAgentProfileNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index d5f4e0bab4..c32934b2e5 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -488,12 +488,18 @@ func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profile FROM agent_profile_mcp_configs WHERE profile_id = ? `), profileID) + return scanAgentProfileMcpConfig(row) +} + +func scanAgentProfileMcpConfig(scanner interface { + Scan(dest ...any) error +}) (*models.AgentProfileMcpConfig, error) { var config models.AgentProfileMcpConfig var enabled int var serversJSON string var metaJSON string - if err := row.Scan(&config.ProfileID, &enabled, &serversJSON, &metaJSON, &config.CreatedAt, &config.UpdatedAt); err != nil { + if err := scanner.Scan(&config.ProfileID, &enabled, &serversJSON, &metaJSON, &config.CreatedAt, &config.UpdatedAt); err != nil { return nil, err } config.Enabled = enabled == 1 @@ -506,6 +512,89 @@ func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profile return &config, nil } +// UpdateAgentProfileMcpConfigPatch updates only the requested MCP columns in +// one database statement. The upsert keeps the other document columns intact, +// including when two callers update different fields concurrently. +// +//nolint:cyclop // The transaction validates ownership, builds the partial SQL update, and returns the row. +func (r *sqliteRepository) UpdateAgentProfileMcpConfigPatch( + ctx context.Context, + profileID string, + enabled *bool, + servers *map[string]interface{}, +) (*models.AgentProfileMcpConfig, error) { + if profileID == "" { + return nil, fmt.Errorf("profile ID is required") + } + if enabled == nil && servers == nil { + return nil, fmt.Errorf("MCP config patch is empty") + } + + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + var supportsMCP int + if err := tx.QueryRowContext(ctx, tx.Rebind(` + SELECT a.supports_mcp + FROM agent_profiles p + JOIN agents a ON a.id = p.agent_id + WHERE p.id = ? AND p.deleted_at IS NULL + `), profileID).Scan(&supportsMCP); err != nil { + return nil, err + } + if supportsMCP != 1 { + return nil, fmt.Errorf("mcp not supported by agent") + } + + const emptyJSON = "{}" + enabledValue := 0 + if enabled != nil && *enabled { + enabledValue = 1 + } + serversJSON := emptyJSON + if servers != nil { + encoded, marshalErr := json.Marshal(*servers) + if marshalErr != nil { + return nil, fmt.Errorf("failed to serialize MCP servers: %w", marshalErr) + } + serversJSON = string(encoded) + } + updates := make([]string, 0, 3) + if enabled != nil { + updates = append(updates, "enabled = excluded.enabled") + } + if servers != nil { + updates = append(updates, "servers_json = excluded.servers_json") + } + updates = append(updates, "updated_at = excluded.updated_at") + now := time.Now().UTC() + _, err = tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO agent_profile_mcp_configs (profile_id, enabled, servers_json, meta_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(profile_id) DO UPDATE SET + `+strings.Join(updates, ",\n\t\t\t")+ + ` + `), profileID, enabledValue, serversJSON, emptyJSON, now, now) + if err != nil { + return nil, err + } + config, err := scanAgentProfileMcpConfig(tx.QueryRowxContext(ctx, tx.Rebind(` + SELECT profile_id, enabled, servers_json, meta_json, created_at, updated_at + FROM agent_profile_mcp_configs + WHERE profile_id = ? + `), profileID)) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return config, nil +} + func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error { if config.ProfileID == "" { return fmt.Errorf("profile ID is required") diff --git a/apps/backend/internal/agent/settings/store/sqlite_mcp_patch_test.go b/apps/backend/internal/agent/settings/store/sqlite_mcp_patch_test.go new file mode 100644 index 0000000000..7b84c3babe --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_mcp_patch_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func TestUpdateAgentProfileMcpConfigPatchPreservesUnchangedFields(t *testing.T) { + repo := newFreshRepo(t) + ctx := context.Background() + if err := repo.CreateAgent(ctx, &models.Agent{Name: "mcp-agent", SupportsMCP: true}); err != nil { + t.Fatalf("create agent: %v", err) + } + agent, err := repo.GetAgentByName(ctx, "mcp-agent") + if err != nil { + t.Fatalf("get agent: %v", err) + } + profile := &models.AgentProfile{AgentID: agent.ID, Name: "Default", AgentDisplayName: "MCP Agent"} + if err := repo.CreateAgentProfile(ctx, profile); err != nil { + t.Fatalf("create profile: %v", err) + } + if err := repo.UpsertAgentProfileMcpConfig(ctx, &models.AgentProfileMcpConfig{ + ProfileID: profile.ID, + Enabled: true, + Servers: map[string]interface{}{"before": map[string]interface{}{"command": "one"}}, + Meta: map[string]interface{}{"source": "preserved"}, + }); err != nil { + t.Fatalf("seed MCP config: %v", err) + } + + servers := map[string]interface{}{"after": map[string]interface{}{"command": "two"}} + updated, err := repo.UpdateAgentProfileMcpConfigPatch(ctx, profile.ID, nil, &servers) + if err != nil { + t.Fatalf("patch servers: %v", err) + } + if !updated.Enabled || updated.Meta["source"] != "preserved" { + t.Fatalf("server patch changed unrelated fields: %#v", updated) + } + if _, ok := updated.Servers["after"]; !ok { + t.Fatalf("server patch was not applied: %#v", updated.Servers) + } + + enabled := false + updated, err = repo.UpdateAgentProfileMcpConfigPatch(ctx, profile.ID, &enabled, nil) + if err != nil { + t.Fatalf("patch enabled: %v", err) + } + if updated.Enabled { + t.Fatal("enabled patch was not applied") + } + if _, ok := updated.Servers["after"]; !ok || updated.Meta["source"] != "preserved" { + t.Fatalf("enabled patch changed unrelated fields: %#v", updated) + } +} diff --git a/apps/backend/internal/agent/settings/store/store.go b/apps/backend/internal/agent/settings/store/store.go index 90efe4cda0..c76a64b29f 100644 --- a/apps/backend/internal/agent/settings/store/store.go +++ b/apps/backend/internal/agent/settings/store/store.go @@ -80,6 +80,19 @@ type Repository interface { Close() error } +// AgentProfileMcpConfigPatcher updates selected MCP document columns without +// replacing fields that were not part of the request. The optional extension +// keeps lightweight repositories compatible while real stores retain atomic +// partial-update semantics. +type AgentProfileMcpConfigPatcher interface { + UpdateAgentProfileMcpConfigPatch( + ctx context.Context, + profileID string, + enabled *bool, + servers *map[string]interface{}, + ) (*models.AgentProfileMcpConfig, error) +} + // DynamicProfileRepository is the optional extension implemented by settings // stores that persist the dynamic profile document. Keeping it separate from // Repository lets small controller fakes and plugin adapters retain the diff --git a/apps/backend/internal/automation/service.go b/apps/backend/internal/automation/service.go index 3de458a888..4735986ad1 100644 --- a/apps/backend/internal/automation/service.go +++ b/apps/backend/internal/automation/service.go @@ -1001,6 +1001,13 @@ func (s *Service) AddTrigger(ctx context.Context, req *AddTriggerRequest) (*Auto } // UpdateTrigger updates a trigger. +func (s *Service) GetTrigger(ctx context.Context, id string) (*AutomationTrigger, error) { + if err := s.authorizeTrigger(ctx, id); err != nil { + return nil, err + } + return s.store.GetTrigger(ctx, id) +} + func (s *Service) UpdateTrigger(ctx context.Context, id string, req *UpdateTriggerRequest) error { if err := s.authorizeTrigger(ctx, id); err != nil { return err diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 8f553ee6a4..906107715a 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1851,6 +1851,42 @@ func registerMCPAndDebugRoutes( mcpHandlers.SetRemoteContributionService(newRemoteContributionCoordinator(p.services.GitHub, p.services.GitLab)) // Wire config-mode dependencies for agent-native configuration mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc) + mcpHandlers.SetSettingsBroadcaster(p.gateway.Hub) + if settingsRegistry, err := buildSettingsRegistry(); err != nil { + p.log.Error("failed to build settings catalog", zap.Error(err)) + } else { + mcpHandlers.SetSettingsCatalog(settingsRegistry) + var storageSettings settingsStorageService + if p.systemSvc != nil { + storageSettings = p.systemSvc.Storage + } + mcpHandlers.SetSettingsOperations(newSettingsOperations( + settingsRegistry, + p.agentSettingsController, + p.services.User, + settingsDomainDependencies{ + broadcaster: p.gateway.Hub, + authEnabled: func() bool { return p.authSvc != nil && p.authSvc.Mode() != auth.ModeDisabled }, + task: p.taskSvc, + workflow: p.services.Workflow, + workflowCtrl: wfCtrl, + prompts: p.services.Prompts, + utility: p.services.Utility, + editors: p.services.Editor, + notifications: p.notificationCtrl, + runtimeFlags: p.services.RuntimeFlags, + storage: storageSettings, + automation: automationServiceFromComponents(p.services.Automation), + agent: p.agentSettingsController, + jira: settingsJiraServiceFromPointer(p.services.Jira), + linear: settingsLinearServiceFromPointer(p.services.Linear), + sentry: settingsSentryServiceFromPointer(p.services.Sentry), + github: settingsGitHubServiceFromPointer(p.services.GitHub), + gitlab: settingsGitLabServiceFromPointer(p.services.GitLab), + azureDevOps: settingsAzureDevOpsServiceFromPointer(p.services.AzureDevOps), + }, + )) + } mcpHandlers.SetClarificationInputPauser(p.orchestratorSvc) mcpHandlers.SetPromptReferenceResolver(p.services.Prompts) mcpHandlers.SetPromptReader(p.services.Prompts) diff --git a/apps/backend/internal/backendapp/settings_domain_dependencies.go b/apps/backend/internal/backendapp/settings_domain_dependencies.go new file mode 100644 index 0000000000..611d4f4651 --- /dev/null +++ b/apps/backend/internal/backendapp/settings_domain_dependencies.go @@ -0,0 +1,231 @@ +package backendapp + +import ( + "context" + "encoding/json" + + agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" + editormodels "github.com/kandev/kandev/internal/editors/models" + editorservice "github.com/kandev/kandev/internal/editors/service" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/notifications/dto" + promptmodels "github.com/kandev/kandev/internal/prompts/models" + promptservice "github.com/kandev/kandev/internal/prompts/service" + "github.com/kandev/kandev/internal/runtimeflags" + "github.com/kandev/kandev/internal/sentry" + storage "github.com/kandev/kandev/internal/system/storage" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + utilityservice "github.com/kandev/kandev/internal/utility/service" + workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" +) + +type settingsTaskService interface { + GetWorkflow(context.Context, string) (*taskmodels.Workflow, error) + UpdateWorkflow(context.Context, string, *taskservice.UpdateWorkflowRequest) (*taskmodels.Workflow, error) + ListWorkflows(context.Context, string, bool) ([]*taskmodels.Workflow, error) + GetWorkspace(context.Context, string) (*taskmodels.Workspace, error) + UpdateWorkspace(context.Context, string, *taskservice.UpdateWorkspaceRequest) (*taskmodels.Workspace, error) + ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) + GetRepository(context.Context, string) (*taskmodels.Repository, error) + UpdateRepository(context.Context, string, *taskservice.UpdateRepositoryRequest) (*taskmodels.Repository, error) + ListRepositories(context.Context, string) ([]*taskmodels.Repository, error) + GetRepositoryScript(context.Context, string) (*taskmodels.RepositoryScript, error) + UpdateRepositoryScript(context.Context, string, *taskservice.UpdateRepositoryScriptRequest) (*taskmodels.RepositoryScript, error) + ListRepositoryScripts(context.Context, string) ([]*taskmodels.RepositoryScript, error) + GetRepositorySet(context.Context, string) (*taskmodels.RepositorySet, error) + UpdateRepositorySet(context.Context, string, *taskservice.UpdateRepositorySetRequest) (*taskmodels.RepositorySet, error) + ListRepositorySets(context.Context, string) ([]*taskmodels.RepositorySet, error) + GetExecutor(context.Context, string) (*taskmodels.Executor, error) + UpdateExecutor(context.Context, string, *taskservice.UpdateExecutorRequest) (*taskmodels.Executor, error) + ListExecutors(context.Context) ([]*taskmodels.Executor, error) + GetExecutorProfile(context.Context, string) (*taskmodels.ExecutorProfile, error) + UpdateExecutorProfile(context.Context, string, *taskservice.UpdateExecutorProfileRequest) (*taskmodels.ExecutorProfile, error) + ListAllExecutorProfiles(context.Context) ([]*taskmodels.ExecutorProfile, error) + GetEnvironment(context.Context, string) (*taskmodels.Environment, error) + UpdateEnvironment(context.Context, string, *taskservice.UpdateEnvironmentRequest) (*taskmodels.Environment, error) + ListEnvironments(context.Context) ([]*taskmodels.Environment, error) + GetTask(context.Context, string) (*taskmodels.Task, error) + UpdateTask(context.Context, string, *taskservice.UpdateTaskRequest) (*taskmodels.Task, error) + ListTasks(context.Context, string) ([]*taskmodels.Task, error) + AuthorizeWorkspaceScope(context.Context, string, authz.Scope) error +} + +type settingsWorkflowService interface { + ListStepsByWorkflow(context.Context, string) ([]*workflowmodels.WorkflowStep, error) + ListStepsByWorkspaceID(context.Context, string) ([]*workflowmodels.WorkflowStep, error) +} + +type settingsWorkflowController interface { + GetStep(context.Context, string) (*workflowcontroller.GetStepResponse, error) + UpdateStep(context.Context, workflowcontroller.UpdateStepRequest) (*workflowcontroller.GetStepResponse, error) +} + +type settingsPromptService interface { + ListPrompts(context.Context) ([]*promptmodels.Prompt, error) + UpdatePrompt(context.Context, string, *string, *string) (*promptmodels.Prompt, error) +} + +type settingsUtilityService interface { + ListAgents(context.Context) ([]*utilitymodels.UtilityAgent, error) + GetAgentByID(context.Context, string) (*utilitymodels.UtilityAgent, error) + UpdateAgent(context.Context, string, *string, *string, *string, *string, *string, *string, *string, *bool) (*utilitymodels.UtilityAgent, error) +} + +type settingsEditorService interface { + ListEditors(context.Context) ([]*editormodels.Editor, error) + UpdateEditor(context.Context, editorservice.UpdateEditorInput) (*editormodels.Editor, error) +} + +type settingsNotificationController interface { + ListProviders(context.Context) (dto.NotificationProvidersResponse, error) + UpdateProvider(context.Context, string, dto.UpdateProviderRequest) (dto.NotificationProviderDTO, error) +} + +type settingsRuntimeFlagsService interface { + ListStates(context.Context) ([]runtimeflags.RuntimeFlagState, error) + SetOverride(context.Context, string, *bool) ([]runtimeflags.RuntimeFlagState, error) +} + +type settingsStorageService interface { + GetSettings(context.Context) (storage.StorageMaintenanceSettings, error) + SaveSettingsWithConfirmations(context.Context, storage.StorageMaintenanceSettings, storage.SaveConfirmations) (storage.StorageMaintenanceSettings, error) + PatchSettingsWithConfirmations(context.Context, map[string]json.RawMessage, storage.SaveConfirmations) (storage.StorageMaintenanceSettings, error) +} + +type settingsAutomationService interface { + GetAutomation(context.Context, string) (*automation.Automation, error) + ListAutomations(context.Context, string) ([]*automation.Automation, error) + UpdateAutomation(context.Context, string, *automation.UpdateAutomationRequest) (*automation.Automation, error) + GetTrigger(context.Context, string) (*automation.AutomationTrigger, error) + UpdateTrigger(context.Context, string, *automation.UpdateTriggerRequest) error +} + +type settingsAgentService interface { + GetAgent(context.Context, string) (*agentsettingsdto.AgentDTO, error) + ListAgents(context.Context) (*agentsettingsdto.ListAgentsResponse, error) + UpdateAgent(context.Context, agentsettingscontroller.UpdateAgentRequest) (*agentsettingsdto.AgentDTO, error) +} + +type settingsJiraService interface { + GetConfigForWorkspace(context.Context, string) (*jira.JiraConfig, error) + SetConfigForWorkspace(context.Context, string, *jira.SetConfigRequest) (*jira.JiraConfig, error) + GetIssueWatch(context.Context, string) (*jira.IssueWatch, error) + ListIssueWatches(context.Context, string) ([]*jira.IssueWatch, error) + UpdateIssueWatch(context.Context, string, *jira.UpdateIssueWatchRequest) (*jira.IssueWatch, error) +} + +type settingsLinearService interface { + GetConfigForWorkspace(context.Context, string) (*linear.LinearConfig, error) + SetConfigForWorkspace(context.Context, string, *linear.SetConfigRequest) (*linear.LinearConfig, error) + GetIssueWatch(context.Context, string) (*linear.IssueWatch, error) + ListIssueWatches(context.Context, string) ([]*linear.IssueWatch, error) + UpdateIssueWatch(context.Context, string, *linear.UpdateIssueWatchRequest) (*linear.IssueWatch, error) +} + +type settingsSentryService interface { + GetInstance(context.Context, string, string) (*sentry.SentryConfig, error) + ListInstances(context.Context, string) ([]*sentry.SentryConfig, error) + UpdateInstance(context.Context, string, string, *sentry.UpdateConfigRequest) (*sentry.SentryConfig, error) + GetIssueWatch(context.Context, string) (*sentry.IssueWatch, error) + ListIssueWatches(context.Context, string) ([]*sentry.IssueWatch, error) + UpdateIssueWatch(context.Context, string, *sentry.UpdateIssueWatchRequest) (*sentry.IssueWatch, error) +} + +type settingsGitHubService interface { + GetWorkspaceSettings(context.Context, string) (*github.WorkspaceSettings, error) + UpdateWorkspaceSettings(context.Context, *github.UpdateWorkspaceSettingsRequest) (*github.WorkspaceSettings, error) + GetReviewWatch(context.Context, string) (*github.ReviewWatch, error) + ListReviewWatches(context.Context, string) ([]*github.ReviewWatch, error) + UpdateReviewWatch(context.Context, string, *github.UpdateReviewWatchRequest) error + GetIssueWatch(context.Context, string) (*github.IssueWatch, error) + ListIssueWatches(context.Context, string) ([]*github.IssueWatch, error) + UpdateIssueWatch(context.Context, string, *github.UpdateIssueWatchRequest) error +} + +type settingsGitLabService interface { + GetConfigForWorkspace(context.Context, string) (*gitlab.GitLabConfig, error) + SetConfigForWorkspace(context.Context, string, *gitlab.SetConfigRequest) (*gitlab.GitLabConfig, error) + GetReviewWatch(context.Context, string) (*gitlab.ReviewWatch, error) + ListReviewWatches(context.Context, string) ([]*gitlab.ReviewWatch, error) + UpdateReviewWatch(context.Context, string, *gitlab.UpdateReviewWatchRequest) error + GetIssueWatch(context.Context, string) (*gitlab.IssueWatch, error) + ListIssueWatches(context.Context, string) ([]*gitlab.IssueWatch, error) + UpdateIssueWatch(context.Context, string, *gitlab.UpdateIssueWatchRequest) error +} + +type settingsAzureDevOpsService interface { + GetConfigForWorkspace(context.Context, string) (*azuredevops.Config, error) + SetConfigForWorkspace(context.Context, string, *azuredevops.SetConfigRequest) (*azuredevops.Config, error) + GetWorkspaceSettings(context.Context, string) (*azuredevops.WorkspaceSettings, error) + UpdateWorkspaceSettings(context.Context, *azuredevops.UpdateWorkspaceSettingsRequest) (*azuredevops.WorkspaceSettings, error) + ListWorkItemWatches(context.Context, string) ([]*azuredevops.WorkItemWatch, error) + UpdateWorkItemWatch(context.Context, string, string, *azuredevops.UpdateWorkItemWatchRequest) (*azuredevops.WorkItemWatch, error) + ListPullRequestWatches(context.Context, string) ([]*azuredevops.PullRequestWatch, error) + UpdatePullRequestWatch(context.Context, string, string, *azuredevops.UpdatePullRequestWatchRequest) (*azuredevops.PullRequestWatch, error) +} + +func automationServiceFromComponents(components *automation.Components) settingsAutomationService { + if components == nil || components.Service == nil { + return nil + } + return components.Service +} + +func settingsJiraServiceFromPointer(service *jira.Service) settingsJiraService { + if service == nil { + return nil + } + return service +} + +func settingsLinearServiceFromPointer(service *linear.Service) settingsLinearService { + if service == nil { + return nil + } + return service +} + +func settingsSentryServiceFromPointer(service *sentry.Service) settingsSentryService { + if service == nil { + return nil + } + return service +} + +func settingsGitHubServiceFromPointer(service *github.Service) settingsGitHubService { + if service == nil { + return nil + } + return service +} + +func settingsGitLabServiceFromPointer(service *gitlab.Service) settingsGitLabService { + if service == nil { + return nil + } + return service +} + +func settingsAzureDevOpsServiceFromPointer(service *azuredevops.Service) settingsAzureDevOpsService { + if service == nil { + return nil + } + return service +} + +var _ settingsTaskService = (*taskservice.Service)(nil) +var _ settingsPromptService = (*promptservice.Service)(nil) +var _ settingsUtilityService = (*utilityservice.Service)(nil) +var _ settingsEditorService = (*editorservice.Service)(nil) +var _ settingsRuntimeFlagsService = (*runtimeflags.Service)(nil) +var _ settingsAgentService = (*agentsettingscontroller.Controller)(nil) diff --git a/apps/backend/internal/backendapp/settings_domain_operations.go b/apps/backend/internal/backendapp/settings_domain_operations.go new file mode 100644 index 0000000000..a5c1169766 --- /dev/null +++ b/apps/backend/internal/backendapp/settings_domain_operations.go @@ -0,0 +1,802 @@ +//nolint:goconst // Resource type identifiers are protocol vocabulary kept readable at dispatch sites. +package backendapp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/automation" + editormodels "github.com/kandev/kandev/internal/editors/models" + editorservice "github.com/kandev/kandev/internal/editors/service" + "github.com/kandev/kandev/internal/notifications/dto" + "github.com/kandev/kandev/internal/settingscatalog" + storage "github.com/kandev/kandev/internal/system/storage" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" +) + +//nolint:cyclop,funlen // The central read dispatch preserves one domain-owned adapter boundary. +func (s *settingsOperations) readDomainSettings(ctx context.Context, target settingscatalog.ResourceTarget, keys []string) (any, error) { + if err := s.requireDomainDependency(target.ResourceType); err != nil { + return nil, err + } + if target.ResourceID == nil && target.ResourceType != "storage_maintenance" { + return nil, fmt.Errorf("resource_id is required for %q", target.ResourceType) + } + if err := s.validateDomainWorkspace(ctx, target); err != nil { + return nil, err + } + var value any + var err error + switch target.ResourceType { + case "workflow": + value, err = s.deps.task.GetWorkflow(ctx, *target.ResourceID) + case "workflow_step": + var response *workflowcontroller.GetStepResponse + response, err = s.deps.workflowCtrl.GetStep(ctx, *target.ResourceID) + if response != nil && response.Step != nil { + value = response.Step + } else if err == nil { + err = fmt.Errorf("workflow step lookup returned no step") + } + case "workspace": + value, err = s.deps.task.GetWorkspace(ctx, *target.ResourceID) + case "repository": + value, err = s.deps.task.GetRepository(ctx, *target.ResourceID) + case "repository_script": + value, err = s.deps.task.GetRepositoryScript(ctx, *target.ResourceID) + case "repository_set": + value, err = s.deps.task.GetRepositorySet(ctx, *target.ResourceID) + case "executor": + value, err = s.deps.task.GetExecutor(ctx, *target.ResourceID) + case "executor_profile": + value, err = s.deps.task.GetExecutorProfile(ctx, *target.ResourceID) + case "environment": + value, err = s.deps.task.GetEnvironment(ctx, *target.ResourceID) + case "task": + value, err = s.deps.task.GetTask(ctx, *target.ResourceID) + case "prompt": + value, err = s.readPrompt(ctx, *target.ResourceID) + case "utility_agent": + value, err = s.deps.utility.GetAgentByID(ctx, *target.ResourceID) + case "editor": + value, err = s.readEditor(ctx, *target.ResourceID) + case "notification_provider": + value, err = s.readNotificationProvider(ctx, *target.ResourceID) + case "automation": + value, err = s.deps.automation.GetAutomation(ctx, *target.ResourceID) + case "automation_trigger": + value, err = s.deps.automation.GetTrigger(ctx, *target.ResourceID) + case "runtime_flag": + value, err = s.readRuntimeFlag(ctx, *target.ResourceID) + case "storage_maintenance": + value, err = s.deps.storage.GetSettings(ctx) + default: + return nil, fmt.Errorf("settings read is not implemented for resource type %q", target.ResourceType) + } + if err != nil { + return nil, err + } + values := projectJSON(value, s.registry, target.ResourceType, keys) + redactSettingsValues(values, s.registry, target.ResourceType) + return map[string]any{"target": target, "values": values, "source": target.ResourceType}, nil +} + +//nolint:cyclop,funlen // The central update dispatch preserves one domain-owned adapter boundary. +func (s *settingsOperations) updateDomainSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage, options map[string]json.RawMessage) (any, error) { + if err := s.requireDomainDependency(target.ResourceType); err != nil { + return nil, err + } + if target.ResourceID == nil && target.ResourceType != "storage_maintenance" { + return nil, fmt.Errorf("resource_id is required for %q", target.ResourceType) + } + if err := s.validateDomainWorkspace(ctx, target); err != nil { + return nil, err + } + normalized, err := normalizeChangeMap(changes, target.ResourceType) + if err != nil { + return nil, err + } + var updated any + switch target.ResourceType { + case "workflow": + var request taskservice.UpdateWorkflowRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateWorkflow(ctx, *target.ResourceID, &request) + case "workflow_step": + updated, err = s.updateWorkflowStep(ctx, *target.ResourceID, normalized) + case "workspace": + var request taskservice.UpdateWorkspaceRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateWorkspace(ctx, *target.ResourceID, &request) + case "repository": + var request taskservice.UpdateRepositoryRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateRepository(ctx, *target.ResourceID, &request) + case "repository_script": + var request taskservice.UpdateRepositoryScriptRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateRepositoryScript(ctx, *target.ResourceID, &request) + case "repository_set": + var request taskservice.UpdateRepositorySetRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateRepositorySet(ctx, *target.ResourceID, &request) + case "executor": + var request taskservice.UpdateExecutorRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateExecutor(ctx, *target.ResourceID, &request) + case "executor_profile": + var request taskservice.UpdateExecutorProfileRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateExecutorProfile(ctx, *target.ResourceID, &request) + case "environment": + var request taskservice.UpdateEnvironmentRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateEnvironment(ctx, *target.ResourceID, &request) + case "task": + var request taskservice.UpdateTaskRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.task.UpdateTask(ctx, *target.ResourceID, &request) + case "prompt": + updated, err = s.updatePrompt(ctx, *target.ResourceID, normalized) + case "utility_agent": + updated, err = s.updateUtilityAgent(ctx, *target.ResourceID, normalized) + case "editor": + updated, err = s.updateEditor(ctx, *target.ResourceID, normalized) + case "notification_provider": + updated, err = s.updateNotificationProvider(ctx, *target.ResourceID, normalized) + case "automation": + var request automation.UpdateAutomationRequest + if err := decodePatch(normalized, &request); err != nil { + return nil, err + } + updated, err = s.deps.automation.UpdateAutomation(ctx, *target.ResourceID, &request) + case "automation_trigger": + updated, err = s.updateAutomationTrigger(ctx, *target.ResourceID, normalized) + case "runtime_flag": + updated, err = s.updateRuntimeFlag(ctx, *target.ResourceID, normalized) + case "storage_maintenance": + updated, err = s.updateStorageSettings(ctx, normalized, options) + default: + return nil, fmt.Errorf("settings update is not implemented for resource type %q", target.ResourceType) + } + if err != nil { + return nil, err + } + return map[string]any{"target": target, "accepted_fields": changePaths(changes), "settings": s.sanitizeSettingsValue(target.ResourceType, updated), "source": target.ResourceType}, nil +} + +func decodePatch(changes map[string]json.RawMessage, target any) error { + payload, err := json.Marshal(changes) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("invalid settings patch: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return fmt.Errorf("invalid settings patch: trailing JSON data") + } + return nil +} + +func (s *settingsOperations) updateWorkflowStep(ctx context.Context, id string, changes map[string]json.RawMessage) (any, error) { + request := workflowcontroller.UpdateStepRequest{ID: id} + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + response, err := s.deps.workflowCtrl.UpdateStep(ctx, request) + if err != nil { + return nil, err + } + if response == nil { + return nil, fmt.Errorf("workflow step update returned no step") + } + return response.Step, nil +} + +func (s *settingsOperations) readPrompt(ctx context.Context, id string) (map[string]any, error) { + prompts, err := s.deps.prompts.ListPrompts(ctx) + if err != nil { + return nil, err + } + for _, prompt := range prompts { + if prompt != nil && prompt.ID == id { + return map[string]any{ + "id": prompt.ID, "name": prompt.Name, "content": prompt.Content, "builtin": prompt.Builtin, + "created_at": prompt.CreatedAt, "updated_at": prompt.UpdatedAt, + }, nil + } + } + return nil, fmt.Errorf("prompt not found") +} + +func (s *settingsOperations) updatePrompt(ctx context.Context, id string, changes map[string]json.RawMessage) (any, error) { + var name, content *string + if err := decodeOptionalChange(changes, "name", &name); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "content", &content); err != nil { + return nil, err + } + return s.deps.prompts.UpdatePrompt(ctx, id, name, content) +} + +func (s *settingsOperations) readEditor(ctx context.Context, id string) (any, error) { + editors, err := s.deps.editors.ListEditors(ctx) + if err != nil { + return nil, err + } + for _, editor := range editors { + if editor != nil && editor.ID == id { + return editor, nil + } + } + return nil, fmt.Errorf("editor not found") +} + +func (s *settingsOperations) updateUtilityAgent(ctx context.Context, id string, changes map[string]json.RawMessage) (any, error) { + var name, description, prompt, agentID, model, profileID, bindingState *string + var enabled *bool + for key, target := range map[string]any{ + "name": &name, "description": &description, "prompt": &prompt, "agent_id": &agentID, + "model": &model, "agent_profile_id": &profileID, "profile_binding_state": &bindingState, + "enabled": &enabled, + } { + if err := decodeOptionalChange(changes, key, target); err != nil { + return nil, err + } + } + return s.deps.utility.UpdateAgent(ctx, id, name, description, prompt, agentID, model, profileID, bindingState, enabled) +} + +func (s *settingsOperations) updateEditor(ctx context.Context, id string, changes map[string]json.RawMessage) (any, error) { + var name, kind *string + var enabled *bool + var config json.RawMessage + if err := decodeOptionalChange(changes, "name", &name); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "kind", &kind); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "enabled", &enabled); err != nil { + return nil, err + } + if raw, ok := changes["config"]; ok { + config = append(json.RawMessage(nil), raw...) + } + return s.deps.editors.UpdateEditor(ctx, editorservice.UpdateEditorInput{ + EditorID: id, Name: name, Kind: kind, Config: config, Enabled: enabled, + }) +} + +func (s *settingsOperations) readNotificationProvider(ctx context.Context, id string) (any, error) { + response, err := s.deps.notifications.ListProviders(ctx) + if err != nil { + return nil, err + } + for _, provider := range response.Providers { + if provider.ID == id { + return provider, nil + } + } + return nil, fmt.Errorf("notification provider not found") +} + +func (s *settingsOperations) updateNotificationProvider(ctx context.Context, id string, changes map[string]json.RawMessage) (any, error) { + var request dto.UpdateProviderRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + return s.deps.notifications.UpdateProvider(ctx, id, request) +} + +func (s *settingsOperations) readRuntimeFlag(ctx context.Context, key string) (any, error) { + states, err := s.deps.runtimeFlags.ListStates(ctx) + if err != nil { + return nil, err + } + for _, state := range states { + if state.Key == key { + return runtimeFlagSettingsValue(state), nil + } + } + return nil, fmt.Errorf("runtime flag not found") +} + +func (s *settingsOperations) updateRuntimeFlag(ctx context.Context, key string, changes map[string]json.RawMessage) (any, error) { + if !settingscatalog.RuntimeFlagAgentMutable(key) { + return nil, fmt.Errorf("runtime flag %q is interactive-only", key) + } + raw, ok := changes["override"] + if !ok { + return nil, fmt.Errorf("override is required") + } + var value *bool + if string(raw) != "null" { + var parsed bool + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("override must be boolean or null") + } + value = &parsed + } + states, err := s.deps.runtimeFlags.SetOverride(ctx, key, value) + if err != nil { + return nil, err + } + for _, state := range states { + if state.Key == key { + return runtimeFlagSettingsValue(state), nil + } + } + return nil, fmt.Errorf("runtime flag not found after update") +} + +func runtimeFlagSettingsValue(value any) map[string]any { + payload := map[string]any{} + encoded, err := json.Marshal(value) + if err != nil || json.Unmarshal(encoded, &payload) != nil { + return map[string]any{} + } + override, exists := payload["override_value"] + if !exists { + override = nil + } + payload["override"] = override + delete(payload, "override_value") + return payload +} + +func (s *settingsOperations) updateAutomationTrigger(ctx context.Context, id string, changes map[string]json.RawMessage) (any, error) { + var request automation.UpdateTriggerRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + if err := s.deps.automation.UpdateTrigger(ctx, id, &request); err != nil { + return nil, err + } + return s.deps.automation.GetTrigger(ctx, id) +} + +func (s *settingsOperations) requireDomainDependency(resourceType string) error { + var available bool + switch resourceType { + case "workflow": + available = s.deps.task != nil + case "workflow_step": + available = s.deps.workflow != nil && s.deps.workflowCtrl != nil + case "workspace", "repository", "repository_script", "repository_set", "executor", "executor_profile", "environment", "task": + available = s.deps.task != nil + case "prompt": + available = s.deps.prompts != nil + case "utility_agent": + available = s.deps.utility != nil + case "editor": + available = s.deps.editors != nil + case "notification_provider": + available = s.deps.notifications != nil + case "automation", "automation_trigger": + available = s.deps.automation != nil + case "runtime_flag": + available = s.deps.runtimeFlags != nil + case "storage_maintenance": + available = s.deps.storage != nil + default: + return fmt.Errorf("settings dependency is not defined for resource type %q", resourceType) + } + if !available { + return fmt.Errorf("settings dependency for %q is unavailable", resourceType) + } + return nil +} + +//nolint:cyclop,gocognit,funlen // Each resource type resolves its owning workspace through its domain service. +func (s *settingsOperations) validateDomainWorkspace(ctx context.Context, target settingscatalog.ResourceTarget) error { + if target.WorkspaceID == nil || *target.WorkspaceID == "" { + return nil + } + if target.ResourceID == nil { + return fmt.Errorf("resource_id is required for %q", target.ResourceType) + } + workspaceID := "" + switch target.ResourceType { + case "workspace": + workspaceID = *target.ResourceID + case "workflow": + item, err := s.deps.task.GetWorkflow(ctx, *target.ResourceID) + if err != nil { + return err + } + workspaceID = item.WorkspaceID + case "workflow_step": + response, err := s.deps.workflowCtrl.GetStep(ctx, *target.ResourceID) + if err != nil { + return err + } + if response == nil || response.Step == nil { + return fmt.Errorf("workflow step lookup returned no step") + } + workflow, err := s.deps.task.GetWorkflow(ctx, response.Step.WorkflowID) + if err != nil { + return err + } + workspaceID = workflow.WorkspaceID + case "repository": + item, err := s.deps.task.GetRepository(ctx, *target.ResourceID) + if err != nil { + return err + } + workspaceID = item.WorkspaceID + case "repository_script": + item, err := s.deps.task.GetRepositoryScript(ctx, *target.ResourceID) + if err != nil { + return err + } + repository, err := s.deps.task.GetRepository(ctx, item.RepositoryID) + if err != nil { + return err + } + workspaceID = repository.WorkspaceID + case "repository_set": + item, err := s.deps.task.GetRepositorySet(ctx, *target.ResourceID) + if err != nil { + return err + } + workspaceID = item.WorkspaceID + case "task": + item, err := s.deps.task.GetTask(ctx, *target.ResourceID) + if err != nil { + return err + } + workspaceID = item.WorkspaceID + case "automation": + item, err := s.deps.automation.GetAutomation(ctx, *target.ResourceID) + if err != nil { + return err + } + if item == nil { + return fmt.Errorf("automation not found") + } + workspaceID = item.WorkspaceID + case "automation_trigger": + trigger, err := s.deps.automation.GetTrigger(ctx, *target.ResourceID) + if err != nil { + return err + } + if trigger == nil { + return fmt.Errorf("automation trigger not found") + } + automation, err := s.deps.automation.GetAutomation(ctx, trigger.AutomationID) + if err != nil { + return err + } + if automation == nil { + return fmt.Errorf("automation not found") + } + workspaceID = automation.WorkspaceID + default: + return fmt.Errorf("resource type %q does not support workspace targeting", target.ResourceType) + } + if workspaceID != *target.WorkspaceID { + return fmt.Errorf("resource %q is not in workspace %q", *target.ResourceID, *target.WorkspaceID) + } + return nil +} + +func (s *settingsOperations) updateStorageSettings(ctx context.Context, changes map[string]json.RawMessage, options map[string]json.RawMessage) (any, error) { + dedicatedDocker, adoptGoCache := false, false + for key, raw := range options { + switch key { + case "confirm_dedicated_docker": + if err := json.Unmarshal(raw, &dedicatedDocker); err != nil { + return nil, fmt.Errorf("confirm_dedicated_docker must be boolean") + } + case "adopt_go_cache": + if err := json.Unmarshal(raw, &adoptGoCache); err != nil { + return nil, fmt.Errorf("adopt_go_cache must be boolean") + } + default: + return nil, fmt.Errorf("unsupported storage option %q", key) + } + } + return s.deps.storage.PatchSettingsWithConfirmations(ctx, changes, storage.NewSaveConfirmations(dedicatedDocker, adoptGoCache)) +} + +func decodeOptionalChange(changes map[string]json.RawMessage, key string, target any) error { + raw, ok := changes[key] + if !ok { + return nil + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("invalid %s setting: %w", key, err) + } + return nil +} + +//nolint:cyclop,gocognit,funlen,maintidx // Resource lookup intentionally keeps each domain's list and label semantics together. +func (s *settingsOperations) listDomainSettingsResources(ctx context.Context, resourceType string, workspaceID *string, query string, limit int, cursor string) (any, error) { + if err := s.requireDomainDependency(resourceType); err != nil { + return nil, err + } + resources := make([]map[string]any, 0) + needle := strings.ToLower(strings.TrimSpace(query)) + var targetError error + add := func(id, label, wsID string, extra map[string]any) { + if targetError != nil || id == "" || (workspaceID != nil && strings.TrimSpace(*workspaceID) != "" && wsID != strings.TrimSpace(*workspaceID)) { + return + } + if needle != "" && !strings.Contains(strings.ToLower(id), needle) && !strings.Contains(strings.ToLower(label), needle) { + return + } + target := settingscatalog.ResourceTarget{ResourceType: resourceType, ResourceID: settingsStringPointer(id)} + if s.registry != nil { + if domain, ok := s.registry.Domain(resourceType); ok && + (domain.Target.AllowsWorkspaceID || domain.Target.RequiresWorkspaceID) { + target.WorkspaceID = nonEmptyPointer(wsID) + } + } + if s.registry != nil { + if err := s.registry.ValidateTarget(target); err != nil { + targetError = err + return + } + } + item := map[string]any{ + "target": target, + "label": label, + } + for key, value := range extra { + item[key] = value + } + resources = append(resources, item) + } + var err error + switch resourceType { + case "workflow": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for workflow lookup") + } + items, listErr := s.deps.task.ListWorkflows(ctx, *workspaceID, false) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, item.WorkspaceID, nil) + } + } + case "workflow_step": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for workflow step lookup") + } + items, listErr := s.deps.workflow.ListStepsByWorkspaceID(ctx, *workspaceID) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, *workspaceID, map[string]any{"workflow_id": item.WorkflowID}) + } + } + case "workspace": + items, listErr := s.deps.task.ListWorkspaces(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, item.ID, nil) + } + } + case "repository": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for repository lookup") + } + items, listErr := s.deps.task.ListRepositories(ctx, *workspaceID) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, item.WorkspaceID, nil) + } + } + case "repository_script": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for repository script lookup") + } + repositories, listErr := s.deps.task.ListRepositories(ctx, *workspaceID) + err = listErr + for _, repository := range repositories { + if repository == nil { + continue + } + items, scriptErr := s.deps.task.ListRepositoryScripts(ctx, repository.ID) + if scriptErr != nil { + err = scriptErr + break + } + for _, item := range items { + if item != nil { + add(item.ID, item.Name, *workspaceID, map[string]any{"repository_id": item.RepositoryID}) + } + } + } + case "repository_set": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for repository set lookup") + } + items, listErr := s.deps.task.ListRepositorySets(ctx, *workspaceID) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, item.WorkspaceID, nil) + } + } + case "executor": + items, listErr := s.deps.task.ListExecutors(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, "", map[string]any{"type": item.Type}) + } + } + case "executor_profile": + items, listErr := s.deps.task.ListAllExecutorProfiles(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, "", map[string]any{"executor_id": item.ExecutorID}) + } + } + case "environment": + items, listErr := s.deps.task.ListEnvironments(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, "", map[string]any{"kind": item.Kind}) + } + } + case "prompt": + items, listErr := s.deps.prompts.ListPrompts(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, "", nil) + } + } + case "utility_agent": + items, listErr := s.deps.utility.ListAgents(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, "", nil) + } + } + case "editor": + items, listErr := s.deps.editors.ListEditors(ctx) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, "", nil) + } + } + case "notification_provider": + response, listErr := s.deps.notifications.ListProviders(ctx) + err = listErr + for _, item := range response.Providers { + add(item.ID, item.Name, "", map[string]any{"type": item.Type}) + } + case "automation": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for automation lookup") + } + items, listErr := s.deps.automation.ListAutomations(ctx, *workspaceID) + err = listErr + for _, item := range items { + if item != nil { + add(item.ID, item.Name, item.WorkspaceID, nil) + } + } + case "automation_trigger": + if workspaceID == nil || *workspaceID == "" { + return nil, fmt.Errorf("workspace_id is required for automation trigger lookup") + } + automations, listErr := s.deps.automation.ListAutomations(ctx, *workspaceID) + err = listErr + for _, item := range automations { + if item == nil { + continue + } + for _, trigger := range item.Triggers { + add(trigger.ID, fmt.Sprintf("%s (%s)", item.Name, trigger.Type), item.WorkspaceID, map[string]any{ + "automation_id": item.ID, + "type": trigger.Type, + }) + } + } + case "runtime_flag": + items, listErr := s.deps.runtimeFlags.ListStates(ctx) + err = listErr + for _, item := range items { + agentMutable := settingscatalog.RuntimeFlagAgentMutable(item.Key) + add(item.Key, item.Label, "", map[string]any{ + "env_locked": item.EnvLocked, + "agent_mutable": agentMutable, + "support": runtimeFlagSupport(agentMutable), + "classification": runtimeFlagClassification(agentMutable), + "writable": agentMutable, + }) + } + default: + return nil, fmt.Errorf("resource lookup is not implemented for resource type %q", resourceType) + } + if err != nil { + return nil, err + } + if targetError != nil { + return nil, fmt.Errorf("resource lookup returned invalid target: %w", targetError) + } + sort.Slice(resources, func(i, j int) bool { + return fmt.Sprint(resources[i]["label"]) < fmt.Sprint(resources[j]["label"]) + }) + return pageSettingsResources(resources, limit, cursor), nil +} + +func runtimeFlagSupport(agentMutable bool) settingscatalog.SupportStatus { + if agentMutable { + return settingscatalog.SupportSupported + } + return settingscatalog.SupportException +} + +func runtimeFlagClassification(agentMutable bool) settingscatalog.Classification { + if agentMutable { + return settingscatalog.ClassificationWritable + } + return settingscatalog.ClassificationException +} + +func pageSettingsResources(resources []map[string]any, limit int, cursor string) map[string]any { + start := parseSettingsCursor(cursor) + if start > len(resources) { + start = len(resources) + } + end := start + limit + if end > len(resources) { + end = len(resources) + } + result := map[string]any{"resources": resources[start:end], "total": len(resources)} + if end < len(resources) { + result["next_cursor"] = strconv.Itoa(end) + } + return result +} + +var _ = editormodels.Editor{} +var _ = taskmodels.Task{} +var _ = utilitymodels.UtilityAgent{} +var _ = workflowmodels.WorkflowStep{} diff --git a/apps/backend/internal/backendapp/settings_integration_operations.go b/apps/backend/internal/backendapp/settings_integration_operations.go new file mode 100644 index 0000000000..b963f51b10 --- /dev/null +++ b/apps/backend/internal/backendapp/settings_integration_operations.go @@ -0,0 +1,721 @@ +//nolint:goconst // Provider resource types are stable protocol identifiers kept readable in switches. +package backendapp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/azuredevops" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/sentry" + "github.com/kandev/kandev/internal/settingscatalog" +) + +func isIntegrationResource(resourceType string) bool { + switch resourceType { + case "jira_settings", "linear_settings", "sentry_instance", "jira_issue_watch", "linear_issue_watch", "sentry_issue_watch", + "github_settings", "gitlab_settings", "azure_devops_settings", "github_review_watch", "github_issue_watch", + "gitlab_review_watch", "gitlab_issue_watch", "azure_devops_work_item_watch", "azure_devops_pull_request_watch": + return true + default: + return false + } +} + +//nolint:cyclop // Provider reads stay in one adapter so target validation and redaction are shared. +func (s *settingsOperations) readIntegrationSettings(ctx context.Context, target settingscatalog.ResourceTarget, keys []string) (any, error) { + if err := s.requireIntegrationDependency(target.ResourceType); err != nil { + return nil, err + } + if err := s.authorizeIntegrationRead(ctx, target); err != nil { + return nil, err + } + var value any + var err error + switch target.ResourceType { + case "jira_settings": + value, err = s.deps.jira.GetConfigForWorkspace(ctx, requireWorkspaceTarget(target)) + case "linear_settings": + value, err = s.deps.linear.GetConfigForWorkspace(ctx, requireWorkspaceTarget(target)) + case "sentry_instance": + value, err = s.deps.sentry.GetInstance(ctx, requireWorkspaceTarget(target), requireResourceTarget(target)) + case "jira_issue_watch": + value, err = s.deps.jira.GetIssueWatch(ctx, requireResourceTarget(target)) + case "linear_issue_watch": + value, err = s.deps.linear.GetIssueWatch(ctx, requireResourceTarget(target)) + case "sentry_issue_watch": + value, err = s.deps.sentry.GetIssueWatch(ctx, requireResourceTarget(target)) + case "github_settings": + value, err = s.deps.github.GetWorkspaceSettings(ctx, requireWorkspaceTarget(target)) + case "gitlab_settings": + value, err = s.deps.gitlab.GetConfigForWorkspace(ctx, requireWorkspaceTarget(target)) + case "azure_devops_settings": + value, err = s.deps.azureDevOps.GetWorkspaceSettings(ctx, requireWorkspaceTarget(target)) + case "github_review_watch": + value, err = s.deps.github.GetReviewWatch(ctx, requireResourceTarget(target)) + case "github_issue_watch": + value, err = s.deps.github.GetIssueWatch(ctx, requireResourceTarget(target)) + case "gitlab_review_watch": + value, err = s.deps.gitlab.GetReviewWatch(ctx, requireResourceTarget(target)) + case "gitlab_issue_watch": + value, err = s.deps.gitlab.GetIssueWatch(ctx, requireResourceTarget(target)) + case "azure_devops_work_item_watch": + value, err = s.findAzureWorkItemWatch(ctx, requireWorkspaceTarget(target), requireResourceTarget(target)) + case "azure_devops_pull_request_watch": + value, err = s.findAzurePullRequestWatch(ctx, requireWorkspaceTarget(target), requireResourceTarget(target)) + default: + return nil, fmt.Errorf("integration settings read is not implemented for resource type %q", target.ResourceType) + } + if err != nil { + return nil, err + } + if err := validateIntegrationTargetValue(target, value); err != nil { + return nil, err + } + values := projectJSON(value, s.registry, target.ResourceType, keys) + redactSettingsValues(values, s.registry, target.ResourceType) + return map[string]any{ + "target": target, + "values": values, + "source": target.ResourceType, + }, nil +} + +func (s *settingsOperations) authorizeIntegrationRead(ctx context.Context, target settingscatalog.ResourceTarget) error { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return nil + } + workspaceID := requireWorkspaceTarget(target) + if workspaceID == "" { + return fmt.Errorf("workspace_id is required for integration settings") + } + if s.deps.task == nil { + return fmt.Errorf("workspace authorization is unavailable") + } + return s.deps.task.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead) +} + +//nolint:cyclop // Provider updates stay in one adapter so normalization and projection are shared. +func (s *settingsOperations) updateIntegrationSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage, options map[string]json.RawMessage) (any, error) { + if err := s.requireIntegrationDependency(target.ResourceType); err != nil { + return nil, err + } + if len(options) != 0 { + return nil, fmt.Errorf("options are not supported for %q", target.ResourceType) + } + normalized, err := normalizeChangeMap(changes, target.ResourceType) + if err != nil { + return nil, err + } + var updated any + switch target.ResourceType { + case "jira_settings": + updated, err = s.updateJiraSettings(ctx, target, normalized) + case "linear_settings": + updated, err = s.updateLinearSettings(ctx, target, normalized) + case "sentry_instance": + updated, err = s.updateSentryInstance(ctx, target, normalized) + case "jira_issue_watch": + updated, err = s.updateJiraIssueWatch(ctx, target, normalized) + case "linear_issue_watch": + updated, err = s.updateLinearIssueWatch(ctx, target, normalized) + case "sentry_issue_watch": + updated, err = s.updateSentryIssueWatch(ctx, target, normalized) + case "github_settings": + updated, err = s.updateGitHubSettings(ctx, target, normalized) + case "gitlab_settings": + return nil, fmt.Errorf("gitlab connection settings are interactive-only") + case "azure_devops_settings": + updated, err = s.updateAzureDevOpsSettings(ctx, target, normalized) + case "github_review_watch": + updated, err = s.updateGitHubReviewWatch(ctx, target, normalized) + case "github_issue_watch": + updated, err = s.updateGitHubIssueWatch(ctx, target, normalized) + case "gitlab_review_watch": + updated, err = s.updateGitLabReviewWatch(ctx, target, normalized) + case "gitlab_issue_watch": + updated, err = s.updateGitLabIssueWatch(ctx, target, normalized) + case "azure_devops_work_item_watch": + updated, err = s.updateAzureWorkItemWatch(ctx, target, normalized) + case "azure_devops_pull_request_watch": + updated, err = s.updateAzurePullRequestWatch(ctx, target, normalized) + default: + return nil, fmt.Errorf("integration settings update is not implemented for resource type %q", target.ResourceType) + } + if err != nil { + return nil, err + } + return map[string]any{"target": target, "accepted_fields": changePaths(changes), "settings": s.sanitizeSettingsValue(target.ResourceType, updated), "source": target.ResourceType}, nil +} + +func (s *settingsOperations) updateJiraSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID := requireWorkspaceTarget(target) + current, err := s.deps.jira.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if current == nil { + return nil, fmt.Errorf("jira settings are not configured") + } + request := &jira.SetConfigRequest{ + SiteURL: current.SiteURL, Email: current.Email, AuthMethod: current.AuthMethod, + InstanceType: current.InstanceType, DefaultProjectKey: current.DefaultProjectKey, ClientID: current.ClientID, + } + if err := decodeOptionalChange(changes, "defaultProjectKey", &request.DefaultProjectKey); err != nil { + return nil, err + } + for key := range changes { + if key != "defaultProjectKey" { + return nil, fmt.Errorf("setting %q is not writable", key) + } + } + return s.deps.jira.SetConfigForWorkspace(ctx, workspaceID, request) +} + +func (s *settingsOperations) updateLinearSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID := requireWorkspaceTarget(target) + current, err := s.deps.linear.GetConfigForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if current == nil { + return nil, fmt.Errorf("linear settings are not configured") + } + request := &linear.SetConfigRequest{AuthMethod: current.AuthMethod, DefaultTeamKey: current.DefaultTeamKey} + if err := decodeOptionalChange(changes, "defaultTeamKey", &request.DefaultTeamKey); err != nil { + return nil, err + } + for key := range changes { + if key != "defaultTeamKey" { + return nil, fmt.Errorf("setting %q is not writable", key) + } + } + return s.deps.linear.SetConfigForWorkspace(ctx, workspaceID, request) +} + +func (s *settingsOperations) updateSentryInstance(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID, instanceID := requireWorkspaceTarget(target), requireResourceTarget(target) + current, err := s.deps.sentry.GetInstance(ctx, workspaceID, instanceID) + if err != nil { + return nil, err + } + if current == nil { + return nil, fmt.Errorf("sentry instance is not configured") + } + request := &sentry.UpdateConfigRequest{Name: current.Name, AuthMethod: current.AuthMethod, URL: current.URL} + if err := decodeOptionalChange(changes, "name", &request.Name); err != nil { + return nil, err + } + for key := range changes { + if key != "name" { + return nil, fmt.Errorf("setting %q is not writable", key) + } + } + return s.deps.sentry.UpdateInstance(ctx, workspaceID, instanceID, request) +} + +func (s *settingsOperations) updateJiraIssueWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.jira.GetIssueWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request jira.UpdateIssueWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + return s.deps.jira.UpdateIssueWatch(ctx, watch.ID, &request) +} + +func (s *settingsOperations) updateLinearIssueWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.linear.GetIssueWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request linear.UpdateIssueWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + return s.deps.linear.UpdateIssueWatch(ctx, watch.ID, &request) +} + +func (s *settingsOperations) updateSentryIssueWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.sentry.GetIssueWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request sentry.UpdateIssueWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + return s.deps.sentry.UpdateIssueWatch(ctx, watch.ID, &request) +} + +func (s *settingsOperations) updateGitHubSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID := requireWorkspaceTarget(target) + current, err := s.deps.github.GetWorkspaceSettings(ctx, workspaceID) + if err != nil { + return nil, err + } + request := &github.UpdateWorkspaceSettingsRequest{WorkspaceID: workspaceID} + if err := decodeOptionalChange(changes, "task_git_credentials_mode", &request.TaskGitCredentialsMode); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "repo_scope_mode", &request.RepoScopeMode); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "repo_scope_orgs", &request.RepoScopeOrgs); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "repo_scope_repos", &request.RepoScopeRepos); err != nil { + return nil, err + } + if err := decodeGitHubRawChange(changes, "saved_presets", &request.SavedPresets, &request.SavedPresetsSet); err != nil { + return nil, err + } + if err := decodeGitHubRawChange(changes, "default_query_presets", &request.DefaultQueryPresets, &request.DefaultQueriesSet); err != nil { + return nil, err + } + if current != nil { + if request.TaskGitCredentialsMode == nil && request.RepoScopeMode == nil && request.RepoScopeOrgs == nil && request.RepoScopeRepos == nil && !request.SavedPresetsSet && !request.DefaultQueriesSet { + return current, nil + } + } + return s.deps.github.UpdateWorkspaceSettings(ctx, request) +} + +func decodeGitHubRawChange(changes map[string]json.RawMessage, key string, target **json.RawMessage, set *bool) error { + raw, ok := changes[key] + if !ok { + return nil + } + if string(raw) == "null" { + *target = nil + *set = true + return nil + } + copyValue := append(json.RawMessage(nil), raw...) + *target, *set = ©Value, true + return nil +} + +func (s *settingsOperations) updateAzureDevOpsSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID := requireWorkspaceTarget(target) + request := &azuredevops.UpdateWorkspaceSettingsRequest{WorkspaceID: workspaceID} + if err := decodeOptionalChange(changes, "workItemQueries", &request.WorkItemQueries); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "pullRequestQueries", &request.PullRequestQueries); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "workItemActions", &request.WorkItemActions); err != nil { + return nil, err + } + if err := decodeOptionalChange(changes, "pullRequestActions", &request.PullRequestActions); err != nil { + return nil, err + } + request.WorkItemQueriesSet = request.WorkItemQueries != nil + request.PullRequestQueriesSet = request.PullRequestQueries != nil + request.WorkItemActionsSet = request.WorkItemActions != nil + request.PullRequestActionsSet = request.PullRequestActions != nil + return s.deps.azureDevOps.UpdateWorkspaceSettings(ctx, request) +} + +func (s *settingsOperations) updateGitHubReviewWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.github.GetReviewWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request github.UpdateReviewWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + if err := s.deps.github.UpdateReviewWatch(ctx, watch.ID, &request); err != nil { + return nil, err + } + return s.deps.github.GetReviewWatch(ctx, watch.ID) +} + +func (s *settingsOperations) updateGitHubIssueWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.github.GetIssueWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request github.UpdateIssueWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + if err := s.deps.github.UpdateIssueWatch(ctx, watch.ID, &request); err != nil { + return nil, err + } + return s.deps.github.GetIssueWatch(ctx, watch.ID) +} + +func (s *settingsOperations) updateGitLabReviewWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.gitlab.GetReviewWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request gitlab.UpdateReviewWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + if err := s.deps.gitlab.UpdateReviewWatch(ctx, watch.ID, &request); err != nil { + return nil, err + } + return s.deps.gitlab.GetReviewWatch(ctx, watch.ID) +} + +func (s *settingsOperations) updateGitLabIssueWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + watch, err := s.deps.gitlab.GetIssueWatch(ctx, requireResourceTarget(target)) + if err != nil { + return nil, err + } + if err := ensureWatchWorkspace(target, watch.WorkspaceID); err != nil { + return nil, err + } + var request gitlab.UpdateIssueWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + if err := s.deps.gitlab.UpdateIssueWatch(ctx, watch.ID, &request); err != nil { + return nil, err + } + return s.deps.gitlab.GetIssueWatch(ctx, watch.ID) +} + +func (s *settingsOperations) updateAzureWorkItemWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID, watchID := requireWorkspaceTarget(target), requireResourceTarget(target) + watch, err := s.findAzureWorkItemWatch(ctx, workspaceID, watchID) + if err != nil { + return nil, err + } + var request azuredevops.UpdateWorkItemWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + return s.deps.azureDevOps.UpdateWorkItemWatch(ctx, workspaceID, watch.ID, &request) +} + +func (s *settingsOperations) updateAzurePullRequestWatch(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + workspaceID, watchID := requireWorkspaceTarget(target), requireResourceTarget(target) + watch, err := s.findAzurePullRequestWatch(ctx, workspaceID, watchID) + if err != nil { + return nil, err + } + var request azuredevops.UpdatePullRequestWatchRequest + if err := decodePatch(changes, &request); err != nil { + return nil, err + } + return s.deps.azureDevOps.UpdatePullRequestWatch(ctx, workspaceID, watch.ID, &request) +} + +func requireWorkspaceTarget(target settingscatalog.ResourceTarget) string { + if target.WorkspaceID == nil { + return "" + } + return strings.TrimSpace(*target.WorkspaceID) +} + +func requireResourceTarget(target settingscatalog.ResourceTarget) string { + if target.ResourceID == nil { + return "" + } + return strings.TrimSpace(*target.ResourceID) +} + +func ensureWatchWorkspace(target settingscatalog.ResourceTarget, actual string) error { + requested := requireWorkspaceTarget(target) + if requested == "" || actual != requested { + return fmt.Errorf("resource is not visible in the requested workspace") + } + return nil +} + +func validateIntegrationTargetValue(target settingscatalog.ResourceTarget, value any) error { + if value == nil { + return fmt.Errorf("resource %q was not found", requireResourceTarget(target)) + } + encoded, err := json.Marshal(value) + if err != nil { + return err + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + return err + } + if workspace, ok := payload["workspaceId"].(string); ok { + return ensureWatchWorkspace(target, workspace) + } + if workspace, ok := payload["workspace_id"].(string); ok { + return ensureWatchWorkspace(target, workspace) + } + return nil +} + +func (s *settingsOperations) findAzureWorkItemWatch(ctx context.Context, workspaceID, id string) (*azuredevops.WorkItemWatch, error) { + items, err := s.deps.azureDevOps.ListWorkItemWatches(ctx, workspaceID) + if err != nil { + return nil, err + } + for _, item := range items { + if item != nil && item.ID == id { + return item, nil + } + } + return nil, fmt.Errorf("azure work item watch not found") +} + +func (s *settingsOperations) findAzurePullRequestWatch(ctx context.Context, workspaceID, id string) (*azuredevops.PullRequestWatch, error) { + items, err := s.deps.azureDevOps.ListPullRequestWatches(ctx, workspaceID) + if err != nil { + return nil, err + } + for _, item := range items { + if item != nil && item.ID == id { + return item, nil + } + } + return nil, fmt.Errorf("azure pull request watch not found") +} + +//nolint:cyclop,gocognit,funlen // Provider lookup keeps each provider's list and display label close to its service call. +func (s *settingsOperations) listIntegrationSettingsResources(ctx context.Context, resourceType string, workspaceID *string, query string, limit int, cursor string) (any, error) { + if err := s.requireIntegrationDependency(resourceType); err != nil { + return nil, err + } + if err := s.authorizeIntegrationList(ctx, workspaceID); err != nil { + return nil, err + } + workspaces, err := s.settingsWorkspaces(ctx, workspaceID) + if err != nil { + return nil, err + } + resources := make([]map[string]any, 0) + needle := strings.ToLower(strings.TrimSpace(query)) + add := func(id, label, workspace string) { + if id == "" && workspace == "" { + return + } + if needle != "" && !strings.Contains(strings.ToLower(id), needle) && !strings.Contains(strings.ToLower(label), needle) { + return + } + target := settingscatalog.ResourceTarget{ResourceType: resourceType, WorkspaceID: nonEmptyPointer(workspace)} + if id != "" { + target.ResourceID = settingsStringPointer(id) + } + resources = append(resources, map[string]any{"target": target, "label": label}) + } + for _, workspace := range workspaces { + switch resourceType { + case "jira_settings": + add("", "Jira settings ("+workspace.name+")", workspace.id) + case "linear_settings": + add("", "Linear settings ("+workspace.name+")", workspace.id) + case "github_settings": + add("", "GitHub settings ("+workspace.name+")", workspace.id) + case "gitlab_settings": + add("", "GitLab settings ("+workspace.name+")", workspace.id) + case "azure_devops_settings": + add("", "Azure DevOps settings ("+workspace.name+")", workspace.id) + case "sentry_instance": + instances, listErr := s.deps.sentry.ListInstances(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, instance := range instances { + if instance != nil { + add(instance.ID, instance.Name, workspace.id) + } + } + case "jira_issue_watch": + watches, listErr := s.deps.jira.ListIssueWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.JQL, workspace.id) + } + } + case "linear_issue_watch": + watches, listErr := s.deps.linear.ListIssueWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.ID, workspace.id) + } + } + case "sentry_issue_watch": + watches, listErr := s.deps.sentry.ListIssueWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.SentryInstanceID, workspace.id) + } + } + case "github_review_watch": + watches, listErr := s.deps.github.ListReviewWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.CustomQuery, workspace.id) + } + } + case "github_issue_watch": + watches, listErr := s.deps.github.ListIssueWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.CustomQuery, workspace.id) + } + } + case "gitlab_review_watch": + watches, listErr := s.deps.gitlab.ListReviewWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.CustomQuery, workspace.id) + } + } + case "gitlab_issue_watch": + watches, listErr := s.deps.gitlab.ListIssueWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.CustomQuery, workspace.id) + } + } + case "azure_devops_work_item_watch": + watches, listErr := s.deps.azureDevOps.ListWorkItemWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.WIQL, workspace.id) + } + } + case "azure_devops_pull_request_watch": + watches, listErr := s.deps.azureDevOps.ListPullRequestWatches(ctx, workspace.id) + if listErr != nil { + return nil, listErr + } + for _, watch := range watches { + if watch != nil { + add(watch.ID, watch.Status, workspace.id) + } + } + } + } + return pageSettingsResources(resources, limit, cursor), nil +} + +func (s *settingsOperations) authorizeIntegrationList(ctx context.Context, workspaceID *string) error { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return nil + } + if workspaceID == nil || strings.TrimSpace(*workspaceID) == "" { + return fmt.Errorf("workspace_id is required for integration resource lookup") + } + if s.deps.task == nil { + return fmt.Errorf("workspace authorization is unavailable") + } + return s.deps.task.AuthorizeWorkspaceScope(ctx, strings.TrimSpace(*workspaceID), authz.ScopeWorkspaceRead) +} + +func (s *settingsOperations) requireIntegrationDependency(resourceType string) error { + var available bool + switch resourceType { + case "jira_settings", "jira_issue_watch": + available = s.deps.jira != nil + case "linear_settings", "linear_issue_watch": + available = s.deps.linear != nil + case "sentry_instance", "sentry_issue_watch": + available = s.deps.sentry != nil + case "github_settings", "github_review_watch", "github_issue_watch": + available = s.deps.github != nil + case "gitlab_settings", "gitlab_review_watch", "gitlab_issue_watch": + available = s.deps.gitlab != nil + case "azure_devops_settings", "azure_devops_work_item_watch", "azure_devops_pull_request_watch": + available = s.deps.azureDevOps != nil + default: + return fmt.Errorf("unknown integration resource type %q", resourceType) + } + if !available { + return fmt.Errorf("integration settings for %q are unavailable", resourceType) + } + return nil +} + +type settingsWorkspace struct { + id string + name string +} + +func (s *settingsOperations) settingsWorkspaces(ctx context.Context, requested *string) ([]settingsWorkspace, error) { + if s.deps.task == nil { + return nil, fmt.Errorf("workspace lookup is unavailable") + } + if requested != nil && strings.TrimSpace(*requested) != "" { + workspaceID := strings.TrimSpace(*requested) + item, err := s.deps.task.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if item == nil { + return nil, fmt.Errorf("workspace was not found") + } + return []settingsWorkspace{{id: item.ID, name: item.Name}}, nil + } + items, err := s.deps.task.ListWorkspaces(ctx) + if err != nil { + return nil, err + } + result := make([]settingsWorkspace, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + result = append(result, settingsWorkspace{id: item.ID, name: item.Name}) + } + return result, nil +} diff --git a/apps/backend/internal/backendapp/settings_operations.go b/apps/backend/internal/backendapp/settings_operations.go new file mode 100644 index 0000000000..9818860de6 --- /dev/null +++ b/apps/backend/internal/backendapp/settings_operations.go @@ -0,0 +1,1078 @@ +//revive:disable:file-length-limit // This adapter owns the complete shared settings boundary. +//nolint:goconst // Settings protocol keys stay readable at dispatch sites. +package backendapp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/kandev/kandev/internal/agent/mcpconfig" + agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" + "github.com/kandev/kandev/internal/settingscatalog" + usercontroller "github.com/kandev/kandev/internal/user/controller" + userdto "github.com/kandev/kandev/internal/user/dto" + userservice "github.com/kandev/kandev/internal/user/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type settingsOperations struct { + registry *settingscatalog.Registry + profiles *agentsettingscontroller.Controller + user *usercontroller.Controller + userService *userservice.Service + deps settingsDomainDependencies +} + +type settingsBroadcaster interface { + Broadcast(*ws.Message) +} + +type settingsDomainDependencies struct { + broadcaster settingsBroadcaster + authEnabled func() bool + task settingsTaskService + workflow settingsWorkflowService + workflowCtrl settingsWorkflowController + prompts settingsPromptService + utility settingsUtilityService + editors settingsEditorService + notifications settingsNotificationController + runtimeFlags settingsRuntimeFlagsService + storage settingsStorageService + automation settingsAutomationService + agent settingsAgentService + jira settingsJiraService + linear settingsLinearService + sentry settingsSentryService + github settingsGitHubService + gitlab settingsGitLabService + azureDevOps settingsAzureDevOpsService +} + +func newSettingsOperations( + registry *settingscatalog.Registry, + profiles *agentsettingscontroller.Controller, + userService *userservice.Service, + deps settingsDomainDependencies, +) mcphandlers.SettingsOperations { + var user *usercontroller.Controller + if userService != nil { + user = usercontroller.NewController(userService) + } + return &settingsOperations{registry: registry, profiles: profiles, user: user, userService: userService, deps: deps} +} + +//nolint:cyclop,gocognit // Authorization combines caller mode, field authority, and workspace ownership rules. +func (s *settingsOperations) authorizeSettingsMutation(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) error { + identity, ok := authn.IdentityFromContext(ctx) + if !ok { + if s.deps.authEnabled != nil && !s.deps.authEnabled() { + return nil + } + return fmt.Errorf("settings mutation requires a caller identity") + } + if identity.Synthetic { + return nil + } + if isIntegrationResource(target.ResourceType) { + if err := s.requireIntegrationDependency(target.ResourceType); err != nil { + return err + } + } else { + switch target.ResourceType { + case "agent", "agent_profile", "agent_profile_mcp", "user_settings": + default: + if err := s.requireDomainDependency(target.ResourceType); err != nil { + return err + } + } + } + + for key := range changes { + field, ok := settingsField(s.registry, target.ResourceType, key) + if !ok { + return fmt.Errorf("unknown setting %q", key) + } + if field.Authority == "user.self" { + continue + } + scope, ok := settingsAuthorityScope(field.Authority) + if !ok { + return fmt.Errorf("setting %q has no authorization binding", field.Key) + } + if scope == authz.ScopeOrgConfigManage || scope == authz.ScopeOrgSettingsManage { + subject := authz.Subject{ + UserID: identity.UserID, OrgID: identity.OrgID, + OrgRole: authz.NormalizeOrgRole(string(identity.Role)), + } + if !authz.SubjectOrgScopes(subject).Has(scope) { + return fmt.Errorf("caller lacks %s", scope) + } + continue + } + if s.deps.task == nil { + return fmt.Errorf("workspace authorization is unavailable") + } + workspaceID, err := s.settingsWorkspaceID(ctx, target) + if err != nil { + return err + } + if err := s.deps.task.AuthorizeWorkspaceScope(ctx, workspaceID, scope); err != nil { + return err + } + } + return nil +} + +func settingsAuthorityScope(authority string) (authz.Scope, bool) { + switch authority { + case "org.config.manage", "execution.manage", "prompt.manage", "utility.manage", "editor.manage", "notification.manage": + return authz.ScopeOrgConfigManage, true + case "org.settings.manage": + return authz.ScopeOrgSettingsManage, true + case "workspace.manage": + return authz.ScopeWorkspaceManage, true + case "repository.manage": + return authz.ScopeRepositoryManage, true + case "task.manage", "task.write": + return authz.ScopeTaskWrite, true + default: + return "", false + } +} + +//nolint:cyclop,gocognit,funlen // Resource-to-workspace resolution must remain adjacent to the authorization boundary. +func (s *settingsOperations) settingsWorkspaceID(ctx context.Context, target settingscatalog.ResourceTarget) (string, error) { + if target.WorkspaceID != nil && strings.TrimSpace(*target.WorkspaceID) != "" { + return strings.TrimSpace(*target.WorkspaceID), nil + } + if target.ResourceID == nil || strings.TrimSpace(*target.ResourceID) == "" { + return "", fmt.Errorf("resource_id is required for workspace authorization") + } + id := strings.TrimSpace(*target.ResourceID) + switch target.ResourceType { + case "workspace": + return id, nil + case "workflow": + item, err := s.deps.task.GetWorkflow(ctx, id) + if err != nil { + return "", err + } + if item == nil { + return "", fmt.Errorf("workflow not found") + } + return item.WorkspaceID, nil + case "workflow_step": + response, err := s.deps.workflowCtrl.GetStep(ctx, id) + if err != nil { + return "", err + } + if response == nil || response.Step == nil { + return "", fmt.Errorf("workflow step not found") + } + workflow, err := s.deps.task.GetWorkflow(ctx, response.Step.WorkflowID) + if err != nil { + return "", err + } + if workflow == nil { + return "", fmt.Errorf("workflow not found") + } + return workflow.WorkspaceID, nil + case "repository": + item, err := s.deps.task.GetRepository(ctx, id) + if err != nil { + return "", err + } + if item == nil { + return "", fmt.Errorf("repository not found") + } + return item.WorkspaceID, nil + case "repository_script": + item, err := s.deps.task.GetRepositoryScript(ctx, id) + if err != nil { + return "", err + } + if item == nil { + return "", fmt.Errorf("repository script not found") + } + repository, err := s.deps.task.GetRepository(ctx, item.RepositoryID) + if err != nil { + return "", err + } + if repository == nil { + return "", fmt.Errorf("repository not found") + } + return repository.WorkspaceID, nil + case "repository_set": + item, err := s.deps.task.GetRepositorySet(ctx, id) + if err != nil { + return "", err + } + if item == nil { + return "", fmt.Errorf("repository set not found") + } + return item.WorkspaceID, nil + case "task": + item, err := s.deps.task.GetTask(ctx, id) + if err != nil { + return "", err + } + if item == nil { + return "", fmt.Errorf("task not found") + } + return item.WorkspaceID, nil + case "automation": + item, err := s.deps.automation.GetAutomation(ctx, id) + if err != nil { + return "", err + } + if item == nil { + return "", fmt.Errorf("automation not found") + } + return item.WorkspaceID, nil + case "automation_trigger": + trigger, err := s.deps.automation.GetTrigger(ctx, id) + if err != nil { + return "", err + } + if trigger == nil { + return "", fmt.Errorf("automation trigger not found") + } + automation, err := s.deps.automation.GetAutomation(ctx, trigger.AutomationID) + if err != nil { + return "", err + } + if automation == nil { + return "", fmt.Errorf("automation not found") + } + return automation.WorkspaceID, nil + case "agent": + agent, err := s.profiles.GetAgent(ctx, id) + if err != nil { + return "", err + } + return agentWorkspaceID(*agent), nil + case "agent_profile": + profile, err := s.findProfile(ctx, target) + if err != nil { + return "", err + } + return profile.WorkspaceID, nil + default: + return "", fmt.Errorf("resource type %q does not have a workspace scope", target.ResourceType) + } +} + +func (s *settingsOperations) ReadSettings(ctx context.Context, target settingscatalog.ResourceTarget, keys []string) (any, error) { + if isIntegrationResource(target.ResourceType) { + return s.readIntegrationSettings(ctx, target, keys) + } + switch target.ResourceType { + case "agent": + return s.readAgentSettings(ctx, target, keys) + case "agent_profile": + profile, err := s.findProfile(ctx, target) + if err != nil { + return nil, err + } + values := projectJSON(profile, s.registry, target.ResourceType, keys) + redactSettingsValues(values, s.registry, target.ResourceType) + return map[string]any{ + "target": target, "values": values, "source": "profile", "updated_at": profile.UpdatedAt, + }, nil + case "agent_profile_mcp": + if s.profiles == nil || target.ResourceID == nil { + return nil, fmt.Errorf("profile MCP settings are unavailable") + } + config, err := s.profiles.GetAgentProfileMcpConfig(ctx, *target.ResourceID) + if err != nil { + return nil, err + } + values := projectJSON(config, s.registry, target.ResourceType, keys) + redactSettingsValues(values, s.registry, target.ResourceType) + return map[string]any{"target": target, "values": values, "source": "profile_mcp"}, nil + case "user_settings": + if s.user == nil { + return nil, fmt.Errorf("user settings are unavailable") + } + response, err := s.user.GetUserSettings(ctx) + if err != nil { + return nil, err + } + values := projectJSON(response.Settings, s.registry, target.ResourceType, keys) + return map[string]any{"target": target, "values": values, "source": "user_settings", "updated_at": response.Settings.UpdatedAt}, nil + default: + return s.readDomainSettings(ctx, target, keys) + } +} + +func (s *settingsOperations) DescribeSetting(ctx context.Context, request mcphandlers.SettingDescriptionRequest) (mcphandlers.SettingDescription, error) { + field, domain, ok := s.registry.Find(request.Key) + if !ok { + return mcphandlers.SettingDescription{}, fmt.Errorf("setting %q was not found", request.Key) + } + field, err := s.targetDescriptionField(field, domain, request.Target) + if err != nil { + return mcphandlers.SettingDescription{}, err + } + if len(request.Context) == 0 { + return mcphandlers.SettingDescription{}, nil + } + if err := validateDescriptionContext(request.Key, field, request.Target, request.Context); err != nil { + return mcphandlers.SettingDescription{}, err + } + return s.describeProfileOptions(ctx, request) +} + +func (s *settingsOperations) targetDescriptionField( + field settingscatalog.FieldDescriptor, + domain settingscatalog.DomainDescriptor, + target *settingscatalog.ResourceTarget, +) (settingscatalog.FieldDescriptor, error) { + if target == nil { + return field, nil + } + if err := s.registry.ValidateTarget(*target); err != nil { + return settingscatalog.FieldDescriptor{}, err + } + if target.ResourceType != domain.ResourceType { + return settingscatalog.FieldDescriptor{}, fmt.Errorf("target resource_type does not match setting") + } + return settingscatalog.FieldForTarget(field, target), nil +} + +func validateDescriptionContext( + key string, + field settingscatalog.FieldDescriptor, + target *settingscatalog.ResourceTarget, + contextValues map[string]any, +) error { + if field.Key != "agent_profile.config_options" || target == nil || target.ResourceID == nil { + return fmt.Errorf("setting context is not supported for %q", key) + } + for contextKey := range contextValues { + switch contextKey { + case "model", "mode", "config_options", "refresh": + default: + return fmt.Errorf("unsupported setting context %q", contextKey) + } + } + return nil +} + +func (s *settingsOperations) describeProfileOptions( + ctx context.Context, + request mcphandlers.SettingDescriptionRequest, +) (mcphandlers.SettingDescription, error) { + profile, err := s.findProfile(ctx, *request.Target) + if err != nil { + return mcphandlers.SettingDescription{}, err + } + agentName, err := s.profileAgentName(ctx, profile.ID) + if err != nil { + return mcphandlers.SettingDescription{}, err + } + refresh, err := descriptionBoolContext(request.Context, "refresh") + if err != nil { + return mcphandlers.SettingDescription{}, err + } + caps, err := s.profiles.FetchDynamicModels(ctx, agentName, refresh) + if err != nil { + return mcphandlers.SettingDescription{}, err + } + choices := make([]map[string]any, 0, len(caps.Models)+len(caps.Modes)) + for _, model := range caps.Models { + choices = append(choices, map[string]any{"kind": "model", "id": model.ID, "label": model.Name, "description": model.Description}) + } + for _, mode := range caps.Modes { + choices = append(choices, map[string]any{"kind": "mode", "id": mode.ID, "label": mode.Name, "description": mode.Description}) + } + model, err := descriptionModelContext(request.Context, profile.Model) + if err != nil { + return mcphandlers.SettingDescription{}, err + } + configOptions := []agentsettingsdto.ConfigOptionDTO{} + if model != "" { + mode, err := descriptionStringContext(request.Context, "mode") + if err != nil { + return mcphandlers.SettingDescription{}, err + } + selectedOptions, err := descriptionConfigOptionsContext(request.Context, "config_options") + if err != nil { + return mcphandlers.SettingDescription{}, err + } + resolution, err := s.profiles.ResolveAgentModelConfig(ctx, agentName, agentsettingsdto.ResolveAgentModelConfigRequest{ + Model: model, Mode: mode, ConfigOptions: selectedOptions, Refresh: refresh, + }) + if err != nil { + return mcphandlers.SettingDescription{}, err + } + configOptions = resolution.ConfigOptions + } + for _, option := range configOptions { + item := map[string]any{ + "kind": "config_option", "id": option.ID, "label": option.Name, + "type": option.Type, "current_value": option.CurrentValue, "description": option.Description, + } + if len(option.Options) > 0 { + values := make([]map[string]any, 0, len(option.Options)) + for _, value := range option.Options { + values = append(values, map[string]any{"value": value.Value, "label": value.Name, "description": value.Description}) + } + item["options"] = values + } + choices = append(choices, item) + } + return mcphandlers.SettingDescription{Choices: choices}, nil +} + +func descriptionModelContext(contextValues map[string]any, fallback string) (string, error) { + value, ok := contextValues["model"] + if !ok { + return fallback, nil + } + model, ok := value.(string) + if !ok { + return "", fmt.Errorf("setting context %q must be a string", "model") + } + return model, nil +} + +func descriptionStringContext(contextValues map[string]any, key string) (string, error) { + value, ok := contextValues[key] + if !ok { + return "", nil + } + parsed, ok := value.(string) + if !ok { + return "", fmt.Errorf("setting context %q must be a string", key) + } + return parsed, nil +} + +func descriptionConfigOptionsContext(contextValues map[string]any, key string) (map[string]string, error) { + value, ok := contextValues[key] + if !ok { + return nil, nil + } + entries, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("setting context %q must be an object", key) + } + result := make(map[string]string, len(entries)) + for entryKey, entryValue := range entries { + parsed, ok := entryValue.(string) + if !ok { + return nil, fmt.Errorf("setting context %q values must be strings", key) + } + result[entryKey] = parsed + } + return result, nil +} + +func descriptionBoolContext(contextValues map[string]any, key string) (bool, error) { + value, ok := contextValues[key] + if !ok { + return false, nil + } + parsed, ok := value.(bool) + if !ok { + return false, fmt.Errorf("setting context %q must be boolean", key) + } + return parsed, nil +} + +func (s *settingsOperations) profileAgentName(ctx context.Context, profileID string) (string, error) { + response, err := s.profiles.ListAgents(ctx) + if err != nil { + return "", err + } + for _, agent := range response.Agents { + for _, profile := range agent.Profiles { + if profile.ID == profileID { + return agent.Name, nil + } + } + } + return "", fmt.Errorf("agent profile %q is not visible", profileID) +} + +func (s *settingsOperations) UpdateSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage, options map[string]json.RawMessage) (any, error) { + if err := s.authorizeSettingsMutation(ctx, target, changes); err != nil { + return nil, err + } + if isIntegrationResource(target.ResourceType) { + return s.updateIntegrationSettings(ctx, target, changes, options) + } + switch target.ResourceType { + case "agent": + return s.updateAgentSettings(ctx, target, changes) + case "agent_profile": + if s.profiles == nil || target.ResourceID == nil { + return nil, fmt.Errorf("profile settings are unavailable") + } + request, err := decodeProfileUpdate(*target.ResourceID, changes, options) + if err != nil { + return nil, err + } + profile, err := s.profiles.UpdateProfile(ctx, agentsettingscontroller.UpdateProfileRequestFromDTO(request)) + if err != nil { + return nil, err + } + return map[string]any{"target": target, "accepted_fields": changePaths(changes), "profile": s.sanitizeSettingsValue(target.ResourceType, profile), "source": "profile", "updated_at": profile.UpdatedAt}, nil + case "agent_profile_mcp": + return s.updateProfileMCP(ctx, target, changes) + case "user_settings": + if s.user == nil { + return nil, fmt.Errorf("user settings are unavailable") + } + request, err := decodeUserSettingsUpdate(changes) + if err != nil { + return nil, err + } + response, err := s.user.UpdateUserSettings(ctx, request) + if err != nil { + return nil, err + } + return map[string]any{"target": target, "accepted_fields": changePaths(changes), "settings": s.sanitizeSettingsValue(target.ResourceType, response.Settings), "source": "user_settings", "updated_at": response.Settings.UpdatedAt}, nil + default: + return s.updateDomainSettings(ctx, target, changes, options) + } +} + +//nolint:cyclop,gocognit // This public boundary selects personal, profile, integration, and domain resource lookup. +func (s *settingsOperations) ListSettingsResources(ctx context.Context, resourceType string, workspaceID *string, query string, limit int, cursor string) (any, error) { + if isIntegrationResource(resourceType) { + return s.listIntegrationSettingsResources(ctx, resourceType, workspaceID, query, limit, cursor) + } + if resourceType == "user_settings" { + return map[string]any{"resources": []any{map[string]any{"target": settingscatalog.ResourceTarget{ResourceType: resourceType}, "label": "Current user"}}, "total": 1}, nil + } + if resourceType == "agent" && s.profiles != nil { + response, err := s.profiles.ListAgents(ctx) + if err != nil { + return nil, err + } + resources := make([]map[string]any, 0, len(response.Agents)) + needle := strings.ToLower(strings.TrimSpace(query)) + for _, agent := range response.Agents { + if workspaceID != nil && *workspaceID != "" && (agent.WorkspaceID == nil || *agent.WorkspaceID != *workspaceID) { + continue + } + if needle != "" && !strings.Contains(strings.ToLower(agent.ID), needle) && !strings.Contains(strings.ToLower(agent.Name), needle) { + continue + } + resources = append(resources, map[string]any{ + "target": settingscatalog.ResourceTarget{ResourceType: resourceType, ResourceID: settingsStringPointer(agent.ID), WorkspaceID: nonEmptyPointer(agentWorkspaceID(agent))}, + "label": agent.Name, + }) + } + sort.Slice(resources, func(i, j int) bool { return resources[i]["label"].(string) < resources[j]["label"].(string) }) + return pageSettingsResources(resources, limit, cursor), nil + } + if resourceType != "agent_profile" || s.profiles == nil { + return s.listDomainSettingsResources(ctx, resourceType, workspaceID, query, limit, cursor) + } + response, err := s.profiles.ListAgents(ctx) + if err != nil { + return nil, err + } + resources := make([]map[string]any, 0) + needle := strings.ToLower(strings.TrimSpace(query)) + for _, agent := range response.Agents { + for _, profile := range agent.Profiles { + if workspaceID != nil && *workspaceID != "" && profile.WorkspaceID != *workspaceID { + continue + } + if needle != "" && !strings.Contains(strings.ToLower(profile.ID), needle) && !strings.Contains(strings.ToLower(profile.Name), needle) { + continue + } + resources = append(resources, map[string]any{ + "target": settingscatalog.ResourceTarget{ResourceType: resourceType, ResourceID: settingsStringPointer(profile.ID), WorkspaceID: nonEmptyPointer(profile.WorkspaceID)}, + "label": profile.Name, "agent_id": profile.AgentID, "agent_name": agent.Name, + }) + } + } + sort.Slice(resources, func(i, j int) bool { + return resources[i]["label"].(string) < resources[j]["label"].(string) + }) + start := parseSettingsCursor(cursor) + if start > len(resources) { + start = len(resources) + } + end := start + limit + if end > len(resources) { + end = len(resources) + } + result := map[string]any{"resources": resources[start:end], "total": len(resources)} + if end < len(resources) { + result["next_cursor"] = fmt.Sprintf("%d", end) + } + return result, nil +} + +func agentWorkspaceID(agent agentsettingsdto.AgentDTO) string { + if agent.WorkspaceID == nil { + return "" + } + return *agent.WorkspaceID +} + +func (s *settingsOperations) readAgentSettings(ctx context.Context, target settingscatalog.ResourceTarget, keys []string) (any, error) { + if s.profiles == nil || target.ResourceID == nil { + return nil, fmt.Errorf("agent settings are unavailable") + } + agent, err := s.profiles.GetAgent(ctx, *target.ResourceID) + if err != nil { + return nil, err + } + if target.WorkspaceID != nil && (agent.WorkspaceID == nil || *agent.WorkspaceID != *target.WorkspaceID) { + return nil, fmt.Errorf("agent is not visible in the requested workspace") + } + values := projectJSON(agent, s.registry, target.ResourceType, keys) + redactSettingsValues(values, s.registry, target.ResourceType) + redactAgentProfileCollections(values) + return map[string]any{"target": target, "values": values, "source": "agent", "updated_at": agent.UpdatedAt}, nil +} + +func (s *settingsOperations) updateAgentSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + if s.profiles == nil || target.ResourceID == nil { + return nil, fmt.Errorf("agent settings are unavailable") + } + normalized, err := normalizeChangeMap(changes, "agent") + if err != nil { + return nil, err + } + request := agentsettingscontroller.UpdateAgentRequest{ID: *target.ResourceID} + for key, raw := range normalized { + switch key { + case "supports_mcp": + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("supports_mcp must be boolean") + } + request.SupportsMCP = &value + case "mcp_config_path": + request.MCPConfigPathSet = true + var value *string + if string(raw) != "null" { + var decoded string + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, fmt.Errorf("mcp_config_path must be string or null") + } + value = &decoded + } + request.MCPConfigPath = value + default: + return nil, fmt.Errorf("setting %q is not writable", key) + } + } + updated, err := s.profiles.UpdateAgent(ctx, request) + if err != nil { + return nil, err + } + return map[string]any{"target": target, "accepted_fields": changePaths(changes), "agent": s.sanitizeSettingsValue(target.ResourceType, updated), "source": "agent", "updated_at": updated.UpdatedAt}, nil +} + +func (s *settingsOperations) findProfile(ctx context.Context, target settingscatalog.ResourceTarget) (*agentsettingsdto.AgentProfileDTO, error) { + if s.profiles == nil || target.ResourceID == nil { + return nil, fmt.Errorf("profile settings are unavailable") + } + response, err := s.profiles.ListAgents(ctx) + if err != nil { + return nil, err + } + for _, agent := range response.Agents { + for index := range agent.Profiles { + profile := &agent.Profiles[index] + if profile.ID == *target.ResourceID { + if target.WorkspaceID != nil && profile.WorkspaceID != *target.WorkspaceID { + return nil, fmt.Errorf("profile is not visible in the requested workspace") + } + return profile, nil + } + } + } + return nil, fmt.Errorf("agent profile not found") +} + +func decodeProfileUpdate(profileID string, changes map[string]json.RawMessage, options map[string]json.RawMessage) (agentsettingsdto.ProfileUpdateRequest, error) { + normalized, err := normalizeChangeMap(changes, "agent_profile") + if err != nil { + return agentsettingsdto.ProfileUpdateRequest{}, err + } + normalized["id"] = json.RawMessage(strconvQuote(profileID)) + requestJSON, err := json.Marshal(normalized) + if err != nil { + return agentsettingsdto.ProfileUpdateRequest{}, err + } + var request agentsettingsdto.ProfileUpdateRequest + if err := json.Unmarshal(requestJSON, &request); err != nil { + return agentsettingsdto.ProfileUpdateRequest{}, fmt.Errorf("invalid profile settings: %w", err) + } + if force, exists := options["force"]; exists { + if err := json.Unmarshal(force, &request.Force); err != nil { + return agentsettingsdto.ProfileUpdateRequest{}, fmt.Errorf("force must be boolean") + } + } + for key := range options { + if key != "force" { + return agentsettingsdto.ProfileUpdateRequest{}, fmt.Errorf("unsupported profile option %q", key) + } + } + return request, nil +} + +func decodeUserSettingsUpdate(changes map[string]json.RawMessage) (userdto.UpdateUserSettingsRequest, error) { + normalized, err := normalizeChangeMap(changes, "user_settings") + if err != nil { + return userdto.UpdateUserSettingsRequest{}, err + } + if raw, ok := normalized["sidebar_task_colors"]; ok { + if string(raw) == "null" { + return userdto.UpdateUserSettingsRequest{}, fmt.Errorf("sidebar_task_colors must be an object") + } + var colors map[string]*string + if err := json.Unmarshal(raw, &colors); err != nil || colors == nil { + return userdto.UpdateUserSettingsRequest{}, fmt.Errorf("sidebar_task_colors must be an object") + } + patch, marshalErr := json.Marshal(map[string]any{"colors": colors}) + if marshalErr != nil { + return userdto.UpdateUserSettingsRequest{}, marshalErr + } + delete(normalized, "sidebar_task_colors") + normalized["sidebar_task_color_patch"] = patch + } + payload, err := json.Marshal(normalized) + if err != nil { + return userdto.UpdateUserSettingsRequest{}, err + } + var request userdto.UpdateUserSettingsRequest + if err := json.Unmarshal(payload, &request); err != nil { + return userdto.UpdateUserSettingsRequest{}, fmt.Errorf("invalid user settings: %w", err) + } + return request, nil +} + +func normalizeChangeMap(changes map[string]json.RawMessage, resourceType string) (map[string]json.RawMessage, error) { + normalized := make(map[string]json.RawMessage, len(changes)) + for key, value := range changes { + prefix := resourceType + "." + key = strings.TrimPrefix(key, prefix) + if _, exists := normalized[key]; exists { + return nil, fmt.Errorf("setting %q was supplied more than once", key) + } + normalized[key] = value + } + return normalized, nil +} + +func (s *settingsOperations) updateProfileMCP(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) { + if s.profiles == nil || target.ResourceID == nil { + return nil, fmt.Errorf("profile MCP settings are unavailable") + } + profile, err := s.findProfile(ctx, target) + if err != nil { + return nil, err + } + normalized, err := normalizeChangeMap(changes, "agent_profile_mcp") + if err != nil { + return nil, err + } + request := agentsettingscontroller.UpdateAgentProfileMcpConfigPatchRequest{} + for key, value := range normalized { + switch key { + case "enabled": + var enabled bool + if string(value) == "null" || json.Unmarshal(value, &enabled) != nil { + return nil, fmt.Errorf("enabled must be boolean") + } + request.Enabled = &enabled + case "servers": + if string(value) == "null" { + return nil, fmt.Errorf("servers must be an object") + } + var servers map[string]mcpconfig.ServerDef + if err := json.Unmarshal(value, &servers); err != nil || servers == nil { + return nil, fmt.Errorf("servers must be an object") + } + request.Servers = &servers + default: + return nil, fmt.Errorf("setting %q is not writable", key) + } + } + updated, err := s.profiles.UpdateAgentProfileMcpConfigPatch(ctx, *target.ResourceID, request) + if err != nil { + return nil, err + } + s.broadcastProfileMCPConfigUpdated(*target.ResourceID, profile.WorkspaceID) + return map[string]any{"target": target, "accepted_fields": changePaths(changes), "settings": s.sanitizeSettingsValue(target.ResourceType, updated), "source": "profile_mcp"}, nil +} + +func (s *settingsOperations) broadcastProfileMCPConfigUpdated(profileID, workspaceID string) { + if s.deps.broadcaster == nil || profileID == "" { + return + } + var scopedWorkspaceID any + if workspaceID != "" { + scopedWorkspaceID = workspaceID + } + notification, err := ws.NewNotification(ws.ActionAgentProfileMCPConfigUpdated, map[string]any{ + "profile_id": profileID, + "workspace_id": scopedWorkspaceID, + }) + if err != nil { + return + } + if workspaceID != "" { + workspaceHub, ok := s.deps.broadcaster.(interface { + BroadcastToWorkspaceOrDrop(string, *ws.Message) + }) + if ok { + workspaceHub.BroadcastToWorkspaceOrDrop(workspaceID, notification) + } + return + } + //ws:global profile MCP updates without workspace ownership are global. + s.deps.broadcaster.Broadcast(notification) +} + +//nolint:nestif // The repository-set projection needs its nested stored-resource fallback. +func projectJSON(value any, registry *settingscatalog.Registry, resourceType string, keys []string) map[string]any { + payload := map[string]any{} + encoded, err := json.Marshal(value) + if err != nil { + return payload + } + _ = json.Unmarshal(encoded, &payload) + result := make(map[string]any, len(keys)) + for _, key := range keys { + field, ok := settingsField(registry, resourceType, key) + if !ok { + continue + } + if current, exists := lookupJSONPath(payload, field.FieldPath); exists { + result[field.FieldPath] = current + continue + } + if resourceType == "repository_set" && field.FieldPath == "repository_ids" { + if items, ok := payload["repositories"].([]any); ok { + ids := make([]any, 0, len(items)) + for _, item := range items { + if object, ok := item.(map[string]any); ok { + if id, exists := object["repository_id"]; exists { + ids = append(ids, id) + } + } + } + result[field.FieldPath] = ids + } + } + } + return result +} + +func lookupJSONPath(payload map[string]any, path string) (any, bool) { + parts := strings.Split(path, ".") + var current any = payload + for _, part := range parts { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[part] + if !ok { + return nil, false + } + } + return current, true +} + +func settingsField(registry *settingscatalog.Registry, resourceType, key string) (settingscatalog.FieldDescriptor, bool) { + domain, ok := registry.Domain(resourceType) + if !ok { + return settingscatalog.FieldDescriptor{}, false + } + for _, field := range domain.Fields { + if field.Key == key || field.FieldPath == key { + return field, true + } + } + return settingscatalog.FieldDescriptor{}, false +} + +func (s *settingsOperations) sanitizeSettingsValue(resourceType string, value any) any { + if isIntegrationResource(resourceType) { + paths := make([]string, 0) + if domain, ok := s.registry.Domain(resourceType); ok { + for _, field := range domain.Fields { + paths = append(paths, field.FieldPath) + } + } + values := projectJSON(value, s.registry, resourceType, paths) + redactSettingsValues(values, s.registry, resourceType) + return values + } + payload, err := json.Marshal(value) + if err != nil { + return map[string]any{"redacted": true} + } + var decoded any + if err := json.Unmarshal(payload, &decoded); err != nil { + return map[string]any{"redacted": true} + } + if object, ok := decoded.(map[string]any); ok { + redactSettingsValues(object, s.registry, resourceType) + if resourceType == "agent" { + redactAgentProfileCollections(object) + } + return object + } + return decoded +} + +func redactAgentProfileCollections(values map[string]any) { + profiles, ok := values["profiles"].([]any) + if !ok { + return + } + for _, rawProfile := range profiles { + profile, ok := rawProfile.(map[string]any) + if !ok { + continue + } + if envVars, exists := profile["env_vars"]; exists { + profile["env_vars"] = redactSensitiveValue(envVars) + } + } +} + +func redactSettingsValues(values map[string]any, registry *settingscatalog.Registry, resourceType string) { + domain, ok := registry.Domain(resourceType) + if !ok { + return + } + for _, field := range domain.Fields { + if !field.Sensitive { + continue + } + if current, exists := lookupJSONPath(values, field.FieldPath); exists { + _ = setJSONPath(values, field.FieldPath, redactSensitiveValue(current)) + } + } +} + +func setJSONPath(payload map[string]any, path string, value any) error { + parts := strings.Split(path, ".") + if len(parts) == 0 || parts[0] == "" { + return fmt.Errorf("invalid JSON path %q", path) + } + current := payload + for _, part := range parts[:len(parts)-1] { + nested, ok := current[part].(map[string]any) + if !ok { + return fmt.Errorf("invalid JSON path %q", path) + } + current = nested + } + current[parts[len(parts)-1]] = value + return nil +} + +func redactSensitiveValue(value any) any { + switch typed := value.(type) { + case []any: + result := make([]any, len(typed)) + for index, item := range typed { + result[index] = redactSensitiveValue(item) + } + return result + case map[string]any: + result := make(map[string]any, len(typed)+1) + redacted := false + for key, item := range typed { + if sensitiveJSONKey(key) { + result[key] = "[redacted]" + redacted = true + continue + } + switch item.(type) { + case map[string]any, []any: + result[key] = redactSensitiveValue(item) + default: + if safeJSONKey(key) { + result[key] = item + } else { + result[key] = "[redacted]" + redacted = true + } + } + } + if redacted { + result["redacted"] = true + } + return result + default: + return "[redacted]" + } +} + +func sensitiveJSONKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_")) + if normalized == "secret_id" || normalized == "secret_ref" { + return false + } + for _, token := range []string{"value", "secret", "token", "password", "credential", "authorization", "api_key", "private_key", "headers", "env", "env_vars"} { + if normalized == token || strings.Contains(normalized, token) { + return true + } + } + return false +} + +func safeJSONKey(key string) bool { + switch strings.ToLower(key) { + case "id", "key", "name", "type", "kind", "scope", "ref", "secret_id", "description", "command", "args", "enabled", "required": + return true + default: + return false + } +} + +func changePaths(changes map[string]json.RawMessage) []string { + paths := make([]string, 0, len(changes)) + for key := range changes { + paths = append(paths, key) + } + sort.Strings(paths) + return paths +} + +func parseSettingsCursor(cursor string) int { + var value int + if _, err := fmt.Sscanf(cursor, "%d", &value); err != nil || value < 0 { + return 0 + } + return value +} + +func settingsStringPointer(value string) *string { return &value } + +func nonEmptyPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func strconvQuote(value string) []byte { + encoded, _ := json.Marshal(value) + return encoded +} diff --git a/apps/backend/internal/backendapp/settings_operations_test.go b/apps/backend/internal/backendapp/settings_operations_test.go new file mode 100644 index 0000000000..51ea243251 --- /dev/null +++ b/apps/backend/internal/backendapp/settings_operations_test.go @@ -0,0 +1,179 @@ +package backendapp + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/runtimeflags" + "github.com/kandev/kandev/internal/settingscatalog" + storage "github.com/kandev/kandev/internal/system/storage" +) + +func TestSettingsMutationRequiresCallerIdentity(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + operations := &settingsOperations{registry: registry} + target := settingscatalog.ResourceTarget{ResourceType: "user_settings"} + changes := map[string]json.RawMessage{"confirm_task_archive": json.RawMessage(`false`)} + if err := operations.authorizeSettingsMutation(context.Background(), target, changes); err == nil { + t.Fatal("mutation without caller identity was accepted") + } + if err := operations.authorizeSettingsMutation( + authn.WithIdentity(context.Background(), authn.Identity{Synthetic: true}), target, changes, + ); err != nil { + t.Fatalf("synthetic identity was rejected: %v", err) + } +} + +func TestSettingsSensitiveValuesAreRedactedWithoutDroppingReferences(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + values := map[string]any{ + "env_vars": []any{map[string]any{"key": "TOKEN", "value": "secret-value", "secret_id": "secret-1"}}, + "config_options": map[string]any{"safe": "kept"}, + } + redactSettingsValues(values, registry, "agent_profile") + + encoded, _ := json.Marshal(values) + if strings.Contains(string(encoded), "secret-value") { + t.Fatalf("sensitive value leaked: %s", encoded) + } + entry := values["env_vars"].([]any)[0].(map[string]any) + if entry["secret_id"] != "secret-1" || entry["redacted"] != true { + t.Fatalf("secret reference was not preserved safely: %#v", entry) + } +} + +func TestRuntimeFlagSettingsValueUsesCatalogOverridePath(t *testing.T) { + override := true + value := runtimeFlagSettingsValue(runtimeflags.RuntimeFlagState{ + Key: "features.example", + OverrideValue: &override, + }) + if value["override"] != true { + t.Fatalf("override = %#v, want true", value["override"]) + } + if _, ok := value["override_value"]; ok { + t.Fatalf("service-specific override_value leaked into settings contract: %#v", value) + } +} + +type recordingSettingsRuntimeFlags struct { + setOverrideCalls int +} + +func (r *recordingSettingsRuntimeFlags) ListStates(context.Context) ([]runtimeflags.RuntimeFlagState, error) { + return []runtimeflags.RuntimeFlagState{{Key: "features.auth"}}, nil +} + +func (r *recordingSettingsRuntimeFlags) SetOverride(context.Context, string, *bool) ([]runtimeflags.RuntimeFlagState, error) { + r.setOverrideCalls++ + return r.ListStates(context.Background()) +} + +func TestRuntimeFlagSettingsCannotMutateAuthenticationEnablement(t *testing.T) { + for _, raw := range []string{`true`, `false`, `null`} { + t.Run(raw, func(t *testing.T) { + flags := &recordingSettingsRuntimeFlags{} + operations := &settingsOperations{deps: settingsDomainDependencies{runtimeFlags: flags}} + + _, err := operations.updateRuntimeFlag(context.Background(), "features.auth", map[string]json.RawMessage{ + "override": json.RawMessage(raw), + }) + if err == nil { + t.Fatal("authentication runtime flag mutation was accepted") + } + if flags.setOverrideCalls != 0 { + t.Fatalf("SetOverride calls = %d, want 0", flags.setOverrideCalls) + } + }) + } +} + +type recordingStorageSettings struct { + getCalls int + saveCalls int + patchCalls int + patched map[string]json.RawMessage +} + +func (s *recordingStorageSettings) GetSettings(context.Context) (storage.StorageMaintenanceSettings, error) { + s.getCalls++ + return storage.DefaultSettings(), nil +} + +func (s *recordingStorageSettings) SaveSettingsWithConfirmations(context.Context, storage.StorageMaintenanceSettings, storage.SaveConfirmations) (storage.StorageMaintenanceSettings, error) { + s.saveCalls++ + return storage.DefaultSettings(), nil +} + +func (s *recordingStorageSettings) PatchSettingsWithConfirmations(_ context.Context, changes map[string]json.RawMessage, _ storage.SaveConfirmations) (storage.StorageMaintenanceSettings, error) { + s.patchCalls++ + s.patched = changes + updated := storage.DefaultSettings() + updated.Enabled = true + return updated, nil +} + +func TestStorageSettingsUpdateUsesAtomicStoragePatch(t *testing.T) { + store := &recordingStorageSettings{} + operations := &settingsOperations{deps: settingsDomainDependencies{storage: store}} + + updated, err := operations.updateStorageSettings(context.Background(), map[string]json.RawMessage{ + "enabled": json.RawMessage(`true`), + "docker.build_cache_enabled": json.RawMessage(`false`), + }, nil) + if err != nil { + t.Fatal(err) + } + if store.patchCalls != 1 || store.getCalls != 0 || store.saveCalls != 0 { + t.Fatalf("storage calls = patch:%d get:%d save:%d, want one atomic patch only", store.patchCalls, store.getCalls, store.saveCalls) + } + if len(store.patched) != 2 || updated == nil { + t.Fatalf("patched changes = %#v, updated = %#v", store.patched, updated) + } +} + +func TestDecodeUserSettingsUpdateMapsSidebarTaskColors(t *testing.T) { + request, err := decodeUserSettingsUpdate(map[string]json.RawMessage{ + "user_settings.sidebar_task_colors": json.RawMessage(`{"task-1":"blue","task-2":null}`), + }) + if err != nil { + t.Fatalf("decode user settings: %v", err) + } + if request.SidebarTaskColorPatch == nil { + t.Fatal("sidebar task color patch is nil") + } + if request.SidebarTaskColorPatch.Colors["task-1"] == nil || + *request.SidebarTaskColorPatch.Colors["task-1"] != "blue" { + t.Fatalf("task-1 color = %#v", request.SidebarTaskColorPatch.Colors["task-1"]) + } + if value, exists := request.SidebarTaskColorPatch.Colors["task-2"]; !exists || value != nil { + t.Fatalf("task-2 color = %#v, want a clear tombstone", value) + } +} + +func TestAgentProfileCollectionsRedactEmbeddedEnvironmentValues(t *testing.T) { + values := map[string]any{ + "profiles": []any{ + map[string]any{ + "name": "Default", + "env_vars": []any{map[string]any{"key": "TOKEN", "value": "secret-value", "secret_id": "secret-1"}}, + }, + }, + } + redactAgentProfileCollections(values) + + profile := values["profiles"].([]any)[0].(map[string]any) + entry := profile["env_vars"].([]any)[0].(map[string]any) + if entry["value"] != "[redacted]" || entry["secret_id"] != "secret-1" { + t.Fatalf("embedded environment value was not safely redacted: %#v", entry) + } +} diff --git a/apps/backend/internal/backendapp/settings_registry.go b/apps/backend/internal/backendapp/settings_registry.go new file mode 100644 index 0000000000..1e1df5336b --- /dev/null +++ b/apps/backend/internal/backendapp/settings_registry.go @@ -0,0 +1,27 @@ +package backendapp + +import ( + agentsettingscatalog "github.com/kandev/kandev/internal/agent/settings/catalog" + "github.com/kandev/kandev/internal/settingscatalog" +) + +// buildSettingsRegistry composes the platform catalog with the agent-settings +// catalog so runtime validation and generated discovery use one profile +// contract. +func buildSettingsRegistry() (*settingscatalog.Registry, error) { + profileDomains := agentsettingscatalog.Descriptors() + profileTypes := make(map[string]struct{}, len(profileDomains)) + for _, domain := range profileDomains { + profileTypes[domain.ResourceType] = struct{}{} + } + + domains := make([]settingscatalog.DomainDescriptor, 0) + for _, domain := range settingscatalog.DefaultDomainDescriptors() { + if _, replaced := profileTypes[domain.ResourceType]; replaced { + continue + } + domains = append(domains, domain) + } + domains = append(domains, profileDomains...) + return settingscatalog.NewRegistry(domains) +} diff --git a/apps/backend/internal/backendapp/settings_registry_test.go b/apps/backend/internal/backendapp/settings_registry_test.go new file mode 100644 index 0000000000..bc18bdb815 --- /dev/null +++ b/apps/backend/internal/backendapp/settings_registry_test.go @@ -0,0 +1,66 @@ +package backendapp + +import ( + "testing" + + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/settingscatalog" +) + +func TestBuildSettingsRegistryUsesSharedProfileContract(t *testing.T) { + registry, err := buildSettingsRegistry() + if err != nil { + t.Fatal(err) + } + + profile, ok := registry.Domain("agent_profile") + if !ok { + t.Fatal("agent profile domain is missing") + } + fields := make(map[string]struct{}, len(profile.Fields)) + for _, field := range profile.Fields { + fields[field.FieldPath] = struct{}{} + } + for _, contract := range agentsettingsdto.ProfileContractFields() { + if _, ok := fields[contract.Path]; !ok { + t.Fatalf("shared profile field %q is missing from runtime catalog", contract.Path) + } + } + + mcp, ok := registry.Domain("agent_profile_mcp") + if !ok { + t.Fatal("agent profile MCP domain is missing") + } + for _, field := range mcp.Fields { + if field.FieldPath == "meta" && field.Support == "supported" { + t.Fatal("MCP metadata must remain read-only") + } + } +} + +func TestSettingsRegistryCoversRequiredDomainsWithoutPendingFields(t *testing.T) { + registry, err := buildSettingsRegistry() + if err != nil { + t.Fatal(err) + } + + required := map[string]struct{}{ + "agent": {}, "agent_profile": {}, "agent_profile_mcp": {}, "user_settings": {}, + "workflow": {}, "workflow_step": {}, "workspace": {}, "repository": {}, + "repository_script": {}, "repository_set": {}, "executor": {}, "executor_profile": {}, + "environment": {}, "task": {}, "prompt": {}, "utility_agent": {}, "editor": {}, + "notification_provider": {}, "automation": {}, "automation_trigger": {}, + "runtime_flag": {}, "storage_maintenance": {}, + } + for _, domain := range registry.Domains() { + for _, field := range domain.Fields { + if field.Support == settingscatalog.SupportPending { + t.Fatalf("required field %q is still pending", field.Key) + } + } + delete(required, domain.ResourceType) + } + for resourceType := range required { + t.Errorf("required resource type %q is missing", resourceType) + } +} diff --git a/apps/backend/internal/backendapp/settings_resource_targets_test.go b/apps/backend/internal/backendapp/settings_resource_targets_test.go new file mode 100644 index 0000000000..f6d374c58a --- /dev/null +++ b/apps/backend/internal/backendapp/settings_resource_targets_test.go @@ -0,0 +1,57 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/settingscatalog" + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +type workspaceSettingsTaskStub struct { + settingsTaskService + workspace *taskmodels.Workspace +} + +func (s workspaceSettingsTaskStub) ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) { + return []*taskmodels.Workspace{s.workspace}, nil +} + +func (s workspaceSettingsTaskStub) GetWorkspace(context.Context, string) (*taskmodels.Workspace, error) { + return s.workspace, nil +} + +func TestListedWorkspaceTargetsRoundTripThroughRegistryAndRead(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + workspace := &taskmodels.Workspace{ID: "workspace-1", Name: "Workspace one"} + operations := &settingsOperations{ + registry: registry, + deps: settingsDomainDependencies{task: workspaceSettingsTaskStub{workspace: workspace}}, + } + + result, err := operations.listDomainSettingsResources(context.Background(), "workspace", nil, "", 20, "") + if err != nil { + t.Fatal(err) + } + body, ok := result.(map[string]any) + if !ok { + t.Fatalf("result = %#v, want object", result) + } + resources, ok := body["resources"].([]map[string]any) + if !ok || len(resources) != 1 { + t.Fatalf("resources = %#v, want one resource", body["resources"]) + } + target, ok := resources[0]["target"].(settingscatalog.ResourceTarget) + if !ok { + t.Fatalf("target = %#v, want settings target", resources[0]["target"]) + } + if err := registry.ValidateTarget(target); err != nil { + t.Fatalf("listed target failed registry validation: %v", err) + } + if _, err := operations.readDomainSettings(context.Background(), target, []string{"name"}); err != nil { + t.Fatalf("listed target failed read round trip: %v", err) + } +} diff --git a/apps/backend/internal/mcp/handlers/config_agent_handlers.go b/apps/backend/internal/mcp/handlers/config_agent_handlers.go index 54f460c609..20bf4bb370 100644 --- a/apps/backend/internal/mcp/handlers/config_agent_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_agent_handlers.go @@ -1,11 +1,14 @@ package handlers import ( + "bytes" "context" "encoding/json" "errors" + "io" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" ws "github.com/kandev/kandev/pkg/websocket" @@ -67,31 +70,66 @@ func (h *Handlers) handleListAgentProfiles(ctx context.Context, msg *ws.Message) } func (h *Handlers) handleCreateAgentProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - AgentID string `json:"agent_id"` - Name string `json:"name"` - Model string `json:"model"` - AutoApprove bool `json:"auto_approve"` + var envelope struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + Model *string `json:"model"` + AutoApprove *bool `json:"auto_approve"` + Settings map[string]json.RawMessage `json:"settings"` } - if err := json.Unmarshal(msg.Payload, &req); err != nil { + if err := json.Unmarshal(msg.Payload, &envelope); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.AgentID == "" { + if envelope.AgentID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_id is required", nil) } - if req.Name == "" { + if envelope.Name == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil) } - if req.Model == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "model is required", nil) + for key := range envelope.Settings { + if key == "agent_id" || key == "name" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "settings cannot override agent_id or name", map[string]interface{}{"field": key}) + } + } + settings := make(map[string]json.RawMessage, len(envelope.Settings)+3) + for key, value := range envelope.Settings { + settings[key] = value + } + if envelope.Model != nil { + if _, exists := settings["model"]; exists { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "model was supplied both at the top level and in settings", nil) + } + encoded, _ := json.Marshal(*envelope.Model) + settings["model"] = encoded + } + if envelope.AutoApprove != nil { + if _, exists := settings["auto_approve"]; exists { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "auto_approve was supplied both at the top level and in settings", nil) + } + encoded, _ := json.Marshal(*envelope.AutoApprove) + settings["auto_approve"] = encoded + } + encoded, err := json.Marshal(settings) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid settings payload", nil) + } + var req agentsettingsdto.ProfileCreateRequest + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid settings payload: "+err.Error(), nil) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid settings payload", nil) + } + req.AgentID = envelope.AgentID + req.Name = envelope.Name + if err := req.Validate(); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) } - profile, err := h.agentSettingsCtrl.CreateProfile(ctx, agentsettingscontroller.CreateProfileRequest{ - AgentID: req.AgentID, - Name: req.Name, - Model: req.Model, - AutoApprove: req.AutoApprove, - }) + profile, err := h.agentSettingsCtrl.CreateProfile(ctx, agentsettingscontroller.CreateProfileRequestFromDTO(req)) if err != nil { h.logger.Error("failed to create agent profile", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create agent profile: "+err.Error(), nil) @@ -129,27 +167,47 @@ func (h *Handlers) handleDeleteAgentProfile(ctx context.Context, msg *ws.Message } func (h *Handlers) handleUpdateAgentProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req struct { - ProfileID string `json:"profile_id"` - Name *string `json:"name"` - Model *string `json:"model"` - Mode *string `json:"mode"` - AutoApprove *bool `json:"auto_approve"` + var envelope struct { + ProfileID string `json:"profile_id"` + Settings map[string]json.RawMessage `json:"settings"` } - if err := json.Unmarshal(msg.Payload, &req); err != nil { + var raw map[string]json.RawMessage + if err := json.Unmarshal(msg.Payload, &raw); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } - if req.ProfileID == "" { + if err := json.Unmarshal(msg.Payload, &envelope); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if envelope.ProfileID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "profile_id is required", nil) } - - profile, err := h.agentSettingsCtrl.UpdateProfile(ctx, agentsettingscontroller.UpdateProfileRequest{ - ID: req.ProfileID, - Name: req.Name, - Model: req.Model, - Mode: req.Mode, - AutoApprove: req.AutoApprove, - }) + settings := make(map[string]json.RawMessage, len(raw)+len(envelope.Settings)) + for key, value := range raw { + if key == "profile_id" || key == "settings" { + continue + } + settings[key] = value + } + for key, value := range envelope.Settings { + if _, exists := settings[key]; exists { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "setting was supplied both at the top level and in settings", map[string]interface{}{"field": key}) + } + settings[key] = value + } + encodedID, _ := json.Marshal(envelope.ProfileID) + settings["id"] = encodedID + encoded, err := json.Marshal(settings) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid profile settings", nil) + } + var req agentsettingsdto.ProfileUpdateRequest + if err := json.Unmarshal(encoded, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid profile settings: "+err.Error(), nil) + } + if err := req.Validate(); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + profile, err := h.agentSettingsCtrl.UpdateProfile(ctx, agentsettingscontroller.UpdateProfileRequestFromDTO(req)) if err != nil { h.logger.Error("failed to update agent profile", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update agent profile", nil) diff --git a/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go b/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go index f3de6196d0..b05c4b35a8 100644 --- a/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go @@ -70,6 +70,26 @@ func TestHandleCreateAgentProfile_PersistsAutoApprove(t *testing.T) { require.True(t, stored.AutoApprove) } +func TestHandleCreateAgentProfile_RejectsUnknownSettings(t *testing.T) { + h, repo, _ := newAgentSettingsHandlers(t) + ctx := context.Background() + agent := &settingsmodels.Agent{Name: "codex-acp"} + require.NoError(t, repo.CreateAgent(ctx, agent)) + + msg := makeWSMessage(t, ws.ActionMCPCreateAgentProfile, map[string]interface{}{ + "agent_id": agent.ID, + "name": "Unknown setting", + "settings": map[string]interface{}{"typo_model": "gpt-5"}, + }) + resp, err := h.handleCreateAgentProfile(ctx, msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeBadRequest) + + profiles, err := repo.ListAgentProfiles(ctx, agent.ID) + require.NoError(t, err) + require.Empty(t, profiles) +} + func TestHandleUpdateAgentProfile_AppliesExplicitAutoApprove(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/mcp/handlers/config_mcp_handlers.go b/apps/backend/internal/mcp/handlers/config_mcp_handlers.go index d3385b10b8..50ed6eea04 100644 --- a/apps/backend/internal/mcp/handlers/config_mcp_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_mcp_handlers.go @@ -39,24 +39,49 @@ func (h *Handlers) handleUpdateMcpConfig(ctx context.Context, msg *ws.Message) ( return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "profile_id is required", nil) } - // Get existing config to merge with - existing, err := h.mcpConfigSvc.GetConfigByProfileID(ctx, req.ProfileID) - if err != nil { - h.logger.Error("failed to get existing MCP config", zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get existing MCP config", nil) - } - - if req.Enabled != nil { - existing.Enabled = *req.Enabled - } - if req.Servers != nil { - existing.Servers = req.Servers - } - - updated, err := h.mcpConfigSvc.UpsertConfigByProfileID(ctx, req.ProfileID, existing) + updated, err := h.mcpConfigSvc.PatchConfigByProfileID(ctx, req.ProfileID, mcpconfig.ConfigPatch{ + Enabled: req.Enabled, + Servers: serverDefsPointer(req.Servers), + }) if err != nil { h.logger.Error("failed to update MCP config", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update MCP config", nil) } + h.broadcastProfileMCPConfigUpdated(req.ProfileID, updated.WorkspaceID) return ws.NewResponse(msg.ID, msg.Action, updated) } + +func serverDefsPointer(value map[string]mcpconfig.ServerDef) *map[string]mcpconfig.ServerDef { + if value == nil { + return nil + } + return &value +} + +func (h *Handlers) broadcastProfileMCPConfigUpdated(profileID, workspaceID string) { + if h.settingsBroadcaster == nil || profileID == "" { + return + } + var scopedWorkspaceID any + if workspaceID != "" { + scopedWorkspaceID = workspaceID + } + notification, err := ws.NewNotification(ws.ActionAgentProfileMCPConfigUpdated, map[string]any{ + "profile_id": profileID, + "workspace_id": scopedWorkspaceID, + }) + if err != nil { + return + } + if workspaceID != "" { + workspaceHub, ok := h.settingsBroadcaster.(interface { + BroadcastToWorkspaceOrDrop(string, *ws.Message) + }) + if ok { + workspaceHub.BroadcastToWorkspaceOrDrop(workspaceID, notification) + } + return + } + //ws:global profile MCP updates without workspace ownership are global. + h.settingsBroadcaster.Broadcast(notification) +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 0d82e0afb0..e0aa846b64 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -27,6 +27,7 @@ import ( "github.com/kandev/kandev/internal/plugins" promptmodels "github.com/kandev/kandev/internal/prompts/models" promptservice "github.com/kandev/kandev/internal/prompts/service" + "github.com/kandev/kandev/internal/settingscatalog" "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/dto" @@ -272,12 +273,17 @@ type Handlers struct { promptResolver PromptReferenceResolver promptReader PromptReader userSettingsProvider UserSettingsProvider + settingsRegistry *settingscatalog.Registry + settingsOperations SettingsOperations logger *logger.Logger // Config-mode dependencies (optional, set via SetConfigDeps) - workflowSvc *workflowsvc.Service - agentSettingsCtrl *agentsettingscontroller.Controller - mcpConfigSvc *mcpconfig.Service + workflowSvc *workflowsvc.Service + agentSettingsCtrl *agentsettingscontroller.Controller + mcpConfigSvc *mcpconfig.Service + settingsBroadcaster interface { + Broadcast(*ws.Message) + } // Cross-task handoff service (optional, set via SetHandoffService). // Wires the list_related_tasks_kandev / *_task_document_kandev @@ -419,6 +425,12 @@ func (h *Handlers) SetConfigDeps( h.mcpConfigSvc = mcpConfigSvc } +// SetSettingsBroadcaster wires the notification path used by settings writes +// that originate in legacy MCP configuration tools. +func (h *Handlers) SetSettingsBroadcaster(broadcaster interface{ Broadcast(*ws.Message) }) { + h.settingsBroadcaster = broadcaster +} + // SetPluginService wires the plugin agent-tool catalog and invocation bridge. func (h *Handlers) SetPluginService(svc *plugins.Service) { h.pluginSvc = svc @@ -515,6 +527,9 @@ func (h *Handlers) registerTaskQuestionHandlers(d *guardedMCPDispatcher) { } func (h *Handlers) registerConfigModeHandlers(d *guardedMCPDispatcher) { + if h.settingsRegistry != nil { + h.registerSettingsHandlers(d) + } if h.promptReader != nil { h.registerPromptHandlers(d) } diff --git a/apps/backend/internal/mcp/handlers/settings_handlers.go b/apps/backend/internal/mcp/handlers/settings_handlers.go new file mode 100644 index 0000000000..acd330e057 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/settings_handlers.go @@ -0,0 +1,514 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + + "github.com/kandev/kandev/internal/settingscatalog" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +const ( + maxSettingsPayloadBytes = 256 * 1024 + jsonNull = "null" + settingsReadOperation = "read" +) + +// SettingsOperations is the domain-owned execution boundary for compact +// settings reads, updates, and target lookup. The generic handler validates +// metadata and limits, while implementations retain authorization, reference +// checks, persistence atomicity, and event publication. +type SettingsOperations interface { + ReadSettings(context.Context, settingscatalog.ResourceTarget, []string) (any, error) + UpdateSettings(context.Context, settingscatalog.ResourceTarget, map[string]json.RawMessage, map[string]json.RawMessage) (any, error) + ListSettingsResources(context.Context, string, *string, string, int, string) (any, error) +} + +// SettingDescriptionRequest carries the authorized target and contextual +// inputs needed by a domain-owned schema resolver. +type SettingDescriptionRequest struct { + Key string + Target *settingscatalog.ResourceTarget + Context map[string]any + Operation string + IncludeResourceSchema bool +} + +// SettingDescription contains executable schema and choice data. Domain +// adapters may fill the dynamic portions after validating the target. +type SettingDescription struct { + Schema map[string]any + ResourceSchema map[string]any + Choices []map[string]any +} + +// SettingsDescriber is optional for simple adapters. When present, the +// generic handler delegates target-aware schemas and contextual choices to it. +type SettingsDescriber interface { + DescribeSetting(context.Context, SettingDescriptionRequest) (SettingDescription, error) +} + +type settingsUpdateRequest struct { + Target settingscatalog.ResourceTarget `json:"target"` + Changes map[string]json.RawMessage `json:"changes"` + Options map[string]json.RawMessage `json:"options,omitempty"` +} + +type settingsReadRequest struct { + Target settingscatalog.ResourceTarget `json:"target"` + Keys []string `json:"keys"` +} + +type settingsDescribeRequest struct { + Key string `json:"key"` + Target *settingscatalog.ResourceTarget `json:"target,omitempty"` + Context map[string]any `json:"context,omitempty"` + Operation string `json:"operation,omitempty"` + IncludeResourceSchema bool `json:"include_resource_schema,omitempty"` +} + +type settingsSearchRequest struct { + Query string `json:"query,omitempty"` + Domain string `json:"domain,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Limit int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` +} + +type settingsResourceListRequest struct { + ResourceType string `json:"resource_type"` + WorkspaceID *string `json:"workspace_id,omitempty"` + Query string `json:"query,omitempty"` + Limit int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` +} + +type settingsRequestError struct { + Code string + Key string + FieldPath string + ExpectedType string + Cause error +} + +func (e *settingsRequestError) Error() string { + if e.Cause != nil { + return e.Cause.Error() + } + return e.Code +} + +func decodeSettingsUpdate(payload []byte) (settingsUpdateRequest, error) { + if len(payload) > maxSettingsPayloadBytes { + return settingsUpdateRequest{}, fmt.Errorf("settings update exceeds %d bytes", maxSettingsPayloadBytes) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(payload, &raw); err != nil { + return settingsUpdateRequest{}, fmt.Errorf("invalid settings update: %w", err) + } + for key := range raw { + if key != "target" && key != "changes" && key != "options" { + return settingsUpdateRequest{}, fmt.Errorf("unknown settings update field %q", key) + } + } + var request settingsUpdateRequest + if err := json.Unmarshal(payload, &request); err != nil { + return settingsUpdateRequest{}, fmt.Errorf("invalid settings update: %w", err) + } + if len(request.Changes) == 0 { + return settingsUpdateRequest{}, fmt.Errorf("changes must contain at least one setting") + } + if len(request.Changes) > settingscatalog.MaxChangedFields { + return settingsUpdateRequest{}, fmt.Errorf("changes cannot contain more than %d settings", settingscatalog.MaxChangedFields) + } + return request, nil +} + +func decodeSettingsRead(payload []byte) (settingsReadRequest, error) { + var request settingsReadRequest + if err := json.Unmarshal(payload, &request); err != nil { + return settingsReadRequest{}, fmt.Errorf("invalid settings read: %w", err) + } + if len(request.Keys) == 0 || len(request.Keys) > settingscatalog.MaxChangedFields { + return settingsReadRequest{}, fmt.Errorf("keys must contain between 1 and %d settings", settingscatalog.MaxChangedFields) + } + return request, nil +} + +func validateSettingsPatch(registry *settingscatalog.Registry, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) error { + if err := registry.ValidateTarget(target); err != nil { + return &settingsRequestError{Code: "invalid_target", Cause: err} + } + if len(changes) == 0 || len(changes) > settingscatalog.MaxChangedFields { + return &settingsRequestError{Code: "invalid_changes", Cause: fmt.Errorf("changes must contain between 1 and %d settings", settingscatalog.MaxChangedFields)} + } + for key, value := range changes { + field, _, ok := findTargetField(registry, target.ResourceType, key) + if !ok { + return &settingsRequestError{Code: "unknown_setting", Key: key, Cause: fmt.Errorf("unknown setting %q", key)} + } + field = settingscatalog.FieldForTarget(field, &target) + if !field.Writable || field.Support != settingscatalog.SupportSupported { + return &settingsRequestError{Code: "setting_not_writable", Key: field.Key, FieldPath: field.FieldPath, Cause: fmt.Errorf("setting %q is not writable", key)} + } + if err := validateJSONType(value, field.JSONType, field.Nullable); err != nil { + return &settingsRequestError{Code: "invalid_value_type", Key: field.Key, FieldPath: field.FieldPath, ExpectedType: field.JSONType, Cause: err} + } + } + return nil +} + +func findTargetField(registry *settingscatalog.Registry, resourceType, key string) (settingscatalog.FieldDescriptor, settingscatalog.DomainDescriptor, bool) { + domain, ok := registry.Domain(resourceType) + if !ok { + return settingscatalog.FieldDescriptor{}, settingscatalog.DomainDescriptor{}, false + } + for _, field := range domain.Fields { + if field.Key == key || field.FieldPath == key { + return field, domain, true + } + } + return settingscatalog.FieldDescriptor{}, settingscatalog.DomainDescriptor{}, false +} + +func validateJSONType(raw json.RawMessage, expected string, nullable bool) error { + if string(raw) == jsonNull { + if nullable { + return nil + } + return errors.New("null is not accepted by this setting contract") + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("value is not valid JSON: %w", err) + } + valid := false + switch expected { + case "string": + _, valid = value.(string) + case "boolean": + _, valid = value.(bool) + case "number": + _, valid = value.(float64) + case "integer": + if number, ok := value.(float64); ok { + valid = math.Trunc(number) == number + } + case "object": + _, valid = value.(map[string]any) + case "array": + _, valid = value.([]any) + default: + valid = true + } + if !valid { + return fmt.Errorf("expected %s", expected) + } + return nil +} + +func (h *Handlers) SetSettingsCatalog(registry *settingscatalog.Registry) { + h.settingsRegistry = registry +} + +func (h *Handlers) SetSettingsOperations(operations SettingsOperations) { + h.settingsOperations = operations +} + +func (h *Handlers) registerSettingsHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPSearchSettings, h.handleSearchSettings) + d.RegisterFunc(ws.ActionMCPDescribeSetting, h.handleDescribeSetting) + d.RegisterFunc(ws.ActionMCPGetSettings, h.handleGetSettings) + d.RegisterFunc(ws.ActionMCPUpdateSettings, h.handleUpdateSettings) + d.RegisterFunc(ws.ActionMCPListSettingsResources, h.handleListSettingsResources) +} + +func (h *Handlers) handleSearchSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.settingsRegistry == nil { + return h.settingsUnavailable(msg) + } + var request settingsSearchRequest + if err := json.Unmarshal(msg.Payload, &request); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid settings search", nil) + } + result, err := h.settingsRegistry.Search(settingscatalog.SearchRequest{ + Query: request.Query, Domain: request.Domain, ResourceType: request.ResourceType, Limit: request.Limit, Cursor: request.Cursor, + }) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, result) +} + +func (h *Handlers) handleDescribeSetting(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.settingsRegistry == nil { + return h.settingsUnavailable(msg) + } + var request settingsDescribeRequest + if err := json.Unmarshal(msg.Payload, &request); err != nil || strings.TrimSpace(request.Key) == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "key is required", nil) + } + field, domain, ok := h.settingsRegistry.Find(request.Key) + if !ok { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "setting was not found", map[string]interface{}{"code": "setting_not_found", "key": request.Key}) + } + if err := validateDescriptionTarget(h.settingsRegistry, domain, request.Target); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + operation := descriptionOperation(domain, request.Operation) + if !supportedSettingsOperation(domain, operation) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "unsupported settings operation", map[string]interface{}{"operation": operation}) + } + field = settingscatalog.FieldForTarget(field, request.Target) + description, err := h.resolveSettingDescription(ctx, request, operation, field, domain) + if err != nil { + return h.settingsOperationError(msg, err) + } + return ws.NewResponse(msg.ID, msg.Action, describeSettingResponse(field, domain, operation, request.IncludeResourceSchema, description)) +} + +func validateDescriptionTarget( + registry *settingscatalog.Registry, + domain settingscatalog.DomainDescriptor, + target *settingscatalog.ResourceTarget, +) error { + if target == nil { + return nil + } + if err := registry.ValidateTarget(*target); err != nil { + return err + } + if target.ResourceType != domain.ResourceType { + return errors.New("target resource_type does not match setting") + } + return nil +} + +func descriptionOperation(domain settingscatalog.DomainDescriptor, requested string) string { + if requested != "" { + return requested + } + if len(domain.Operations) == 0 { + return settingsReadOperation + } + return "update" +} + +func (h *Handlers) resolveSettingDescription( + ctx context.Context, + request settingsDescribeRequest, + operation string, + field settingscatalog.FieldDescriptor, + domain settingscatalog.DomainDescriptor, +) (SettingDescription, error) { + description := SettingDescription{Schema: fieldSchema(field)} + if request.IncludeResourceSchema { + description.ResourceSchema = resourceSchema(domain, request.Target) + } + describer, ok := h.settingsOperations.(SettingsDescriber) + if !ok { + if len(request.Context) > 0 { + return SettingDescription{}, errors.New("setting context is not supported for this setting") + } + return description, nil + } + resolved, err := describer.DescribeSetting(ctx, SettingDescriptionRequest{ + Key: request.Key, Target: request.Target, Context: request.Context, + Operation: operation, IncludeResourceSchema: request.IncludeResourceSchema, + }) + if err != nil { + return SettingDescription{}, err + } + if resolved.Schema != nil { + description.Schema = resolved.Schema + } + if resolved.ResourceSchema != nil { + description.ResourceSchema = resolved.ResourceSchema + } + description.Choices = resolved.Choices + return description, nil +} + +func describeSettingResponse( + field settingscatalog.FieldDescriptor, + domain settingscatalog.DomainDescriptor, + operation string, + includeResourceSchema bool, + description SettingDescription, +) map[string]interface{} { + response := map[string]interface{}{ + "key": field.Key, "field_path": field.FieldPath, "domain": domain.Domain, "resource_type": domain.ResourceType, + "support": field.Support, "classification": field.Classification, "description": field.Description, + "schema": description.Schema, "target": domain.Target, "operations": domain.Operations, + "operation": operation, "settings_href": field.SettingsHref, "dependencies": field.Dependencies, + "default_behavior": field.DefaultBehavior, "change_timing": field.ChangeTiming, "sensitive": field.Sensitive, + "writable": field.Writable, "include_resource_schema": includeResourceSchema, + } + if includeResourceSchema { + response["resource_schema"] = description.ResourceSchema + } + if len(description.Choices) > 0 { + response["choices"] = description.Choices + } + return response +} + +func fieldSchema(field settingscatalog.FieldDescriptor) map[string]interface{} { + schema := make(map[string]interface{}, len(field.Schema)+4) + for key, value := range field.Schema { + schema[key] = value + } + if _, exists := schema["type"]; !exists { + schema["type"] = field.JSONType + } + schema["nullable"] = field.Nullable + schema["replacement"] = field.Replacement + schema["writable"] = field.Writable + return schema +} + +func supportedSettingsOperation(domain settingscatalog.DomainDescriptor, operation string) bool { + for _, candidate := range domain.Operations { + if candidate.Name == operation { + return true + } + } + return operation == settingsReadOperation && len(domain.Operations) == 0 +} + +func resourceSchema(domain settingscatalog.DomainDescriptor, target *settingscatalog.ResourceTarget) map[string]any { + root := map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{}} + properties := root["properties"].(map[string]any) + for _, field := range domain.Fields { + field = settingscatalog.FieldForTarget(field, target) + setSchemaPath(properties, field.FieldPath, fieldSchema(field)) + } + return root +} + +func setSchemaPath(properties map[string]any, path string, schema map[string]interface{}) { + parts := strings.Split(path, ".") + current := properties + for index, part := range parts { + if index == len(parts)-1 { + current[part] = schema + return + } + nested, ok := current[part].(map[string]any) + if !ok { + nested = map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{}} + current[part] = nested + } + current = nested["properties"].(map[string]any) + } +} + +func (h *Handlers) handleGetSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.settingsRegistry == nil || h.settingsOperations == nil { + return h.settingsUnavailable(msg) + } + request, err := decodeSettingsRead(msg.Payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + if err := h.settingsRegistry.ValidateTarget(request.Target); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + for _, key := range request.Keys { + _, _, ok := findTargetField(h.settingsRegistry, request.Target.ResourceType, key) + if !ok { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "setting was not found", map[string]interface{}{"code": "setting_not_found", "key": key}) + } + } + result, err := h.settingsOperations.ReadSettings(ctx, request.Target, request.Keys) + if err != nil { + return h.settingsOperationError(msg, err) + } + return ws.NewResponse(msg.ID, msg.Action, result) +} + +func (h *Handlers) handleUpdateSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.settingsRegistry == nil || h.settingsOperations == nil { + return h.settingsUnavailable(msg) + } + request, err := decodeSettingsUpdate(msg.Payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + if err := validateSettingsPatch(h.settingsRegistry, request.Target, request.Changes); err != nil { + return h.settingsOperationError(msg, err) + } + result, err := h.settingsOperations.UpdateSettings(ctx, request.Target, request.Changes, request.Options) + if err != nil { + return h.settingsOperationError(msg, err) + } + return ws.NewResponse(msg.ID, msg.Action, result) +} + +func (h *Handlers) handleListSettingsResources(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if h.settingsRegistry == nil || h.settingsOperations == nil { + return h.settingsUnavailable(msg) + } + var request settingsResourceListRequest + if err := json.Unmarshal(msg.Payload, &request); err != nil || strings.TrimSpace(request.ResourceType) == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "resource_type is required", nil) + } + domain, ok := h.settingsRegistry.Domain(request.ResourceType) + if !ok { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "resource type was not found", nil) + } + if err := validateResourceListWorkspace(domain, request.WorkspaceID); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + limit := request.Limit + if limit == 0 { + limit = settingscatalog.DefaultSearchLimit + } + if limit < 1 || limit > settingscatalog.MaxSearchResults { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "limit must be between 1 and 50", nil) + } + result, err := h.settingsOperations.ListSettingsResources(ctx, request.ResourceType, request.WorkspaceID, request.Query, limit, request.Cursor) + if err != nil { + return h.settingsOperationError(msg, err) + } + return ws.NewResponse(msg.ID, msg.Action, result) +} + +func validateResourceListWorkspace(domain settingscatalog.DomainDescriptor, workspaceID *string) error { + if domain.Target.RequiresWorkspaceID && (workspaceID == nil || strings.TrimSpace(*workspaceID) == "") { + return errors.New("workspace_id is required for resource lookup") + } + if !domain.Target.AllowsWorkspaceID && !domain.Target.RequiresWorkspaceID && workspaceID != nil { + return errors.New("resource type does not accept workspace_id") + } + return nil +} + +func (h *Handlers) settingsUnavailable(msg *ws.Message) (*ws.Message, error) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnavailable, "settings service is unavailable", map[string]interface{}{"code": "settings_unavailable"}) +} + +func (h *Handlers) settingsOperationError(msg *ws.Message, err error) (*ws.Message, error) { + var requestErr *settingsRequestError + if errors.As(err, &requestErr) { + details := map[string]interface{}{"code": requestErr.Code} + if requestErr.Key != "" { + details["key"] = requestErr.Key + } + if requestErr.FieldPath != "" { + details["field_path"] = requestErr.FieldPath + } + if requestErr.ExpectedType != "" { + details["expected_type"] = requestErr.ExpectedType + } + details["suggested_tool"] = "describe_setting_kandev" + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, requestErr.Error(), details) + } + h.logger.Error("settings operation failed", zap.Error(err), zap.String("action", msg.Action)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "settings operation failed", nil) +} diff --git a/apps/backend/internal/mcp/handlers/settings_handlers_test.go b/apps/backend/internal/mcp/handlers/settings_handlers_test.go new file mode 100644 index 0000000000..d8400235d2 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/settings_handlers_test.go @@ -0,0 +1,356 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/settingscatalog" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestDecodeSettingsUpdatePreservesExplicitEmptyValues(t *testing.T) { + request, err := decodeSettingsUpdate([]byte(`{"target":{"resource_type":"agent_profile","resource_id":"profile-1"},"changes":{"auto_approve":false,"config_options":{},"env_vars":[]}}`)) + if err != nil { + t.Fatal(err) + } + if request.Changes["auto_approve"] == nil || string(request.Changes["auto_approve"]) != "false" { + t.Fatalf("auto_approve = %s, want false", request.Changes["auto_approve"]) + } + if string(request.Changes["config_options"]) != "{}" || string(request.Changes["env_vars"]) != "[]" { + t.Fatalf("explicit empty values were not preserved: %#v", request.Changes) + } +} + +func TestValidateSettingsPatchRejectsUnknownAndComputedFields(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + target := settingscatalog.ResourceTarget{ResourceType: "agent_profile", ResourceID: stringPtr("profile-1")} + if err := validateSettingsPatch(registry, target, map[string]json.RawMessage{"missing": json.RawMessage(`true`)}); err == nil { + t.Fatal("unknown setting was accepted") + } + if err := validateSettingsPatch(registry, settingscatalog.ResourceTarget{ResourceType: "agent_profile_mcp", ResourceID: stringPtr("profile-1")}, map[string]json.RawMessage{"meta": json.RawMessage(`{}`)}); err == nil { + t.Fatal("computed setting was accepted") + } +} + +func TestValidateSettingsPatchRejectsFractionalIntegers(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + target := settingscatalog.ResourceTarget{ResourceType: "user_settings"} + if err := validateSettingsPatch(registry, target, map[string]json.RawMessage{ + "terminal_font_size": json.RawMessage(`13.5`), + }); err == nil { + t.Fatal("fractional integer was accepted") + } +} + +func TestDescribeSettingReturnsClosedNestedSchemas(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + handlers := &Handlers{settingsRegistry: registry} + + tests := []struct { + name string + key string + target *settingscatalog.ResourceTarget + property string + itemKey string + itemType string + mapValue bool + }{ + { + name: "keyboard shortcuts", + key: "user_settings.keyboard_shortcuts", + property: "key", + itemKey: "key", + itemType: "string", + mapValue: true, + }, + { + name: "workflow events", + key: "workflow_step.events", + target: &settingscatalog.ResourceTarget{ResourceType: "workflow_step", ResourceID: stringPtr("step-1"), WorkspaceID: stringPtr("workspace-1")}, + property: "on_enter", + itemKey: "type", + itemType: "string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := map[string]any{"key": tt.key, "include_resource_schema": true} + if tt.target != nil { + payload["target"] = tt.target + } + response, err := handlers.handleDescribeSetting(context.Background(), makeWSMessage(t, ws.ActionMCPDescribeSetting, payload)) + if err != nil { + t.Fatal(err) + } + if response.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %q, want response", response.Type) + } + var body map[string]any + if err := json.Unmarshal(response.Payload, &body); err != nil { + t.Fatal(err) + } + schema, ok := body["schema"].(map[string]any) + if !ok { + t.Fatalf("schema = %#v, want object", body["schema"]) + } + properties, ok := schema["properties"].(map[string]any) + if tt.mapValue { + valueSchema, valueOK := schema["additionalProperties"].(map[string]any) + if !valueOK { + t.Fatalf("map value schema = %#v, want object schema", schema["additionalProperties"]) + } + valueProperties, valueOK := valueSchema["properties"].(map[string]any) + if !valueOK { + t.Fatalf("map value properties = %#v, want nested properties", valueSchema["properties"]) + } + item, itemOK := valueProperties[tt.itemKey].(map[string]any) + if !itemOK || item["type"] != tt.itemType { + t.Fatalf("map value property %q = %#v, want type %q", tt.itemKey, valueProperties[tt.itemKey], tt.itemType) + } + if valueSchema["additionalProperties"] != false { + t.Fatalf("map value additionalProperties = %#v, want false", valueSchema["additionalProperties"]) + } + return + } + if !ok { + t.Fatalf("schema properties = %#v, want nested properties", schema["properties"]) + } + property, ok := properties[tt.property].(map[string]any) + if !ok { + t.Fatalf("property %q = %#v", tt.property, properties[tt.property]) + } + items, ok := property["items"].(map[string]any) + if !ok { + t.Fatalf("property %q items = %#v", tt.property, property["items"]) + } + itemProperties, ok := items["properties"].(map[string]any) + if !ok { + t.Fatalf("property %q item properties = %#v", tt.property, items["properties"]) + } + item, ok := itemProperties[tt.itemKey].(map[string]any) + if !ok || item["type"] != tt.itemType { + t.Fatalf("property %q item %q = %#v, want type %q", tt.property, tt.itemKey, itemProperties[tt.itemKey], tt.itemType) + } + if !tt.mapValue && schema["additionalProperties"] != false { + t.Fatalf("schema additionalProperties = %#v, want false", schema["additionalProperties"]) + } + }) + } +} + +func TestDescribeSettingRejectsUnsupportedOperationAndClassifiesAuthFlag(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + handlers := &Handlers{settingsRegistry: registry} + + unsupported, err := handlers.handleDescribeSetting(context.Background(), makeWSMessage(t, ws.ActionMCPDescribeSetting, map[string]any{ + "key": "user_settings.keyboard_shortcuts", "operation": "create", + })) + if err != nil { + t.Fatal(err) + } + if unsupported.Type != ws.MessageTypeError { + t.Fatalf("unsupported operation response type = %q, want error", unsupported.Type) + } + + authTarget := settingscatalog.ResourceTarget{ResourceType: "runtime_flag", ResourceID: stringPtr("features.auth")} + authResponse, err := handlers.handleDescribeSetting(context.Background(), makeWSMessage(t, ws.ActionMCPDescribeSetting, map[string]any{ + "key": "runtime_flag.override", "target": authTarget, + })) + if err != nil { + t.Fatal(err) + } + if authResponse.Type != ws.MessageTypeResponse { + t.Fatalf("auth response type = %q, want response", authResponse.Type) + } + var body map[string]any + if err := json.Unmarshal(authResponse.Payload, &body); err != nil { + t.Fatal(err) + } + if body["support"] != string(settingscatalog.SupportException) || body["classification"] != string(settingscatalog.ClassificationException) { + t.Fatalf("auth classification = support %v classification %v, want interactive exception", body["support"], body["classification"]) + } + if body["writable"] != false { + t.Fatalf("auth writable = %#v, want false", body["writable"]) + } +} + +func TestDescribeSettingIncludesOperationOptionSchemas(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + handlers := &Handlers{settingsRegistry: registry} + response, err := handlers.handleDescribeSetting(context.Background(), makeWSMessage(t, ws.ActionMCPDescribeSetting, map[string]any{ + "key": "storage_maintenance.enabled", + })) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(response.Payload, &body); err != nil { + t.Fatal(err) + } + operations, ok := body["operations"].([]any) + if !ok || len(operations) != 1 { + t.Fatalf("operations = %#v, want one operation", body["operations"]) + } + operation, ok := operations[0].(map[string]any) + if !ok { + t.Fatalf("operation = %#v, want object", operations[0]) + } + options, ok := operation["options"].(map[string]any) + if !ok || options["additionalProperties"] != false { + t.Fatalf("operation options = %#v, want closed schema", operation["options"]) + } + properties, ok := options["properties"].(map[string]any) + if !ok { + t.Fatalf("operation option properties = %#v, want object", options["properties"]) + } + for _, key := range []string{"confirm_dedicated_docker", "adopt_go_cache"} { + if _, exists := properties[key]; !exists { + t.Fatalf("operation option %q is missing from %#v", key, properties) + } + } +} + +func TestDescribeReadOnlySettingDefaultsToReadOperation(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + handlers := &Handlers{settingsRegistry: registry} + response, err := handlers.handleDescribeSetting(context.Background(), makeWSMessage(t, ws.ActionMCPDescribeSetting, map[string]any{ + "key": "gitlab_settings.host", + })) + if err != nil { + t.Fatal(err) + } + if response.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %q, want response", response.Type) + } + var body map[string]any + if err := json.Unmarshal(response.Payload, &body); err != nil { + t.Fatal(err) + } + if body["operation"] != "read" { + t.Fatalf("operation = %#v, want read", body["operation"]) + } +} + +type contextualSettingsDescriptionStub struct { + settingsResourceListStub +} + +func (contextualSettingsDescriptionStub) DescribeSetting(_ context.Context, request SettingDescriptionRequest) (SettingDescription, error) { + choice := "provider-default" + if request.Target != nil && request.Target.ResourceID != nil { + choice = *request.Target.ResourceID + } + return SettingDescription{ + Schema: map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + Choices: []map[string]any{{"kind": "model", "id": choice, "label": choice}}, + }, nil +} + +func TestDescribeSettingResolvesTargetContextChoicesThroughHandler(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + handlers := &Handlers{settingsRegistry: registry, settingsOperations: contextualSettingsDescriptionStub{}} + for _, profileID := range []string{"profile-codex", "profile-claude"} { + response, err := handlers.handleDescribeSetting(context.Background(), makeWSMessage(t, ws.ActionMCPDescribeSetting, map[string]any{ + "key": "agent_profile.config_options", + "target": map[string]any{"resource_type": "agent_profile", "resource_id": profileID}, + "context": map[string]any{"model": "selected-model"}, + "include_resource_schema": true, + })) + if err != nil { + t.Fatal(err) + } + if response.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %q, want response", response.Type) + } + var body map[string]any + if err := json.Unmarshal(response.Payload, &body); err != nil { + t.Fatal(err) + } + if _, ok := body["resource_schema"]; !ok { + t.Fatalf("resource_schema missing from %#v", body) + } + if _, ok := body["context"]; ok { + t.Fatalf("request context was echoed instead of resolved: %#v", body["context"]) + } + choices, ok := body["choices"].([]any) + if !ok || len(choices) != 1 { + t.Fatalf("choices = %#v, want one contextual choice", body["choices"]) + } + choice := choices[0].(map[string]any) + if choice["id"] != profileID { + t.Fatalf("choice id = %#v, want %q", choice["id"], profileID) + } + } +} + +type settingsResourceListStub struct{} + +func (settingsResourceListStub) ReadSettings(context.Context, settingscatalog.ResourceTarget, []string) (any, error) { + return map[string]any{}, nil +} + +func (settingsResourceListStub) UpdateSettings(context.Context, settingscatalog.ResourceTarget, map[string]json.RawMessage, map[string]json.RawMessage) (any, error) { + return map[string]any{}, nil +} + +func (settingsResourceListStub) ListSettingsResources(context.Context, string, *string, string, int, string) (any, error) { + return map[string]any{"resources": []any{}, "total": 0}, nil +} + +func TestListSettingsResourcesHonorsRequiredOptionalAndProhibitedWorkspaceSelectors(t *testing.T) { + registry, err := settingscatalog.DefaultRegistry() + if err != nil { + t.Fatal(err) + } + handlers := &Handlers{settingsRegistry: registry, settingsOperations: settingsResourceListStub{}} + workspaceID := "workspace-1" + + tests := []struct { + name string + resource string + workspace *string + wantType ws.MessageType + }{ + {name: "required workspace", resource: "jira_settings", workspace: &workspaceID, wantType: ws.MessageTypeResponse}, + {name: "optional workspace", resource: "workflow", workspace: &workspaceID, wantType: ws.MessageTypeResponse}, + {name: "prohibited workspace", resource: "workspace", workspace: &workspaceID, wantType: ws.MessageTypeError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response, err := handlers.handleListSettingsResources(context.Background(), makeWSMessage(t, ws.ActionMCPListSettingsResources, map[string]any{ + "resource_type": tt.resource, "workspace_id": tt.workspace, + })) + if err != nil { + t.Fatal(err) + } + if response.Type != tt.wantType { + t.Fatalf("response type = %q, want %q", response.Type, tt.wantType) + } + }) + } +} + +func stringPtr(value string) *string { return &value } diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index eb733377b1..3ef7d4346b 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -176,8 +176,9 @@ func (s *Server) registerConfigAgentTools() { mcp.WithDescription("Create a new agent profile for an agent."), mcp.WithString("agent_id", mcp.Required(), mcp.Description("The agent ID to create a profile for")), mcp.WithString("name", mcp.Required(), mcp.Description("Profile name")), - mcp.WithString("model", mcp.Required(), mcp.Description("Model name (e.g. 'claude-sonnet-4-5-20250514')")), + mcp.WithString("model", mcp.Description("Optional model name. Omit to use the agent default.")), mcp.WithBoolean("auto_approve", mcp.Description("Auto-approve permissions (default: false)")), + mcp.WithObject("settings", mcp.Description("Optional complete profile settings object. Use describe_setting_kandev for the current schema.")), ), s.wrapHandler("create_agent_profile_kandev", s.createAgentProfileHandler()), ) @@ -546,18 +547,19 @@ func (s *Server) createAgentProfileHandler() server.ToolHandlerFunc { if err != nil { return mcp.NewToolResultError("name is required"), nil } - model, err := req.RequireString("model") - if err != nil { - return mcp.NewToolResultError("model is required"), nil - } payload := map[string]interface{}{ "agent_id": agentID, "name": name, - "model": model, + } + if model := req.GetString("model", ""); model != "" { + payload["model"] = model } if args := req.GetArguments(); args["auto_approve"] != nil { payload["auto_approve"] = args["auto_approve"] } + if args := req.GetArguments(); args["settings"] != nil { + payload["settings"] = args["settings"] + } return s.forwardToBackend(ctx, ws.ActionMCPCreateAgentProfile, payload) } } diff --git a/apps/backend/internal/mcp/server/config_handlers_test.go b/apps/backend/internal/mcp/server/config_handlers_test.go index 172b483a57..cc4c195ac5 100644 --- a/apps/backend/internal/mcp/server/config_handlers_test.go +++ b/apps/backend/internal/mcp/server/config_handlers_test.go @@ -493,8 +493,8 @@ func TestCreateAgentProfileHandler_MissingAgentID(t *testing.T) { assert.True(t, result.IsError) } -func TestCreateAgentProfileHandler_MissingModel(t *testing.T) { - backend := &testBackend{} +func TestCreateAgentProfileHandler_AllowsAgentDefaultModel(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"id": "profile-1"}} s := newTestServer(t, backend) result := callTool(t, s, "create_agent_profile_kandev", map[string]interface{}{ @@ -502,7 +502,8 @@ func TestCreateAgentProfileHandler_MissingModel(t *testing.T) { "name": "My Profile", }) - assert.True(t, result.IsError) + assert.False(t, result.IsError) + assert.Equal(t, ws.ActionMCPCreateAgentProfile, backend.lastAction) } func TestUpdateAgentHandler_Success(t *testing.T) { diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 360bb0eb7c..472ed2ccb0 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -1078,6 +1078,7 @@ func (s *Server) profileToolGroups() []profileToolGroup { {name: "configuration-prompts", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigPromptTools() }}, {name: "configuration-executors", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigExecutorTools() }}, {name: "configuration-tasks", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigTaskTools() }}, + {name: "configuration-settings", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigSettingsTools() }}, {name: "external-create-task", enabled: external, register: func(s *Server) { s.registerCreateTaskTool() }}, {name: "external-questions", enabled: external, register: func(s *Server) { s.registerQuestionAnsweringTools() }}, {name: "external-agent-permissions", enabled: external, register: func(s *Server) { s.registerAgentPermissionTools() }}, diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 8096d55342..846e4c7b60 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -971,9 +971,9 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 2 saved prompts + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 36 + // Existing configuration tools plus the five compact settings tools. assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev requires a live task session; must NOT register in config mode") - assert.Equal(t, 36, len(tools)) + assert.Equal(t, 41, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -1147,9 +1147,9 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 2 saved prompts + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency + 2 question-answering (list_pending_questions + answer_question) + 2 agent permission (list_pending_agent_permissions + resolve_agent_permission) = 42. + // External configuration tools include the five compact settings tools. // add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to. - assert.Equal(t, 42, len(tools)) + assert.Equal(t, 47, len(tools)) assert.NotContains(t, tools, "add_branch_to_task_kandev") } diff --git a/apps/backend/internal/mcp/server/settings_tools.go b/apps/backend/internal/mcp/server/settings_tools.go new file mode 100644 index 0000000000..25c5dabdaa --- /dev/null +++ b/apps/backend/internal/mcp/server/settings_tools.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "context" + "encoding/json" + "strings" + + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// The compact settings tools intentionally advertise only the stable envelope. +// Domain fields arrive through describe_setting_kandev and are validated by +// the backend registry selected for the exact target. +func (s *Server) registerConfigSettingsTools() { + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema( + "search_settings_kandev", + "Search setting definitions by metadata. Search never reads saved values or secrets.", + json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{"query":{"type":"string","maxLength":256},"domain":{"type":"string"},"resource_type":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":50},"cursor":{"type":"string"}}}`), + ), + s.wrapHandler("search_settings_kandev", s.searchSettingsHandler()), + ) + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema( + "describe_setting_kandev", + "Describe one setting definition, including its target rules, schema, authority, and supported operations.", + json.RawMessage(`{"type":"object","additionalProperties":false,"required":["key"],"properties":{"key":{"type":"string","minLength":1},"target":{"type":"object","additionalProperties":false,"properties":{"resource_type":{"type":"string"},"resource_id":{"type":"string"},"workspace_id":{"type":"string"}}},"context":{"type":"object"},"operation":{"type":"string","enum":["read","update","create"]},"include_resource_schema":{"type":"boolean"}}}`), + ), + s.wrapHandler("describe_setting_kandev", s.describeSettingHandler()), + ) + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema( + "get_settings_kandev", + "Read saved settings for one exact authorized target. Sensitive values are redacted or returned as references.", + json.RawMessage(`{"type":"object","additionalProperties":false,"required":["target","keys"],"properties":{"target":{"type":"object","additionalProperties":false,"required":["resource_type"],"properties":{"resource_type":{"type":"string"},"resource_id":{"type":"string"},"workspace_id":{"type":"string"}}},"keys":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"string","minLength":1}}}}`), + ), + s.wrapSensitiveHandler("get_settings_kandev", s.getSettingsHandler()), + ) + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema( + "update_settings_kandev", + "Update declared settings for one exact authorized target. Changes are validated by the owning domain before one mutation.", + json.RawMessage(`{"type":"object","additionalProperties":false,"required":["target","changes"],"properties":{"target":{"type":"object","additionalProperties":false,"required":["resource_type"],"properties":{"resource_type":{"type":"string"},"resource_id":{"type":"string"},"workspace_id":{"type":"string"}}},"changes":{"type":"object","minProperties":1,"maxProperties":50,"additionalProperties":true},"options":{"type":"object"}}}`), + ), + s.wrapSensitiveHandler("update_settings_kandev", s.updateSettingsHandler()), + ) + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema( + "list_settings_resources_kandev", + "List bounded, authorized targets for one registered settings resource type.", + json.RawMessage(`{"type":"object","additionalProperties":false,"required":["resource_type"],"properties":{"resource_type":{"type":"string","minLength":1},"workspace_id":{"type":"string"},"query":{"type":"string","maxLength":256},"limit":{"type":"integer","minimum":1,"maximum":50},"cursor":{"type":"string"}}}`), + ), + s.wrapHandler("list_settings_resources_kandev", s.listSettingsResourcesHandler()), + ) +} + +func (s *Server) searchSettingsHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return s.forwardToBackend(ctx, ws.ActionMCPSearchSettings, cloneArguments(req.GetArguments())) + } +} + +func (s *Server) describeSettingHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil || strings.TrimSpace(key) == "" { + return mcp.NewToolResultError("key is required"), nil + } + return s.forwardToBackend(ctx, ws.ActionMCPDescribeSetting, cloneArguments(req.GetArguments())) + } +} + +func (s *Server) getSettingsHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if req.GetArguments()["target"] == nil { + return mcp.NewToolResultError("target is required"), nil + } + if req.GetArguments()["keys"] == nil { + return mcp.NewToolResultError("keys is required"), nil + } + return s.forwardToBackend(ctx, ws.ActionMCPGetSettings, cloneArguments(req.GetArguments())) + } +} + +func (s *Server) updateSettingsHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + if args["target"] == nil { + return mcp.NewToolResultError("target is required"), nil + } + if args["changes"] == nil { + return mcp.NewToolResultError("changes is required"), nil + } + return s.forwardToBackend(ctx, ws.ActionMCPUpdateSettings, cloneArguments(args)) + } +} + +func (s *Server) listSettingsResourcesHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resourceType, err := req.RequireString("resource_type") + if err != nil || strings.TrimSpace(resourceType) == "" { + return mcp.NewToolResultError("resource_type is required"), nil + } + return s.forwardToBackend(ctx, ws.ActionMCPListSettingsResources, cloneArguments(req.GetArguments())) + } +} + +func cloneArguments(arguments map[string]any) map[string]any { + if arguments == nil { + return nil + } + cloned := make(map[string]any, len(arguments)) + for key, value := range arguments { + cloned[key] = value + } + return cloned +} diff --git a/apps/backend/internal/mcp/server/settings_tools_test.go b/apps/backend/internal/mcp/server/settings_tools_test.go new file mode 100644 index 0000000000..9bc6763aa9 --- /dev/null +++ b/apps/backend/internal/mcp/server/settings_tools_test.go @@ -0,0 +1,59 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompactSettingsToolsExposeOnlyStableEnvelopes(t *testing.T) { + server := newTestServer(t, &testBackend{}) + for _, name := range []string{ + "search_settings_kandev", "describe_setting_kandev", "get_settings_kandev", + "update_settings_kandev", "list_settings_resources_kandev", + } { + tool, ok := server.mcpServer.ListTools()[name] + require.True(t, ok, "missing compact settings tool %q", name) + data := tool.Tool.RawInputSchema + if len(data) == 0 { + var err error + data, err = json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + } + var schema map[string]any + require.NoError(t, json.Unmarshal(data, &schema)) + assert.Equal(t, "object", schema["type"]) + assert.Equal(t, false, schema["additionalProperties"], "tool %q must keep its outer envelope closed", name) + } + var updateSchema map[string]any + require.NoError(t, json.Unmarshal(server.mcpServer.ListTools()["update_settings_kandev"].Tool.RawInputSchema, &updateSchema)) + properties, ok := updateSchema["properties"].(map[string]any) + require.True(t, ok) + assert.Contains(t, properties, "target") + assert.Contains(t, properties, "changes") + assert.Contains(t, properties, "options") + assert.NotContains(t, properties, "agent_profile") +} + +func TestCompactSettingsToolForwardsStableAction(t *testing.T) { + backend := &testBackend{} + server := newTestServer(t, backend) + tool := server.mcpServer.ListTools()["update_settings_kandev"] + req := mcplib.CallToolRequest{} + req.Params.Name = "update_settings_kandev" + req.Params.Arguments = map[string]any{ + "target": map[string]any{"resource_type": "agent_profile", "resource_id": "profile-1"}, + "changes": map[string]any{"auto_approve": false}, + } + _, err := tool.Handler(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, ws.ActionMCPUpdateSettings, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]any) + require.True(t, ok) + assert.Equal(t, false, payload["changes"].(map[string]any)["auto_approve"]) +} diff --git a/apps/backend/internal/settingscatalog/catalog.go b/apps/backend/internal/settingscatalog/catalog.go new file mode 100644 index 0000000000..a7866e4f2a --- /dev/null +++ b/apps/backend/internal/settingscatalog/catalog.go @@ -0,0 +1,484 @@ +// Package settingscatalog owns the process-wide metadata contract for agent +// accessible settings. It deliberately does not read persistence or call a +// domain service. Domain adapters provide the values and mutations. +package settingscatalog + +import ( + "fmt" + "sort" + "strconv" + "strings" + "unicode" +) + +const ( + MaxSearchQueryLength = 256 + MaxSearchResults = 50 + DefaultSearchLimit = 20 + MaxChangedFields = 50 +) + +type SupportStatus string + +const ( + SupportSupported SupportStatus = "supported" + SupportReadOnly SupportStatus = "read_only" + SupportException SupportStatus = "exception" + SupportPending SupportStatus = "pending" + SupportUnavailable SupportStatus = "unavailable" +) + +type Classification string + +const ( + ClassificationWritable Classification = "writable" + ClassificationComputed Classification = "computed" + ClassificationAction Classification = "action" + ClassificationException Classification = "exception" +) + +type Scope string + +const ( + ScopeCaller Scope = "caller" + ScopeWorkspace Scope = "workspace" + ScopeResource Scope = "resource" + ScopeInstallation Scope = "installation" +) + +type MatchReason string + +const ( + MatchExact MatchReason = "exact" + MatchPrefix MatchReason = "prefix" + MatchAlias MatchReason = "alias" + MatchDescription MatchReason = "description" + MatchDomain MatchReason = "domain" +) + +type ResourceTarget struct { + ResourceType string `json:"resource_type"` + ResourceID *string `json:"resource_id,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` +} + +type TargetRules struct { + Scope Scope `json:"scope"` + Singleton bool `json:"singleton,omitempty"` + RequiresResourceID bool `json:"requires_resource_id,omitempty"` + RequiresWorkspaceID bool `json:"requires_workspace_id,omitempty"` + AllowsWorkspaceID bool `json:"allows_workspace_id,omitempty"` +} + +type FieldDescriptor struct { + Key string `json:"key"` + FieldPath string `json:"field_path"` + Label string `json:"label"` + Description string `json:"description"` + Aliases []string `json:"aliases,omitempty"` + JSONType string `json:"json_type"` + Nullable bool `json:"nullable,omitempty"` + Support SupportStatus `json:"support"` + Classification Classification `json:"classification"` + Owner string `json:"owner"` + Writable bool `json:"writable,omitempty"` + Validator string `json:"validator,omitempty"` + Authority string `json:"authority,omitempty"` + Sensitive bool `json:"sensitive,omitempty"` + Replacement bool `json:"replacement,omitempty"` + DefaultBehavior string `json:"default_behavior,omitempty"` + ChangeTiming string `json:"change_timing,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + SettingsHref string `json:"settings_href,omitempty"` + ExceptionReason string `json:"exception_reason,omitempty"` + Recovery string `json:"recovery,omitempty"` + Schema map[string]any `json:"schema,omitempty"` +} + +type OperationDescriptor struct { + Name string `json:"name"` + Binding string `json:"binding"` + Authority string `json:"authority,omitempty"` + Options map[string]any `json:"options,omitempty"` + Description string `json:"description,omitempty"` +} + +type DomainDescriptor struct { + Domain string `json:"domain"` + ResourceType string `json:"resource_type"` + Label string `json:"label"` + Description string `json:"description"` + Owner string `json:"owner"` + Scope Scope `json:"scope"` + Target TargetRules `json:"target"` + Fields []FieldDescriptor `json:"fields"` + Operations []OperationDescriptor `json:"operations,omitempty"` + SettingsHref string `json:"settings_href,omitempty"` +} + +type Registry struct { + domainsByType map[string]DomainDescriptor + fieldsByKey map[string]FieldDescriptor + domainByField map[string]DomainDescriptor + fields []FieldDescriptor +} + +type SearchRequest struct { + Query string + Domain string + ResourceType string + Limit int + Cursor string +} + +type SearchItem struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + ResourceType string `json:"resource_type"` + FieldPath string `json:"field_path"` + Support SupportStatus `json:"support"` + MatchReason MatchReason `json:"match_reason"` + SettingsHref string `json:"settings_href,omitempty"` +} + +type SearchResponse struct { + Items []SearchItem `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` + CoveredDomains []string `json:"covered_domains"` +} + +func NewRegistry(domains []DomainDescriptor) (*Registry, error) { + registry := &Registry{ + domainsByType: make(map[string]DomainDescriptor, len(domains)), + fieldsByKey: make(map[string]FieldDescriptor), + domainByField: make(map[string]DomainDescriptor), + } + for _, domain := range domains { + if err := validateDomain(domain); err != nil { + return nil, err + } + if _, exists := registry.domainsByType[domain.ResourceType]; exists { + return nil, fmt.Errorf("duplicate resource type %q", domain.ResourceType) + } + registry.domainsByType[domain.ResourceType] = cloneDomain(domain) + for _, field := range domain.Fields { + if _, exists := registry.fieldsByKey[field.Key]; exists { + return nil, fmt.Errorf("duplicate setting key %q", field.Key) + } + registry.fieldsByKey[field.Key] = cloneField(field) + registry.domainByField[field.Key] = registry.domainsByType[domain.ResourceType] + registry.fields = append(registry.fields, cloneField(field)) + } + } + sort.Slice(registry.fields, func(i, j int) bool { return registry.fields[i].Key < registry.fields[j].Key }) + return registry, nil +} + +//nolint:cyclop // Catalog validation checks independent target and field invariants in one pass. +func validateDomain(domain DomainDescriptor) error { + if strings.TrimSpace(domain.Domain) == "" { + return fmt.Errorf("domain owner is required for %q", domain.ResourceType) + } + if strings.TrimSpace(domain.ResourceType) == "" { + return fmt.Errorf("resource type is required") + } + if strings.TrimSpace(domain.Owner) == "" { + return fmt.Errorf("owner is required for resource type %q", domain.ResourceType) + } + if domain.Target.Singleton && (domain.Target.RequiresResourceID || domain.Target.RequiresWorkspaceID) { + return fmt.Errorf("singleton target %q cannot require selectors", domain.ResourceType) + } + if domain.Target.RequiresWorkspaceID && domain.Target.RequiresResourceID && !domain.Target.AllowsWorkspaceID { + return fmt.Errorf("resource target %q must explicitly allow workspace selector", domain.ResourceType) + } + seenPaths := make(map[string]struct{}, len(domain.Fields)) + for _, field := range domain.Fields { + if strings.TrimSpace(field.Key) == "" || strings.TrimSpace(field.FieldPath) == "" { + return fmt.Errorf("setting key and field path are required for resource type %q", domain.ResourceType) + } + if strings.TrimSpace(field.Owner) == "" { + return fmt.Errorf("setting %q is missing an owner", field.Key) + } + if _, exists := seenPaths[field.FieldPath]; exists { + return fmt.Errorf("duplicate field path %q in resource type %q", field.FieldPath, domain.ResourceType) + } + seenPaths[field.FieldPath] = struct{}{} + if field.Writable { + if field.Support != SupportSupported || field.Classification != ClassificationWritable || + strings.TrimSpace(field.Validator) == "" || strings.TrimSpace(field.Authority) == "" { + return fmt.Errorf("writable setting %q requires supported classification, validator, and authority", field.Key) + } + } + if field.Support == SupportException && strings.TrimSpace(field.ExceptionReason) == "" { + return fmt.Errorf("exception setting %q requires a reason", field.Key) + } + } + return nil +} + +//nolint:cyclop // Search validates bounds, ranks fields, and applies stable pagination as one operation. +func (r *Registry) Search(request SearchRequest) (SearchResponse, error) { + query := strings.TrimSpace(request.Query) + if len([]rune(query)) > MaxSearchQueryLength { + return SearchResponse{}, fmt.Errorf("query exceeds %d characters", MaxSearchQueryLength) + } + limit, err := boundedLimit(request.Limit) + if err != nil { + return SearchResponse{}, err + } + offset, err := parseCursor(request.Cursor) + if err != nil { + return SearchResponse{}, err + } + queryTokens := tokenize(query) + type ranked struct { + item SearchItem + score int + } + rankedItems := make([]ranked, 0, len(r.fields)) + for _, field := range r.fields { + domain, ok := r.domainForField(field.Key) + if !ok || (request.Domain != "" && request.Domain != domain.Domain) || + (request.ResourceType != "" && request.ResourceType != domain.ResourceType) { + continue + } + reason, score, matched := matchField(field, domain, query, queryTokens) + if !matched { + continue + } + rankedItems = append(rankedItems, ranked{item: SearchItem{ + Key: field.Key, + Label: field.Label, + Description: field.Description, + ResourceType: domain.ResourceType, + FieldPath: field.FieldPath, + Support: field.Support, + MatchReason: reason, + SettingsHref: field.SettingsHref, + }, score: score}) + } + sort.SliceStable(rankedItems, func(i, j int) bool { + if rankedItems[i].score != rankedItems[j].score { + return rankedItems[i].score > rankedItems[j].score + } + return rankedItems[i].item.Key < rankedItems[j].item.Key + }) + response := SearchResponse{CoveredDomains: r.CoveredDomains()} + if offset >= len(rankedItems) { + return response, nil + } + end := offset + limit + if end > len(rankedItems) { + end = len(rankedItems) + } + response.Items = make([]SearchItem, 0, end-offset) + for _, match := range rankedItems[offset:end] { + response.Items = append(response.Items, match.item) + } + if end < len(rankedItems) { + response.NextCursor = strconv.Itoa(end) + } + return response, nil +} + +func (r *Registry) Find(key string) (FieldDescriptor, DomainDescriptor, bool) { + field, ok := r.fieldsByKey[key] + if !ok { + return FieldDescriptor{}, DomainDescriptor{}, false + } + domain, ok := r.domainForField(key) + return field, domain, ok +} + +func (r *Registry) Domain(resourceType string) (DomainDescriptor, bool) { + domain, ok := r.domainsByType[resourceType] + return cloneDomain(domain), ok +} + +func (r *Registry) Domains() []DomainDescriptor { + domains := make([]DomainDescriptor, 0, len(r.domainsByType)) + for _, domain := range r.domainsByType { + domains = append(domains, cloneDomain(domain)) + } + sort.Slice(domains, func(i, j int) bool { return domains[i].ResourceType < domains[j].ResourceType }) + return domains +} + +func (r *Registry) CoveredDomains() []string { + domains := r.Domains() + result := make([]string, 0, len(domains)) + for _, domain := range domains { + result = append(result, domain.Domain) + } + return result +} + +//nolint:cyclop // Target validation applies the complete singleton and selector contract. +func (r *Registry) ValidateTarget(target ResourceTarget) error { + if strings.TrimSpace(target.ResourceType) == "" { + return fmt.Errorf("resource_type is required") + } + domain, ok := r.domainsByType[target.ResourceType] + if !ok { + return fmt.Errorf("unknown resource type %q", target.ResourceType) + } + if domain.Target.Singleton && (target.ResourceID != nil || target.WorkspaceID != nil) { + return fmt.Errorf("resource type %q does not accept resource selectors", target.ResourceType) + } + if domain.Target.RequiresResourceID && (target.ResourceID == nil || strings.TrimSpace(*target.ResourceID) == "") { + return fmt.Errorf("resource_id is required for %q", target.ResourceType) + } + if !domain.Target.RequiresResourceID && target.ResourceID != nil { + return fmt.Errorf("resource type %q does not accept resource_id", target.ResourceType) + } + if domain.Target.RequiresWorkspaceID && (target.WorkspaceID == nil || strings.TrimSpace(*target.WorkspaceID) == "") { + return fmt.Errorf("workspace_id is required for %q", target.ResourceType) + } + if !domain.Target.AllowsWorkspaceID && !domain.Target.RequiresWorkspaceID && target.WorkspaceID != nil { + return fmt.Errorf("resource type %q does not accept workspace_id", target.ResourceType) + } + return nil +} + +// RuntimeFlagAgentMutable reports whether the compact settings boundary may +// persist an override for a runtime flag. Interactive administration retains +// the separate ability to change installation authentication settings. +func RuntimeFlagAgentMutable(key string) bool { + return strings.TrimSpace(key) != "features.auth" +} + +// FieldForTarget applies target-specific policy to a catalog field. The +// runtime flag catalog is shared across keys, but authentication enablement is +// intentionally interactive-only for agent mutations. +func FieldForTarget(field FieldDescriptor, target *ResourceTarget) FieldDescriptor { + if target == nil || target.ResourceType != "runtime_flag" || target.ResourceID == nil || + field.FieldPath != "override" || RuntimeFlagAgentMutable(*target.ResourceID) { + return field + } + field.Support = SupportException + field.Classification = ClassificationException + field.Writable = false + field.ExceptionReason = "Authentication enablement requires the interactive administration flow." + field.Recovery = "Use the interactive authentication settings control and restart the installation." + return field +} + +func (r *Registry) domainForField(key string) (DomainDescriptor, bool) { + domain, ok := r.domainByField[key] + return domain, ok +} + +func matchField(field FieldDescriptor, domain DomainDescriptor, query string, queryTokens []string) (MatchReason, int, bool) { + if query == "" { + return MatchDomain, 1, true + } + normalizedQuery := strings.ToLower(query) + key := strings.ToLower(field.Key) + label := strings.ToLower(field.Label) + if normalizedQuery == key || normalizedQuery == label { + return MatchExact, 1000, true + } + if strings.HasPrefix(key, normalizedQuery) || strings.HasPrefix(label, normalizedQuery) { + return MatchPrefix, 800, true + } + for _, alias := range field.Aliases { + if strings.HasPrefix(strings.ToLower(alias), normalizedQuery) { + return MatchAlias, 600, true + } + } + metadata := strings.ToLower(strings.Join(append([]string{field.Description, domain.Description}, field.Aliases...), " ")) + for _, token := range queryTokens { + if !strings.Contains(metadata, token) { + return "", 0, false + } + } + if len(queryTokens) > 0 { + return MatchDescription, 400, true + } + return "", 0, false +} + +func tokenize(value string) []string { + var tokens []string + var current strings.Builder + flush := func() { + if current.Len() > 0 { + tokens = append(tokens, strings.ToLower(current.String())) + current.Reset() + } + } + for _, char := range strings.ToLower(value) { + if unicode.IsLetter(char) || unicode.IsDigit(char) { + current.WriteRune(char) + continue + } + flush() + } + flush() + return tokens +} + +func boundedLimit(limit int) (int, error) { + if limit == 0 { + return DefaultSearchLimit, nil + } + if limit < 1 || limit > MaxSearchResults { + return 0, fmt.Errorf("limit must be between 1 and %d", MaxSearchResults) + } + return limit, nil +} + +func parseCursor(cursor string) (int, error) { + if cursor == "" { + return 0, nil + } + offset, err := strconv.Atoi(cursor) + if err != nil || offset < 0 { + return 0, fmt.Errorf("cursor must be a non-negative offset") + } + return offset, nil +} + +func cloneField(field FieldDescriptor) FieldDescriptor { + field.Aliases = append([]string(nil), field.Aliases...) + field.Dependencies = append([]string(nil), field.Dependencies...) + field.Schema = cloneJSONMap(field.Schema) + return field +} + +func cloneJSONMap(source map[string]any) map[string]any { + if source == nil { + return nil + } + result := make(map[string]any, len(source)) + for key, value := range source { + result[key] = cloneJSONValue(value) + } + return result +} + +func cloneJSONValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneJSONMap(typed) + case []any: + result := make([]any, len(typed)) + for index, item := range typed { + result[index] = cloneJSONValue(item) + } + return result + default: + return value + } +} + +func cloneDomain(domain DomainDescriptor) DomainDescriptor { + fields := domain.Fields + domain.Fields = make([]FieldDescriptor, len(fields)) + for i, field := range fields { + domain.Fields[i] = cloneField(field) + } + domain.Operations = append([]OperationDescriptor(nil), domain.Operations...) + return domain +} diff --git a/apps/backend/internal/settingscatalog/catalog_test.go b/apps/backend/internal/settingscatalog/catalog_test.go new file mode 100644 index 0000000000..01014e40da --- /dev/null +++ b/apps/backend/internal/settingscatalog/catalog_test.go @@ -0,0 +1,120 @@ +package settingscatalog + +import ( + "strings" + "testing" +) + +func TestRegistryRejectsDuplicateKeysAndUnboundWritableFields(t *testing.T) { + _, err := NewRegistry([]DomainDescriptor{ + { + Domain: "example", + ResourceType: "example", + Owner: "example", + Target: TargetRules{RequiresResourceID: true}, + Fields: []FieldDescriptor{ + {Key: "example.name", FieldPath: "name", Owner: "example", Support: SupportSupported}, + {Key: "example.name", FieldPath: "name_copy", Owner: "example", Support: SupportSupported}, + }, + }, + }) + if err == nil || !strings.Contains(err.Error(), "duplicate setting key") { + t.Fatalf("NewRegistry error = %v, want duplicate-key validation", err) + } + + _, err = NewRegistry([]DomainDescriptor{{ + Domain: "example", + ResourceType: "example", + Owner: "example", + Target: TargetRules{RequiresResourceID: true}, + Fields: []FieldDescriptor{{ + Key: "example.name", + FieldPath: "name", + Owner: "example", + Support: SupportSupported, + Writable: true, + Validator: "", + Authority: "", + }}, + }}) + if err == nil || !strings.Contains(err.Error(), "writable setting") { + t.Fatalf("NewRegistry error = %v, want writable binding validation", err) + } +} + +func TestSearchRanksExactKeyBeforeDescriptionAndNeverSearchesValues(t *testing.T) { + registry, err := NewRegistry([]DomainDescriptor{{ + Domain: "profiles", + ResourceType: "profile", + Owner: "profiles", + Target: TargetRules{RequiresResourceID: true}, + Fields: []FieldDescriptor{ + { + Key: "profile.model", + FieldPath: "model", + Label: "Model", + Description: "Select the preferred model.", + Aliases: []string{"provider choice"}, + Owner: "profiles", + Support: SupportSupported, + }, + { + Key: "profile.name", + FieldPath: "name", + Label: "Name", + Description: "The saved profile label.", + Owner: "profiles", + Support: SupportSupported, + }, + }, + }}) + if err != nil { + t.Fatal(err) + } + + results, err := registry.Search(SearchRequest{Query: "profile.model"}) + if err != nil { + t.Fatal(err) + } + if len(results.Items) != 1 || results.Items[0].Key != "profile.model" || results.Items[0].MatchReason != MatchExact { + t.Fatalf("exact search = %#v, want one exact model result", results.Items) + } + + results, err = registry.Search(SearchRequest{Query: "secret-value"}) + if err != nil { + t.Fatal(err) + } + if len(results.Items) != 0 { + t.Fatalf("value-like query returned %#v", results.Items) + } +} + +func TestRegistryValidatesTargetSelectors(t *testing.T) { + registry, err := NewRegistry([]DomainDescriptor{{ + Domain: "user", + ResourceType: "user_settings", + Owner: "user", + Target: TargetRules{Singleton: true}, + Fields: []FieldDescriptor{{ + Key: "user_settings.appearance", + FieldPath: "appearance", + Owner: "user", + Support: SupportSupported, + }}, + }}) + if err != nil { + t.Fatal(err) + } + + if err := registry.ValidateTarget(ResourceTarget{ResourceType: "user_settings"}); err != nil { + t.Fatalf("singleton target rejected: %v", err) + } + if err := registry.ValidateTarget(ResourceTarget{ResourceType: "user_settings", ResourceID: stringPtr("other")}); err == nil { + t.Fatal("resource ID accepted for singleton target") + } + if err := registry.ValidateTarget(ResourceTarget{ResourceType: "unknown"}); err == nil { + t.Fatal("unknown resource type accepted") + } +} + +func stringPtr(value string) *string { return &value } diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go new file mode 100644 index 0000000000..0642eaeecb --- /dev/null +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -0,0 +1,808 @@ +//nolint:goconst // Catalog owner and scope labels are stable metadata vocabulary. +package settingscatalog + +import "strings" + +// DefaultDomainDescriptors is the stable cross-domain inventory used by the +// backend when composition has not supplied a narrower registry. +func DefaultDomainDescriptors() []DomainDescriptor { + return []DomainDescriptor{ + profileDomain(), + profileMCPDomain(), + agentDomain(), + userSettingsDomain(), + workflowDomain(), + workflowStepDomain(), + workspaceDomain(), + repositoryDomain(), + repositoryScriptDomain(), + repositorySetDomain(), + executorDomain(), + executorProfileDomain(), + environmentDomain(), + taskDomain(), + promptDomain(), + utilityAgentDomain(), + editorDomain(), + notificationProviderDomain(), + compatibilityIntegrationDomain("issue_integrations", "issue_integration", "Issue integrations", "Use provider-specific Jira, Linear, and Sentry resource types for settings updates."), + compatibilityIntegrationDomain("code_host_integrations", "code_host_integration", "Code host integrations", "Use provider-specific GitHub, GitLab, and Azure DevOps resource types for settings updates."), + jiraSettingsDomain(), + linearSettingsDomain(), + sentryInstanceDomain(), + jiraIssueWatchDomain(), + linearIssueWatchDomain(), + sentryIssueWatchDomain(), + githubSettingsDomain(), + gitlabSettingsDomain(), + azureDevOpsSettingsDomain(), + githubReviewWatchDomain(), + githubIssueWatchDomain(), + gitlabReviewWatchDomain(), + gitlabIssueWatchDomain(), + azureDevOpsWorkItemWatchDomain(), + azureDevOpsPullRequestWatchDomain(), + automationDomain(), + automationTriggerDomain(), + runtimeFlagDomain(), + storageMaintenanceDomain(), + } +} + +func compatibilityIntegrationDomain(domain, resourceType, label, description string) DomainDescriptor { + return DomainDescriptor{ + Domain: domain, ResourceType: resourceType, Label: label, Description: description, Owner: domain, + Scope: ScopeWorkspace, Target: TargetRules{Scope: ScopeWorkspace, RequiresWorkspaceID: true}, SettingsHref: "/settings/integrations", + Fields: []FieldDescriptor{{ + Key: resourceType + ".settings", FieldPath: "settings", Label: "Provider settings", Description: description, + JSONType: "object", Support: SupportReadOnly, Classification: ClassificationException, Owner: domain, + ExceptionReason: "Provider-specific resource types preserve the provider's validation and workspace boundary.", + Recovery: "Use the provider-specific settings resource returned by search or target lookup.", + }}, + } +} + +func integrationReadOnly(resourceType, path, label, description, jsonType, owner string) FieldDescriptor { + return FieldDescriptor{ + Key: resourceType + "." + path, FieldPath: path, Label: label, Description: description, JSONType: jsonType, + Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner, + } +} + +func integrationWritable(resourceType, path, label, description, jsonType, owner, authority string) FieldDescriptor { + return domainWritable(resourceType, path, label, description, jsonType, owner, authority) +} + +func integrationNullableWritable(resourceType, path, label, description, jsonType, owner, authority string) FieldDescriptor { + field := integrationWritable(resourceType, path, label, description, jsonType, owner, authority) + field.Nullable = true + return field +} + +func integrationDomain(domain, resourceType, label, description, owner string, target TargetRules, fields ...FieldDescriptor) DomainDescriptor { + return supportedDomain(domain, resourceType, label, description, owner, target.Scope, target, fields...) +} + +func workspaceIntegrationTarget() TargetRules { + return TargetRules{Scope: ScopeWorkspace, RequiresWorkspaceID: true} +} + +func watchTarget() TargetRules { + return TargetRules{Scope: ScopeResource, RequiresResourceID: true, RequiresWorkspaceID: true, AllowsWorkspaceID: true} +} + +func jiraSettingsDomain() DomainDescriptor { + owner := "jira" + target := workspaceIntegrationTarget() + return integrationDomain("issue_integrations", "jira_settings", "Jira settings", "Noncredential Jira defaults for one workspace.", owner, target, + integrationWritable("jira_settings", "defaultProjectKey", "Default project", "Default Jira project key for issue workflows.", "string", owner, "workspace.manage"), + integrationReadOnly("jira_settings", "siteUrl", "Site URL", "Configured Jira site identity. Credential and identity changes use the connection flow.", "string", owner), + integrationReadOnly("jira_settings", "email", "Account email", "Configured Jira account metadata.", "string", owner), + integrationReadOnly("jira_settings", "authMethod", "Authentication method", "Configured Jira authentication method.", "string", owner), + integrationReadOnly("jira_settings", "instanceType", "Instance type", "Configured Jira deployment type.", "string", owner), + integrationReadOnly("jira_settings", "hasSecret", "Credential present", "Whether a credential is enrolled, without exposing it.", "boolean", owner), + ) +} + +func linearSettingsDomain() DomainDescriptor { + owner := "linear" + target := workspaceIntegrationTarget() + return integrationDomain("issue_integrations", "linear_settings", "Linear settings", "Noncredential Linear defaults for one workspace.", owner, target, + integrationWritable("linear_settings", "defaultTeamKey", "Default team", "Default Linear team key for issue workflows.", "string", owner, "workspace.manage"), + integrationReadOnly("linear_settings", "authMethod", "Authentication method", "Configured Linear authentication method.", "string", owner), + integrationReadOnly("linear_settings", "orgSlug", "Organization slug", "Computed Linear organization metadata.", "string", owner), + integrationReadOnly("linear_settings", "hasSecret", "Credential present", "Whether a credential is enrolled, without exposing it.", "boolean", owner), + ) +} + +func sentryInstanceDomain() DomainDescriptor { + owner := "sentry" + return integrationDomain("issue_integrations", "sentry_instance", "Sentry instances", "Named noncredential Sentry instance settings.", owner, TargetRules{Scope: ScopeResource, RequiresResourceID: true, RequiresWorkspaceID: true, AllowsWorkspaceID: true}, + integrationWritable("sentry_instance", "name", "Name", "Display name for the Sentry instance.", "string", owner, "workspace.manage"), + integrationReadOnly("sentry_instance", "url", "URL", "Configured Sentry instance identity. Connection changes use the interactive flow.", "string", owner), + integrationReadOnly("sentry_instance", "authMethod", "Authentication method", "Configured Sentry authentication method.", "string", owner), + integrationReadOnly("sentry_instance", "hasSecret", "Credential present", "Whether a credential is enrolled, without exposing it.", "boolean", owner), + ) +} + +func issueWatchFields(resourceType, owner, filterPath, filterType string) []FieldDescriptor { + fields := []FieldDescriptor{ + integrationWritable(resourceType, "workflowId", "Workflow", "Workflow that receives matching issues.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "workflowStepId", "Workflow step", "Workflow step that receives matching issues.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "repositoryId", "Repository", "Optional repository binding for created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "baseBranch", "Base branch", "Base branch for the optional repository binding.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "agentProfileId", "Agent profile", "Profile used for created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "executorProfileId", "Executor profile", "Executor profile used for created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "prompt", "Prompt", "Prompt added to created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "enabled", "Enabled", "Whether background polling is enabled.", "boolean", owner, "workspace.manage"), + integrationWritable(resourceType, "pollIntervalSeconds", "Poll interval", "Background polling interval in seconds.", "integer", owner, "workspace.manage"), + integrationNullableWritable(resourceType, "maxInflightTasks", "Concurrent task limit", "Maximum open tasks created by this watch, or null for unlimited.", "integer", owner, "workspace.manage"), + } + if filterPath != "" { + fields = append(fields, integrationWritable(resourceType, filterPath, "Filter", "Provider-specific issue filter.", filterType, owner, "workspace.manage")) + } + return fields +} + +func jiraIssueWatchDomain() DomainDescriptor { + return integrationDomain("issue_integrations", "jira_issue_watch", "Jira issue watches", "Jira issue watch schedules and task bindings.", "jira", watchTarget(), issueWatchFields("jira_issue_watch", "jira", "jql", "string")...) +} + +func linearIssueWatchDomain() DomainDescriptor { + fields := issueWatchFields("linear_issue_watch", "linear", "filter", "object") + fields = append(fields, integrationWritable("linear_issue_watch", "sortBy", "Sort order", "Order in which matching Linear issues are dispatched.", "string", "linear", "workspace.manage")) + return integrationDomain("issue_integrations", "linear_issue_watch", "Linear issue watches", "Linear issue watch schedules and task bindings.", "linear", watchTarget(), fields...) +} + +func sentryIssueWatchDomain() DomainDescriptor { + return integrationDomain("issue_integrations", "sentry_issue_watch", "Sentry issue watches", "Sentry issue watch schedules and task bindings.", "sentry", watchTarget(), issueWatchFields("sentry_issue_watch", "sentry", "filter", "object")...) +} + +func githubSettingsDomain() DomainDescriptor { + owner := "github" + return integrationDomain("code_host_integrations", "github_settings", "GitHub settings", "Noncredential GitHub scope and query preferences for one workspace.", owner, workspaceIntegrationTarget(), + integrationWritable("github_settings", "task_git_credentials_mode", "Task Git credentials", "Credential routing mode for task worktrees.", "string", owner, "workspace.manage"), + integrationWritable("github_settings", "repo_scope_mode", "Repository scope mode", "Repository scope selection mode.", "string", owner, "workspace.manage"), + integrationWritable("github_settings", "repo_scope_orgs", "Organizations", "Organization allowlist for repository scope.", "array", owner, "workspace.manage"), + integrationWritable("github_settings", "repo_scope_repos", "Repositories", "Repository allowlist for repository scope.", "array", owner, "workspace.manage"), + integrationWritable("github_settings", "saved_presets", "Saved presets", "Workspace-scoped GitHub action presets.", "object", owner, "workspace.manage"), + integrationWritable("github_settings", "default_query_presets", "Default query presets", "Workspace-scoped GitHub query presets.", "object", owner, "workspace.manage"), + ) +} + +func gitlabSettingsDomain() DomainDescriptor { + owner := "gitlab" + return integrationDomain("code_host_integrations", "gitlab_settings", "GitLab settings", "Configured GitLab workspace connection metadata.", owner, workspaceIntegrationTarget(), + integrationReadOnly("gitlab_settings", "host", "Host", "Configured GitLab host. Connection identity changes use the interactive flow.", "string", owner), + integrationReadOnly("gitlab_settings", "auth_method", "Authentication method", "Configured GitLab authentication method.", "string", owner), + integrationReadOnly("gitlab_settings", "username", "Username", "Last authenticated GitLab username.", "string", owner), + ) +} + +func azureDevOpsSettingsDomain() DomainDescriptor { + owner := "azuredevops" + return integrationDomain("code_host_integrations", "azure_devops_settings", "Azure DevOps settings", "Noncredential Azure DevOps query and action presets for one workspace.", owner, workspaceIntegrationTarget(), + integrationWritable("azure_devops_settings", "workItemQueries", "Work item queries", "Saved work item query presets.", "array", owner, "workspace.manage"), + integrationWritable("azure_devops_settings", "pullRequestQueries", "Pull request queries", "Saved pull request query presets.", "array", owner, "workspace.manage"), + integrationWritable("azure_devops_settings", "workItemActions", "Work item actions", "Saved work item action presets.", "array", owner, "workspace.manage"), + integrationWritable("azure_devops_settings", "pullRequestActions", "Pull request actions", "Saved pull request action presets.", "array", owner, "workspace.manage"), + ) +} + +func codeHostWatchFields(resourceType, owner string, projects bool, issue, camelCase bool) []FieldDescriptor { + fields := []FieldDescriptor{ + integrationWritable(resourceType, codeHostWatchPath("workflow_id", camelCase), "Workflow", "Workflow that receives matching items.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, codeHostWatchPath("workflow_step_id", camelCase), "Workflow step", "Workflow step that receives matching items.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, codeHostWatchPath("agent_profile_id", camelCase), "Agent profile", "Profile used for created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, codeHostWatchPath("executor_profile_id", camelCase), "Executor profile", "Executor profile used for created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "prompt", "Prompt", "Prompt added to created tasks.", "string", owner, "workspace.manage"), + integrationWritable(resourceType, "enabled", "Enabled", "Whether background polling is enabled.", "boolean", owner, "workspace.manage"), + integrationWritable(resourceType, codeHostWatchPath("poll_interval_seconds", camelCase), "Poll interval", "Background polling interval in seconds.", "integer", owner, "workspace.manage"), + integrationWritable(resourceType, codeHostWatchPath("cleanup_policy", camelCase), "Cleanup policy", "Policy for tasks created by this watch.", "string", owner, "workspace.manage"), + integrationNullableWritable(resourceType, codeHostWatchPath("max_inflight_tasks", camelCase), "Concurrent task limit", "Maximum open tasks created by this watch, or null for unlimited.", "integer", owner, "workspace.manage"), + } + if projects { + fields = append(fields, integrationWritable(resourceType, "projects", "Projects", "Provider project filters.", "array", owner, "workspace.manage")) + } else { + fields = append(fields, integrationWritable(resourceType, "repos", "Repositories", "Provider repository filters.", "array", owner, "workspace.manage")) + } + if issue { + fields = append(fields, integrationWritable(resourceType, "labels", "Labels", "Issue label filters.", "array", owner, "workspace.manage")) + fields = append(fields, integrationWritable(resourceType, "custom_query", "Custom query", "Provider-specific issue query.", "string", owner, "workspace.manage")) + } else { + fields = append(fields, integrationWritable(resourceType, "review_scope", "Review scope", "Review assignment scope.", "string", owner, "workspace.manage")) + fields = append(fields, integrationWritable(resourceType, "custom_query", "Custom query", "Provider-specific review query.", "string", owner, "workspace.manage")) + } + return fields +} + +func codeHostWatchPath(snake string, camelCase bool) string { + if !camelCase { + return snake + } + parts := strings.Split(snake, "_") + for index := 1; index < len(parts); index++ { + if parts[index] == "" { + continue + } + parts[index] = strings.ToUpper(parts[index][:1]) + parts[index][1:] + } + return strings.Join(parts, "") +} + +func githubReviewWatchDomain() DomainDescriptor { + fields := withoutIntegrationFields(codeHostWatchFields("github_review_watch", "github", false, false, false), "max_inflight_tasks") + return integrationDomain("code_host_integrations", "github_review_watch", "GitHub review watches", "GitHub review watch schedules and task bindings.", "github", watchTarget(), fields...) +} + +func githubIssueWatchDomain() DomainDescriptor { + fields := withoutIntegrationFields(codeHostWatchFields("github_issue_watch", "github", false, true, false), "max_inflight_tasks") + return integrationDomain("code_host_integrations", "github_issue_watch", "GitHub issue watches", "GitHub issue watch schedules and task bindings.", "github", watchTarget(), fields...) +} + +func gitlabReviewWatchDomain() DomainDescriptor { + return integrationDomain("code_host_integrations", "gitlab_review_watch", "GitLab review watches", "GitLab review watch schedules and task bindings.", "gitlab", watchTarget(), codeHostWatchFields("gitlab_review_watch", "gitlab", true, false, false)...) +} + +func gitlabIssueWatchDomain() DomainDescriptor { + return integrationDomain("code_host_integrations", "gitlab_issue_watch", "GitLab issue watches", "GitLab issue watch schedules and task bindings.", "gitlab", watchTarget(), codeHostWatchFields("gitlab_issue_watch", "gitlab", true, true, false)...) +} + +func azureDevOpsWorkItemWatchDomain() DomainDescriptor { + fields := withoutIntegrationFields(codeHostWatchFields("azure_devops_work_item_watch", "azuredevops", false, true, true), "repos", "labels", "custom_query") + fields = append(fields, + integrationWritable("azure_devops_work_item_watch", "projectId", "Project", "Azure DevOps project filter.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_work_item_watch", "wiql", "WIQL", "Azure Boards query.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_work_item_watch", "repositoryId", "Repository", "Optional repository binding.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_work_item_watch", "baseBranch", "Base branch", "Base branch for the optional repository binding.", "string", "azuredevops", "workspace.manage"), + ) + return integrationDomain("code_host_integrations", "azure_devops_work_item_watch", "Azure work item watches", "Azure work item watch schedules and task bindings.", "azuredevops", watchTarget(), fields...) +} + +func azureDevOpsPullRequestWatchDomain() DomainDescriptor { + fields := withoutIntegrationFields(codeHostWatchFields("azure_devops_pull_request_watch", "azuredevops", false, false, true), "repos", "review_scope", "custom_query") + fields = append(fields, + integrationWritable("azure_devops_pull_request_watch", "projectId", "Project", "Azure DevOps project filter.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_pull_request_watch", "azureRepositoryId", "Azure repository", "Azure Repos repository filter.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_pull_request_watch", "status", "Status", "Pull request status filter.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_pull_request_watch", "creatorId", "Creator", "Pull request creator filter.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_pull_request_watch", "reviewerId", "Reviewer", "Pull request reviewer filter.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_pull_request_watch", "repositoryId", "Repository", "Optional repository binding.", "string", "azuredevops", "workspace.manage"), + integrationWritable("azure_devops_pull_request_watch", "baseBranch", "Base branch", "Base branch for the optional repository binding.", "string", "azuredevops", "workspace.manage"), + ) + return integrationDomain("code_host_integrations", "azure_devops_pull_request_watch", "Azure pull request watches", "Azure pull request watch schedules and task bindings.", "azuredevops", watchTarget(), fields...) +} + +func withoutIntegrationFields(fields []FieldDescriptor, paths ...string) []FieldDescriptor { + remove := make(map[string]struct{}, len(paths)) + for _, path := range paths { + remove[path] = struct{}{} + } + filtered := make([]FieldDescriptor, 0, len(fields)) + for _, field := range fields { + if _, skip := remove[field.FieldPath]; !skip { + filtered = append(filtered, field) + } + } + return filtered +} + +func agentDomain() DomainDescriptor { + owner := "agent-settings" + return DomainDescriptor{ + Domain: "agents", ResourceType: "agent", Label: "Agent definitions", + Description: "Saved agent definition metadata and MCP connection settings.", Owner: owner, + Scope: ScopeResource, Target: TargetRules{Scope: ScopeResource, RequiresResourceID: true, AllowsWorkspaceID: true}, + SettingsHref: "/settings/agents", + Operations: []OperationDescriptor{{Name: "update", Binding: "agent-settings.agent.update", Authority: "org.config.manage"}}, + Fields: []FieldDescriptor{ + {Key: "agent.name", FieldPath: "name", Label: "Name", Description: "Stable agent definition name.", JSONType: "string", Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner}, + {Key: "agent.workspace_id", FieldPath: "workspace_id", Label: "Workspace", Description: "Owning workspace, when the definition is workspace-scoped.", JSONType: "string", Nullable: true, Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner}, + domainWritable("agent", "supports_mcp", "Supports MCP", "Whether the agent definition supports MCP configuration.", "boolean", owner, "org.config.manage"), + func() FieldDescriptor { + field := domainWritable("agent", "mcp_config_path", "MCP configuration path", "Path used by the agent definition for MCP configuration.", "string", owner, "org.config.manage") + field.Nullable = true + return field + }(), + {Key: "agent.tui_config", FieldPath: "tui_config", Label: "TUI definition", Description: "Custom TUI definition owned by the explicit agent lifecycle surface.", JSONType: "object", Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner}, + {Key: "agent.profiles", FieldPath: "profiles", Label: "Profiles", Description: "Profiles attached to this agent definition.", JSONType: "array", Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner}, + {Key: "agent.capability_status", FieldPath: "capability_status", Label: "Capability status", Description: "Computed host capability status.", JSONType: "string", Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner}, + {Key: "agent.capability_error", FieldPath: "capability_error", Label: "Capability error", Description: "Computed host capability diagnostic.", JSONType: "string", Support: SupportReadOnly, Classification: ClassificationComputed, Owner: owner}, + }, + } +} + +func supportedDomain(domain, resourceType, label, description, owner string, scope Scope, target TargetRules, fields ...FieldDescriptor) DomainDescriptor { + authority := "" + for _, field := range fields { + if field.Writable && field.Authority != "" { + authority = field.Authority + break + } + } + operations := []OperationDescriptor(nil) + if authority != "" { + operations = []OperationDescriptor{{Name: "update", Binding: owner + "." + resourceType + ".update", Authority: authority}} + } + return DomainDescriptor{ + Domain: domain, ResourceType: resourceType, Label: label, Description: description, Owner: owner, + Scope: scope, Target: target, Fields: fields, + Operations: operations, + SettingsHref: "/settings", + } +} + +func domainWritable(resourceType, path, label, description, jsonType, owner, authority string) FieldDescriptor { + return FieldDescriptor{ + Key: resourceType + "." + path, FieldPath: path, Label: label, Description: description, JSONType: jsonType, + Support: SupportSupported, Classification: ClassificationWritable, Owner: owner, Writable: true, + Validator: owner + "." + resourceType, Authority: authority, SettingsHref: "/settings", + } +} + +func domainSensitiveWritable(resourceType, path, label, description, jsonType, owner, authority string) FieldDescriptor { + field := domainWritable(resourceType, path, label, description, jsonType, owner, authority) + field.Sensitive = true + return field +} + +func exceptionField(resourceType, path, label, description, jsonType, owner, recovery string) FieldDescriptor { + return FieldDescriptor{ + Key: resourceType + "." + path, FieldPath: path, Label: label, Description: description, + JSONType: jsonType, Support: SupportException, Classification: ClassificationException, + Owner: owner, SettingsHref: "/settings", ExceptionReason: description, Recovery: recovery, + } +} + +func lifecycleException(resourceType, path, label, description, jsonType, owner, recovery string) FieldDescriptor { + field := exceptionField(resourceType, path, label, description, jsonType, owner, recovery) + field.Classification = ClassificationAction + return field +} + +func workflowDomain() DomainDescriptor { + owner := "workflow" + target := TargetRules{Scope: ScopeResource, RequiresResourceID: true, AllowsWorkspaceID: true} + return supportedDomain("workflows", "workflow", "Workflows", "Saved workflow definitions.", owner, ScopeResource, target, + domainWritable("workflow", "name", "Name", "Workflow name.", "string", owner, "workspace.manage"), + domainWritable("workflow", "description", "Description", "Workflow description.", "string", owner, "workspace.manage"), + domainWritable("workflow", "prompt", "Prompt", "Workflow-level instructions.", "string", owner, "workspace.manage"), + domainWritable("workflow", "agent_profile_id", "Agent profile", "Profile used by the workflow when configured.", "string", owner, "workspace.manage"), + ) +} + +func workflowStepDomain() DomainDescriptor { + owner := "workflow" + fields := []FieldDescriptor{ + domainWritable("workflow_step", "name", "Name", "Step name.", "string", owner, "workspace.manage"), + lifecycleException("workflow_step", "position", "Position", "Step ordering is controlled by the workflow reorder action.", "integer", owner, "Use the workflow step reorder action."), + domainWritable("workflow_step", "color", "Color", "Step display color.", "string", owner, "workspace.manage"), + domainWritable("workflow_step", "prompt", "Prompt", "Step instructions.", "string", owner, "workspace.manage"), + workflowEventsField(owner), + domainWritable("workflow_step", "allow_manual_move", "Manual move", "Whether users may move tasks into this step.", "boolean", owner, "workspace.manage"), + domainWritable("workflow_step", "is_start_step", "Start step", "Whether this is the workflow start step.", "boolean", owner, "workspace.manage"), + domainWritable("workflow_step", "show_in_command_panel", "Command panel", "Whether the step appears in the command panel.", "boolean", owner, "workspace.manage"), + domainWritable("workflow_step", "auto_archive_after_hours", "Auto archive", "Hours before tasks in this step are archived.", "integer", owner, "workspace.manage"), + domainWritable("workflow_step", "agent_profile_id", "Agent profile", "Profile bound to this step.", "string", owner, "workspace.manage"), + domainWritable("workflow_step", "profile_session_start_policy", "Session start policy", "Session policy when entering a profile-bound step.", "string", owner, "workspace.manage"), + domainWritable("workflow_step", "profile_session_end_policy", "Session end policy", "Session policy when leaving a profile-bound step.", "string", owner, "workspace.manage"), + domainWritable("workflow_step", "wip_limit", "WIP limit", "Maximum admitted work in this step.", "integer", owner, "workspace.manage"), + domainWritable("workflow_step", "pull_from_step_id", "Pull source", "Step that supplies work to this step.", "string", owner, "workspace.manage"), + domainWritable("workflow_step", "stage_type", "Stage type", "Workflow stage type.", "string", owner, "workspace.manage"), + domainWritable("workflow_step", "auto_advance_requires_signal", "Completion signal", "Require an explicit completion signal before advancing.", "boolean", owner, "workspace.manage"), + domainWritable("workflow_step", "cancel_triggers_turn_complete", "Cancel transition", "Whether cancellation runs turn-complete actions.", "boolean", owner, "workspace.manage"), + } + return supportedDomain("workflows", "workflow_step", "Workflow steps", "Editable steps in a saved workflow.", owner, ScopeResource, targetRulesResourceWorkspace(), fields...) +} + +func workflowEventsField(owner string) FieldDescriptor { + field := domainWritable("workflow_step", "events", "Events", "Validated step event actions and bindings.", "object", owner, "workspace.manage") + triggers := []string{ + "on_enter", "on_turn_start", "on_turn_complete", "on_exit", "on_comment", + "on_blocker_resolved", "on_children_completed", "on_approval_resolved", "on_heartbeat", + "on_budget_alert", "on_agent_error", + } + properties := make(map[string]any, len(triggers)) + for _, trigger := range triggers { + properties[trigger] = map[string]any{ + "type": "array", + "items": map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ + "type": map[string]any{"type": "string"}, + "config": map[string]any{"type": "object", "additionalProperties": true}, + }}, + } + } + field.Schema = map[string]any{"type": "object", "additionalProperties": false, "properties": properties} + return field +} + +func targetRulesResourceWorkspace() TargetRules { + return TargetRules{Scope: ScopeResource, RequiresResourceID: true, AllowsWorkspaceID: true} +} + +func workspaceDomain() DomainDescriptor { + owner := "workspace" + return supportedDomain("workspaces", "workspace", "Workspaces", "Workspace defaults and display settings.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("workspace", "name", "Name", "Workspace name.", "string", owner, "workspace.manage"), + domainWritable("workspace", "description", "Description", "Workspace description.", "string", owner, "workspace.manage"), + domainWritable("workspace", "default_executor_id", "Default executor", "Default execution target.", "string", owner, "workspace.manage"), + domainWritable("workspace", "default_environment_id", "Default environment", "Default runtime environment.", "string", owner, "workspace.manage"), + domainWritable("workspace", "default_agent_profile_id", "Default agent profile", "Default profile for new work.", "string", owner, "workspace.manage"), + domainWritable("workspace", "default_config_agent_profile_id", "Default configuration profile", "Profile used for configuration work.", "string", owner, "workspace.manage"), + ) +} + +func repositoryDomain() DomainDescriptor { + owner := "workspace" + paths := []struct{ path, label, typ string }{ + {"name", "Name", "string"}, {"source_type", "Source type", "string"}, {"local_path", "Local path", "string"}, + {"provider", "Provider", "string"}, {"provider_repo_id", "Provider repository ID", "string"}, {"provider_host", "Provider host", "string"}, + {"provider_scope", "Provider scope", "string"}, {"provider_owner", "Provider owner", "string"}, {"provider_name", "Provider name", "string"}, + {"remote_url", "Remote URL", "string"}, {"default_branch", "Default branch", "string"}, {"worktree_branch_prefix", "Branch prefix", "string"}, + {"worktree_branch_template", "Branch template", "string"}, {"pull_before_worktree", "Pull before worktree", "boolean"}, + {"setup_script", "Setup script", "string"}, {"cleanup_script", "Cleanup script", "string"}, {"dev_script", "Development script", "string"}, + {"copy_files", "Copy files", "string"}, {"secret_bindings", "Secret bindings", "array"}, + } + fields := make([]FieldDescriptor, 0, len(paths)) + for _, item := range paths { + field := domainWritable("repository", item.path, item.label, "Repository setting.", item.typ, owner, "workspace.manage") + if item.path == "secret_bindings" { + field.Sensitive = true + } + fields = append(fields, field) + } + return supportedDomain("workspaces", "repository", "Repositories", "Workspace repository settings.", owner, ScopeResource, targetRulesResourceWorkspace(), fields...) +} + +func repositoryScriptDomain() DomainDescriptor { + owner := "workspace" + return supportedDomain("workspaces", "repository_script", "Repository scripts", "Custom repository commands.", owner, ScopeResource, targetRulesResourceWorkspace(), + domainWritable("repository_script", "name", "Name", "Script name.", "string", owner, "workspace.manage"), + domainWritable("repository_script", "command", "Command", "Script command.", "string", owner, "workspace.manage"), + lifecycleException("repository_script", "position", "Position", "Script ordering is controlled by the repository script reorder action.", "integer", owner, "Use the repository script reorder action."), + ) +} + +func repositorySetDomain() DomainDescriptor { + owner := "workspace" + return supportedDomain("workspaces", "repository_set", "Repository sets", "Reusable repository selections.", owner, ScopeResource, targetRulesResourceWorkspace(), + domainWritable("repository_set", "name", "Name", "Repository set name.", "string", owner, "workspace.manage"), + domainWritable("repository_set", "description", "Description", "Repository set description.", "string", owner, "workspace.manage"), + domainWritable("repository_set", "repository_ids", "Repositories", "Ordered repository membership.", "array", owner, "workspace.manage"), + ) +} + +func executorDomain() DomainDescriptor { + owner := "execution" + return supportedDomain("execution", "executor", "Executors", "Execution target settings.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("executor", "name", "Name", "Executor name.", "string", owner, "execution.manage"), + domainWritable("executor", "type", "Type", "Executor runtime type.", "string", owner, "execution.manage"), + domainWritable("executor", "status", "Status", "Whether the executor accepts work.", "string", owner, "execution.manage"), + domainWritable("executor", "resumable", "Resumable", "Whether sessions can resume on this executor.", "boolean", owner, "execution.manage"), + domainSensitiveWritable("executor", "config", "Configuration", "Validated executor configuration.", "object", owner, "execution.manage"), + ) +} + +func executorProfileDomain() DomainDescriptor { + owner := "execution" + return supportedDomain("execution", "executor_profile", "Executor profiles", "Named executor presets.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("executor_profile", "name", "Name", "Profile name.", "string", owner, "execution.manage"), + domainWritable("executor_profile", "mcp_policy", "MCP policy", "MCP policy for this executor profile.", "string", owner, "execution.manage"), + domainWritable("executor_profile", "config", "Configuration", "Validated executor profile configuration.", "object", owner, "execution.manage"), + domainWritable("executor_profile", "prepare_script", "Prepare script", "Preparation command.", "string", owner, "execution.manage"), + domainWritable("executor_profile", "cleanup_script", "Cleanup script", "Cleanup command.", "string", owner, "execution.manage"), + domainSensitiveWritable("executor_profile", "env_vars", "Environment variables", "Environment values or secret references.", "array", owner, "execution.manage"), + ) +} + +func environmentDomain() DomainDescriptor { + owner := "execution" + return supportedDomain("execution", "environment", "Environments", "Execution environment definitions.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("environment", "name", "Name", "Environment name.", "string", owner, "execution.manage"), + domainWritable("environment", "kind", "Kind", "Environment kind.", "string", owner, "execution.manage"), + domainWritable("environment", "worktree_root", "Worktree root", "Worktree root path.", "string", owner, "execution.manage"), + domainWritable("environment", "image_tag", "Image tag", "Container image tag.", "string", owner, "execution.manage"), + domainWritable("environment", "dockerfile", "Dockerfile", "Dockerfile content.", "string", owner, "execution.manage"), + domainWritable("environment", "build_config", "Build configuration", "Validated build options.", "object", owner, "execution.manage"), + ) +} + +func taskDomain() DomainDescriptor { + owner := "task" + return supportedDomain("tasks", "task", "Tasks", "Permitted saved task configuration.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true, AllowsWorkspaceID: true}, + domainWritable("task", "title", "Title", "Task title.", "string", owner, "task.manage"), + domainWritable("task", "description", "Description", "Task description.", "string", owner, "task.manage"), + domainWritable("task", "priority", "Priority", "Task priority.", "string", owner, "task.manage"), + lifecycleException("task", "workflow_step_id", "Workflow step", "Task movement is controlled by the task lifecycle action.", "string", owner, "Use the task move action."), + lifecycleException("task", "position", "Position", "Task ordering is controlled by the task reorder action.", "integer", owner, "Use the task reorder action."), + exceptionField("task", "metadata", "Metadata", "Open-ended task metadata is controlled by domain-specific task operations.", "object", owner, "Use the task metadata operation for a known metadata key."), + ) +} + +func promptDomain() DomainDescriptor { + owner := "prompts" + return supportedDomain("prompts", "prompt", "Saved prompts", "Saved prompt definitions.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("prompt", "name", "Name", "Prompt name.", "string", owner, "prompt.manage"), + domainWritable("prompt", "content", "Content", "Prompt content.", "string", owner, "prompt.manage"), + ) +} + +func utilityAgentDomain() DomainDescriptor { + owner := "utilities" + return supportedDomain("utilities", "utility_agent", "Utility agents", "Utility agent definitions.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("utility_agent", "name", "Name", "Utility name.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "description", "Description", "Utility description.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "prompt", "Prompt", "Utility prompt template.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "agent_id", "Agent", "Inference agent identifier.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "model", "Model", "Default model.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "agent_profile_id", "Agent profile", "Profile binding.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "profile_binding_state", "Profile binding state", "Profile inheritance state.", "string", owner, "utility.manage"), + domainWritable("utility_agent", "enabled", "Enabled", "Whether the utility is available.", "boolean", owner, "utility.manage"), + ) +} + +func editorDomain() DomainDescriptor { + owner := "editors" + return supportedDomain("editors", "editor", "Editor definitions", "Custom editor definitions.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("editor", "name", "Name", "Editor display name.", "string", owner, "editor.manage"), + domainWritable("editor", "kind", "Kind", "Custom editor kind.", "string", owner, "editor.manage"), + domainWritable("editor", "config", "Configuration", "Validated editor configuration.", "object", owner, "editor.manage"), + domainWritable("editor", "enabled", "Enabled", "Whether the editor can be selected.", "boolean", owner, "editor.manage"), + ) +} + +func notificationProviderDomain() DomainDescriptor { + owner := "notifications" + return supportedDomain("notifications", "notification_provider", "Notification providers", "Notification provider settings and subscriptions.", owner, ScopeResource, TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + domainWritable("notification_provider", "name", "Name", "Provider name.", "string", owner, "notification.manage"), + domainWritable("notification_provider", "type", "Type", "Provider type.", "string", owner, "notification.manage"), + domainSensitiveWritable("notification_provider", "config", "Configuration", "Validated provider configuration.", "object", owner, "notification.manage"), + domainWritable("notification_provider", "enabled", "Enabled", "Whether notifications are enabled.", "boolean", owner, "notification.manage"), + domainWritable("notification_provider", "events", "Events", "Subscribed event types.", "array", owner, "notification.manage"), + ) +} + +func automationDomain() DomainDescriptor { + owner := "automations" + return supportedDomain("automations", "automation", "Workspace automations", "Workspace automation definitions.", owner, ScopeWorkspace, TargetRules{Scope: ScopeWorkspace, RequiresResourceID: true, AllowsWorkspaceID: true}, + domainWritable("automation", "name", "Name", "Automation name.", "string", owner, "workspace.manage"), + domainWritable("automation", "description", "Description", "Automation description.", "string", owner, "workspace.manage"), + domainWritable("automation", "workflow_id", "Workflow", "Workflow binding.", "string", owner, "workspace.manage"), + domainWritable("automation", "workflow_step_id", "Workflow step", "Workflow step binding.", "string", owner, "workspace.manage"), + domainWritable("automation", "agent_profile_id", "Agent profile", "Agent profile binding.", "string", owner, "workspace.manage"), + domainWritable("automation", "executor_profile_id", "Executor profile", "Executor profile binding.", "string", owner, "workspace.manage"), + domainWritable("automation", "repositories", "Repositories", "Repository and base-branch selection.", "array", owner, "workspace.manage"), + domainWritable("automation", "prompt", "Prompt", "Automation prompt template.", "string", owner, "workspace.manage"), + domainWritable("automation", "task_title_template", "Task title template", "Generated task title template.", "string", owner, "workspace.manage"), + domainWritable("automation", "enabled", "Enabled", "Whether the automation is active.", "boolean", owner, "workspace.manage"), + domainWritable("automation", "max_concurrent_runs", "Concurrent runs", "Maximum simultaneous runs.", "integer", owner, "workspace.manage"), + domainWritable("automation", "continuation_policy", "Continuation policy", "How automation runs reuse tasks.", "string", owner, "workspace.manage"), + domainWritable("automation", "task_mode", "Task mode", "Whether runs create visible tasks.", "string", owner, "workspace.manage"), + domainWritable("automation", "repository_mode", "Repository mode", "How runs select repositories.", "string", owner, "workspace.manage"), + ) +} + +func automationTriggerDomain() DomainDescriptor { + owner := "automations" + return supportedDomain("automations", "automation_trigger", "Automation triggers", "Triggers attached to a workspace automation.", owner, ScopeResource, targetRulesResourceWorkspace(), + domainSensitiveWritable("automation_trigger", "config", "Configuration", "Trigger configuration.", "object", owner, "workspace.manage"), + domainWritable("automation_trigger", "enabled", "Enabled", "Whether the trigger is active.", "boolean", owner, "workspace.manage"), + ) +} + +func runtimeFlagDomain() DomainDescriptor { + owner := "runtime" + field := domainWritable("runtime_flag", "override", "Override", "Explicit persisted runtime override. Null clears the override.", "boolean", owner, "org.settings.manage") + field.Nullable = true + return supportedDomain("runtime", "runtime_flag", "Runtime overrides", "Permitted persisted runtime flag overrides.", owner, ScopeInstallation, TargetRules{Scope: ScopeInstallation, RequiresResourceID: true}, field) +} + +func storageMaintenanceDomain() DomainDescriptor { + owner := "storage" + target := TargetRules{Scope: ScopeInstallation, Singleton: true} + paths := []struct{ path, label, typ string }{ + {"enabled", "Enabled", "boolean"}, {"check_interval_hours", "Check interval", "integer"}, {"idle_for_minutes", "Idle period", "integer"}, + {"orphan_grace_hours", "Orphan grace", "integer"}, {"quarantine_retention_hours", "Quarantine retention", "integer"}, + {"workspaces.enabled", "Workspace cleanup", "boolean"}, {"workspaces.dependency_cleanup_enabled", "Dependency cleanup", "boolean"}, + {"kandev_containers.enabled", "Kandev containers", "boolean"}, {"go_cache.enabled", "Go cache", "boolean"}, {"go_cache.max_bytes", "Go cache limit", "integer"}, + {"docker.dedicated_daemon_acknowledged", "Dedicated Docker acknowledgement", "boolean"}, {"docker.build_cache_enabled", "Docker build cache", "boolean"}, + {"docker.build_cache_keep_bytes", "Docker build cache limit", "integer"}, {"docker.build_cache_unused_hours", "Docker build cache age", "integer"}, + {"docker.unused_images_enabled", "Unused image cleanup", "boolean"}, {"docker.unused_images_hours", "Unused image age", "integer"}, + } + fields := make([]FieldDescriptor, 0, len(paths)) + for _, item := range paths { + fields = append(fields, domainWritable("storage_maintenance", item.path, item.label, "Storage maintenance policy.", item.typ, owner, "org.settings.manage")) + } + domain := supportedDomain("storage", "storage_maintenance", "Storage maintenance", "Install-wide storage maintenance policy.", owner, ScopeInstallation, target, fields...) + domain.Operations[0].Options = map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "confirm_dedicated_docker": map[string]any{"type": "boolean"}, + "adopt_go_cache": map[string]any{"type": "boolean"}, + }, + } + return domain +} + +func userSettingsDomain() DomainDescriptor { + values := []string{ + "workspace_id", "workspace", "string", "kanban_view_mode", "kanban view mode", "string", "startup_page", "startup page", "string", + "workflow_filter_id", "workflow filter", "string", "repository_ids", "repository selection", "array", "tasks_list_sort", "task sort", "string", + "tasks_list_group", "task grouping", "string", "tasks_list_show_details", "task details", "boolean", "initial_setup_complete", "setup complete", "boolean", + "preferred_shell", "preferred shell", "string", "default_editor_id", "default editor", "string", "enable_preview_on_click", "preview on click", "boolean", + "chat_submit_key", "chat submit key", "string", "review_auto_mark_on_scroll", "review auto mark", "boolean", "confirm_task_archive", "confirm task archive", "boolean", + "prevent_auto_start_agent_on_open", "prevent auto start", "boolean", "unread_divider", "unread divider", "boolean", "agent_generated_task_titles", "generated titles", "boolean", + "mcp_task_agent_profile_default", "MCP task profile", "string", "show_anchored_prompt_bar", "anchored prompt bar", "boolean", "show_scroll_to_last_prompt", "scroll to last prompt", "boolean", + "show_scroll_to_start", "scroll to start", "boolean", "show_transcript_auto_scroll_control", "transcript scroll control", "boolean", "show_todo_list_panel", "todo list panel", "boolean", + "show_todo_list_panel_only_when_not_empty", "todo list empty state", "boolean", "show_release_notification", "release notification", "boolean", "release_notes_last_seen_version", "release notes version", "string", + "lsp_auto_start_languages", "LSP auto start", "array", "lsp_auto_install_languages", "LSP auto install", "array", "lsp_server_configs", "LSP server configs", "object", + "lsp_status_location", "LSP status location", "string", "saved_layouts", "saved layouts", "array", "sidebar_views", "sidebar views", "array", + "sidebar_active_view_id", "active sidebar view", "string", "sidebar_draft", "sidebar draft", "object", "thread_views", "thread views", "array", + "thread_active_view_id", "active thread view", "string", "thread_view_draft", "thread draft", "object", "sidebar_task_prefs", "sidebar task preferences", "object", + "sidebar_task_color_automation", "sidebar color automation", "object", "sidebar_task_colors", "sidebar task colors", "object", "sidebar_task_color_patch", "sidebar task color patch", "object", "task_create_last_used", "last task create values", "object", + "jira_saved_views", "Jira saved views", "object", "jira_task_presets", "Jira task presets", "object", "github_saved_presets", "GitHub saved presets", "object", + "github_default_query_presets", "GitHub query presets", "object", "gitlab_saved_presets", "GitLab saved presets", "object", "azure_devops_browse_preferences", "Azure DevOps browse preferences", "object", + "default_utility_agent_id", "default utility agent", "string", "default_utility_model", "default utility model", "string", "default_utility_agent_profile_id", "default utility profile", "string", + "keyboard_shortcuts", "keyboard shortcuts", "object", "terminal_link_behavior", "terminal link behavior", "string", "terminal_font_family", "terminal font family", "string", + "terminal_font_size", "terminal font size", "integer", "changes_panel_layout", "changes panel layout", "string", "last_seen_display", "last seen display", "string", + "system_metrics_display", "system metrics display", "object", "app_status_bar_enabled", "status bar", "boolean", "resolve_session_hostnames", "resolve hostnames", "boolean", + "app_status_bar_order", "status bar order", "object", "quick_chat_tab_order_by_workspace", "quick chat tab order", "object", "kanban_hidden_step_ids", "hidden kanban steps", "object", + "workflow_ids_with_auto_hide_empty_steps", "auto-hide workflows", "array", + } + fields := make([]FieldDescriptor, 0, len(values)/3) + for index := 0; index+2 < len(values); index += 3 { + field := preferenceField(values[index], values[index+1], values[index+2]) + switch values[index] { + case "keyboard_shortcuts": + field.Schema = keyboardShortcutsSchema() + case "sidebar_task_color_patch": + field.Schema = map[string]any{"type": "object", "additionalProperties": false, "properties": map[string]any{ + "if_missing": map[string]any{"type": "boolean"}, + "colors": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string", "nullable": true}}, + }} + } + switch values[index] { + case "sidebar_draft", "thread_view_draft", "jira_saved_views", "jira_task_presets", "github_saved_presets", "github_default_query_presets", "gitlab_saved_presets", "azure_devops_browse_preferences": + field.Nullable = true + } + fields = append(fields, field) + } + return DomainDescriptor{ + Domain: "user_preferences", ResourceType: "user_settings", Label: "Personal preferences", + Description: "Portable settings belonging to the authenticated user.", Owner: "user-settings", Scope: ScopeCaller, + Target: TargetRules{Scope: ScopeCaller, Singleton: true}, SettingsHref: "/settings/preferences", Fields: fields, + Operations: []OperationDescriptor{{Name: "update", Binding: "user-settings.update", Authority: "user.self"}}, + } +} + +func preferenceField(path, label, jsonType string) FieldDescriptor { + return FieldDescriptor{ + Key: "user_settings." + path, FieldPath: path, Label: label, + Description: "Portable preference owned by the current user.", JSONType: jsonType, + Support: SupportSupported, Classification: ClassificationWritable, Owner: "user-settings", Writable: true, + Validator: "user-settings", Authority: "user.self", ChangeTiming: "future_views", SettingsHref: "/settings/preferences", + } +} + +func keyboardShortcutsSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "key": map[string]any{"type": "string"}, + "modifiers": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "alt": map[string]any{"type": "boolean"}, + "ctrl": map[string]any{"type": "boolean"}, + "meta": map[string]any{"type": "boolean"}, + "shift": map[string]any{"type": "boolean"}, + "command": map[string]any{"type": "boolean"}, + }, + }, + }, + }, + } +} + +func DefaultRegistry() (*Registry, error) { + return NewRegistry(DefaultDomainDescriptors()) +} + +func profileDomain() DomainDescriptor { + return DomainDescriptor{ + Domain: "profiles", + ResourceType: "agent_profile", + Label: "Agent profiles", + Description: "Saved agent profile settings used for future sessions.", + Owner: "agent-settings", + Scope: ScopeResource, + Target: TargetRules{Scope: ScopeResource, RequiresResourceID: true, AllowsWorkspaceID: true}, + SettingsHref: "/settings/agents", + Operations: []OperationDescriptor{{ + Name: "update", + Binding: "agent-settings.profile.update", + Authority: "org.config.manage", + Options: map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "force": map[string]any{"type": "boolean"}, + }, + }, + Description: "Validate and update one saved profile through the agent settings controller.", + }}, + Fields: []FieldDescriptor{ + profileWritable("agent_profile.name", "name", "Profile name", "string", "The saved display name.", false), + profileWritable("agent_profile.model", "model", "Model", "string", "Preferred model. Empty inherits the provider default.", false), + profileWritable("agent_profile.fallback_model", "fallback_model", "Fallback model", "string", "Optional fallback model.", false), + profileWritable("agent_profile.auto_fallback", "auto_fallback", "Automatic fallback", "boolean", "Enable automatic fallback behavior.", false), + profileWritable("agent_profile.mode", "mode", "Mode", "string", "Agent operating mode.", false), + profileWritable("agent_profile.config_options", "config_options", "Configuration options", "object", "Typed provider options. Supplied maps replace the saved map.", true), + profileWritable("agent_profile.allow_indexing", "allow_indexing", "Allow indexing", "boolean", "Legacy compatibility permission.", false), + profileWritable("agent_profile.auto_approve", "auto_approve", "Auto approve", "boolean", "Automatically approve supported agent permissions.", false), + profileWritable("agent_profile.cli_passthrough", "cli_passthrough", "CLI passthrough", "boolean", "Enable CLI passthrough mode.", false), + profileWritable("agent_profile.enabled", "enabled", "Enabled", "boolean", "Allow this profile for new work.", false), + profileWritable("agent_profile.cli_flags", "cli_flags", "CLI flags", "array", "Complete replacement list of validated CLI flags.", true), + profileWritableSensitive("agent_profile.env_vars", "env_vars", "Environment variables", "array", "Complete replacement list. Secret values remain references.", true), + profileWritable("agent_profile.command_prefix", "command_prefix", "Command prefix", "string", "Optional validated launcher prefix.", false), + profileWritable("agent_profile.dynamic", "dynamic", "Dynamic routing", "object", "Versioned dynamic routing document.", true), + }, + } +} + +func profileMCPDomain() DomainDescriptor { + return DomainDescriptor{ + Domain: "profiles", + ResourceType: "agent_profile_mcp", + Label: "Profile MCP documents", + Description: "Separate MCP server settings for a saved agent profile.", + Owner: "agent-settings", + Scope: ScopeResource, + Target: TargetRules{Scope: ScopeResource, RequiresResourceID: true}, + SettingsHref: "/settings/agents", + Operations: []OperationDescriptor{{Name: "update", Binding: "agent-settings.profile-mcp.update", Authority: "org.config.manage"}}, + Fields: []FieldDescriptor{ + profileWritable("agent_profile_mcp.enabled", "enabled", "MCP enabled", "boolean", "Enable the profile MCP document.", false), + profileWritableSensitive("agent_profile_mcp.servers", "servers", "MCP servers", "object", "Complete replacement map of validated MCP servers.", true), + {Key: "agent_profile_mcp.meta", FieldPath: "meta", Label: "MCP metadata", Description: "Preserved metadata from the MCP document.", JSONType: "object", Support: SupportReadOnly, Classification: ClassificationComputed, Owner: "agent-settings", Sensitive: true}, + }, + } +} + +func profileWritable(key, path, label, jsonType, description string, replacement bool) FieldDescriptor { + field := FieldDescriptor{ + Key: key, + FieldPath: path, + Label: label, + Description: description, + JSONType: jsonType, + Support: SupportSupported, + Classification: ClassificationWritable, + Owner: "agent-settings", + Writable: true, + Validator: "agent-settings.profile", + Authority: "org.config.manage", + Replacement: replacement, + ChangeTiming: "future_sessions", + SettingsHref: "/settings/agents", + } + if path == "config_options" { + field.Schema = map[string]any{ + "type": "object", + "additionalProperties": map[string]any{"type": "string"}, + "x-kandev-dynamic": "provider_config_options", + } + } + return field +} + +func profileWritableSensitive(key, path, label, jsonType, description string, replacement bool) FieldDescriptor { + field := profileWritable(key, path, label, jsonType, description, replacement) + field.Sensitive = true + return field +} diff --git a/apps/backend/internal/settingscatalog/defaults_test.go b/apps/backend/internal/settingscatalog/defaults_test.go new file mode 100644 index 0000000000..870bc8bfc2 --- /dev/null +++ b/apps/backend/internal/settingscatalog/defaults_test.go @@ -0,0 +1,76 @@ +package settingscatalog + +import "testing" + +func TestDefaultRegistryContainsEveryRequiredResourceType(t *testing.T) { + registry, err := DefaultRegistry() + if err != nil { + t.Fatal(err) + } + want := []string{ + "agent_profile", "agent_profile_mcp", "agent", "user_settings", "workflow", "workflow_step", + "workspace", "repository", "repository_script", "repository_set", "executor", "executor_profile", + "environment", "task", "prompt", "utility_agent", "editor", "notification_provider", + "issue_integration", "code_host_integration", "automation", "automation_trigger", "runtime_flag", + "storage_maintenance", + } + for _, resourceType := range want { + if _, ok := registry.Domain(resourceType); !ok { + t.Errorf("resource type %q is missing", resourceType) + } + } +} + +func TestDefaultProfileFieldsAreWritableAndBound(t *testing.T) { + registry, err := DefaultRegistry() + if err != nil { + t.Fatal(err) + } + domain, ok := registry.Domain("agent_profile") + if !ok { + t.Fatal("agent_profile descriptor missing") + } + if len(domain.Fields) < 14 { + t.Fatalf("profile field count = %d, want complete profile contract", len(domain.Fields)) + } + for _, field := range domain.Fields { + if !field.Writable || field.Validator == "" || field.Authority == "" { + t.Errorf("profile field %q is not bound for writes: %#v", field.Key, field) + } + } +} + +func TestWritableDomainsExposeConcreteOperationAuthorities(t *testing.T) { + registry, err := DefaultRegistry() + if err != nil { + t.Fatal(err) + } + for _, domain := range registry.Domains() { + writable := false + for _, field := range domain.Fields { + writable = writable || field.Writable + } + if !writable { + continue + } + for _, operation := range domain.Operations { + if operation.Name == "update" && (operation.Authority == "" || operation.Authority == "domain.owner") { + t.Errorf("%s update authority = %q, want concrete field authority", domain.ResourceType, operation.Authority) + } + } + } +} + +func TestCatalogOperationsNeverUsePlaceholderAuthorities(t *testing.T) { + registry, err := DefaultRegistry() + if err != nil { + t.Fatal(err) + } + for _, domain := range registry.Domains() { + for _, operation := range domain.Operations { + if operation.Authority == "domain.owner" { + t.Fatalf("%s operation %q uses placeholder authority", domain.ResourceType, operation.Name) + } + } + } +} diff --git a/apps/backend/internal/sysprompt/sysprompt_test.go b/apps/backend/internal/sysprompt/sysprompt_test.go index 631fee2fd2..c51478294a 100644 --- a/apps/backend/internal/sysprompt/sysprompt_test.go +++ b/apps/backend/internal/sysprompt/sysprompt_test.go @@ -45,6 +45,11 @@ func TestConfigContext_ContainsAllTools(t *testing.T) { "update_mcp_config_kandev", "list_shared_prompts_kandev", "get_shared_prompt_kandev", + "search_settings_kandev", + "describe_setting_kandev", + "list_settings_resources_kandev", + "get_settings_kandev", + "update_settings_kandev", "list_tasks_kandev", "move_task_kandev", "delete_task_kandev", @@ -64,6 +69,7 @@ func TestConfigContext_ContainsSections(t *testing.T) { assert.Contains(t, ConfigContext(), "EXECUTOR PROFILE TOOLS:") assert.Contains(t, ConfigContext(), "MCP CONFIG TOOLS:") assert.Contains(t, ConfigContext(), "SAVED PROMPT TOOLS:") + assert.Contains(t, ConfigContext(), "SETTINGS TOOLS:") assert.Contains(t, ConfigContext(), "TASK TOOLS:") assert.Contains(t, ConfigContext(), "INTERACTION:") assert.Contains(t, ConfigContext(), "EXAMPLE REQUESTS") @@ -87,6 +93,28 @@ func TestConfigContext_DocumentsWorkflowStepSignalGate(t *testing.T) { assert.Contains(t, ctx, "update_workflow_step_kandev") } +func TestConfigContext_DocumentsSettingsDiscoveryFlow(t *testing.T) { + ctx := ConfigContext() + sectionStart := strings.Index(ctx, "\nSETTINGS TOOLS:\n") + assert.GreaterOrEqual(t, sectionStart, 0) + ctx = ctx[sectionStart:] + orderedTools := []string{ + "search_settings_kandev", + "describe_setting_kandev", + "list_settings_resources_kandev", + "get_settings_kandev", + "update_settings_kandev", + } + previous := -1 + for _, tool := range orderedTools { + position := strings.Index(ctx, tool) + assert.Greater(t, position, previous, "settings discovery must keep %s after the previous step", tool) + previous = position + } + assert.Contains(t, ctx, "Do not guess resource IDs") + assert.Contains(t, ctx, "Describe the setting with describe_setting_kandev before reading or writing it.") +} + func TestInjectConfigContext_WrapsInSystemTags(t *testing.T) { result := InjectConfigContext(testSessionID, testConfigPrompt) assert.True(t, strings.HasPrefix(result, TagStart)) diff --git a/apps/backend/internal/system/storage/handler.go b/apps/backend/internal/system/storage/handler.go index 6eddf9bc6a..98fb5afa5b 100644 --- a/apps/backend/internal/system/storage/handler.go +++ b/apps/backend/internal/system/storage/handler.go @@ -2,6 +2,7 @@ package storage import ( "context" + "encoding/json" "errors" "net/http" "strconv" @@ -14,6 +15,10 @@ type SettingsManager interface { SaveSettingsWithConfirmations(context.Context, StorageMaintenanceSettings, SaveConfirmations) (StorageMaintenanceSettings, error) } +type SettingsPatcher interface { + PatchSettingsWithConfirmations(context.Context, map[string]json.RawMessage, SaveConfirmations) (StorageMaintenanceSettings, error) +} + type RunLister interface { ListRuns(context.Context, int) ([]MaintenanceRun, error) } @@ -105,6 +110,39 @@ func NewHandler(config HandlerConfig) *Handler { return &Handler{config: config} } +func (h *Handler) GetSettings(ctx context.Context) (StorageMaintenanceSettings, error) { + if h == nil || h.config.Settings == nil { + return StorageMaintenanceSettings{}, errors.New("storage settings are unavailable") + } + return h.config.Settings.GetSettings(ctx) +} + +func (h *Handler) SaveSettingsWithConfirmations(ctx context.Context, settings StorageMaintenanceSettings, confirmations SaveConfirmations) (StorageMaintenanceSettings, error) { + if h == nil || h.config.Settings == nil { + return StorageMaintenanceSettings{}, errors.New("storage settings are unavailable") + } + updated, err := h.config.Settings.SaveSettingsWithConfirmations(ctx, settings, confirmations) + if err == nil && h.config.OnSettingsChanged != nil { + h.config.OnSettingsChanged(updated) + } + return updated, err +} + +func (h *Handler) PatchSettingsWithConfirmations(ctx context.Context, changes map[string]json.RawMessage, confirmations SaveConfirmations) (StorageMaintenanceSettings, error) { + if h == nil || h.config.Settings == nil { + return StorageMaintenanceSettings{}, errors.New("storage settings are unavailable") + } + patcher, ok := h.config.Settings.(SettingsPatcher) + if !ok { + return StorageMaintenanceSettings{}, errors.New("atomic storage settings patch is unavailable") + } + updated, err := patcher.PatchSettingsWithConfirmations(ctx, changes, confirmations) + if err == nil && h.config.OnSettingsChanged != nil { + h.config.OnSettingsChanged(updated) + } + return updated, err +} + func (h *Handler) logError(message string, err error) { if h.config.LogError != nil { h.config.LogError(message, err) diff --git a/apps/backend/internal/system/storage/settings.go b/apps/backend/internal/system/storage/settings.go index 3a59bc6852..639902cb6c 100644 --- a/apps/backend/internal/system/storage/settings.go +++ b/apps/backend/internal/system/storage/settings.go @@ -5,6 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "strconv" + "strings" + "sync" systemsettings "github.com/kandev/kandev/internal/system/settings" ) @@ -13,6 +16,7 @@ const settingsKey = "storage_maintenance" type SettingsStore struct { settings *systemsettings.Store + mu sync.Mutex } type SaveConfirmations struct { @@ -20,11 +24,21 @@ type SaveConfirmations struct { adoptGoCache bool } +func NewSaveConfirmations(dedicatedDocker, adoptGoCache bool) SaveConfirmations { + return SaveConfirmations{DedicatedDocker: dedicatedDocker, adoptGoCache: adoptGoCache} +} + func NewSettingsStore(settings *systemsettings.Store) *SettingsStore { return &SettingsStore{settings: settings} } func (s *SettingsStore) GetSettings(ctx context.Context) (StorageMaintenanceSettings, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.getSettings(ctx) +} + +func (s *SettingsStore) getSettings(ctx context.Context) (StorageMaintenanceSettings, error) { raw, found, err := s.settings.Get(ctx, settingsKey) if err != nil || !found { return DefaultSettings(), err @@ -51,12 +65,22 @@ func (s *SettingsStore) SaveSettingsWithConfirmations( ctx context.Context, settings StorageMaintenanceSettings, confirmations SaveConfirmations, +) (StorageMaintenanceSettings, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.saveSettingsWithConfirmations(ctx, settings, confirmations) +} + +func (s *SettingsStore) saveSettingsWithConfirmations( + ctx context.Context, + settings StorageMaintenanceSettings, + confirmations SaveConfirmations, ) (StorageMaintenanceSettings, error) { normalized, err := NormalizeSettings(settings) if err != nil { return StorageMaintenanceSettings{}, err } - current, err := s.GetSettings(ctx) + current, err := s.getSettings(ctx) if err != nil { if !errors.Is(err, ErrInvalidPersistedSettings) { return StorageMaintenanceSettings{}, err @@ -80,6 +104,78 @@ func (s *SettingsStore) SaveSettingsWithConfirmations( return normalized, nil } +// PatchSettingsWithConfirmations merges a bounded JSON patch while holding the +// storage ownership lock. The current document, confirmation checks, result +// validation, and write therefore share one atomic boundary with HTTP saves. +func (s *SettingsStore) PatchSettingsWithConfirmations( + ctx context.Context, + changes map[string]json.RawMessage, + confirmations SaveConfirmations, +) (StorageMaintenanceSettings, error) { + s.mu.Lock() + defer s.mu.Unlock() + current, err := s.getSettings(ctx) + if err != nil { + if !errors.Is(err, ErrInvalidPersistedSettings) { + return StorageMaintenanceSettings{}, err + } + current = DefaultSettings() + } + payload, err := json.Marshal(current) + if err != nil { + return StorageMaintenanceSettings{}, err + } + var document map[string]any + if err := json.Unmarshal(payload, &document); err != nil { + return StorageMaintenanceSettings{}, err + } + for path, raw := range changes { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return StorageMaintenanceSettings{}, fmt.Errorf("invalid storage setting %q: %w", path, err) + } + if err := setNestedJSONValue(document, path, value); err != nil { + return StorageMaintenanceSettings{}, err + } + } + nextPayload, err := json.Marshal(document) + if err != nil { + return StorageMaintenanceSettings{}, err + } + var next StorageMaintenanceSettings + if err := json.Unmarshal(nextPayload, &next); err != nil { + return StorageMaintenanceSettings{}, fmt.Errorf("invalid storage settings: %w", err) + } + return s.saveSettingsWithCurrent(ctx, next, current, confirmations) +} + +func (s *SettingsStore) saveSettingsWithCurrent( + ctx context.Context, + settings StorageMaintenanceSettings, + current StorageMaintenanceSettings, + confirmations SaveConfirmations, +) (StorageMaintenanceSettings, error) { + normalized, err := NormalizeSettings(settings) + if err != nil { + return StorageMaintenanceSettings{}, err + } + if normalized.GoCache.AdoptedPath != current.GoCache.AdoptedPath && !confirmations.adoptGoCache { + return StorageMaintenanceSettings{}, ErrAdoptionRequired + } + if normalized.Docker.DedicatedDaemonAcknowledged && + !current.Docker.DedicatedDaemonAcknowledged && !confirmations.DedicatedDocker { + return StorageMaintenanceSettings{}, ErrDedicatedDockerConfirmation + } + raw, err := json.Marshal(normalized) + if err != nil { + return StorageMaintenanceSettings{}, err + } + if err := s.settings.Save(ctx, settingsKey, raw); err != nil { + return StorageMaintenanceSettings{}, err + } + return normalized, nil +} + func (s *SettingsStore) AdoptGoCachePath( ctx context.Context, path string, @@ -87,13 +183,24 @@ func (s *SettingsStore) AdoptGoCachePath( if path == "" { return StorageMaintenanceSettings{}, validationError("go_cache.adopted_path is required") } - settings, err := s.GetSettings(ctx) - if err != nil { - if !errors.Is(err, ErrInvalidPersistedSettings) { - return StorageMaintenanceSettings{}, err - } - settings = DefaultSettings() - } - settings.GoCache.AdoptedPath = path - return s.SaveSettingsWithConfirmations(ctx, settings, SaveConfirmations{adoptGoCache: true}) + return s.PatchSettingsWithConfirmations(ctx, map[string]json.RawMessage{ + "go_cache.adopted_path": json.RawMessage(strconv.Quote(path)), + }, SaveConfirmations{adoptGoCache: true}) +} + +func setNestedJSONValue(document map[string]any, path string, value any) error { + parts := strings.Split(path, ".") + if len(parts) == 0 || parts[0] == "" { + return fmt.Errorf("storage setting path %q is invalid", path) + } + current := document + for _, part := range parts[:len(parts)-1] { + nested, ok := current[part].(map[string]any) + if !ok { + return fmt.Errorf("storage setting path %q is invalid", path) + } + current = nested + } + current[parts[len(parts)-1]] = value + return nil } diff --git a/apps/backend/internal/system/storage/settings_test.go b/apps/backend/internal/system/storage/settings_test.go index 3358cfb7c8..e006ef26df 100644 --- a/apps/backend/internal/system/storage/settings_test.go +++ b/apps/backend/internal/system/storage/settings_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "testing" "github.com/jmoiron/sqlx" @@ -105,6 +106,47 @@ func TestSettingsStoreInvalidSavePreservesPreviousValue(t *testing.T) { } } +func TestPatchSettingsPreservesDisjointConcurrentUpdates(t *testing.T) { + store, _ := newTestStores(t) + ctx := context.Background() + if _, err := store.SaveSettings(ctx, DefaultSettings()); err != nil { + t.Fatalf("seed settings: %v", err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + errors := make(chan error, 2) + patches := []map[string]json.RawMessage{ + {"enabled": json.RawMessage(`true`)}, + {"workspaces.enabled": json.RawMessage(`false`)}, + } + for _, patch := range patches { + wg.Add(1) + go func(patch map[string]json.RawMessage) { + defer wg.Done() + <-start + _, err := store.PatchSettingsWithConfirmations(ctx, patch, SaveConfirmations{}) + errors <- err + }(patch) + } + close(start) + wg.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatalf("concurrent patch: %v", err) + } + } + + got, err := store.GetSettings(ctx) + if err != nil { + t.Fatalf("read patched settings: %v", err) + } + if !got.Enabled || got.Workspaces.Enabled { + t.Fatalf("disjoint patches lost an update: %#v", got) + } +} + func TestNormalizeSettingsValidatesRangesAndDockerAcknowledgement(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 00c63e0221..742f4c6565 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -253,6 +253,7 @@ type UpdateRepositoryRequest struct { ProviderScope *string `json:"provider_scope,omitempty"` ProviderOwner *string `json:"provider_owner,omitempty"` ProviderName *string `json:"provider_name,omitempty"` + RemoteURL *string `json:"remote_url,omitempty"` DefaultBranch *string `json:"default_branch,omitempty"` WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` WorktreeBranchTemplate *string `json:"worktree_branch_template,omitempty"` diff --git a/apps/backend/internal/task/service/service_requests_test.go b/apps/backend/internal/task/service/service_requests_test.go index 13dee8b56c..9ce0608244 100644 --- a/apps/backend/internal/task/service/service_requests_test.go +++ b/apps/backend/internal/task/service/service_requests_test.go @@ -7,7 +7,7 @@ import ( "github.com/kandev/kandev/internal/task/models" ) -func TestApplyRepositoryUpdates_IgnoresRemoteURLFromJSON(t *testing.T) { +func TestApplyRepositoryUpdates_AppliesRemoteURLFromJSON(t *testing.T) { repo := &models.Repository{RemoteURL: "https://github.com/owner/old.git"} var updates UpdateRepositoryRequest if err := json.Unmarshal([]byte(`{"remote_url":"https://github.com/owner/repo.git"}`), &updates); err != nil { @@ -16,7 +16,7 @@ func TestApplyRepositoryUpdates_IgnoresRemoteURLFromJSON(t *testing.T) { if err := applyRepositoryUpdates(repo, &updates); err != nil { t.Fatalf("applyRepositoryUpdates: %v", err) } - if repo.RemoteURL != "https://github.com/owner/old.git" { - t.Errorf("RemoteURL = %q, want original value %q", repo.RemoteURL, "https://github.com/owner/old.git") + if repo.RemoteURL != "https://github.com/owner/repo.git" { + t.Errorf("RemoteURL = %q, want updated value", repo.RemoteURL) } } diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index e70f26b029..e2c9560af1 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -1267,6 +1267,9 @@ func applyRepositoryUpdates(repository *models.Repository, req *UpdateRepository if req.ProviderName != nil { repository.ProviderName = *req.ProviderName } + if req.RemoteURL != nil { + repository.RemoteURL = strings.TrimSpace(*req.RemoteURL) + } if req.DefaultBranch != nil { if *req.DefaultBranch != "" && !securityutil.IsValidDefaultBranchName(*req.DefaultBranch) { return fmt.Errorf("%w: invalid default branch", ErrInvalidRepositorySettings) diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index e2f6ab6042..c8b5f5a84a 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -218,6 +218,7 @@ const ( ActionTaskReviewFindingUpdated = "task.review.finding_updated" ActionTaskReviewCleared = "task.review.cleared" ActionAgentUpdated = "agent.updated" + ActionAgentProfileMCPConfigUpdated = "agent.profile.mcp_config.updated" ActionAgentAvailableUpdated = "agent.available.updated" ActionAgentInstallStarted = "agent.install.started" ActionAgentInstallOutput = "agent.install.output" @@ -487,14 +488,19 @@ const ( ActionMCPListAgents = "mcp.list_agents" ActionMCPUpdateAgent = "mcp.update_agent" - ActionMCPListAgentProfiles = "mcp.list_agent_profiles" - ActionMCPCreateAgentProfile = "mcp.create_agent_profile" - ActionMCPUpdateAgentProfile = "mcp.update_agent_profile" - ActionMCPDeleteAgentProfile = "mcp.delete_agent_profile" - ActionMCPGetMcpConfig = "mcp.get_mcp_config" - ActionMCPUpdateMcpConfig = "mcp.update_mcp_config" - ActionMCPListSharedPrompts = "mcp.list_shared_prompts" - ActionMCPGetSharedPrompt = "mcp.get_shared_prompt" + ActionMCPListAgentProfiles = "mcp.list_agent_profiles" + ActionMCPCreateAgentProfile = "mcp.create_agent_profile" + ActionMCPUpdateAgentProfile = "mcp.update_agent_profile" + ActionMCPDeleteAgentProfile = "mcp.delete_agent_profile" + ActionMCPGetMcpConfig = "mcp.get_mcp_config" + ActionMCPUpdateMcpConfig = "mcp.update_mcp_config" + ActionMCPListSharedPrompts = "mcp.list_shared_prompts" + ActionMCPGetSharedPrompt = "mcp.get_shared_prompt" + ActionMCPSearchSettings = "mcp.search_settings" + ActionMCPDescribeSetting = "mcp.describe_setting" + ActionMCPGetSettings = "mcp.get_settings" + ActionMCPUpdateSettings = "mcp.update_settings" + ActionMCPListSettingsResources = "mcp.list_settings_resources" ActionMCPListExecutors = "mcp.list_executors" ActionMCPListExecutorProfiles = "mcp.list_executor_profiles" diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx index d37b3432c3..643ca9f961 100644 --- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -1,6 +1,9 @@ "use client"; import { useTranslation } from "react-i18next"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; +import { Button } from "@kandev/ui/button"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; @@ -328,6 +331,16 @@ type McpEnableToggleProps = { setMcpEnabled: (enabled: boolean) => void; }; +function resolveMcpInvalidReason( + canManage: boolean, + mcpConflict: boolean, + currentError: string | null, +) { + if (!canManage) return translate("agents:adminOnly"); + if (mcpConflict) return translate("agents:profileExternalChangeInvalidReason"); + return currentError ?? undefined; +} + function McpEnableToggle({ currentEnabled, isDirty, @@ -398,6 +411,7 @@ export function ProfileMcpConfigCard({ mcpBaselineEnabled, mcpBaselineServers, mcpError, + mcpConflict, setMcpEnabled, handleMcpServersChange, handleSaveMcp, @@ -422,8 +436,8 @@ export function ProfileMcpConfigCard({ }), isDirty: supportsMcp && state.isEditableProfile && state.currentDirty, // Same org.config.manage gate as the agent form this card saves beside. - canSave: canManage && !state.currentError, - invalidReason: canManage ? (state.currentError ?? undefined) : t("agents:adminOnly"), + canSave: canManage && !state.currentError && !mcpConflict, + invalidReason: resolveMcpInvalidReason(canManage, mcpConflict, state.currentError), save: handleSaveMcp, discard: resetMcpDraft, }); @@ -436,6 +450,24 @@ export function ProfileMcpConfigCard({ {t("agents:mcpConfiguration")} + {mcpConflict ? ( + + + {t("agents:profileExternalChangeTitle")} + + {t("agents:profileExternalChangeDescription")} + + + + ) : null} { expect(result.current.mcpServers).toContain("newer"); expect(result.current.mcpDirty).toBe(true); }); + + it("ignores an in-flight load invalidated by a save", async () => { + let finishLoad: (config: AgentProfileMcpConfig) => void = () => undefined; + vi.mocked(getAgentProfileMcpConfigAction).mockImplementation( + () => + new Promise((resolve) => { + finishLoad = resolve; + }), + ); + let finishSave: (config: AgentProfileMcpConfig) => void = () => undefined; + vi.mocked(updateAgentProfileMcpConfigAction).mockImplementation( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + + const { result } = renderHook(() => + useProfileMcpConfig({ + profileId: "profile-1", + supportsMcp: true, + onToastError: vi.fn(), + }), + ); + + act(() => result.current.handleMcpServersChange(serverText("submitted"))); + let savePromise!: Promise; + act(() => { + savePromise = result.current.handleSaveMcp(); + }); + await act(async () => { + finishLoad(config({ command: "stale" })); + await Promise.resolve(); + }); + + expect(result.current.mcpConflict).toBe(false); + expect(result.current.mcpServers).toContain("submitted"); + + await act(async () => { + finishSave(config({ command: "submitted" })); + await savePromise; + }); + + expect(result.current.mcpConflict).toBe(false); + expect(result.current.mcpServers).toContain("submitted"); + expect(result.current.mcpDirty).toBe(false); + }); }); function config(server: Record): AgentProfileMcpConfig { diff --git a/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts b/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts index ef748e5620..164b9dbaf4 100644 --- a/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts +++ b/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts @@ -1,11 +1,13 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { getAgentProfileMcpConfigAction, updateAgentProfileMcpConfigAction, } from "@/app/actions/agents"; import { t } from "@/lib/i18n"; +import { useWebSocketClient } from "@/lib/ws/connection"; import type { AgentProfileMcpConfig, McpServerDef } from "@/lib/types/http"; type McpStatus = "idle" | "loading" | "success" | "error"; @@ -23,6 +25,7 @@ type UseProfileMcpConfigResult = { mcpBaselineEnabled: boolean; mcpBaselineServers: string; mcpError: string | null; + mcpConflict: boolean; mcpDirty: boolean; mcpStatus: McpStatus; setMcpEnabled: (enabled: boolean) => void; @@ -39,9 +42,10 @@ const isEmptyExample = (value: string) => value.trim() === EMPTY_EXAMPLE.trim(); type McpStateSetters = { setMcpConfig: (value: AgentProfileMcpConfig | null) => void; - setMcpEnabledState: (value: boolean) => void; - setMcpServers: (value: string) => void; + setMcpEnabledState: Dispatch>; + setMcpServers: Dispatch>; setMcpDirty: (value: boolean) => void; + setMcpConflict: (value: boolean) => void; setMcpError: (value: string | null) => void; setMcpStatus: (value: McpStatus) => void; }; @@ -81,6 +85,7 @@ function useResetOnUnsupported( setters.setMcpEnabledState(false); setters.setMcpServers(EMPTY_EXAMPLE); setters.setMcpDirty(false); + setters.setMcpConflict(false); setters.setMcpError(null); setters.setMcpStatus("idle"); }); @@ -91,45 +96,6 @@ function useResetOnUnsupported( }, [supportsMcp, isEditableProfile]); } -function useFetchConfig( - profileId: string, - supportsMcp: boolean, - isEditableProfile: boolean, - hasInitialConfig: boolean, - setters: McpStateSetters, -) { - useEffect(() => { - if (!supportsMcp || !isEditableProfile || hasInitialConfig) return; - let active = true; - Promise.resolve().then(() => { - if (!active) return; - setters.setMcpStatus("loading"); - }); - getAgentProfileMcpConfigAction(profileId) - .then((config) => { - if (!active) return; - setters.setMcpConfig(config); - setters.setMcpEnabledState(config.enabled); - setters.setMcpServers(serializeServers(config)); - setters.setMcpDirty(false); - setters.setMcpError(null); - setters.setMcpStatus("idle"); - }) - .catch((error) => { - if (!active) return; - setters.setMcpError( - error instanceof Error ? error.message : t("agents:failedToLoadMcpConfig"), - ); - setters.setMcpStatus("error"); - }); - - return () => { - active = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only tracking these deps - }, [profileId, supportsMcp, isEditableProfile, hasInitialConfig]); -} - function useLatestMcpDraft(enabled: boolean, servers: string) { const ref = useRef({ enabled, servers }); ref.current = { enabled, servers }; @@ -140,6 +106,7 @@ function resetMcpDraftState(config: AgentProfileMcpConfig | null, setters: McpSt setters.setMcpEnabledState(config?.enabled ?? false); setters.setMcpServers(serializeServers(config)); setters.setMcpDirty(false); + setters.setMcpConflict(false); setters.setMcpError(null); setters.setMcpStatus("idle"); } @@ -151,66 +118,117 @@ function mcpBaseline(config: AgentProfileMcpConfig | null) { }; } -export function useProfileMcpConfig({ +type McpConfigLoaderParams = { + profileId: string; + supportsMcp: boolean; + isEditableProfile: boolean; + hasInitialConfig: boolean; + latestDraftRef: MutableRefObject<{ enabled: boolean; servers: string }>; + requestGenerationRef: MutableRefObject; + baselineRef: MutableRefObject; + conflictRef: MutableRefObject; + setters: McpStateSetters; +}; + +function useMcpConfigLoader({ profileId, supportsMcp, - initialConfig, + isEditableProfile, + hasInitialConfig, + latestDraftRef, + requestGenerationRef, + baselineRef, + conflictRef, + setters, +}: McpConfigLoaderParams) { + const loadConfig = useCallback(() => { + if (!supportsMcp || !isEditableProfile) return; + const generation = ++requestGenerationRef.current; + setters.setMcpStatus("loading"); + getAgentProfileMcpConfigAction(profileId) + .then((config) => { + if (generation !== requestGenerationRef.current) return; + const currentBaseline = baselineRef.current; + const draftWasClean = + !conflictRef.current && + latestDraftRef.current.enabled === (currentBaseline?.enabled ?? false) && + latestDraftRef.current.servers === serializeServers(currentBaseline); + const draftMatchesIncoming = + latestDraftRef.current.enabled === config.enabled && + latestDraftRef.current.servers === serializeServers(config); + setters.setMcpConfig(config); + if (draftWasClean || draftMatchesIncoming) { + setters.setMcpEnabledState(config.enabled); + setters.setMcpServers(serializeServers(config)); + setters.setMcpDirty(false); + setters.setMcpConflict(false); + } else { + setters.setMcpDirty(true); + setters.setMcpConflict(true); + } + setters.setMcpError(null); + setters.setMcpStatus("idle"); + }) + .catch((error) => { + if (generation !== requestGenerationRef.current) return; + setters.setMcpError( + error instanceof Error ? error.message : t("agents:failedToLoadMcpConfig"), + ); + setters.setMcpStatus("error"); + }); + }, [ + baselineRef, + conflictRef, + isEditableProfile, + latestDraftRef, + profileId, + requestGenerationRef, + setters, + supportsMcp, + ]); + + useEffect(() => { + if (hasInitialConfig) return; + loadConfig(); + return () => { + requestGenerationRef.current += 1; + }; + }, [hasInitialConfig, loadConfig, requestGenerationRef]); + + return loadConfig; +} + +type McpSaveParams = { + profileId: string; + isEditableProfile: boolean; + mcpConflict: boolean; + mcpEnabled: boolean; + mcpServers: string; + mcpConfig: AgentProfileMcpConfig | null; + latestDraftRef: MutableRefObject<{ enabled: boolean; servers: string }>; + requestGenerationRef: MutableRefObject; + onToastError: (error: unknown) => void; + setters: McpStateSetters; +}; + +function useMcpSave({ + profileId, + isEditableProfile, + mcpConflict, + mcpEnabled, + mcpServers, + mcpConfig, + latestDraftRef, + requestGenerationRef, onToastError, -}: UseProfileMcpConfigParams): UseProfileMcpConfigResult { - const initialServers = serializeServers(initialConfig ?? null); - const [mcpConfig, setMcpConfig] = useState(initialConfig ?? null); - const [mcpEnabled, setMcpEnabledState] = useState(initialConfig?.enabled ?? false); - const [mcpServers, setMcpServers] = useState(initialServers); - const [mcpError, setMcpError] = useState(null); - const [mcpDirty, setMcpDirty] = useState(false); - const [mcpStatus, setMcpStatus] = useState("idle"); - const [hasInitialConfig] = useState(initialConfig !== undefined); - const latestDraftRef = useLatestMcpDraft(mcpEnabled, mcpServers); - - const isEditableProfile = Boolean(profileId) && !profileId.startsWith("draft-"); - - const stateSetters = { - setMcpConfig, - setMcpEnabledState, - setMcpServers, - setMcpDirty, - setMcpError, - setMcpStatus, - }; - - useResetOnUnsupported(supportsMcp, isEditableProfile, stateSetters); - useFetchConfig(profileId, supportsMcp, isEditableProfile, hasInitialConfig, stateSetters); - - const setMcpEnabled = (enabled: boolean) => { - setMcpEnabledState(enabled); - setMcpDirty(true); - }; - - const handleMcpServersChange = (value: string) => { - if (!value.trim()) { - setMcpServers(EMPTY_EXAMPLE); - } else { - setMcpServers(value); - } - setMcpDirty(true); - if (!value.trim()) { - setMcpError(null); - return; - } - try { - const parsed = JSON.parse(value); - normalizeServers(parsed); - setMcpError(null); - } catch { - setMcpError(t("agents:invalidJson")); - } - }; - - const handleSaveMcp = async () => { - if (!isEditableProfile) return; + setters, +}: McpSaveParams) { + return useCallback(async () => { + if (!isEditableProfile || mcpConflict) return; + requestGenerationRef.current += 1; const submittedEnabled = mcpEnabled; const submittedServers = mcpServers; - setMcpStatus("loading"); + setters.setMcpStatus("loading"); let servers: Record = {}; try { @@ -218,8 +236,8 @@ export function useProfileMcpConfig({ const parsed = raw.trim() ? JSON.parse(raw) : {}; servers = normalizeServers(parsed); } catch (error) { - setMcpStatus("error"); - setMcpError(error instanceof Error ? error.message : t("agents:invalidMcpConfig")); + setters.setMcpStatus("error"); + setters.setMcpError(error instanceof Error ? error.message : t("agents:invalidMcpConfig")); throw error; } @@ -229,29 +247,140 @@ export function useProfileMcpConfig({ mcpServers: servers, meta: mcpConfig?.meta ?? {}, }); - setMcpConfig(updated); - setMcpEnabledState((current) => (current === submittedEnabled ? updated.enabled : current)); - setMcpServers((current) => + setters.setMcpConfig(updated); + setters.setMcpEnabledState((current) => + current === submittedEnabled ? updated.enabled : current, + ); + setters.setMcpServers((current) => current === submittedServers ? serializeServers(updated) : current, ); - setMcpDirty( + setters.setMcpDirty( latestDraftRef.current.enabled !== submittedEnabled || latestDraftRef.current.servers !== submittedServers, ); - setMcpError(null); - setMcpStatus("success"); + setters.setMcpConflict(false); + setters.setMcpError(null); + setters.setMcpStatus("success"); } catch (error) { - setMcpStatus("error"); + setters.setMcpStatus("error"); onToastError(error); throw error; } + }, [ + isEditableProfile, + latestDraftRef, + mcpConfig?.meta, + mcpConflict, + mcpEnabled, + mcpServers, + onToastError, + profileId, + requestGenerationRef, + setters, + ]); +} + +function updateMcpServersDraft(value: string, setters: McpStateSetters) { + setters.setMcpServers(value.trim() ? value : EMPTY_EXAMPLE); + setters.setMcpDirty(true); + if (!value.trim()) { + setters.setMcpError(null); + return; + } + try { + normalizeServers(JSON.parse(value)); + setters.setMcpError(null); + } catch { + setters.setMcpError(t("agents:invalidJson")); + } +} + +export function useProfileMcpConfig({ + profileId, + supportsMcp, + initialConfig, + onToastError, +}: UseProfileMcpConfigParams): UseProfileMcpConfigResult { + const websocketClient = useWebSocketClient(); + const initialServers = serializeServers(initialConfig ?? null); + const [mcpConfig, setMcpConfig] = useState(initialConfig ?? null); + const [mcpEnabled, setMcpEnabledState] = useState(initialConfig?.enabled ?? false); + const [mcpServers, setMcpServers] = useState(initialServers); + const [mcpError, setMcpError] = useState(null); + const [mcpConflict, setMcpConflict] = useState(false); + const [mcpDirty, setMcpDirty] = useState(false); + const [mcpStatus, setMcpStatus] = useState("idle"); + const [hasInitialConfig] = useState(initialConfig !== undefined); + const latestDraftRef = useLatestMcpDraft(mcpEnabled, mcpServers); + const requestGenerationRef = useRef(0); + const baselineRef = useRef(mcpConfig); + const conflictRef = useRef(mcpConflict); + baselineRef.current = mcpConfig; + conflictRef.current = mcpConflict; + + const isEditableProfile = Boolean(profileId) && !profileId.startsWith("draft-"); + + const stateSetters = useMemo( + () => ({ + setMcpConfig, + setMcpEnabledState, + setMcpServers, + setMcpDirty, + setMcpConflict, + setMcpError, + setMcpStatus, + }), + [], + ); + + useResetOnUnsupported(supportsMcp, isEditableProfile, stateSetters); + + const loadConfig = useMcpConfigLoader({ + profileId, + supportsMcp, + isEditableProfile, + hasInitialConfig, + latestDraftRef, + requestGenerationRef, + baselineRef, + conflictRef, + setters: stateSetters, + }); + + useEffect(() => { + if (!websocketClient || !supportsMcp || !isEditableProfile) return; + return websocketClient.on("agent.profile.mcp_config.updated", (message) => { + if (message.payload.profile_id !== profileId) return; + loadConfig(); + }); + }, [isEditableProfile, loadConfig, profileId, supportsMcp, websocketClient]); + + const setMcpEnabled = (enabled: boolean) => { + setMcpEnabledState(enabled); + setMcpDirty(true); }; + const handleMcpServersChange = (value: string) => updateMcpServersDraft(value, stateSetters); + + const handleSaveMcp = useMcpSave({ + profileId, + isEditableProfile, + mcpConflict, + mcpEnabled, + mcpServers, + mcpConfig, + latestDraftRef, + requestGenerationRef, + onToastError, + setters: stateSetters, + }); + return { mcpEnabled, mcpServers, ...mcpBaseline(mcpConfig), mcpError, + mcpConflict, mcpDirty, mcpStatus, setMcpEnabled, diff --git a/apps/web/components/settings/agent-profile-page-state.test.ts b/apps/web/components/settings/agent-profile-page-state.test.ts index 0f00596f3c..0ea8a73469 100644 --- a/apps/web/components/settings/agent-profile-page-state.test.ts +++ b/apps/web/components/settings/agent-profile-page-state.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { Agent } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; -import { reconcileAgentProfileOptions } from "./agent-profile-page-state"; +import { + reconcileAgentProfileOptions, + shouldSyncProfileSaveResponse, +} from "./agent-profile-page-state"; vi.mock("@/app/actions/agents", () => ({ deleteAgentProfileAction: vi.fn(), @@ -130,3 +133,13 @@ describe("reconcileAgentProfileOptions", () => { expect(options[0].updatedAt).toBe("2026-08-11T22:00:00.100Z"); }); }); + +describe("shouldSyncProfileSaveResponse", () => { + it("rejects a response that is older than a newer websocket baseline", () => { + const current = agent("a1", "p1").profiles[0]; + const response = { ...current, name: "stale response", updatedAt: "2026-01-01T00:00:00Z" }; + const baseline = { ...current, name: "websocket update", updatedAt: "2026-01-01T01:00:00Z" }; + + expect(shouldSyncProfileSaveResponse(response, baseline)).toBe(false); + }); +}); diff --git a/apps/web/components/settings/agent-profile-page-state.ts b/apps/web/components/settings/agent-profile-page-state.ts index 6836804595..964ce6a50b 100644 --- a/apps/web/components/settings/agent-profile-page-state.ts +++ b/apps/web/components/settings/agent-profile-page-state.ts @@ -9,7 +9,7 @@ * its toast copy is guarded only by the pseudo-locale. */ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { permissionsToProfilePatch } from "@/lib/agent-permissions"; import { deleteAgentProfileAction, updateAgentProfileAction } from "@/app/actions/agents"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; @@ -24,6 +24,11 @@ import { } from "@/lib/state/slices/settings/types"; import { ApiError, isHandledApiError } from "@/lib/api/client"; import type { Agent, AgentProfile, PermissionSetting } from "@/lib/types/http"; +import { + isProfileRevisionNewer, + reconcileAgentProfileSnapshot, + sameEditableProfile, +} from "@/components/settings/agent-profile-reconciliation"; export type SaveStatus = "idle" | "loading" | "success" | "error"; @@ -57,6 +62,13 @@ export function useSyncAgentsToStore() { }; } +export function shouldSyncProfileSaveResponse( + response: AgentProfile, + currentSaved: AgentProfile, +): boolean { + return isProfileRevisionNewer(response, currentSaved); +} + export function useProfileEditorState( profile: AgentProfile, permissionSettings: Record, @@ -64,13 +76,85 @@ export function useProfileEditorState( const [draft, setDraft] = useState({ ...profile }); const [savedProfile, setSavedProfile] = useState(profile); const [saveStatus, setSaveStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [hasExternalConflict, setHasExternalConflict] = useState(false); + const previousProfileRef = useRef(profile); + const submittedProfileRef = useRef(null); + const draftRef = useRef(draft); + const savedProfileRef = useRef(savedProfile); + draftRef.current = draft; + savedProfileRef.current = savedProfile; + + useEffect(() => { + const previous = previousProfileRef.current; + previousProfileRef.current = profile; + if (profile.id !== previous.id) { + submittedProfileRef.current = null; + setDraft(profile); + setSavedProfile(profile); + setHasExternalConflict(false); + setSaveStatus("idle"); + return; + } + + const result = reconcileAgentProfileSnapshot({ + previous, + incoming: profile, + draft: draftRef.current, + saved: savedProfileRef.current, + submitted: submittedProfileRef.current, + conflicted: hasExternalConflict, + }); + if (result.kind === "ignored") return; + setSavedProfile(result.saved); + setDraft(result.draft); + setHasExternalConflict(result.conflicted); + if (result.kind === "own-acknowledgement") submittedProfileRef.current = null; + }, [hasExternalConflict, profile]); + + const markProfileSubmitted = useCallback((submitted: AgentProfile | null) => { + submittedProfileRef.current = submitted; + }, []); + + const acceptProfileSaveResponse = useCallback( + (response: AgentProfile, submitted: AgentProfile): boolean => { + const currentSaved = savedProfileRef.current; + if (!shouldSyncProfileSaveResponse(response, currentSaved)) { + submittedProfileRef.current = null; + return false; + } + setSavedProfile(response); + if (sameEditableProfile(draftRef.current, submitted)) setDraft(response); + setHasExternalConflict(false); + submittedProfileRef.current = null; + return true; + }, + [], + ); + + const discardProfileDraft = useCallback(() => { + setDraft(savedProfileRef.current); + setHasExternalConflict(false); + submittedProfileRef.current = null; + }, []); const isDirty = useMemo( () => isProfileDirty(draft, savedProfile, permissionSettings), [draft, savedProfile, permissionSettings], ); - return { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty }; + return { + draft, + setDraft, + savedProfile, + setSavedProfile, + saveStatus, + setSaveStatus, + isDirty, + hasExternalConflict, + markProfileSubmitted, + acceptProfileSaveResponse, + discardProfileDraft, + }; } export function errorMessage(error: unknown): string { @@ -81,9 +165,9 @@ type ProfileEditorActionsOptions = { agent: Agent; draft: AgentProfile; savedProfile: AgentProfile; - setSavedProfile: (p: AgentProfile) => void; - setDraft: React.Dispatch>; setSaveStatus: (s: SaveStatus) => void; + markProfileSubmitted: (profile: AgentProfile | null) => void; + acceptProfileSaveResponse: (response: AgentProfile, submitted: AgentProfile) => boolean; settingsAgents: Agent[]; syncAgentsToStore: (agents: Agent[]) => void; toast: ReturnType["toast"]; @@ -94,9 +178,9 @@ export function useProfileSave({ agent, draft, savedProfile, - setSavedProfile, - setDraft, setSaveStatus, + markProfileSubmitted, + acceptProfileSaveResponse, settingsAgents, syncAgentsToStore, toast, @@ -115,6 +199,8 @@ export function useProfileSave({ // Model is optional — an empty profile model means "use the agent's // default", which is applied through ACP session model selection at session start. setSaveStatus("loading"); + const submitted = draft; + markProfileSubmitted(submitted); try { const updated = await updateAgentProfileAction( draft.id, @@ -139,21 +225,22 @@ export function useProfileSave({ }, force, ); - setSavedProfile(updated); - setDraft((current) => preserveNewerProfileDraft(current, draft, updated)); - const nextAgents = settingsAgents.map((agentItem: Agent) => - agentItem.id === agent.id - ? { - ...agentItem, - profiles: agentItem.profiles.map((p: AgentProfile) => - p.id === updated.id ? updated : p, - ), - } - : agentItem, - ); - syncAgentsToStore(nextAgents); + if (acceptProfileSaveResponse(updated, submitted)) { + const nextAgents = settingsAgents.map((agentItem: Agent) => + agentItem.id === agent.id + ? { + ...agentItem, + profiles: agentItem.profiles.map((p: AgentProfile) => + p.id === updated.id ? updated : p, + ), + } + : agentItem, + ); + syncAgentsToStore(nextAgents); + } setSaveStatus("success"); } catch (error) { + markProfileSubmitted(null); if ( error instanceof ApiError && error.status === 409 && diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index b409bddf80..06450d7132 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -5,9 +5,11 @@ import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { useParams } from "@/lib/routing/client-router"; import { IconCopy, IconTrash } from "@tabler/icons-react"; +import { IconAlertTriangle } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Separator } from "@kandev/ui/separator"; import { Switch } from "@kandev/ui/switch"; import { useToast } from "@/components/toast-provider"; @@ -401,8 +403,17 @@ function ProfileEditor({ const settingsAgents = useAppStore((state) => state.settingsAgents.items); const syncAgentsToStore = useSyncAgentsToStore(); const { items: secrets } = useSecrets(); - const { draft, setDraft, savedProfile, setSavedProfile, setSaveStatus, isDirty } = - useProfileEditorState(profile, permissionSettings); + const { + draft, + setDraft, + savedProfile, + setSaveStatus, + isDirty, + hasExternalConflict, + markProfileSubmitted, + acceptProfileSaveResponse, + discardProfileDraft, + } = useProfileEditorState(profile, permissionSettings); const [utilityConflict, setUtilityConflict] = useState([]); const updateDraft = useCallback( (patch: Partial) => { @@ -422,9 +433,9 @@ function ProfileEditor({ agent, draft, savedProfile, - setSavedProfile, - setDraft, setSaveStatus, + markProfileSubmitted, + acceptProfileSaveResponse, settingsAgents, syncAgentsToStore, toast, @@ -434,10 +445,12 @@ function ProfileEditor({ id: `agent-profile:${draft.id}`, revision: JSON.stringify(draft), isDirty, - canSave: Boolean(draft.name.trim()) && !modelConfigResolutionPending, - invalidReason: profileSaveInvalidReason(draft.name, modelConfigResolutionPending, t), + canSave: Boolean(draft.name.trim()) && !modelConfigResolutionPending && !hasExternalConflict, + invalidReason: hasExternalConflict + ? t("agents:profileExternalChangeInvalidReason") + : profileSaveInvalidReason(draft.name, modelConfigResolutionPending, t), save: () => handleSave(), - discard: () => setDraft(savedProfile), + discard: discardProfileDraft, }); const deleteState = useProfileDelete(agent, draft, settingsAgents, syncAgentsToStore, toast); @@ -450,6 +463,24 @@ function ProfileEditor({ return (
+ {hasExternalConflict ? ( + + + {t("agents:profileExternalChangeTitle")} + + {t("agents:profileExternalChangeDescription")} + + + + ) : null} = {}): AgentProfile { + return { + id: "profile-1" as AgentProfile["id"], + name: "Default", + agentId: "agent-1", + agentDisplayName: "Agent", + model: "model-a", + allowIndexing: false, + autoApprove: false, + cliFlags: [], + cliPassthrough: false, + createdAt: BASE_UPDATED_AT, + updatedAt: BASE_UPDATED_AT, + ...overrides, + }; +} + +describe("sameEditableProfile", () => { + it("ignores timestamps and object key order", () => { + expect( + sameEditableProfile( + profile({ configOptions: { z: "2", a: "1" }, updatedAt: UPDATED_AT }), + profile({ configOptions: { a: "1", z: "2" }, updatedAt: UPDATED_AT }), + ), + ).toBe(true); + }); +}); + +describe("reconcileAgentProfileSnapshot", () => { + it("adopts a newer update when the editor is clean", () => { + const previous = profile(); + const incoming = profile({ name: "Assistant", updatedAt: UPDATED_AT }); + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft: previous, + saved: previous, + conflicted: false, + }); + + expect(result.kind).toBe("clean-adopted"); + expect(result.draft.name).toBe("Assistant"); + expect(result.conflicted).toBe(false); + }); + + it("preserves a dirty draft and records a recoverable conflict", () => { + const previous = profile(); + const draft = profile({ name: "Local draft" }); + const incoming = profile({ name: "Assistant", updatedAt: UPDATED_AT }); + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft, + saved: previous, + conflicted: false, + }); + + expect(result.kind).toBe("external-conflict"); + expect(result.draft.name).toBe("Local draft"); + expect(result.saved.name).toBe("Assistant"); + expect(result.conflicted).toBe(true); + }); + + it("treats the matching submitted snapshot as its own acknowledgement", () => { + const previous = profile(); + const submitted = profile({ name: "Submitted" }); + const incoming = profile({ name: "Submitted", updatedAt: UPDATED_AT }); + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft: submitted, + saved: previous, + submitted, + conflicted: false, + }); + + expect(result.kind).toBe("own-acknowledgement"); + expect(result.draft).toBe(incoming); + expect(result.conflicted).toBe(false); + }); + + it("keeps edits made after submission when the acknowledgement arrives", () => { + const previous = profile(); + const submitted = profile({ name: "Submitted" }); + const newerDraft = profile({ name: "Edited again" }); + const incoming = profile({ name: "Submitted", updatedAt: UPDATED_AT }); + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft: newerDraft, + saved: previous, + submitted, + conflicted: false, + }); + + expect(result.kind).toBe("own-acknowledgement"); + expect(result.draft.name).toBe("Edited again"); + expect(result.saved.name).toBe("Submitted"); + }); + + it("ignores a late response older than the current baseline", () => { + const previous = profile({ name: "Current", updatedAt: LATEST_UPDATED_AT }); + const incoming = profile({ name: "Late", updatedAt: UPDATED_AT }); + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft: previous, + saved: previous, + conflicted: false, + }); + + expect(result.kind).toBe("ignored"); + expect(result.saved.name).toBe("Current"); + }); +}); + +describe("reconcileAgentProfileSnapshot dynamic drafts", () => { + it("preserves a dynamic candidate draft during an external routing update", () => { + const candidate = { + position: 0, + executionProfileId: "profile-2" as AgentProfile["id"], + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip" as const, + }, + hard: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip" as const, + }, + }, + }; + const previous = profile({ kind: "dynamic", dynamic: { version: 1, candidates: [] } }); + const draft = profile({ kind: "dynamic", dynamic: { version: 1, candidates: [candidate] } }); + const incoming = profile({ + kind: "dynamic", + dynamic: { version: 2, candidates: [] }, + updatedAt: UPDATED_AT, + }); + + const result = reconcileAgentProfileSnapshot({ + previous, + incoming, + draft, + saved: previous, + conflicted: false, + }); + + expect(result.kind).toBe("external-conflict"); + expect(result.draft.dynamic?.candidates).toHaveLength(1); + expect(result.saved.dynamic?.version).toBe(2); + }); +}); diff --git a/apps/web/components/settings/agent-profile-reconciliation.ts b/apps/web/components/settings/agent-profile-reconciliation.ts new file mode 100644 index 0000000000..9fc76581f7 --- /dev/null +++ b/apps/web/components/settings/agent-profile-reconciliation.ts @@ -0,0 +1,108 @@ +import { compareTimestamps } from "@/lib/state/slices/settings/types"; +import type { AgentProfile } from "@/lib/types/agent-profile"; + +const EDITABLE_FIELDS = [ + "name", + "model", + "fallbackModel", + "autoFallback", + "mode", + "configOptions", + "allowIndexing", + "autoApprove", + "cliFlags", + "commandPrefix", + "envVars", + "cliPassthrough", + "enabled", + "dynamic", +] as const satisfies readonly (keyof AgentProfile)[]; + +type EditableProfile = Pick; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ); +} + +function editableSnapshot(profile: AgentProfile): EditableProfile { + return Object.fromEntries( + EDITABLE_FIELDS.map((field) => [field, profile[field]]), + ) as EditableProfile; +} + +export function sameEditableProfile(left: AgentProfile, right: AgentProfile): boolean { + return ( + JSON.stringify(canonicalize(editableSnapshot(left))) === + JSON.stringify(canonicalize(editableSnapshot(right))) + ); +} + +function incomingIsNewer(incoming: AgentProfile, baseline: AgentProfile): boolean { + const timestampOrder = compareTimestamps(incoming.updatedAt, baseline.updatedAt); + if (timestampOrder !== 0) return timestampOrder > 0; + return !sameEditableProfile(incoming, baseline); +} + +export type ProfileReconciliationInput = { + previous: AgentProfile; + incoming: AgentProfile; + draft: AgentProfile; + saved: AgentProfile; + submitted?: AgentProfile | null; + conflicted: boolean; +}; + +export type ProfileReconciliationResult = { + draft: AgentProfile; + saved: AgentProfile; + conflicted: boolean; + kind: "ignored" | "own-acknowledgement" | "clean-adopted" | "external-conflict"; +}; + +/** + * Reconciles one authoritative profile snapshot with an open local editor. + * The helper never merges competing field values: a dirty draft remains local + * and the newest saved snapshot becomes the baseline for explicit recovery. + */ +export function reconcileAgentProfileSnapshot({ + previous, + incoming, + draft, + saved, + submitted, + conflicted, +}: ProfileReconciliationInput): ProfileReconciliationResult { + if (incoming.id !== previous.id || incoming.id !== saved.id) { + return { draft, saved, conflicted, kind: "ignored" }; + } + + const ownAcknowledgement = Boolean(submitted && sameEditableProfile(incoming, submitted)); + if (!incomingIsNewer(incoming, saved)) { + return { draft, saved, conflicted, kind: "ignored" }; + } + + if (ownAcknowledgement) { + return { + draft: sameEditableProfile(draft, submitted!) ? incoming : draft, + saved: incoming, + conflicted, + kind: "own-acknowledgement", + }; + } + + if (sameEditableProfile(draft, saved)) { + return { draft: incoming, saved: incoming, conflicted, kind: "clean-adopted" }; + } + + return { draft, saved: incoming, conflicted: true, kind: "external-conflict" }; +} + +export function isProfileRevisionNewer(incoming: AgentProfile, baseline: AgentProfile): boolean { + return incomingIsNewer(incoming, baseline); +} diff --git a/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts b/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts index 5b7e0e0b5a..9caa2946a2 100644 --- a/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts +++ b/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import type { AgentProfile } from "@/lib/types/http"; import type { @@ -9,6 +9,11 @@ import type { DynamicErrorClass, DynamicErrorPolicy, } from "@/lib/types/agent-profile"; +import { + isProfileRevisionNewer, + reconcileAgentProfileSnapshot, + sameEditableProfile, +} from "@/components/settings/agent-profile-reconciliation"; type DynamicAgentProfileEditorDraftProps = { profile: AgentProfile; @@ -20,6 +25,9 @@ export type DynamicAgentProfileEditorDraft = { candidates: DynamicAgentCandidate[]; profileEnabled: boolean; dynamicVersion: number; + currentProfile: AgentProfile; + savedProfile: AgentProfile; + hasExternalConflict: boolean; updateName: (name: string) => void; updateProfileEnabled: (enabled: boolean) => void; addCandidate: (executionProfileId: string) => void; @@ -32,6 +40,8 @@ export type DynamicAgentProfileEditorDraft = { patch: Partial, ) => void; applyProfile: (profile: AgentProfile) => void; + markProfileSubmitted: (profile: AgentProfile | null) => void; + acceptProfileSaveResponse: (profile: AgentProfile, submitted: AgentProfile) => void; reset: () => void; }; @@ -66,8 +76,22 @@ export function useDynamicAgentProfileEditorDraft({ ); const [profileEnabled, setProfileEnabled] = useState(profile.enabled !== false); const [dynamicVersion, setDynamicVersion] = useState(profile.dynamic?.version ?? 1); + const [savedProfile, setSavedProfile] = useState(profile); + const [hasExternalConflict, setHasExternalConflict] = useState(false); + const previousProfileRef = useRef(profile); + const submittedProfileRef = useRef(null); + const savedProfileRef = useRef(savedProfile); + const currentProfileRef = useRef(profile); + + const currentProfile = { + ...profile, + name, + enabled: profileEnabled, + dynamic: { version: dynamicVersion, candidates }, + }; + savedProfileRef.current = savedProfile; + currentProfileRef.current = currentProfile; - const initialCandidates = profile.dynamic?.candidates ?? []; const notifyDraft = ( nextName: string, nextCandidates: DynamicAgentCandidate[], @@ -161,24 +185,74 @@ export function useDynamicAgentProfileEditorDraft({ }; const reset = () => { - setName(profile.name); - setProfileEnabled(profile.enabled !== false); - setCandidates(initialCandidates); - setDynamicVersion(profile.dynamic?.version ?? 1); - }; - - const applyProfile = (nextProfile: AgentProfile) => { + const nextProfile = savedProfileRef.current; setName(nextProfile.name); setProfileEnabled(nextProfile.enabled !== false); setCandidates(nextProfile.dynamic?.candidates ?? []); setDynamicVersion(nextProfile.dynamic?.version ?? 1); + setHasExternalConflict(false); + submittedProfileRef.current = null; }; + const applyProfile = useCallback((nextProfile: AgentProfile) => { + setName(nextProfile.name); + setProfileEnabled(nextProfile.enabled !== false); + setCandidates(nextProfile.dynamic?.candidates ?? []); + setDynamicVersion(nextProfile.dynamic?.version ?? 1); + }, []); + + const markProfileSubmitted = useCallback((submitted: AgentProfile | null) => { + submittedProfileRef.current = submitted; + }, []); + + const acceptProfileSaveResponse = useCallback( + (nextProfile: AgentProfile, submitted: AgentProfile) => { + if (!isProfileRevisionNewer(nextProfile, savedProfileRef.current)) { + submittedProfileRef.current = null; + return; + } + setSavedProfile(nextProfile); + if (sameEditableProfile(currentProfileRef.current, submitted)) applyProfile(nextProfile); + setHasExternalConflict(false); + submittedProfileRef.current = null; + }, + [applyProfile], + ); + + useEffect(() => { + const previous = previousProfileRef.current; + previousProfileRef.current = profile; + if (profile.id !== previous.id) { + submittedProfileRef.current = null; + setSavedProfile(profile); + applyProfile(profile); + setHasExternalConflict(false); + return; + } + + const result = reconcileAgentProfileSnapshot({ + previous, + incoming: profile, + draft: currentProfileRef.current, + saved: savedProfileRef.current, + submitted: submittedProfileRef.current, + conflicted: hasExternalConflict, + }); + if (result.kind === "ignored") return; + setSavedProfile(result.saved); + if (!sameEditableProfile(result.draft, currentProfileRef.current)) applyProfile(result.draft); + setHasExternalConflict(result.conflicted); + if (result.kind === "own-acknowledgement") submittedProfileRef.current = null; + }, [applyProfile, hasExternalConflict, profile]); + return { name, candidates, profileEnabled, dynamicVersion, + currentProfile, + savedProfile, + hasExternalConflict, updateName, updateProfileEnabled, addCandidate, @@ -187,6 +261,8 @@ export function useDynamicAgentProfileEditorDraft({ updateCandidate, updateCandidatePolicy, applyProfile, + markProfileSubmitted, + acceptProfileSaveResponse, reset, }; } diff --git a/apps/web/components/settings/dynamic-agent-profile-editor-state.ts b/apps/web/components/settings/dynamic-agent-profile-editor-state.ts index a5ea3ac4a5..36d3f169e9 100644 --- a/apps/web/components/settings/dynamic-agent-profile-editor-state.ts +++ b/apps/web/components/settings/dynamic-agent-profile-editor-state.ts @@ -31,6 +31,7 @@ export type DynamicAgentProfileEditorState = { name: string; profileEnabled: boolean; standalone: boolean; + hasExternalConflict: boolean; routingEnabled: boolean; enabledLabel: string; concreteProfiles: AgentProfile[]; @@ -47,6 +48,7 @@ export type DynamicAgentProfileEditorState = { patch: Partial, ) => void; candidates: DynamicAgentCandidate[]; + discardDraft: () => void; }; function dynamicProfilePayload( @@ -110,13 +112,6 @@ export function useDynamicAgentProfileEditorState({ const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); const draft = useDynamicAgentProfileEditorDraft({ profile, onDraftChange }); - const [savedRevision, setSavedRevision] = useState( - dynamicDraftRevision( - profile.name, - profile.dynamic?.candidates ?? [], - profile.enabled !== false, - ), - ); const [saving, setSaving] = useState(false); const standalone = onDraftChange === undefined; @@ -154,11 +149,14 @@ export function useDynamicAgentProfileEditorState({ !routingEnabled || !draft.name.trim() || draft.candidates.length === 0 || - !profile.dynamic + !profile.dynamic || + draft.hasExternalConflict ) { return; } setSaving(true); + const submitted = draft.currentProfile; + draft.markProfileSubmitted(submitted); try { const draftPayload = { name: draft.name.trim(), @@ -195,16 +193,10 @@ export function useDynamicAgentProfileEditorState({ item.profiles.map((itemProfile) => toAgentProfileOption(item, itemProfile)), ), ); - draft.applyProfile(updated); - setSavedRevision( - dynamicDraftRevision( - updated.name, - updated.dynamic?.candidates ?? draft.candidates, - updated.enabled !== false, - ), - ); + draft.acceptProfileSaveResponse(updated, submitted); toast({ title: t("agents:dynamicProfileSaved") }); } catch (error) { + draft.markProfileSubmitted(null); if (isHandledApiError(error)) return; toast({ title: t("agents:failedToSaveProfile"), @@ -217,6 +209,11 @@ export function useDynamicAgentProfileEditorState({ }; const draftRevision = dynamicDraftRevision(draft.name, draft.candidates, draft.profileEnabled); + const savedRevision = dynamicDraftRevision( + draft.savedProfile.name, + draft.savedProfile.dynamic?.candidates ?? [], + draft.savedProfile.enabled !== false, + ); const policiesValid = draft.candidates.every( (candidate) => isDynamicErrorPolicyValid(candidate.policies.transient) && @@ -225,6 +222,8 @@ export function useDynamicAgentProfileEditorState({ let invalidReason = t("agents:dynamicPolicyValidation"); if (!draft.name.trim()) invalidReason = t("agents:profileNameRequired"); else if (draft.candidates.length === 0) invalidReason = t("agents:noDynamicCandidates"); + else if (draft.hasExternalConflict) + invalidReason = t("agents:profileExternalChangeInvalidReason"); useSettingsSaveContributor({ id: `dynamic-profile:${profile.id}`, revision: draftRevision, @@ -232,6 +231,7 @@ export function useDynamicAgentProfileEditorState({ canSave: routingEnabled && !saving && + !draft.hasExternalConflict && Boolean(draft.name.trim()) && draft.candidates.length > 0 && policiesValid, @@ -240,13 +240,6 @@ export function useDynamicAgentProfileEditorState({ discard: () => { if (!standalone) return; draft.reset(); - setSavedRevision( - dynamicDraftRevision( - profile.name, - profile.dynamic?.candidates ?? [], - profile.enabled !== false, - ), - ); }, }); @@ -254,6 +247,7 @@ export function useDynamicAgentProfileEditorState({ name: draft.name, profileEnabled: draft.profileEnabled, standalone, + hasExternalConflict: draft.hasExternalConflict, routingEnabled, enabledLabel, concreteProfiles, @@ -266,5 +260,6 @@ export function useDynamicAgentProfileEditorState({ updateCandidate: draft.updateCandidate, updateCandidatePolicy: draft.updateCandidatePolicy, candidates: draft.candidates, + discardDraft: draft.reset, }; } diff --git a/apps/web/components/settings/dynamic-agent-profile-editor.tsx b/apps/web/components/settings/dynamic-agent-profile-editor.tsx index c147a22a4b..d62c67c059 100644 --- a/apps/web/components/settings/dynamic-agent-profile-editor.tsx +++ b/apps/web/components/settings/dynamic-agent-profile-editor.tsx @@ -1,7 +1,9 @@ "use client"; import { useTranslation } from "react-i18next"; -import { IconInfoCircle } from "@tabler/icons-react"; +import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; +import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; +import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { Switch } from "@kandev/ui/switch"; @@ -136,6 +138,24 @@ export function DynamicAgentProfileEditor({ onEnabledChange={state.updateProfileEnabled} /> )} + {state.hasExternalConflict ? ( + + + {t("agents:profileExternalChangeTitle")} + + {t("agents:profileExternalChangeDescription")} + + + + ) : null} {state.standalone ? ( diff --git a/apps/web/e2e/tests/settings/mobile-settings-agent-configuration.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-agent-configuration.spec.ts new file mode 100644 index 0000000000..cea2ee346a --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-settings-agent-configuration.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "../../fixtures/test-base"; +import { + expandSettingsToolCalls, + startSettingsSession, + waitForSettingsSession, +} from "./settings-agent-configuration-helpers"; + +test.describe("Mobile agent-accessible settings", () => { + test("keeps the compact settings flow visible on a phone viewport", async ({ + testPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + const originalProfile = await apiClient.getAgentProfile(seedData.agentProfileId); + try { + const session = await startSettingsSession(apiClient, seedData); + const page = await waitForSettingsSession(testPage, apiClient, session.task_id); + + await expandSettingsToolCalls(page); + await expect(page.chat.getByText("Kandev: Search Settings")).toBeVisible(); + await expect(page.chat.getByText("Kandev: Describe Setting")).toBeVisible(); + await expect(page.chat.getByText("Kandev: List Settings Resources")).toBeVisible(); + await expect(page.chat.getByText("Kandev: Get Settings")).toBeVisible(); + await expect(page.chat.getByText("Kandev: Update Settings")).toBeVisible(); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + } finally { + await apiClient.updateAgentProfile(seedData.agentProfileId, { name: originalProfile.name }); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/settings-agent-configuration-helpers.ts b/apps/web/e2e/tests/settings/settings-agent-configuration-helpers.ts new file mode 100644 index 0000000000..0f2d87f32a --- /dev/null +++ b/apps/web/e2e/tests/settings/settings-agent-configuration-helpers.ts @@ -0,0 +1,46 @@ +import { expect, type Page } from "@playwright/test"; +import type { ApiClient } from "../../helpers/api-client"; +import { waitForLatestSessionDone } from "../../helpers/session"; +import type { SeedData } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +export function startSettingsSession(apiClient: ApiClient, seedData: SeedData) { + return apiClient.startConfigChat( + seedData.workspaceId, + seedData.agentProfileId, + [ + 'e2e:message("Settings flow started")', + 'e2e:mcp:kandev:search_settings_kandev({"query":"terminal font size","limit":10})', + 'e2e:mcp:kandev:describe_setting_kandev({"key":"user_settings.terminal_font_size","operation":"update"})', + `e2e:mcp:kandev:list_settings_resources_kandev({"resource_type":"workflow","workspace_id":"${seedData.workspaceId}","limit":10})`, + `e2e:mcp:kandev:get_settings_kandev({"target":{"resource_type":"agent_profile","resource_id":"${seedData.agentProfileId}"},"keys":["name","model"]})`, + `e2e:mcp:kandev:update_settings_kandev({"target":{"resource_type":"agent_profile","resource_id":"${seedData.agentProfileId}"},"changes":{"name":"Agent settings E2E"}})`, + 'e2e:message("Settings flow complete")', + ].join("\n"), + ); +} + +export async function waitForSettingsSession(testPage: Page, apiClient: ApiClient, taskId: string) { + await waitForLatestSessionDone( + apiClient, + taskId, + 1, + "settings MCP session should complete before reading the result", + 30_000, + ); + await testPage.goto(`/t/${taskId}`); + const page = new SessionPage(testPage); + await page.waitForLoad(); + await page.waitForChatIdle({ timeout: 30_000 }); + await expect(page.activeChat().getByText("Settings flow complete", { exact: true })).toBeVisible({ + timeout: 60_000, + }); + return page; +} + +export async function expandSettingsToolCalls(page: SessionPage) { + const headers = page.chat.getByRole("button", { name: /\d+ tool calls?$/ }); + for (let index = 0; index < (await headers.count()); index++) { + await headers.nth(index).click(); + } +} diff --git a/apps/web/e2e/tests/settings/settings-agent-configuration.spec.ts b/apps/web/e2e/tests/settings/settings-agent-configuration.spec.ts new file mode 100644 index 0000000000..f7c4ccbfe2 --- /dev/null +++ b/apps/web/e2e/tests/settings/settings-agent-configuration.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "../../fixtures/test-base"; +import { + expandSettingsToolCalls, + startSettingsSession, + waitForSettingsSession, +} from "./settings-agent-configuration-helpers"; + +test.describe("Agent-accessible settings", () => { + test("searches and describes settings through the real MCP transport", async ({ + testPage, + apiClient, + seedData, + }) => { + const originalProfile = await apiClient.getAgentProfile(seedData.agentProfileId); + try { + const session = await startSettingsSession(apiClient, seedData); + const page = await waitForSettingsSession(testPage, apiClient, session.task_id); + + await expandSettingsToolCalls(page); + await expect(page.chat.getByText("Kandev: Search Settings")).toBeVisible(); + await expect(page.chat.getByText("Kandev: Describe Setting")).toBeVisible(); + await expect(page.chat.getByText("Kandev: List Settings Resources")).toBeVisible(); + await expect(page.chat.getByText("Kandev: Get Settings")).toBeVisible(); + await expect(page.chat.getByText("Kandev: Update Settings")).toBeVisible(); + await expect((await apiClient.getAgentProfile(seedData.agentProfileId)).name).toBe( + "Agent settings E2E", + ); + } finally { + await apiClient.updateAgentProfile(seedData.agentProfileId, { name: originalProfile.name }); + } + }); +}); diff --git a/apps/web/lib/settings-discovery/contract.generated.json b/apps/web/lib/settings-discovery/contract.generated.json new file mode 100644 index 0000000000..2f1ee8f8c8 --- /dev/null +++ b/apps/web/lib/settings-discovery/contract.generated.json @@ -0,0 +1,6186 @@ +{ + "schema_version": "settings-catalog.v1", + "generated_by": "cmd/settings-catalog", + "domains": [ + { + "domain": "agents", + "resource_type": "agent", + "label": "Agent definitions", + "description": "Saved agent definition metadata and MCP connection settings.", + "owner": "agent-settings", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "agent.name", + "field_path": "name", + "label": "Name", + "description": "Stable agent definition name.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings" + }, + { + "key": "agent.workspace_id", + "field_path": "workspace_id", + "label": "Workspace", + "description": "Owning workspace, when the definition is workspace-scoped.", + "json_type": "string", + "nullable": true, + "support": "read_only", + "classification": "computed", + "owner": "agent-settings" + }, + { + "key": "agent.supports_mcp", + "field_path": "supports_mcp", + "label": "Supports MCP", + "description": "Whether the agent definition supports MCP configuration.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.agent", + "authority": "org.config.manage", + "settings_href": "/settings" + }, + { + "key": "agent.mcp_config_path", + "field_path": "mcp_config_path", + "label": "MCP configuration path", + "description": "Path used by the agent definition for MCP configuration.", + "json_type": "string", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.agent", + "authority": "org.config.manage", + "settings_href": "/settings" + }, + { + "key": "agent.tui_config", + "field_path": "tui_config", + "label": "TUI definition", + "description": "Custom TUI definition owned by the explicit agent lifecycle surface.", + "json_type": "object", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings" + }, + { + "key": "agent.profiles", + "field_path": "profiles", + "label": "Profiles", + "description": "Profiles attached to this agent definition.", + "json_type": "array", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings" + }, + { + "key": "agent.capability_status", + "field_path": "capability_status", + "label": "Capability status", + "description": "Computed host capability status.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings" + }, + { + "key": "agent.capability_error", + "field_path": "capability_error", + "label": "Capability error", + "description": "Computed host capability diagnostic.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "agent-settings.agent.update", + "authority": "org.config.manage" + } + ], + "settings_href": "/settings/agents" + }, + { + "domain": "profiles", + "resource_type": "agent_profile", + "label": "Agent profiles", + "description": "Saved agent profile settings used for future sessions.", + "owner": "agent-settings", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "agent_profile.name", + "field_path": "name", + "label": "name", + "description": "Profile display name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.model", + "field_path": "model", + "label": "model", + "description": "Preferred model; empty uses the agent default.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.fallback_model", + "field_path": "fallback_model", + "label": "fallback_model", + "description": "Optional fallback model.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.auto_fallback", + "field_path": "auto_fallback", + "label": "auto_fallback", + "description": "Enable automatic fallback behavior.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.mode", + "field_path": "mode", + "label": "mode", + "description": "Agent operating mode.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.config_options", + "field_path": "config_options", + "label": "config_options", + "description": "Typed provider configuration options.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.allow_indexing", + "field_path": "allow_indexing", + "label": "allow_indexing", + "description": "Legacy indexing permission field.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.auto_approve", + "field_path": "auto_approve", + "label": "auto_approve", + "description": "Automatically approve agent permissions.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.cli_passthrough", + "field_path": "cli_passthrough", + "label": "cli_passthrough", + "description": "Allow CLI passthrough mode.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.enabled", + "field_path": "enabled", + "label": "enabled", + "description": "Allow this profile for new work.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.cli_flags", + "field_path": "cli_flags", + "label": "cli_flags", + "description": "Complete replacement list of CLI flags.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.env_vars", + "field_path": "env_vars", + "label": "env_vars", + "description": "Complete replacement list of environment variables.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "sensitive": true, + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.command_prefix", + "field_path": "command_prefix", + "label": "command_prefix", + "description": "Optional launcher command prefix.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.dynamic", + "field_path": "dynamic", + "label": "dynamic", + "description": "Versioned dynamic routing document.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + } + ], + "operations": [ + { + "name": "update", + "binding": "agent-settings.profile.update", + "authority": "org.config.manage" + } + ], + "settings_href": "/settings/agents" + }, + { + "domain": "profiles", + "resource_type": "agent_profile_mcp", + "label": "Profile MCP documents", + "description": "Separate MCP server settings for a saved agent profile.", + "owner": "agent-settings", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "agent_profile_mcp.enabled", + "field_path": "enabled", + "label": "MCP enabled", + "description": "Enable the profile MCP document.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile-mcp", + "authority": "org.config.manage" + }, + { + "key": "agent_profile_mcp.servers", + "field_path": "servers", + "label": "MCP servers", + "description": "Complete replacement map of validated MCP servers.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile-mcp", + "authority": "org.config.manage", + "sensitive": true, + "replacement": true + }, + { + "key": "agent_profile_mcp.meta", + "field_path": "meta", + "label": "MCP metadata", + "description": "Preserved metadata from the MCP document.", + "json_type": "object", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings", + "sensitive": true + } + ], + "operations": [ + { + "name": "update", + "binding": "agent-settings.profile-mcp.update", + "authority": "org.config.manage" + } + ], + "settings_href": "/settings/agents" + }, + { + "domain": "automations", + "resource_type": "automation", + "label": "Workspace automations", + "description": "Workspace automation definitions.", + "owner": "automations", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "automation.name", + "field_path": "name", + "label": "Name", + "description": "Automation name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.description", + "field_path": "description", + "label": "Description", + "description": "Automation description.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.workflow_id", + "field_path": "workflow_id", + "label": "Workflow", + "description": "Workflow binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.workflow_step_id", + "field_path": "workflow_step_id", + "label": "Workflow step", + "description": "Workflow step binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Agent profile binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.executor_profile_id", + "field_path": "executor_profile_id", + "label": "Executor profile", + "description": "Executor profile binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.repositories", + "field_path": "repositories", + "label": "Repositories", + "description": "Repository and base-branch selection.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Automation prompt template.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.task_title_template", + "field_path": "task_title_template", + "label": "Task title template", + "description": "Generated task title template.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether the automation is active.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.max_concurrent_runs", + "field_path": "max_concurrent_runs", + "label": "Concurrent runs", + "description": "Maximum simultaneous runs.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.continuation_policy", + "field_path": "continuation_policy", + "label": "Continuation policy", + "description": "How automation runs reuse tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.task_mode", + "field_path": "task_mode", + "label": "Task mode", + "description": "Whether runs create visible tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "automation.repository_mode", + "field_path": "repository_mode", + "label": "Repository mode", + "description": "How runs select repositories.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "automations.automation.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "automations", + "resource_type": "automation_trigger", + "label": "Automation triggers", + "description": "Triggers attached to a workspace automation.", + "owner": "automations", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "automation_trigger.config", + "field_path": "config", + "label": "Configuration", + "description": "Trigger configuration.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation_trigger", + "authority": "workspace.manage", + "sensitive": true, + "settings_href": "/settings" + }, + { + "key": "automation_trigger.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether the trigger is active.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "automations", + "writable": true, + "validator": "automations.automation_trigger", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "automations.automation_trigger.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "azure_devops_pull_request_watch", + "label": "Azure pull request watches", + "description": "Azure pull request watch schedules and task bindings.", + "owner": "azuredevops", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "azure_devops_pull_request_watch.workflowId", + "field_path": "workflowId", + "label": "Workflow", + "description": "Workflow that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.workflowStepId", + "field_path": "workflowStepId", + "label": "Workflow step", + "description": "Workflow step that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.agentProfileId", + "field_path": "agentProfileId", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.executorProfileId", + "field_path": "executorProfileId", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.pollIntervalSeconds", + "field_path": "pollIntervalSeconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.cleanupPolicy", + "field_path": "cleanupPolicy", + "label": "Cleanup policy", + "description": "Policy for tasks created by this watch.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.maxInflightTasks", + "field_path": "maxInflightTasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.projectId", + "field_path": "projectId", + "label": "Project", + "description": "Azure DevOps project filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.azureRepositoryId", + "field_path": "azureRepositoryId", + "label": "Azure repository", + "description": "Azure Repos repository filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.status", + "field_path": "status", + "label": "Status", + "description": "Pull request status filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.creatorId", + "field_path": "creatorId", + "label": "Creator", + "description": "Pull request creator filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.reviewerId", + "field_path": "reviewerId", + "label": "Reviewer", + "description": "Pull request reviewer filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.repositoryId", + "field_path": "repositoryId", + "label": "Repository", + "description": "Optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_pull_request_watch.baseBranch", + "field_path": "baseBranch", + "label": "Base branch", + "description": "Base branch for the optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_pull_request_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "azuredevops.azure_devops_pull_request_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "azure_devops_settings", + "label": "Azure DevOps settings", + "description": "Noncredential Azure DevOps query and action presets for one workspace.", + "owner": "azuredevops", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "azure_devops_settings.workItemQueries", + "field_path": "workItemQueries", + "label": "Work item queries", + "description": "Saved work item query presets.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_settings.pullRequestQueries", + "field_path": "pullRequestQueries", + "label": "Pull request queries", + "description": "Saved pull request query presets.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_settings.workItemActions", + "field_path": "workItemActions", + "label": "Work item actions", + "description": "Saved work item action presets.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_settings.pullRequestActions", + "field_path": "pullRequestActions", + "label": "Pull request actions", + "description": "Saved pull request action presets.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "azuredevops.azure_devops_settings.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "azure_devops_work_item_watch", + "label": "Azure work item watches", + "description": "Azure work item watch schedules and task bindings.", + "owner": "azuredevops", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "azure_devops_work_item_watch.workflowId", + "field_path": "workflowId", + "label": "Workflow", + "description": "Workflow that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.workflowStepId", + "field_path": "workflowStepId", + "label": "Workflow step", + "description": "Workflow step that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.agentProfileId", + "field_path": "agentProfileId", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.executorProfileId", + "field_path": "executorProfileId", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.pollIntervalSeconds", + "field_path": "pollIntervalSeconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.cleanupPolicy", + "field_path": "cleanupPolicy", + "label": "Cleanup policy", + "description": "Policy for tasks created by this watch.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.maxInflightTasks", + "field_path": "maxInflightTasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.projectId", + "field_path": "projectId", + "label": "Project", + "description": "Azure DevOps project filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.wiql", + "field_path": "wiql", + "label": "WIQL", + "description": "Azure Boards query.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.repositoryId", + "field_path": "repositoryId", + "label": "Repository", + "description": "Optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "azure_devops_work_item_watch.baseBranch", + "field_path": "baseBranch", + "label": "Base branch", + "description": "Base branch for the optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "azuredevops", + "writable": true, + "validator": "azuredevops.azure_devops_work_item_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "azuredevops.azure_devops_work_item_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "code_host_integration", + "label": "Code host integrations", + "description": "Use provider-specific GitHub, GitLab, and Azure DevOps resource types for settings updates.", + "owner": "code_host_integrations", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "code_host_integration.settings", + "field_path": "settings", + "label": "Provider settings", + "description": "Use provider-specific GitHub, GitLab, and Azure DevOps resource types for settings updates.", + "json_type": "object", + "support": "read_only", + "classification": "exception", + "owner": "code_host_integrations", + "exception_reason": "Provider-specific resource types preserve the provider's validation and workspace boundary.", + "recovery": "Use the provider-specific settings resource returned by search or target lookup." + } + ], + "settings_href": "/settings/integrations" + }, + { + "domain": "editors", + "resource_type": "editor", + "label": "Editor definitions", + "description": "Custom editor definitions.", + "owner": "editors", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "editor.name", + "field_path": "name", + "label": "Name", + "description": "Editor display name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "editors", + "writable": true, + "validator": "editors.editor", + "authority": "editor.manage", + "settings_href": "/settings" + }, + { + "key": "editor.kind", + "field_path": "kind", + "label": "Kind", + "description": "Custom editor kind.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "editors", + "writable": true, + "validator": "editors.editor", + "authority": "editor.manage", + "settings_href": "/settings" + }, + { + "key": "editor.config", + "field_path": "config", + "label": "Configuration", + "description": "Validated editor configuration.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "editors", + "writable": true, + "validator": "editors.editor", + "authority": "editor.manage", + "settings_href": "/settings" + }, + { + "key": "editor.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether the editor can be selected.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "editors", + "writable": true, + "validator": "editors.editor", + "authority": "editor.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "editors.editor.update", + "authority": "editor.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "execution", + "resource_type": "environment", + "label": "Environments", + "description": "Execution environment definitions.", + "owner": "execution", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "environment.name", + "field_path": "name", + "label": "Name", + "description": "Environment name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.environment", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "environment.kind", + "field_path": "kind", + "label": "Kind", + "description": "Environment kind.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.environment", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "environment.worktree_root", + "field_path": "worktree_root", + "label": "Worktree root", + "description": "Worktree root path.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.environment", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "environment.image_tag", + "field_path": "image_tag", + "label": "Image tag", + "description": "Container image tag.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.environment", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "environment.dockerfile", + "field_path": "dockerfile", + "label": "Dockerfile", + "description": "Dockerfile content.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.environment", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "environment.build_config", + "field_path": "build_config", + "label": "Build configuration", + "description": "Validated build options.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.environment", + "authority": "execution.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "execution.environment.update", + "authority": "execution.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "execution", + "resource_type": "executor", + "label": "Executors", + "description": "Execution target settings.", + "owner": "execution", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "executor.name", + "field_path": "name", + "label": "Name", + "description": "Executor name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor.type", + "field_path": "type", + "label": "Type", + "description": "Executor runtime type.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor.status", + "field_path": "status", + "label": "Status", + "description": "Whether the executor accepts work.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor.resumable", + "field_path": "resumable", + "label": "Resumable", + "description": "Whether sessions can resume on this executor.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor.config", + "field_path": "config", + "label": "Configuration", + "description": "Validated executor configuration.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor", + "authority": "execution.manage", + "sensitive": true, + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "execution.executor.update", + "authority": "execution.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "execution", + "resource_type": "executor_profile", + "label": "Executor profiles", + "description": "Named executor presets.", + "owner": "execution", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "executor_profile.name", + "field_path": "name", + "label": "Name", + "description": "Profile name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor_profile", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor_profile.mcp_policy", + "field_path": "mcp_policy", + "label": "MCP policy", + "description": "MCP policy for this executor profile.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor_profile", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor_profile.config", + "field_path": "config", + "label": "Configuration", + "description": "Validated executor profile configuration.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor_profile", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor_profile.prepare_script", + "field_path": "prepare_script", + "label": "Prepare script", + "description": "Preparation command.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor_profile", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor_profile.cleanup_script", + "field_path": "cleanup_script", + "label": "Cleanup script", + "description": "Cleanup command.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor_profile", + "authority": "execution.manage", + "settings_href": "/settings" + }, + { + "key": "executor_profile.env_vars", + "field_path": "env_vars", + "label": "Environment variables", + "description": "Environment values or secret references.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "execution", + "writable": true, + "validator": "execution.executor_profile", + "authority": "execution.manage", + "sensitive": true, + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "execution.executor_profile.update", + "authority": "execution.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "github_issue_watch", + "label": "GitHub issue watches", + "description": "GitHub issue watch schedules and task bindings.", + "owner": "github", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "github_issue_watch.workflow_id", + "field_path": "workflow_id", + "label": "Workflow", + "description": "Workflow that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.workflow_step_id", + "field_path": "workflow_step_id", + "label": "Workflow step", + "description": "Workflow step that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.executor_profile_id", + "field_path": "executor_profile_id", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.poll_interval_seconds", + "field_path": "poll_interval_seconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.cleanup_policy", + "field_path": "cleanup_policy", + "label": "Cleanup policy", + "description": "Policy for tasks created by this watch.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.repos", + "field_path": "repos", + "label": "Repositories", + "description": "Provider repository filters.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.labels", + "field_path": "labels", + "label": "Labels", + "description": "Issue label filters.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_issue_watch.custom_query", + "field_path": "custom_query", + "label": "Custom query", + "description": "Provider-specific issue query.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "github.github_issue_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "github_review_watch", + "label": "GitHub review watches", + "description": "GitHub review watch schedules and task bindings.", + "owner": "github", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "github_review_watch.workflow_id", + "field_path": "workflow_id", + "label": "Workflow", + "description": "Workflow that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.workflow_step_id", + "field_path": "workflow_step_id", + "label": "Workflow step", + "description": "Workflow step that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.executor_profile_id", + "field_path": "executor_profile_id", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.poll_interval_seconds", + "field_path": "poll_interval_seconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.cleanup_policy", + "field_path": "cleanup_policy", + "label": "Cleanup policy", + "description": "Policy for tasks created by this watch.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.repos", + "field_path": "repos", + "label": "Repositories", + "description": "Provider repository filters.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.review_scope", + "field_path": "review_scope", + "label": "Review scope", + "description": "Review assignment scope.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_review_watch.custom_query", + "field_path": "custom_query", + "label": "Custom query", + "description": "Provider-specific review query.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "github.github_review_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "github_settings", + "label": "GitHub settings", + "description": "Noncredential GitHub scope and query preferences for one workspace.", + "owner": "github", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "github_settings.task_git_credentials_mode", + "field_path": "task_git_credentials_mode", + "label": "Task Git credentials", + "description": "Credential routing mode for task worktrees.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_settings.repo_scope_mode", + "field_path": "repo_scope_mode", + "label": "Repository scope mode", + "description": "Repository scope selection mode.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_settings.repo_scope_orgs", + "field_path": "repo_scope_orgs", + "label": "Organizations", + "description": "Organization allowlist for repository scope.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_settings.repo_scope_repos", + "field_path": "repo_scope_repos", + "label": "Repositories", + "description": "Repository allowlist for repository scope.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_settings.saved_presets", + "field_path": "saved_presets", + "label": "Saved presets", + "description": "Workspace-scoped GitHub action presets.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "github_settings.default_query_presets", + "field_path": "default_query_presets", + "label": "Default query presets", + "description": "Workspace-scoped GitHub query presets.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "github", + "writable": true, + "validator": "github.github_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "github.github_settings.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "gitlab_issue_watch", + "label": "GitLab issue watches", + "description": "GitLab issue watch schedules and task bindings.", + "owner": "gitlab", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "gitlab_issue_watch.workflow_id", + "field_path": "workflow_id", + "label": "Workflow", + "description": "Workflow that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.workflow_step_id", + "field_path": "workflow_step_id", + "label": "Workflow step", + "description": "Workflow step that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.executor_profile_id", + "field_path": "executor_profile_id", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.poll_interval_seconds", + "field_path": "poll_interval_seconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.cleanup_policy", + "field_path": "cleanup_policy", + "label": "Cleanup policy", + "description": "Policy for tasks created by this watch.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.max_inflight_tasks", + "field_path": "max_inflight_tasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.projects", + "field_path": "projects", + "label": "Projects", + "description": "Provider project filters.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.labels", + "field_path": "labels", + "label": "Labels", + "description": "Issue label filters.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_issue_watch.custom_query", + "field_path": "custom_query", + "label": "Custom query", + "description": "Provider-specific issue query.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "gitlab.gitlab_issue_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "gitlab_review_watch", + "label": "GitLab review watches", + "description": "GitLab review watch schedules and task bindings.", + "owner": "gitlab", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "gitlab_review_watch.workflow_id", + "field_path": "workflow_id", + "label": "Workflow", + "description": "Workflow that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.workflow_step_id", + "field_path": "workflow_step_id", + "label": "Workflow step", + "description": "Workflow step that receives matching items.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.executor_profile_id", + "field_path": "executor_profile_id", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.poll_interval_seconds", + "field_path": "poll_interval_seconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.cleanup_policy", + "field_path": "cleanup_policy", + "label": "Cleanup policy", + "description": "Policy for tasks created by this watch.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.max_inflight_tasks", + "field_path": "max_inflight_tasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.projects", + "field_path": "projects", + "label": "Projects", + "description": "Provider project filters.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.review_scope", + "field_path": "review_scope", + "label": "Review scope", + "description": "Review assignment scope.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "gitlab_review_watch.custom_query", + "field_path": "custom_query", + "label": "Custom query", + "description": "Provider-specific review query.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "gitlab", + "writable": true, + "validator": "gitlab.gitlab_review_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "gitlab.gitlab_review_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "code_host_integrations", + "resource_type": "gitlab_settings", + "label": "GitLab settings", + "description": "Configured GitLab workspace connection metadata.", + "owner": "gitlab", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "gitlab_settings.host", + "field_path": "host", + "label": "Host", + "description": "Configured GitLab host. Connection identity changes use the interactive flow.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "gitlab" + }, + { + "key": "gitlab_settings.auth_method", + "field_path": "auth_method", + "label": "Authentication method", + "description": "Configured GitLab authentication method.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "gitlab" + }, + { + "key": "gitlab_settings.username", + "field_path": "username", + "label": "Username", + "description": "Last authenticated GitLab username.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "gitlab" + } + ], + "settings_href": "/settings" + }, + { + "domain": "issue_integrations", + "resource_type": "issue_integration", + "label": "Issue integrations", + "description": "Use provider-specific Jira, Linear, and Sentry resource types for settings updates.", + "owner": "issue_integrations", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "issue_integration.settings", + "field_path": "settings", + "label": "Provider settings", + "description": "Use provider-specific Jira, Linear, and Sentry resource types for settings updates.", + "json_type": "object", + "support": "read_only", + "classification": "exception", + "owner": "issue_integrations", + "exception_reason": "Provider-specific resource types preserve the provider's validation and workspace boundary.", + "recovery": "Use the provider-specific settings resource returned by search or target lookup." + } + ], + "settings_href": "/settings/integrations" + }, + { + "domain": "issue_integrations", + "resource_type": "jira_issue_watch", + "label": "Jira issue watches", + "description": "Jira issue watch schedules and task bindings.", + "owner": "jira", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "jira_issue_watch.workflowId", + "field_path": "workflowId", + "label": "Workflow", + "description": "Workflow that receives matching issues.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.workflowStepId", + "field_path": "workflowStepId", + "label": "Workflow step", + "description": "Workflow step that receives matching issues.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.repositoryId", + "field_path": "repositoryId", + "label": "Repository", + "description": "Optional repository binding for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.baseBranch", + "field_path": "baseBranch", + "label": "Base branch", + "description": "Base branch for the optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.agentProfileId", + "field_path": "agentProfileId", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.executorProfileId", + "field_path": "executorProfileId", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.pollIntervalSeconds", + "field_path": "pollIntervalSeconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.maxInflightTasks", + "field_path": "maxInflightTasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_issue_watch.jql", + "field_path": "jql", + "label": "Filter", + "description": "Provider-specific issue filter.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "jira.jira_issue_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "issue_integrations", + "resource_type": "jira_settings", + "label": "Jira settings", + "description": "Noncredential Jira defaults for one workspace.", + "owner": "jira", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "jira_settings.defaultProjectKey", + "field_path": "defaultProjectKey", + "label": "Default project", + "description": "Default Jira project key for issue workflows.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "jira", + "writable": true, + "validator": "jira.jira_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "jira_settings.siteUrl", + "field_path": "siteUrl", + "label": "Site URL", + "description": "Configured Jira site identity. Credential and identity changes use the connection flow.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "jira" + }, + { + "key": "jira_settings.email", + "field_path": "email", + "label": "Account email", + "description": "Configured Jira account metadata.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "jira" + }, + { + "key": "jira_settings.authMethod", + "field_path": "authMethod", + "label": "Authentication method", + "description": "Configured Jira authentication method.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "jira" + }, + { + "key": "jira_settings.instanceType", + "field_path": "instanceType", + "label": "Instance type", + "description": "Configured Jira deployment type.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "jira" + }, + { + "key": "jira_settings.hasSecret", + "field_path": "hasSecret", + "label": "Credential present", + "description": "Whether a credential is enrolled, without exposing it.", + "json_type": "boolean", + "support": "read_only", + "classification": "computed", + "owner": "jira" + } + ], + "operations": [ + { + "name": "update", + "binding": "jira.jira_settings.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "issue_integrations", + "resource_type": "linear_issue_watch", + "label": "Linear issue watches", + "description": "Linear issue watch schedules and task bindings.", + "owner": "linear", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "linear_issue_watch.workflowId", + "field_path": "workflowId", + "label": "Workflow", + "description": "Workflow that receives matching issues.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.workflowStepId", + "field_path": "workflowStepId", + "label": "Workflow step", + "description": "Workflow step that receives matching issues.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.repositoryId", + "field_path": "repositoryId", + "label": "Repository", + "description": "Optional repository binding for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.baseBranch", + "field_path": "baseBranch", + "label": "Base branch", + "description": "Base branch for the optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.agentProfileId", + "field_path": "agentProfileId", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.executorProfileId", + "field_path": "executorProfileId", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.pollIntervalSeconds", + "field_path": "pollIntervalSeconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.maxInflightTasks", + "field_path": "maxInflightTasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.filter", + "field_path": "filter", + "label": "Filter", + "description": "Provider-specific issue filter.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_issue_watch.sortBy", + "field_path": "sortBy", + "label": "Sort order", + "description": "Order in which matching Linear issues are dispatched.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "linear.linear_issue_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "issue_integrations", + "resource_type": "linear_settings", + "label": "Linear settings", + "description": "Noncredential Linear defaults for one workspace.", + "owner": "linear", + "scope": "workspace", + "target": { + "scope": "workspace", + "requires_workspace_id": true + }, + "fields": [ + { + "key": "linear_settings.defaultTeamKey", + "field_path": "defaultTeamKey", + "label": "Default team", + "description": "Default Linear team key for issue workflows.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "linear", + "writable": true, + "validator": "linear.linear_settings", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "linear_settings.authMethod", + "field_path": "authMethod", + "label": "Authentication method", + "description": "Configured Linear authentication method.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "linear" + }, + { + "key": "linear_settings.orgSlug", + "field_path": "orgSlug", + "label": "Organization slug", + "description": "Computed Linear organization metadata.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "linear" + }, + { + "key": "linear_settings.hasSecret", + "field_path": "hasSecret", + "label": "Credential present", + "description": "Whether a credential is enrolled, without exposing it.", + "json_type": "boolean", + "support": "read_only", + "classification": "computed", + "owner": "linear" + } + ], + "operations": [ + { + "name": "update", + "binding": "linear.linear_settings.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "notifications", + "resource_type": "notification_provider", + "label": "Notification providers", + "description": "Notification provider settings and subscriptions.", + "owner": "notifications", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "notification_provider.name", + "field_path": "name", + "label": "Name", + "description": "Provider name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "notifications", + "writable": true, + "validator": "notifications.notification_provider", + "authority": "notification.manage", + "settings_href": "/settings" + }, + { + "key": "notification_provider.type", + "field_path": "type", + "label": "Type", + "description": "Provider type.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "notifications", + "writable": true, + "validator": "notifications.notification_provider", + "authority": "notification.manage", + "settings_href": "/settings" + }, + { + "key": "notification_provider.config", + "field_path": "config", + "label": "Configuration", + "description": "Validated provider configuration.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "notifications", + "writable": true, + "validator": "notifications.notification_provider", + "authority": "notification.manage", + "sensitive": true, + "settings_href": "/settings" + }, + { + "key": "notification_provider.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether notifications are enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "notifications", + "writable": true, + "validator": "notifications.notification_provider", + "authority": "notification.manage", + "settings_href": "/settings" + }, + { + "key": "notification_provider.events", + "field_path": "events", + "label": "Events", + "description": "Subscribed event types.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "notifications", + "writable": true, + "validator": "notifications.notification_provider", + "authority": "notification.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "notifications.notification_provider.update", + "authority": "notification.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "prompts", + "resource_type": "prompt", + "label": "Saved prompts", + "description": "Saved prompt definitions.", + "owner": "prompts", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "prompt.name", + "field_path": "name", + "label": "Name", + "description": "Prompt name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "prompts", + "writable": true, + "validator": "prompts.prompt", + "authority": "prompt.manage", + "settings_href": "/settings" + }, + { + "key": "prompt.content", + "field_path": "content", + "label": "Content", + "description": "Prompt content.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "prompts", + "writable": true, + "validator": "prompts.prompt", + "authority": "prompt.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "prompts.prompt.update", + "authority": "prompt.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "workspaces", + "resource_type": "repository", + "label": "Repositories", + "description": "Workspace repository settings.", + "owner": "workspace", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "repository.name", + "field_path": "name", + "label": "Name", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.source_type", + "field_path": "source_type", + "label": "Source type", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.local_path", + "field_path": "local_path", + "label": "Local path", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.provider", + "field_path": "provider", + "label": "Provider", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.provider_repo_id", + "field_path": "provider_repo_id", + "label": "Provider repository ID", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.provider_host", + "field_path": "provider_host", + "label": "Provider host", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.provider_scope", + "field_path": "provider_scope", + "label": "Provider scope", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.provider_owner", + "field_path": "provider_owner", + "label": "Provider owner", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.provider_name", + "field_path": "provider_name", + "label": "Provider name", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.remote_url", + "field_path": "remote_url", + "label": "Remote URL", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.default_branch", + "field_path": "default_branch", + "label": "Default branch", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.worktree_branch_prefix", + "field_path": "worktree_branch_prefix", + "label": "Branch prefix", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.worktree_branch_template", + "field_path": "worktree_branch_template", + "label": "Branch template", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.pull_before_worktree", + "field_path": "pull_before_worktree", + "label": "Pull before worktree", + "description": "Repository setting.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.setup_script", + "field_path": "setup_script", + "label": "Setup script", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.cleanup_script", + "field_path": "cleanup_script", + "label": "Cleanup script", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.dev_script", + "field_path": "dev_script", + "label": "Development script", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.copy_files", + "field_path": "copy_files", + "label": "Copy files", + "description": "Repository setting.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository.secret_bindings", + "field_path": "secret_bindings", + "label": "Secret bindings", + "description": "Repository setting.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository", + "authority": "workspace.manage", + "sensitive": true, + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "workspace.repository.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "workspaces", + "resource_type": "repository_script", + "label": "Repository scripts", + "description": "Custom repository commands.", + "owner": "workspace", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "repository_script.name", + "field_path": "name", + "label": "Name", + "description": "Script name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository_script", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository_script.command", + "field_path": "command", + "label": "Command", + "description": "Script command.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository_script", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository_script.position", + "field_path": "position", + "label": "Position", + "description": "Script ordering is controlled by the repository script reorder action.", + "json_type": "integer", + "support": "exception", + "classification": "action", + "owner": "workspace", + "settings_href": "/settings", + "exception_reason": "Script ordering is controlled by the repository script reorder action.", + "recovery": "Use the repository script reorder action." + } + ], + "operations": [ + { + "name": "update", + "binding": "workspace.repository_script.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "workspaces", + "resource_type": "repository_set", + "label": "Repository sets", + "description": "Reusable repository selections.", + "owner": "workspace", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "repository_set.name", + "field_path": "name", + "label": "Name", + "description": "Repository set name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository_set", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository_set.description", + "field_path": "description", + "label": "Description", + "description": "Repository set description.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository_set", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "repository_set.repository_ids", + "field_path": "repository_ids", + "label": "Repositories", + "description": "Ordered repository membership.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.repository_set", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "workspace.repository_set.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "runtime", + "resource_type": "runtime_flag", + "label": "Runtime overrides", + "description": "Permitted persisted runtime flag overrides.", + "owner": "runtime", + "scope": "installation", + "target": { + "scope": "installation", + "requires_resource_id": true + }, + "fields": [ + { + "key": "runtime_flag.override", + "field_path": "override", + "label": "Override", + "description": "Explicit persisted runtime override. Null clears the override.", + "json_type": "boolean", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "runtime", + "writable": true, + "validator": "runtime.runtime_flag", + "authority": "org.settings.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "runtime.runtime_flag.update", + "authority": "org.settings.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "issue_integrations", + "resource_type": "sentry_instance", + "label": "Sentry instances", + "description": "Named noncredential Sentry instance settings.", + "owner": "sentry", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "sentry_instance.name", + "field_path": "name", + "label": "Name", + "description": "Display name for the Sentry instance.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_instance", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_instance.url", + "field_path": "url", + "label": "URL", + "description": "Configured Sentry instance identity. Connection changes use the interactive flow.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "sentry" + }, + { + "key": "sentry_instance.authMethod", + "field_path": "authMethod", + "label": "Authentication method", + "description": "Configured Sentry authentication method.", + "json_type": "string", + "support": "read_only", + "classification": "computed", + "owner": "sentry" + }, + { + "key": "sentry_instance.hasSecret", + "field_path": "hasSecret", + "label": "Credential present", + "description": "Whether a credential is enrolled, without exposing it.", + "json_type": "boolean", + "support": "read_only", + "classification": "computed", + "owner": "sentry" + } + ], + "operations": [ + { + "name": "update", + "binding": "sentry.sentry_instance.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "issue_integrations", + "resource_type": "sentry_issue_watch", + "label": "Sentry issue watches", + "description": "Sentry issue watch schedules and task bindings.", + "owner": "sentry", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "requires_workspace_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "sentry_issue_watch.workflowId", + "field_path": "workflowId", + "label": "Workflow", + "description": "Workflow that receives matching issues.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.workflowStepId", + "field_path": "workflowStepId", + "label": "Workflow step", + "description": "Workflow step that receives matching issues.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.repositoryId", + "field_path": "repositoryId", + "label": "Repository", + "description": "Optional repository binding for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.baseBranch", + "field_path": "baseBranch", + "label": "Base branch", + "description": "Base branch for the optional repository binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.agentProfileId", + "field_path": "agentProfileId", + "label": "Agent profile", + "description": "Profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.executorProfileId", + "field_path": "executorProfileId", + "label": "Executor profile", + "description": "Executor profile used for created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Prompt added to created tasks.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether background polling is enabled.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.pollIntervalSeconds", + "field_path": "pollIntervalSeconds", + "label": "Poll interval", + "description": "Background polling interval in seconds.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.maxInflightTasks", + "field_path": "maxInflightTasks", + "label": "Concurrent task limit", + "description": "Maximum open tasks created by this watch, or null for unlimited.", + "json_type": "integer", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "sentry_issue_watch.filter", + "field_path": "filter", + "label": "Filter", + "description": "Provider-specific issue filter.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "sentry", + "writable": true, + "validator": "sentry.sentry_issue_watch", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "sentry.sentry_issue_watch.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "storage", + "resource_type": "storage_maintenance", + "label": "Storage maintenance", + "description": "Install-wide storage maintenance policy.", + "owner": "storage", + "scope": "installation", + "target": { + "scope": "installation", + "singleton": true + }, + "fields": [ + { + "key": "storage_maintenance.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.check_interval_hours", + "field_path": "check_interval_hours", + "label": "Check interval", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.idle_for_minutes", + "field_path": "idle_for_minutes", + "label": "Idle period", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.orphan_grace_hours", + "field_path": "orphan_grace_hours", + "label": "Orphan grace", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.quarantine_retention_hours", + "field_path": "quarantine_retention_hours", + "label": "Quarantine retention", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.workspaces.enabled", + "field_path": "workspaces.enabled", + "label": "Workspace cleanup", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.workspaces.dependency_cleanup_enabled", + "field_path": "workspaces.dependency_cleanup_enabled", + "label": "Dependency cleanup", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.kandev_containers.enabled", + "field_path": "kandev_containers.enabled", + "label": "Kandev containers", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.go_cache.enabled", + "field_path": "go_cache.enabled", + "label": "Go cache", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.go_cache.max_bytes", + "field_path": "go_cache.max_bytes", + "label": "Go cache limit", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.docker.dedicated_daemon_acknowledged", + "field_path": "docker.dedicated_daemon_acknowledged", + "label": "Dedicated Docker acknowledgement", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.docker.build_cache_enabled", + "field_path": "docker.build_cache_enabled", + "label": "Docker build cache", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.docker.build_cache_keep_bytes", + "field_path": "docker.build_cache_keep_bytes", + "label": "Docker build cache limit", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.docker.build_cache_unused_hours", + "field_path": "docker.build_cache_unused_hours", + "label": "Docker build cache age", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.docker.unused_images_enabled", + "field_path": "docker.unused_images_enabled", + "label": "Unused image cleanup", + "description": "Storage maintenance policy.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + }, + { + "key": "storage_maintenance.docker.unused_images_hours", + "field_path": "docker.unused_images_hours", + "label": "Unused image age", + "description": "Storage maintenance policy.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "storage", + "writable": true, + "validator": "storage.storage_maintenance", + "authority": "org.settings.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "storage.storage_maintenance.update", + "authority": "org.settings.manage", + "options": { + "additionalProperties": false, + "properties": { + "adopt_go_cache": { + "type": "boolean" + }, + "confirm_dedicated_docker": { + "type": "boolean" + } + }, + "type": "object" + } + } + ], + "settings_href": "/settings" + }, + { + "domain": "tasks", + "resource_type": "task", + "label": "Tasks", + "description": "Permitted saved task configuration.", + "owner": "task", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "task.title", + "field_path": "title", + "label": "Title", + "description": "Task title.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "task", + "writable": true, + "validator": "task.task", + "authority": "task.manage", + "settings_href": "/settings" + }, + { + "key": "task.description", + "field_path": "description", + "label": "Description", + "description": "Task description.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "task", + "writable": true, + "validator": "task.task", + "authority": "task.manage", + "settings_href": "/settings" + }, + { + "key": "task.priority", + "field_path": "priority", + "label": "Priority", + "description": "Task priority.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "task", + "writable": true, + "validator": "task.task", + "authority": "task.manage", + "settings_href": "/settings" + }, + { + "key": "task.workflow_step_id", + "field_path": "workflow_step_id", + "label": "Workflow step", + "description": "Task movement is controlled by the task lifecycle action.", + "json_type": "string", + "support": "exception", + "classification": "action", + "owner": "task", + "settings_href": "/settings", + "exception_reason": "Task movement is controlled by the task lifecycle action.", + "recovery": "Use the task move action." + }, + { + "key": "task.position", + "field_path": "position", + "label": "Position", + "description": "Task ordering is controlled by the task reorder action.", + "json_type": "integer", + "support": "exception", + "classification": "action", + "owner": "task", + "settings_href": "/settings", + "exception_reason": "Task ordering is controlled by the task reorder action.", + "recovery": "Use the task reorder action." + }, + { + "key": "task.metadata", + "field_path": "metadata", + "label": "Metadata", + "description": "Open-ended task metadata is controlled by domain-specific task operations.", + "json_type": "object", + "support": "exception", + "classification": "exception", + "owner": "task", + "settings_href": "/settings", + "exception_reason": "Open-ended task metadata is controlled by domain-specific task operations.", + "recovery": "Use the task metadata operation for a known metadata key." + } + ], + "operations": [ + { + "name": "update", + "binding": "task.task.update", + "authority": "task.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "user_preferences", + "resource_type": "user_settings", + "label": "Personal preferences", + "description": "Portable settings belonging to the authenticated user.", + "owner": "user-settings", + "scope": "caller", + "target": { + "scope": "caller", + "singleton": true + }, + "fields": [ + { + "key": "user_settings.workspace_id", + "field_path": "workspace_id", + "label": "workspace", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.kanban_view_mode", + "field_path": "kanban_view_mode", + "label": "kanban view mode", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.startup_page", + "field_path": "startup_page", + "label": "startup page", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.workflow_filter_id", + "field_path": "workflow_filter_id", + "label": "workflow filter", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.repository_ids", + "field_path": "repository_ids", + "label": "repository selection", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.tasks_list_sort", + "field_path": "tasks_list_sort", + "label": "task sort", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.tasks_list_group", + "field_path": "tasks_list_group", + "label": "task grouping", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.tasks_list_show_details", + "field_path": "tasks_list_show_details", + "label": "task details", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.initial_setup_complete", + "field_path": "initial_setup_complete", + "label": "setup complete", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.preferred_shell", + "field_path": "preferred_shell", + "label": "preferred shell", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.default_editor_id", + "field_path": "default_editor_id", + "label": "default editor", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.enable_preview_on_click", + "field_path": "enable_preview_on_click", + "label": "preview on click", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.chat_submit_key", + "field_path": "chat_submit_key", + "label": "chat submit key", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.review_auto_mark_on_scroll", + "field_path": "review_auto_mark_on_scroll", + "label": "review auto mark", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.confirm_task_archive", + "field_path": "confirm_task_archive", + "label": "confirm task archive", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.prevent_auto_start_agent_on_open", + "field_path": "prevent_auto_start_agent_on_open", + "label": "prevent auto start", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.unread_divider", + "field_path": "unread_divider", + "label": "unread divider", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.agent_generated_task_titles", + "field_path": "agent_generated_task_titles", + "label": "generated titles", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.mcp_task_agent_profile_default", + "field_path": "mcp_task_agent_profile_default", + "label": "MCP task profile", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_anchored_prompt_bar", + "field_path": "show_anchored_prompt_bar", + "label": "anchored prompt bar", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_scroll_to_last_prompt", + "field_path": "show_scroll_to_last_prompt", + "label": "scroll to last prompt", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_scroll_to_start", + "field_path": "show_scroll_to_start", + "label": "scroll to start", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_transcript_auto_scroll_control", + "field_path": "show_transcript_auto_scroll_control", + "label": "transcript scroll control", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_todo_list_panel", + "field_path": "show_todo_list_panel", + "label": "todo list panel", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_todo_list_panel_only_when_not_empty", + "field_path": "show_todo_list_panel_only_when_not_empty", + "label": "todo list empty state", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.show_release_notification", + "field_path": "show_release_notification", + "label": "release notification", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.release_notes_last_seen_version", + "field_path": "release_notes_last_seen_version", + "label": "release notes version", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.lsp_auto_start_languages", + "field_path": "lsp_auto_start_languages", + "label": "LSP auto start", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.lsp_auto_install_languages", + "field_path": "lsp_auto_install_languages", + "label": "LSP auto install", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.lsp_server_configs", + "field_path": "lsp_server_configs", + "label": "LSP server configs", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.lsp_status_location", + "field_path": "lsp_status_location", + "label": "LSP status location", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.saved_layouts", + "field_path": "saved_layouts", + "label": "saved layouts", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_views", + "field_path": "sidebar_views", + "label": "sidebar views", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_active_view_id", + "field_path": "sidebar_active_view_id", + "label": "active sidebar view", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_draft", + "field_path": "sidebar_draft", + "label": "sidebar draft", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.thread_views", + "field_path": "thread_views", + "label": "thread views", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.thread_active_view_id", + "field_path": "thread_active_view_id", + "label": "active thread view", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.thread_view_draft", + "field_path": "thread_view_draft", + "label": "thread draft", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_task_prefs", + "field_path": "sidebar_task_prefs", + "label": "sidebar task preferences", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_task_color_automation", + "field_path": "sidebar_task_color_automation", + "label": "sidebar color automation", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_task_colors", + "field_path": "sidebar_task_colors", + "label": "sidebar task colors", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.sidebar_task_color_patch", + "field_path": "sidebar_task_color_patch", + "label": "sidebar task color patch", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences", + "schema": { + "additionalProperties": false, + "properties": { + "colors": { + "additionalProperties": { + "nullable": true, + "type": "string" + }, + "type": "object" + }, + "if_missing": { + "type": "boolean" + } + }, + "type": "object" + } + }, + { + "key": "user_settings.task_create_last_used", + "field_path": "task_create_last_used", + "label": "last task create values", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.jira_saved_views", + "field_path": "jira_saved_views", + "label": "Jira saved views", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.jira_task_presets", + "field_path": "jira_task_presets", + "label": "Jira task presets", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.github_saved_presets", + "field_path": "github_saved_presets", + "label": "GitHub saved presets", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.github_default_query_presets", + "field_path": "github_default_query_presets", + "label": "GitHub query presets", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.gitlab_saved_presets", + "field_path": "gitlab_saved_presets", + "label": "GitLab saved presets", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.azure_devops_browse_preferences", + "field_path": "azure_devops_browse_preferences", + "label": "Azure DevOps browse preferences", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "nullable": true, + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.default_utility_agent_id", + "field_path": "default_utility_agent_id", + "label": "default utility agent", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.default_utility_model", + "field_path": "default_utility_model", + "label": "default utility model", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.default_utility_agent_profile_id", + "field_path": "default_utility_agent_profile_id", + "label": "default utility profile", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.keyboard_shortcuts", + "field_path": "keyboard_shortcuts", + "label": "keyboard shortcuts", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences", + "schema": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "modifiers": { + "additionalProperties": false, + "properties": { + "alt": { + "type": "boolean" + }, + "command": { + "type": "boolean" + }, + "ctrl": { + "type": "boolean" + }, + "meta": { + "type": "boolean" + }, + "shift": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + { + "key": "user_settings.terminal_link_behavior", + "field_path": "terminal_link_behavior", + "label": "terminal link behavior", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.terminal_font_family", + "field_path": "terminal_font_family", + "label": "terminal font family", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.terminal_font_size", + "field_path": "terminal_font_size", + "label": "terminal font size", + "description": "Portable preference owned by the current user.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.changes_panel_layout", + "field_path": "changes_panel_layout", + "label": "changes panel layout", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.last_seen_display", + "field_path": "last_seen_display", + "label": "last seen display", + "description": "Portable preference owned by the current user.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.system_metrics_display", + "field_path": "system_metrics_display", + "label": "system metrics display", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.app_status_bar_enabled", + "field_path": "app_status_bar_enabled", + "label": "status bar", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.resolve_session_hostnames", + "field_path": "resolve_session_hostnames", + "label": "resolve hostnames", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.app_status_bar_order", + "field_path": "app_status_bar_order", + "label": "status bar order", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.quick_chat_tab_order_by_workspace", + "field_path": "quick_chat_tab_order_by_workspace", + "label": "quick chat tab order", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.kanban_hidden_step_ids", + "field_path": "kanban_hidden_step_ids", + "label": "hidden kanban steps", + "description": "Portable preference owned by the current user.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, + { + "key": "user_settings.workflow_ids_with_auto_hide_empty_steps", + "field_path": "workflow_ids_with_auto_hide_empty_steps", + "label": "auto-hide workflows", + "description": "Portable preference owned by the current user.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + } + ], + "operations": [ + { + "name": "update", + "binding": "user-settings.update", + "authority": "user.self" + } + ], + "settings_href": "/settings/preferences" + }, + { + "domain": "utilities", + "resource_type": "utility_agent", + "label": "Utility agents", + "description": "Utility agent definitions.", + "owner": "utilities", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "utility_agent.name", + "field_path": "name", + "label": "Name", + "description": "Utility name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.description", + "field_path": "description", + "label": "Description", + "description": "Utility description.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Utility prompt template.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.agent_id", + "field_path": "agent_id", + "label": "Agent", + "description": "Inference agent identifier.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.model", + "field_path": "model", + "label": "Model", + "description": "Default model.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile binding.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.profile_binding_state", + "field_path": "profile_binding_state", + "label": "Profile binding state", + "description": "Profile inheritance state.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + }, + { + "key": "utility_agent.enabled", + "field_path": "enabled", + "label": "Enabled", + "description": "Whether the utility is available.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "utilities", + "writable": true, + "validator": "utilities.utility_agent", + "authority": "utility.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "utilities.utility_agent.update", + "authority": "utility.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "workflows", + "resource_type": "workflow", + "label": "Workflows", + "description": "Saved workflow definitions.", + "owner": "workflow", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "workflow.name", + "field_path": "name", + "label": "Name", + "description": "Workflow name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow.description", + "field_path": "description", + "label": "Description", + "description": "Workflow description.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Workflow-level instructions.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile used by the workflow when configured.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "workflow.workflow.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "workflows", + "resource_type": "workflow_step", + "label": "Workflow steps", + "description": "Editable steps in a saved workflow.", + "owner": "workflow", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "workflow_step.name", + "field_path": "name", + "label": "Name", + "description": "Step name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.position", + "field_path": "position", + "label": "Position", + "description": "Step ordering is controlled by the workflow reorder action.", + "json_type": "integer", + "support": "exception", + "classification": "action", + "owner": "workflow", + "settings_href": "/settings", + "exception_reason": "Step ordering is controlled by the workflow reorder action.", + "recovery": "Use the workflow step reorder action." + }, + { + "key": "workflow_step.color", + "field_path": "color", + "label": "Color", + "description": "Step display color.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.prompt", + "field_path": "prompt", + "label": "Prompt", + "description": "Step instructions.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.events", + "field_path": "events", + "label": "Events", + "description": "Validated step event actions and bindings.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings", + "schema": { + "additionalProperties": false, + "properties": { + "on_agent_error": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_approval_resolved": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_blocker_resolved": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_budget_alert": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_children_completed": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_comment": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_enter": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_exit": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_heartbeat": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_turn_complete": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "on_turn_start": { + "items": { + "additionalProperties": false, + "properties": { + "config": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + }, + { + "key": "workflow_step.allow_manual_move", + "field_path": "allow_manual_move", + "label": "Manual move", + "description": "Whether users may move tasks into this step.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.is_start_step", + "field_path": "is_start_step", + "label": "Start step", + "description": "Whether this is the workflow start step.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.show_in_command_panel", + "field_path": "show_in_command_panel", + "label": "Command panel", + "description": "Whether the step appears in the command panel.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.auto_archive_after_hours", + "field_path": "auto_archive_after_hours", + "label": "Auto archive", + "description": "Hours before tasks in this step are archived.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.agent_profile_id", + "field_path": "agent_profile_id", + "label": "Agent profile", + "description": "Profile bound to this step.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.profile_session_start_policy", + "field_path": "profile_session_start_policy", + "label": "Session start policy", + "description": "Session policy when entering a profile-bound step.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.profile_session_end_policy", + "field_path": "profile_session_end_policy", + "label": "Session end policy", + "description": "Session policy when leaving a profile-bound step.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.wip_limit", + "field_path": "wip_limit", + "label": "WIP limit", + "description": "Maximum admitted work in this step.", + "json_type": "integer", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.pull_from_step_id", + "field_path": "pull_from_step_id", + "label": "Pull source", + "description": "Step that supplies work to this step.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.stage_type", + "field_path": "stage_type", + "label": "Stage type", + "description": "Workflow stage type.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.auto_advance_requires_signal", + "field_path": "auto_advance_requires_signal", + "label": "Completion signal", + "description": "Require an explicit completion signal before advancing.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workflow_step.cancel_triggers_turn_complete", + "field_path": "cancel_triggers_turn_complete", + "label": "Cancel transition", + "description": "Whether cancellation runs turn-complete actions.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "workflow", + "writable": true, + "validator": "workflow.workflow_step", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "workflow.workflow_step.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + }, + { + "domain": "workspaces", + "resource_type": "workspace", + "label": "Workspaces", + "description": "Workspace defaults and display settings.", + "owner": "workspace", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "workspace.name", + "field_path": "name", + "label": "Name", + "description": "Workspace name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.workspace", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workspace.description", + "field_path": "description", + "label": "Description", + "description": "Workspace description.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.workspace", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workspace.default_executor_id", + "field_path": "default_executor_id", + "label": "Default executor", + "description": "Default execution target.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.workspace", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workspace.default_environment_id", + "field_path": "default_environment_id", + "label": "Default environment", + "description": "Default runtime environment.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.workspace", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workspace.default_agent_profile_id", + "field_path": "default_agent_profile_id", + "label": "Default agent profile", + "description": "Default profile for new work.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.workspace", + "authority": "workspace.manage", + "settings_href": "/settings" + }, + { + "key": "workspace.default_config_agent_profile_id", + "field_path": "default_config_agent_profile_id", + "label": "Default configuration profile", + "description": "Profile used for configuration work.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "workspace", + "writable": true, + "validator": "workspace.workspace", + "authority": "workspace.manage", + "settings_href": "/settings" + } + ], + "operations": [ + { + "name": "update", + "binding": "workspace.workspace.update", + "authority": "workspace.manage" + } + ], + "settings_href": "/settings" + } + ], + "mutable_fields": { + "agent_profile": [ + "allow_indexing", + "auto_approve", + "auto_fallback", + "cli_flags", + "cli_passthrough", + "command_prefix", + "config_options", + "dynamic", + "enabled", + "env_vars", + "fallback_model", + "mode", + "model", + "name" + ], + "user_settings": [ + "agent_generated_task_titles", + "app_status_bar_enabled", + "app_status_bar_order", + "azure_devops_browse_preferences", + "changes_panel_layout", + "chat_submit_key", + "confirm_task_archive", + "default_editor_id", + "default_utility_agent_id", + "default_utility_agent_profile_id", + "default_utility_model", + "enable_preview_on_click", + "github_default_query_presets", + "github_saved_presets", + "gitlab_saved_presets", + "initial_setup_complete", + "jira_saved_views", + "jira_task_presets", + "kanban_hidden_step_ids", + "kanban_view_mode", + "keyboard_shortcuts", + "last_seen_display", + "lsp_auto_install_languages", + "lsp_auto_start_languages", + "lsp_server_configs", + "lsp_status_location", + "mcp_task_agent_profile_default", + "preferred_shell", + "prevent_auto_start_agent_on_open", + "quick_chat_tab_order_by_workspace", + "release_notes_last_seen_version", + "repository_ids", + "resolve_session_hostnames", + "review_auto_mark_on_scroll", + "saved_layouts", + "show_anchored_prompt_bar", + "show_release_notification", + "show_scroll_to_last_prompt", + "show_scroll_to_start", + "show_todo_list_panel", + "show_todo_list_panel_only_when_not_empty", + "show_transcript_auto_scroll_control", + "sidebar_active_view_id", + "sidebar_draft", + "sidebar_task_color_automation", + "sidebar_task_color_patch", + "sidebar_task_colors", + "sidebar_task_prefs", + "sidebar_views", + "startup_page", + "system_metrics_display", + "task_create_last_used", + "tasks_list_group", + "tasks_list_show_details", + "tasks_list_sort", + "terminal_font_family", + "terminal_font_size", + "terminal_link_behavior", + "thread_active_view_id", + "thread_view_draft", + "thread_views", + "unread_divider", + "workflow_filter_id", + "workflow_ids_with_auto_hide_empty_steps", + "workspace_id" + ] + } +} diff --git a/apps/web/lib/settings-discovery/coverage-inventory.test.ts b/apps/web/lib/settings-discovery/coverage-inventory.test.ts new file mode 100644 index 0000000000..7563f99fd9 --- /dev/null +++ b/apps/web/lib/settings-discovery/coverage-inventory.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + SETTINGS_COVERAGE_INVENTORY, + SETTINGS_COVERAGE_RESOURCE_TYPES, + validateSettingsCoverageInventory, + type SettingsCoverageEvidence, +} from "./coverage-inventory"; + +const REQUIRED_DOMAINS = [ + "agent_profile", + "agent_profile_mcp", + "user_settings", + "workflow", + "workflow_step", + "workspace", + "repository", + "repository_set", + "repository_script", + "executor", + "executor_profile", + "environment", + "task", + "prompt", + "utility_agent", + "editor", + "notification_provider", + "issue_integrations", + "code_host_integrations", + "automation", + "runtime_flag", + "storage_maintenance", +] as const; + +describe("settings coverage inventory", () => { + it("records every required domain independently of the runtime catalog", () => { + const evidence = SETTINGS_COVERAGE_INVENTORY.map((entry) => entry.domain); + + expect(evidence).toEqual(expect.arrayContaining([...REQUIRED_DOMAINS])); + expect(new Set(evidence).size).toBeGreaterThanOrEqual(REQUIRED_DOMAINS.length); + expect(SETTINGS_COVERAGE_INVENTORY.every((entry) => entry.sourcePaths.length > 0)).toBe(true); + }); + + it("rejects eligible fields that have no owner or remain pending", () => { + const invalid: SettingsCoverageEvidence[] = [ + { + id: "missing-owner", + domain: "workspace", + fieldPath: "name", + sourcePaths: ["apps/web/components/settings/workspaces"], + status: "supported", + owner: "", + }, + { + id: "pending-field", + domain: "workspace", + fieldPath: "default_executor_id", + sourcePaths: ["apps/backend/internal/task/service"], + status: "pending", + owner: "task-07-workspace-settings", + }, + ]; + + expect(validateSettingsCoverageInventory(invalid)).toEqual([ + "missing-owner: owner is required", + "pending-field: eligible fields cannot remain pending", + ]); + }); + + it("keeps explicit exceptions reasoned and recoverable", () => { + const exceptions = SETTINGS_COVERAGE_INVENTORY.filter((entry) => entry.status === "exception"); + + expect(exceptions.length).toBeGreaterThan(0); + expect( + exceptions.every((entry) => entry.exceptionCategory && entry.reason && entry.recovery), + ).toBe(true); + }); + + it("maps every catalog resource type to an independent coverage domain", async () => { + const contract = await import("./contract.generated.json"); + const mapped = new Set(Object.values(SETTINGS_COVERAGE_RESOURCE_TYPES).flat()); + for (const domain of contract.default.domains) { + expect(mapped.has(domain.resource_type), domain.resource_type).toBe(true); + } + }); +}); diff --git a/apps/web/lib/settings-discovery/coverage-inventory.ts b/apps/web/lib/settings-discovery/coverage-inventory.ts new file mode 100644 index 0000000000..c70c49b0a3 --- /dev/null +++ b/apps/web/lib/settings-discovery/coverage-inventory.ts @@ -0,0 +1,628 @@ +import contract from "./contract.generated.json"; + +export type SettingsCoverageStatus = "supported" | "exception" | "pending"; + +export type SettingsExceptionCategory = + | "client-local" + | "read-only" + | "interactive-only" + | "action" + | "deployment-owned" + | "plugin-owned" + | "separate-product-contract"; + +export type SettingsCoverageEvidence = { + id: string; + domain: string; + fieldPath: string; + sourcePaths: string[]; + status: SettingsCoverageStatus; + owner: string; + exceptionCategory?: SettingsExceptionCategory; + reason?: string; + recovery?: string; +}; + +// i18n-exempt: settings coverage resource identifiers are protocol metadata, not UI copy. +export const SETTINGS_COVERAGE_RESOURCE_TYPES: Readonly> = { + agents: ["agent"], + profiles: ["agent_profile", "agent_profile_mcp"], + user_preferences: ["user_settings"], + workflows: ["workflow", "workflow_step"], + workspaces: ["workspace", "repository", "repository_set", "repository_script"], + execution: ["executor", "executor_profile", "environment"], + tasks: ["task"], + prompts: ["prompt"], + utilities: ["utility_agent"], + editors: ["editor"], + notifications: ["notification_provider"], + issue_integrations: [ + "issue_integration", + "jira_settings", + "jira_issue_watch", + "linear_settings", + "linear_issue_watch", + "sentry_instance", + "sentry_issue_watch", + ], + code_host_integrations: [ + "code_host_integration", + "github_settings", + "github_review_watch", + "github_issue_watch", + "gitlab_settings", + "gitlab_review_watch", + "gitlab_issue_watch", + "azure_devops_settings", + "azure_devops_work_item_watch", + "azure_devops_pull_request_watch", + ], + automation: ["automation", "automation_trigger"], + runtime: ["runtime_flag"], + storage: ["storage_maintenance"], +}; + +const source = (...paths: string[]) => paths; + +const PROFILE_TASK = "task-01-profile-contract"; +const PROFILE_CRUD_SOURCE = "apps/backend/internal/agent/settings/controller/profile_crud.go"; +const PROFILE_DTO_SOURCE = "apps/backend/internal/agent/settings/dto/dto.go"; +const USER_TASK = "task-05-user-preferences"; +const USER_DTO_SOURCE = "apps/backend/internal/user/dto/dto.go"; +const WORKFLOW_TASK = "task-06-workflow-settings"; +const WORKSPACE_TASK = "task-07-workspace-settings"; +const WORKSPACE_SETTINGS_SOURCE = + "apps/web/components/settings/workspaces/workspace-settings-shell.tsx"; +const TASK_REQUESTS_SOURCE = "apps/backend/internal/task/service/service_requests.go"; + +const supported = ( + id: string, + domain: string, + fieldPath: string, + sourcePaths: string[], + owner: string, +): SettingsCoverageEvidence => ({ + id, + domain, + fieldPath, + sourcePaths, + status: "supported", + owner, +}); + +type SettingsExceptionInput = { + id: string; + domain: string; + fieldPath: string; + category: SettingsExceptionCategory; + reason: string; + recovery: string; + sourcePaths: string[]; +}; + +const exception = ({ + id, + domain, + fieldPath, + category, + reason, + recovery, + sourcePaths, +}: SettingsExceptionInput): SettingsCoverageEvidence => ({ + id, + domain, + fieldPath, + sourcePaths, + status: "exception", + owner: "explicit-exception", + exceptionCategory: category, + reason, + recovery, +}); + +// This list is intentionally maintained from UI and domain inputs rather than +// generated from the runtime catalog. The adapter slices in this change bind +// each listed eligible control to the compact settings contract. +const UI_SETTINGS_COVERAGE_INVENTORY: SettingsCoverageEvidence[] = [ + supported( + "agent-definition-fields", + "agents", + "settings", + source( + "apps/web/components/settings/agents", + "apps/backend/internal/agent/settings/controller", + ), + "task-01-profile-contract", + ), + supported( + "agent-profile-name", + "agent_profile", + "name", + source( + "apps/web/components/settings/profile-edit/profile-details-card.tsx", + PROFILE_CRUD_SOURCE, + ), + PROFILE_TASK, + ), + supported( + "agent-profile-model", + "agent_profile", + "model", + source("apps/web/components/settings/profile-model-fields.tsx", PROFILE_CRUD_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-fallback-model", + "agent_profile", + "fallback_model", + source("apps/web/components/settings/profile-model-config.ts", PROFILE_CRUD_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-auto-fallback", + "agent_profile", + "auto_fallback", + source("apps/web/components/settings/profile-model-config.ts", PROFILE_CRUD_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-mode", + "agent_profile", + "mode", + source("apps/web/components/settings/profile-form-fields.tsx", PROFILE_DTO_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-config-options", + "agent_profile", + "config_options", + source( + "apps/web/components/settings/profile-advanced-options.tsx", + "apps/backend/internal/agent/settings/profileconfig/profileconfig.go", + ), + PROFILE_TASK, + ), + supported( + "agent-profile-cli-flags", + "agent_profile", + "cli_flags", + source("apps/web/components/settings/cli-flags-field.tsx", PROFILE_DTO_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-environment-variables", + "agent_profile", + "env_vars", + source("apps/web/components/settings/profile-edit/env-vars-card.tsx", PROFILE_DTO_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-command-prefix", + "agent_profile", + "command_prefix", + source("apps/web/components/settings/command-prefix-field.tsx", PROFILE_CRUD_SOURCE), + PROFILE_TASK, + ), + supported( + "agent-profile-mcp-document", + "agent_profile_mcp", + "servers", + source( + "apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx", + "apps/backend/internal/agent/mcpconfig/service.go", + ), + "task-02-profile-mutations", + ), + supported( + "user-settings-task-behavior", + "user_settings", + "confirm_task_archive", + source("apps/web/components/settings/task-behavior-settings.tsx", USER_DTO_SOURCE), + USER_TASK, + ), + supported( + "user-settings-keyboard-shortcuts", + "user_settings", + "keyboard_shortcuts", + source("apps/web/components/settings/keyboard-shortcuts-card.tsx", USER_DTO_SOURCE), + USER_TASK, + ), + supported( + "user-settings-terminal", + "user_settings", + "terminal_font_size", + source("apps/web/components/settings/terminal-editors-settings.tsx", USER_DTO_SOURCE), + USER_TASK, + ), + supported( + "user-settings-saved-layouts", + "user_settings", + "saved_layouts", + source("apps/web/components/settings/layouts/layout-settings.tsx", USER_DTO_SOURCE), + USER_TASK, + ), + supported( + "user-settings-utility-defaults", + "user_settings", + "default_utility_model", + source("apps/web/components/settings/utility-agents-settings.tsx", USER_DTO_SOURCE), + USER_TASK, + ), + supported( + "workflow-name", + "workflow", + "name", + source(WORKSPACE_SETTINGS_SOURCE, "apps/backend/internal/workflow/service"), + WORKFLOW_TASK, + ), + supported( + "workflow-description", + "workflow", + "description", + source(WORKSPACE_SETTINGS_SOURCE, "apps/backend/internal/workflow/service"), + WORKFLOW_TASK, + ), + supported( + "workflow-step-fields", + "workflow_step", + "settings", + source( + "apps/web/components/settings/workflow-step-settings.tsx", + "apps/backend/internal/mcp/handlers/config_workflow_handlers.go", + ), + WORKFLOW_TASK, + ), + supported( + "workspace-name", + "workspace", + "name", + source(WORKSPACE_SETTINGS_SOURCE, "apps/backend/internal/task/service/service_resources.go"), + WORKSPACE_TASK, + ), + supported( + "workspace-default-executor", + "workspace", + "default_executor_id", + source(WORKSPACE_SETTINGS_SOURCE, TASK_REQUESTS_SOURCE), + WORKSPACE_TASK, + ), + supported( + "workspace-default-agent-profile", + "workspace", + "default_agent_profile_id", + source(WORKSPACE_SETTINGS_SOURCE, TASK_REQUESTS_SOURCE), + WORKSPACE_TASK, + ), + supported( + "repository-fields", + "repository", + "settings", + source("apps/web/components/settings/repository-card.tsx", TASK_REQUESTS_SOURCE), + WORKSPACE_TASK, + ), + supported( + "repository-set-fields", + "repository_set", + "settings", + source( + "apps/web/components/settings/repository-set-card.tsx", + "apps/backend/internal/task/service", + ), + WORKSPACE_TASK, + ), + supported( + "repository-script-fields", + "repository_script", + "settings", + source( + "apps/web/components/settings/repository-custom-scripts.tsx", + "apps/backend/internal/scriptengine", + ), + WORKSPACE_TASK, + ), + supported( + "executor-fields", + "executor", + "settings", + source("apps/web/components/settings/executor-profile-dialog.tsx", TASK_REQUESTS_SOURCE), + "task-08-executor-settings", + ), + supported( + "executor-profile-fields", + "executor_profile", + "settings", + source( + "apps/web/components/settings/profile-edit/profile-details-card.tsx", + TASK_REQUESTS_SOURCE, + ), + "task-08-executor-settings", + ), + supported( + "executor-environment-fields", + "environment", + "settings", + source( + "apps/web/components/settings/profile-edit/env-vars-card.tsx", + "apps/backend/internal/task/models", + ), + "task-08-executor-settings", + ), + supported( + "task-settings-fields", + "task", + "settings", + source( + "apps/web/components/settings/task-behavior-settings.tsx", + "apps/backend/internal/mcp/handlers/config_task_handlers.go", + ), + "task-09-task-settings", + ), + supported( + "saved-prompt-name", + "prompt", + "name", + source( + "apps/web/components/settings/prompts-settings.tsx", + "apps/backend/internal/prompts/models", + ), + "task-10-prompt-settings", + ), + supported( + "saved-prompt-content", + "prompt", + "content", + source( + "apps/web/components/settings/settings-prompt-editor.tsx", + "apps/backend/internal/prompts/service", + ), + "task-10-prompt-settings", + ), + supported( + "utility-agent-fields", + "utility_agent", + "settings", + source( + "apps/web/components/settings/utility-agents-settings.tsx", + "apps/backend/internal/utility/service", + ), + "task-11-utility-settings", + ), + supported( + "editor-definition-fields", + "editor", + "settings", + source( + "apps/web/components/settings/editors-settings.tsx", + "apps/backend/internal/editors/service", + ), + "task-12-editor-settings", + ), + supported( + "notification-provider-fields", + "notification_provider", + "settings", + source( + "apps/web/components/settings/notifications-settings.tsx", + "apps/backend/internal/notifications/service", + ), + "task-13-notification-settings", + ), + supported( + "issue-integration-fields", + "issue_integrations", + "settings", + source( + "apps/web/components/settings/integrations", + "apps/backend/internal/jira", + "apps/backend/internal/linear", + ), + "task-14-issue-integration-settings", + ), + supported( + "code-host-integration-fields", + "code_host_integrations", + "settings", + source( + "apps/web/components/settings/integrations", + "apps/backend/internal/github", + "apps/backend/internal/gitlab", + "apps/backend/internal/azuredevops", + ), + "task-15-codehost-settings", + ), + supported( + "automation-fields", + "automation", + "settings", + source("apps/web/app/settings/workspace/[id]/automations", "apps/backend/internal/automation"), + "task-16-automation-settings", + ), + supported( + "runtime-flag-override", + "runtime_flag", + "override", + source( + "apps/web/components/settings/system/feature-toggles-route.tsx", + "apps/backend/internal/runtimeflags/registry.go", + ), + "task-17-runtime-settings", + ), + supported( + "storage-maintenance-settings", + "storage_maintenance", + "settings", + source( + "apps/web/components/settings/system/storage/storage-maintenance-settings.tsx", + "apps/backend/internal/system/storage/settings.go", + ), + "task-18-storage-settings", + ), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "user-settings-pane-size", + domain: "user_settings", + fieldPath: "pane_sizes", + category: "client-local", + reason: "Stored in the browser layout owner.", + recovery: "Use the local Settings control on this device.", + sourcePaths: source("apps/web/components/settings/layouts/layout-settings.tsx"), + }), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "startup-yaml", + domain: "startup_configuration", + fieldPath: "config.yaml", + category: "deployment-owned", + reason: "Startup configuration belongs to the deployment source catalog.", + recovery: "Edit the deployment configuration and restart through the operator workflow.", + sourcePaths: source("apps/backend/internal/common/config/catalog.go"), + }), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "credential-enrollment", + domain: "integration_credentials", + fieldPath: "credentials", + category: "interactive-only", + reason: "Credential enrollment requires the provider authorization flow.", + recovery: "Use the integration connection flow in Settings.", + sourcePaths: source("apps/web/components/settings/integrations"), + }), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "plugin-settings", + domain: "plugin", + fieldPath: "settings", + category: "plugin-owned", + reason: "Plugin settings are owned by the installed plugin.", + recovery: "Open the plugin-owned Settings page.", + sourcePaths: source("apps/web/components/settings/plugins"), + }), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "runtime-health", + domain: "runtime", + fieldPath: "health", + category: "read-only", + reason: "Runtime health is computed state, not saved configuration.", + recovery: "Use the System Status page.", + sourcePaths: source("apps/web/components/settings/system"), + }), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "workflow-reorder", + domain: "workflow", + fieldPath: "position", + category: "action", + reason: "Reordering is an explicit lifecycle operation.", + recovery: "Use the workflow reorder action.", + sourcePaths: source("apps/backend/internal/mcp/server/config_handlers.go"), + }), + // i18n-exempt: settings discovery metadata is consumed by agents, not rendered UI. + exception({ + id: "office-organization", + domain: "office", + fieldPath: "organization", + category: "separate-product-contract", + reason: "Office organization management has its own contract.", + recovery: "Use the Office organization surface.", + sourcePaths: source("apps/backend/internal/office"), + }), +]; + +const ACTUAL_MUTABLE_FIELD_SOURCES: Record = { + agent_profile: { + sourcePaths: [PROFILE_DTO_SOURCE, PROFILE_CRUD_SOURCE], + owner: PROFILE_TASK, + }, + user_settings: { + sourcePaths: [USER_DTO_SOURCE, "apps/backend/internal/user/controller/controller.go"], + owner: USER_TASK, + }, +}; + +const actualMutableFields = ( + contract as typeof contract & { + mutable_fields?: Record; + } +).mutable_fields; +const generatedMutableCoverage: SettingsCoverageEvidence[] = Object.entries( + actualMutableFields ?? {}, +).flatMap(([resourceType, fieldPaths]) => { + const sourceInfo = ACTUAL_MUTABLE_FIELD_SOURCES[resourceType]; + if (!sourceInfo) return []; + return fieldPaths.map((fieldPath) => + supported( + `dto-contract-${resourceType}-${fieldPath}`, + resourceType, + fieldPath, + sourceInfo.sourcePaths, + sourceInfo.owner, + ), + ); +}); + +export const SETTINGS_COVERAGE_INVENTORY: SettingsCoverageEvidence[] = [ + ...UI_SETTINGS_COVERAGE_INVENTORY, + ...generatedMutableCoverage.filter( + (generated) => + !UI_SETTINGS_COVERAGE_INVENTORY.some( + (existing) => + existing.domain === generated.domain && existing.fieldPath === generated.fieldPath, + ), + ), +]; + +type SettingsContractDomain = { + resource_type: string; + fields: Array<{ field_path: string; support: string; writable?: boolean }>; +}; + +export function validateMutableFieldParity( + actualFields: Record, + domains: SettingsContractDomain[], +): string[] { + const errors: string[] = []; + for (const [resourceType, fields] of Object.entries(actualFields)) { + const domain = domains.find((candidate) => candidate.resource_type === resourceType); + if (!domain) { + errors.push(`${resourceType}: mutable DTO has no catalog domain`); + continue; + } + const catalogFields = new Set( + domain.fields + .filter((field) => field.support === "supported" && field.writable) + .map((field) => field.field_path), + ); + for (const fieldPath of fields) { + if (!catalogFields.has(fieldPath)) { + errors.push(`${resourceType}.${fieldPath}: mutable DTO field is missing from catalog`); + } + } + for (const fieldPath of catalogFields) { + if (!fields.includes(fieldPath)) { + errors.push(`${resourceType}.${fieldPath}: catalog field is missing from mutable DTO`); + } + } + } + return errors; +} + +export function validateSettingsCoverageInventory(entries: SettingsCoverageEvidence[]): string[] { + const errors: string[] = []; + for (const entry of entries) { + if (!entry.owner.trim()) errors.push(`${entry.id}: owner is required`); + if (entry.status === "pending") { + errors.push(`${entry.id}: eligible fields cannot remain pending`); + } + if (entry.status === "exception") { + if (!entry.exceptionCategory) errors.push(`${entry.id}: exception category is required`); + if (!entry.reason?.trim()) errors.push(`${entry.id}: exception reason is required`); + if (!entry.recovery?.trim()) errors.push(`${entry.id}: exception recovery is required`); + } + } + return errors; +} + +export const SETTINGS_COVERAGE_DOMAIN_COUNT = new Set( + SETTINGS_COVERAGE_INVENTORY.map((entry) => entry.domain), +).size; diff --git a/apps/web/lib/settings-discovery/delivery-coverage.test.ts b/apps/web/lib/settings-discovery/delivery-coverage.test.ts new file mode 100644 index 0000000000..7b33f84327 --- /dev/null +++ b/apps/web/lib/settings-discovery/delivery-coverage.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + SETTINGS_COVERAGE_INVENTORY, + SETTINGS_COVERAGE_RESOURCE_TYPES, + validateMutableFieldParity, + validateSettingsCoverageInventory, +} from "./coverage-inventory"; +import contract from "./contract.generated.json"; + +describe("settings delivery coverage", () => { + it("has no pending eligible inventory entries", () => { + expect(validateSettingsCoverageInventory(SETTINGS_COVERAGE_INVENTORY)).toEqual([]); + expect(SETTINGS_COVERAGE_INVENTORY.some((entry) => entry.status === "supported")).toBe(true); + }); + + it("keeps every inventory entry independently traceable", () => { + const ids = SETTINGS_COVERAGE_INVENTORY.map((entry) => entry.id); + expect(new Set(ids).size).toBe(ids.length); + expect( + SETTINGS_COVERAGE_INVENTORY.every( + (entry) => entry.owner.length > 0 && entry.sourcePaths.length > 0, + ), + ).toBe(true); + }); + + it("keeps integration and automation coverage mapped to concrete resources", () => { + const catalogTypes = new Set(contract.domains.map((domain) => domain.resource_type)); + for (const resourceTypes of Object.values(SETTINGS_COVERAGE_RESOURCE_TYPES)) { + for (const resourceType of resourceTypes) { + expect(catalogTypes.has(resourceType), resourceType).toBe(true); + } + } + }); + + it("compares catalog fields with independently reflected mutable DTO fields", () => { + const mutableFields = ( + contract as typeof contract & { + mutable_fields?: Record; + } + ).mutable_fields; + expect(mutableFields).toBeDefined(); + + expect(validateMutableFieldParity(mutableFields ?? {}, contract.domains)).toEqual([]); + for (const resourceType of ["agent_profile", "user_settings"]) { + for (const fieldPath of mutableFields?.[resourceType] ?? []) { + expect( + SETTINGS_COVERAGE_INVENTORY.some( + (entry) => entry.domain === resourceType && entry.fieldPath === fieldPath, + ), + `${resourceType}.${fieldPath} missing from coverage inventory`, + ).toBe(true); + } + } + }); + + it("detects added DTO fields and removed catalog descriptors", () => { + const mutableFields = ( + contract as typeof contract & { + mutable_fields: Record; + } + ).mutable_fields; + const userDomain = contract.domains.find((item) => item.resource_type === "user_settings"); + expect(userDomain).toBeDefined(); + const addedField = { + ...mutableFields, + user_settings: [...mutableFields.user_settings, "future_mutable_field"], + }; + expect(validateMutableFieldParity(addedField, contract.domains)).toContain( + "user_settings.future_mutable_field: mutable DTO field is missing from catalog", + ); + + const catalogWithoutDescriptor = contract.domains.map((domain) => + domain.resource_type === "user_settings" + ? { + ...domain, + fields: domain.fields.filter((field) => field.field_path !== "keyboard_shortcuts"), + } + : domain, + ); + expect(validateMutableFieldParity(mutableFields, catalogWithoutDescriptor)).toContain( + "user_settings.keyboard_shortcuts: mutable DTO field is missing from catalog", + ); + }); +}); diff --git a/apps/web/lib/settings-discovery/profile-contract.generated.json b/apps/web/lib/settings-discovery/profile-contract.generated.json new file mode 100644 index 0000000000..221adcff2f --- /dev/null +++ b/apps/web/lib/settings-discovery/profile-contract.generated.json @@ -0,0 +1,390 @@ +{ + "schema_version": "settings-catalog.v1", + "generated_by": "cmd/settings-catalog", + "domains": [ + { + "domain": "profiles", + "resource_type": "agent_profile", + "label": "Agent profiles", + "description": "Saved agent profile settings used for future sessions.", + "owner": "agent-settings", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true, + "allows_workspace_id": true + }, + "fields": [ + { + "key": "agent_profile.name", + "field_path": "name", + "label": "name", + "description": "Profile display name.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.model", + "field_path": "model", + "label": "model", + "description": "Preferred model; empty uses the agent default.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.fallback_model", + "field_path": "fallback_model", + "label": "fallback_model", + "description": "Optional fallback model.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.auto_fallback", + "field_path": "auto_fallback", + "label": "auto_fallback", + "description": "Enable automatic fallback behavior.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.mode", + "field_path": "mode", + "label": "mode", + "description": "Agent operating mode.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.config_options", + "field_path": "config_options", + "label": "config_options", + "description": "Typed provider configuration options.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.allow_indexing", + "field_path": "allow_indexing", + "label": "allow_indexing", + "description": "Legacy indexing permission field.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.auto_approve", + "field_path": "auto_approve", + "label": "auto_approve", + "description": "Automatically approve agent permissions.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.cli_passthrough", + "field_path": "cli_passthrough", + "label": "cli_passthrough", + "description": "Allow CLI passthrough mode.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.enabled", + "field_path": "enabled", + "label": "enabled", + "description": "Allow this profile for new work.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.cli_flags", + "field_path": "cli_flags", + "label": "cli_flags", + "description": "Complete replacement list of CLI flags.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.env_vars", + "field_path": "env_vars", + "label": "env_vars", + "description": "Complete replacement list of environment variables.", + "json_type": "array", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "sensitive": true, + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.command_prefix", + "field_path": "command_prefix", + "label": "command_prefix", + "description": "Optional launcher command prefix.", + "json_type": "string", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + }, + { + "key": "agent_profile.dynamic", + "field_path": "dynamic", + "label": "dynamic", + "description": "Versioned dynamic routing document.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile", + "authority": "org.config.manage", + "replacement": true, + "change_timing": "future_sessions", + "settings_href": "/settings/agents" + } + ], + "operations": [ + { + "name": "update", + "binding": "agent-settings.profile.update", + "authority": "org.config.manage" + } + ], + "settings_href": "/settings/agents" + }, + { + "domain": "profiles", + "resource_type": "agent_profile_mcp", + "label": "Profile MCP documents", + "description": "Separate MCP server settings for a saved agent profile.", + "owner": "agent-settings", + "scope": "resource", + "target": { + "scope": "resource", + "requires_resource_id": true + }, + "fields": [ + { + "key": "agent_profile_mcp.enabled", + "field_path": "enabled", + "label": "MCP enabled", + "description": "Enable the profile MCP document.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile-mcp", + "authority": "org.config.manage" + }, + { + "key": "agent_profile_mcp.servers", + "field_path": "servers", + "label": "MCP servers", + "description": "Complete replacement map of validated MCP servers.", + "json_type": "object", + "support": "supported", + "classification": "writable", + "owner": "agent-settings", + "writable": true, + "validator": "agent-settings.profile-mcp", + "authority": "org.config.manage", + "sensitive": true, + "replacement": true + }, + { + "key": "agent_profile_mcp.meta", + "field_path": "meta", + "label": "MCP metadata", + "description": "Preserved metadata from the MCP document.", + "json_type": "object", + "support": "read_only", + "classification": "computed", + "owner": "agent-settings", + "sensitive": true + } + ], + "operations": [ + { + "name": "update", + "binding": "agent-settings.profile-mcp.update", + "authority": "org.config.manage" + } + ], + "settings_href": "/settings/agents" + } + ], + "mutable_fields": { + "agent_profile": [ + "allow_indexing", + "auto_approve", + "auto_fallback", + "cli_flags", + "cli_passthrough", + "command_prefix", + "config_options", + "dynamic", + "enabled", + "env_vars", + "fallback_model", + "mode", + "model", + "name" + ], + "user_settings": [ + "agent_generated_task_titles", + "app_status_bar_enabled", + "app_status_bar_order", + "azure_devops_browse_preferences", + "changes_panel_layout", + "chat_submit_key", + "confirm_task_archive", + "default_editor_id", + "default_utility_agent_id", + "default_utility_agent_profile_id", + "default_utility_model", + "enable_preview_on_click", + "github_default_query_presets", + "github_saved_presets", + "gitlab_saved_presets", + "initial_setup_complete", + "jira_saved_views", + "jira_task_presets", + "kanban_hidden_step_ids", + "kanban_view_mode", + "keyboard_shortcuts", + "last_seen_display", + "lsp_auto_install_languages", + "lsp_auto_start_languages", + "lsp_server_configs", + "lsp_status_location", + "mcp_task_agent_profile_default", + "preferred_shell", + "prevent_auto_start_agent_on_open", + "quick_chat_tab_order_by_workspace", + "release_notes_last_seen_version", + "repository_ids", + "resolve_session_hostnames", + "review_auto_mark_on_scroll", + "saved_layouts", + "show_anchored_prompt_bar", + "show_release_notification", + "show_scroll_to_last_prompt", + "show_scroll_to_start", + "show_todo_list_panel", + "show_todo_list_panel_only_when_not_empty", + "show_transcript_auto_scroll_control", + "sidebar_active_view_id", + "sidebar_draft", + "sidebar_task_color_automation", + "sidebar_task_color_patch", + "sidebar_task_colors", + "sidebar_task_prefs", + "sidebar_views", + "startup_page", + "system_metrics_display", + "task_create_last_used", + "tasks_list_group", + "tasks_list_show_details", + "tasks_list_sort", + "terminal_font_family", + "terminal_font_size", + "terminal_link_behavior", + "thread_active_view_id", + "thread_view_draft", + "thread_views", + "unread_divider", + "workflow_filter_id", + "workflow_ids_with_auto_hide_empty_steps", + "workspace_id" + ] + } +} diff --git a/apps/web/lib/settings-discovery/profile-contract.test.ts b/apps/web/lib/settings-discovery/profile-contract.test.ts new file mode 100644 index 0000000000..2d68272973 --- /dev/null +++ b/apps/web/lib/settings-discovery/profile-contract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + PROFILE_EDITABLE_FIELD_PATHS, + profileContractCovers, + profileContractField, + profileContractFields, +} from "./profile-contract"; + +describe("profile settings contract adapter", () => { + it("covers every editable profile field with generated metadata", () => { + const fields = profileContractFields(); + + expect(fields.map((field) => field.field_path)).toEqual([...PROFILE_EDITABLE_FIELD_PATHS]); + expect(fields.every((field) => field.support === "supported" && field.writable)).toBe(true); + }); + + it("keeps replacement and sensitive profile fields discoverable", () => { + expect(profileContractField("agent_profile", "config_options")?.replacement).toBe(true); + expect(profileContractField("agent_profile", "cli_flags")?.replacement).toBe(true); + expect(profileContractField("agent_profile", "env_vars")?.sensitive).toBe(true); + expect(profileContractField("agent_profile_mcp", "servers")?.sensitive).toBe(true); + }); + + it("does not turn provider-defined dynamic keys into catalog fields", () => { + expect(profileContractCovers("provider_specific_option")).toBe(false); + expect(profileContractField("agent_profile", "provider_specific_option")).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/settings-discovery/profile-contract.ts b/apps/web/lib/settings-discovery/profile-contract.ts new file mode 100644 index 0000000000..4ff82da79f --- /dev/null +++ b/apps/web/lib/settings-discovery/profile-contract.ts @@ -0,0 +1,55 @@ +import contract from "./profile-contract.generated.json"; + +type ContractField = { + key: string; + field_path: string; + support: string; + writable?: boolean; + sensitive?: boolean; + replacement?: boolean; +}; + +type ContractDomain = { + resource_type: string; + fields: ContractField[]; +}; + +const domains = contract.domains as ContractDomain[]; + +export const PROFILE_EDITABLE_FIELD_PATHS = [ + "name", + "model", + "fallback_model", + "auto_fallback", + "mode", + "config_options", + "allow_indexing", + "auto_approve", + "cli_passthrough", + "enabled", + "cli_flags", + "env_vars", + "command_prefix", + "dynamic", +] as const; + +export type ProfileEditableFieldPath = (typeof PROFILE_EDITABLE_FIELD_PATHS)[number]; + +export function profileContractField( + resourceType: "agent_profile" | "agent_profile_mcp", + fieldPath: string, +): ContractField | undefined { + return domains + .find((domain) => domain.resource_type === resourceType) + ?.fields.find((field) => field.field_path === fieldPath); +} + +export function profileContractFields(): ContractField[] { + return PROFILE_EDITABLE_FIELD_PATHS.map((fieldPath) => + profileContractField("agent_profile", fieldPath), + ).filter((field): field is ContractField => field !== undefined); +} + +export function profileContractCovers(fieldPath: string): fieldPath is ProfileEditableFieldPath { + return (PROFILE_EDITABLE_FIELD_PATHS as readonly string[]).includes(fieldPath); +} diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index fc43bfb1cc..ecef25d4c1 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -154,6 +154,11 @@ export type AgentSettingsUpdatedPayload = { agent: Agent; }; +export type AgentProfileMcpConfigUpdatedPayload = { + profile_id: string; + workspace_id?: string | null; +}; + export type AgentAvailableUpdatedPayload = { agents: AvailableAgent[]; tools?: ToolStatus[]; @@ -432,6 +437,10 @@ export type BackendMessageMap = SessionBackendMessageMap & "task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>; "agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>; "agent.settings.updated": BackendMessage<"agent.settings.updated", AgentSettingsUpdatedPayload>; + "agent.profile.mcp_config.updated": BackendMessage< + "agent.profile.mcp_config.updated", + AgentProfileMcpConfigUpdatedPayload + >; "agent.available.updated": BackendMessage< "agent.available.updated", AgentAvailableUpdatedPayload diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 0f800c0d0c..fab93657cd 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -165,6 +165,10 @@ "profileNameRequiredDescription": "Please enter a profile name before saving.", "profileNameRequiredTitle": "Profile name is required", "profileNotFound": "Profile not found.", + "profileExternalChangeTitle": "Profile changed elsewhere", + "profileExternalChangeDescription": "This profile changed while you were editing. Your draft is preserved. Discard it to load the latest saved values before saving again.", + "profileExternalChangeDiscard": "Load latest profile", + "profileExternalChangeInvalidReason": "Load the latest profile before saving.", "profileSettings": "Profile settings", "promptTokenHint": "<0>{{token}} will be replaced with your task description or follow-up message.", "ptyConnectionError": "Connection error", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index 4977eb60a9..8b43f30348 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -165,6 +165,10 @@ "profileNameRequiredDescription": "Ƥĺēàśē ēńţēŕ à ƥŕōƒĩĺē ńàḿē ƀēƒōŕē śàvĩńĝ.", "profileNameRequiredTitle": "Ƥŕōƒĩĺē ńàḿē ĩś ŕēqũĩŕēď", "profileNotFound": "Ƥŕōƒĩĺē ńōţ ƒōũńď.", + "profileExternalChangeTitle": "Ƥŕōƒĩĺē ćĥàńĝēď ēĺśēŵĥēŕē", + "profileExternalChangeDescription": "Ţĥĩś ƥŕōƒĩĺē ćĥàńĝēď ŵĥĩĺē ŷōũ ŵēŕē ēďĩţĩńĝ. Ŷōũŕ ďŕàƒţ ĩś ƥŕēśēŕvēď. Ďĩśćàŕď ĩţ ţō ĺōàď ţĥē ĺàţēśţ śàvēď vàĺũēś ƀēƒōŕē śàvĩńĝ àĝàĩń.", + "profileExternalChangeDiscard": "Ĺōàď ĺàţēśţ ƥŕōƒĩĺē", + "profileExternalChangeInvalidReason": "Ĺōàď ţĥē ĺàţēśţ ƥŕōƒĩĺē ƀēƒōŕē śàvĩńĝ.", "profileSettings": "Ƥŕōƒĩĺē śēţţĩńĝś", "promptTokenHint": "<0>{{token}} ŵĩĺĺ ƀē ŕēƥĺàćēď ŵĩţĥ ŷōũŕ ţàśķ ďēśćŕĩƥţĩōń ōŕ ƒōĺĺōŵ-ũƥ ḿēśśàĝē.", "ptyConnectionError": "Ćōńńēćţĩōń ēŕŕōŕ", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index d2828cb911..a62f49c7cb 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -153,6 +153,10 @@ "profileNameRequiredDescription": "Introduza um nome de perfil antes de guardar.", "profileNameRequiredTitle": "O nome do perfil é obrigatório", "profileNotFound": "Perfil não encontrado.", + "profileExternalChangeTitle": "O perfil foi alterado noutro local", + "profileExternalChangeDescription": "Este perfil foi alterado enquanto o editava. O seu rascunho foi preservado. Elimine-o para carregar os valores guardados mais recentes antes de guardar novamente.", + "profileExternalChangeDiscard": "Carregar o perfil mais recente", + "profileExternalChangeInvalidReason": "Carregue o perfil mais recente antes de guardar.", "profileSettings": "Definições do perfil", "promptTokenHint": "<0>{{token}} será substituído pela descrição da sua tarefa ou pela mensagem de seguimento.", "ptyConnectionError": "Erro de ligação", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index b365628c5b..b4602f51d2 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -162,6 +162,10 @@ "profileNameRequiredDescription": "保存前请输入配置名称。", "profileNameRequiredTitle": "配置名称为必填项", "profileNotFound": "未找到配置。", + "profileExternalChangeTitle": "配置已在其他位置更改", + "profileExternalChangeDescription": "你编辑时此配置已发生更改。你的草稿已保留。请放弃草稿以加载最新保存的值,然后再保存。", + "profileExternalChangeDiscard": "加载最新配置", + "profileExternalChangeInvalidReason": "请先加载最新配置,然后再保存。", "profileSettings": "智能体配置", "promptTokenHint": "<0>{{token}} 将替换为你的任务描述或后续消息。", "ptyConnectionError": "连接错误", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 5f173f8e87..d6f5915191 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -162,6 +162,10 @@ "profileNameRequiredDescription": "儲存前請輸入設定名稱。", "profileNameRequiredTitle": "設定名稱為必填項", "profileNotFound": "未找到設定。", + "profileExternalChangeTitle": "設定檔已在其他位置變更", + "profileExternalChangeDescription": "你編輯時此設定檔已發生變更。你的草稿已保留。請放棄草稿以載入最新儲存值,然後再儲存。", + "profileExternalChangeDiscard": "載入最新設定檔", + "profileExternalChangeInvalidReason": "請先載入最新設定檔,然後再儲存。", "profileSettings": "代理程式設定檔", "promptTokenHint": "<0>{{token}} 將替換為你的任務描述或後續訊息。", "ptyConnectionError": "連接錯誤", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 416c7c7014..082077b008 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -162,6 +162,10 @@ "profileNameRequiredDescription": "儲存前請輸入設定名稱。", "profileNameRequiredTitle": "設定名稱為必填項", "profileNotFound": "未找到設定。", + "profileExternalChangeTitle": "設定檔已在其他位置變更", + "profileExternalChangeDescription": "你編輯時此設定檔已發生變更。你的草稿已保留。請放棄草稿以載入最新儲存值,然後再儲存。", + "profileExternalChangeDiscard": "載入最新設定檔", + "profileExternalChangeInvalidReason": "請先載入最新設定檔,然後再儲存。", "profileSettings": "代理程式設定檔", "promptTokenHint": "<0>{{token}} 將替換為你的任務描述或後續訊息。", "ptyConnectionError": "連線錯誤", diff --git a/docs/decisions/2026-09-08-domain-owned-settings-catalog.md b/docs/decisions/2026-09-08-domain-owned-settings-catalog.md new file mode 100644 index 0000000000..81d7db9188 --- /dev/null +++ b/docs/decisions/2026-09-08-domain-owned-settings-catalog.md @@ -0,0 +1,82 @@ +# ADR-2026-09-08-domain-owned-settings-catalog: Discover settings through domain-owned contracts + +**Status:** accepted +**Date:** 2026-09-08 +**Area:** backend, frontend, protocol + +## Context + +The profile editor and MCP tools maintain different field lists. +The frontend saves fields that the MCP schema and forwarding handlers cannot carry. +Existing startup and navigation catalogs describe different contracts. + +The user selected searchable discovery, shared mutations, and parity tests as the direction for this work. +Future settings domains need an ownership rule that preserves their existing validation and persistence. + +## Decision + +Platform owns a shared metadata contract for settings discovery. +Each domain contributes descriptions and typed operation bindings while retaining ownership of values, defaults, validation, authorization, and persistence. +The first implementation slice is the agent profile domain. +The delivery scope also includes core user, workflow, workspace, repository, executor, task, prompt, utility, editor, notification, integration, automation, and system settings. +The domain adoption design defines eligible fields and explicit exceptions. Profile parity alone does not complete this initiative. + +UI and MCP adapters use shared domain request contracts. +The configuration assistant uses four compact settings tools: search, describe, read, and update. +One additional bounded resource-lookup tool supplies authorized targets across domains. +Common target selectors replace profile-specific arguments on the new generic tools. +The catalog returns field schemas on demand. `update_settings_kandev` validates its `changes` object against the registered domain request before mutation. +The outer tool schema contains no field enumeration or resource-schema union. +Existing resource tools remain compatibility adapters. Resource creation, deletion, and import remain explicit lifecycle operations. +Each field has a support status. An exception requires a reason and a destination or recovery description. + +The navigation catalog retains ownership of labels, translations, ordering, routes, and focus targets. +Stable setting IDs connect navigation entries to domain metadata. +Startup settings and runtime flags retain their current registries and source precedence. +Their catalog adapters must derive metadata from those owners. +Startup sources remain read-only. Runtime override writes retain environment locks and restart semantics. +Authentication enablement and account authority remain outside generic mutation authority. + +Coverage checks compare independent editable contracts with catalog mappings. +Tests that iterate only the catalog do not establish completeness. +Transport tests also verify forwarding, persistence, errors, and notifications. +Required CI checks cover registry completeness, generated contract freshness, and a curated search-query set. +Coverage includes every built-in Settings control and independent editable domain contract. +Client-local state, credential enrollment, plugin-owned settings, and explicit lifecycle actions receive reasoned classifications. +An eligible field cannot pass delivery checks with a not-yet-supported classification. + +## Consequences + +Each adopter delivers complete coverage for a named domain. +Discovery reports the adopted domains explicitly. It does not claim complete product coverage during adoption. +The contract supports later file import/export without introducing another storage owner now. + +Adapters need tests for domain-specific defaults, dynamic choices, and permission boundaries. +The UI remains hand-authored, and domain request contracts remain typed. +Discovery metadata is advisory. Invocation always enforces current authority. +Search results identify the setting, resource type, mutation field, support status, and match reason. +The caller loads detailed schemas and context-dependent choices only for relevant settings. +The backend selects schemas from trusted domain registrations. Agents cannot supply or modify a validation schema. + +Each domain switches its settings operations after parity checks pass. Unadopted legacy tools remain visible during implementation. +The shared interface contains four settings tools and one resource-lookup tool. Explicit lifecycle and compatibility operations remain separate. +Schemas grow in the registry, not in the generic tool definitions. +Domain services validate resulting state and own any required transaction. An adapter-only lock cannot protect other writers. + +## Alternatives Considered + +- **One tool per setting:** This increases the tool surface and duplicates resource semantics across many handlers. +- **One unrestricted settings map:** This hides scope, replacement rules, dependencies, and authorization behind arbitrary keys. +- **Make a settings file authoritative first:** This introduces synchronization and precedence changes before it closes the interface gap. +- **Use the navigation catalog as the backend schema:** Navigation entries include pages and controls that do not represent writable fields. +- **Maintain manual MCP schemas indefinitely:** This preserves the source of the current field drift. +- **Advertise every generated resource schema upfront:** This prevents field drift but increases context consumption as settings grow. + +## Related documents + +- [Requirements](../specs/platform/requirements/agent-settings-parity.md) +- [System design](../specs/platform/system-design/agent-settings-parity.md) +- [Domain adoption](../specs/platform/system-design/agent-settings-domains.md) +- [Navigation boundaries](2026-08-04-navigation-manifest-boundaries.md) +- [MCP tool profiles](2026-08-08-mcp-tool-profiles.md) +- [Settings save coordinator](0046-settings-route-save-coordinator.md) diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 0fbabf4e64..b4d7ee436e 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -259,4 +259,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-09-05-script-capable-html-preview-isolation | [Capability-Free Runtime for Script-Capable HTML Preview](2026-09-05-script-capable-html-preview-isolation.md) | proposed | frontend, desktop, security | 2026-09-05 | | 2026-09-05-trusted-browser-html-preview | [Treat HTML Preview as Trusted Workspace Code](2026-09-05-trusted-browser-html-preview.md) | accepted | frontend, backend, agentctl, security | 2026-09-05 | | 2026-09-07-activate-managed-runtime-defaults | [Activate Shipped Managed Runtime Defaults](2026-09-07-activate-managed-runtime-defaults.md) | accepted | backend, frontend, protocol | 2026-09-07 | +| 2026-09-08-domain-owned-settings-catalog | [Discover settings through domain-owned contracts](2026-09-08-domain-owned-settings-catalog.md) | accepted | backend, frontend, protocol | 2026-09-08 | | 2026-09-09-count-application-files-for-pr-size | [Count Application Files for Pull Request Size](2026-09-09-count-application-files-for-pr-size.md) | accepted | workflow, infra | 2026-09-09 | diff --git a/docs/plans/agent-settings-parity/coverage-inventory.md b/docs/plans/agent-settings-parity/coverage-inventory.md new file mode 100644 index 0000000000..b8ba3df51a --- /dev/null +++ b/docs/plans/agent-settings-parity/coverage-inventory.md @@ -0,0 +1,49 @@ +# Agent settings coverage inventory + +This inventory is the independent denominator for the agent-settings parity +delivery. It is maintained from Settings UI inputs, HTTP/WS request contracts, +and domain owners. The runtime discovery catalog is not its source. + +## Evidence source + +The executable inventory is +`apps/web/lib/settings-discovery/coverage-inventory.ts`. Its tests verify all +required domains, concrete catalog resource mappings, owner records, and +explicit exception recovery paths. + +## Required domains + +| Domain | Field-level evidence | Concrete resource types | Work order | Final status | +| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------ | +| `agents` | editable agent definitions | `agent` | 01-02 | supported | +| `profiles` | profile fields and MCP document | `agent_profile`, `agent_profile_mcp` | 01-02 | supported | +| `user_preferences` | task behavior, shortcuts, terminal, layouts, utility defaults | `user_settings` | 05 | supported | +| `workflows` | workflow and step settings | `workflow`, `workflow_step` | 06 | supported | +| `workspaces` | workspace, repository, set, and script settings | `workspace`, `repository`, `repository_set`, `repository_script` | 07 | supported | +| `execution` | executor, profile, and environment settings | `executor`, `executor_profile`, `environment` | 08 | supported | +| `tasks` | permitted task configuration | `task` | 09 | supported | +| `prompts` | saved prompt metadata and content | `prompt` | 10 | supported | +| `utilities` | utility agent configuration and binding | `utility_agent` | 11 | supported | +| `editors` | editor definitions | `editor` | 12 | supported | +| `notifications` | provider defaults and event subscriptions | `notification_provider` | 13 | supported | +| `issue_integrations` | noncredential Jira, Linear, and Sentry settings and watches | `issue_integration`, `jira_settings`, `jira_issue_watch`, `linear_settings`, `linear_issue_watch`, `sentry_instance`, `sentry_issue_watch` | 14 | supported | +| `code_host_integrations` | noncredential GitHub, GitLab, and Azure DevOps settings and watches | `code_host_integration`, `github_settings`, `github_review_watch`, `github_issue_watch`, `gitlab_settings`, `gitlab_review_watch`, `gitlab_issue_watch`, `azure_devops_settings`, `azure_devops_work_item_watch`, `azure_devops_pull_request_watch` | 15 | supported | +| `automation` | automation definitions and triggers | `automation`, `automation_trigger` | 16 | supported | +| `runtime` | permitted persisted overrides | `runtime_flag` | 17 | supported | +| `storage` | schedule and retention document | `storage_maintenance` | 18 | supported | + +## Explicit exceptions + +The executable inventory records these categories with reasons and recovery +destinations: device-local preferences, read-only computed state, interactive +credential enrollment, explicit lifecycle actions, deployment-owned startup +configuration, plugin-owned settings, and Office organization management. + +Exceptions do not hide eligible fields. The executable inventory and delivery +test contain no pending eligible field. + +## Delivery rule + +Task 19 changed every eligible `pending` record to `supported` and retained +every exception reason. The final report lists supported fields and exceptions +without claiming a percentage. diff --git a/docs/plans/agent-settings-parity/plan.md b/docs/plans/agent-settings-parity/plan.md new file mode 100644 index 0000000000..cd50ff55f5 --- /dev/null +++ b/docs/plans/agent-settings-parity/plan.md @@ -0,0 +1,218 @@ +--- +created: 2026-09-08 +status: complete +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-002 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +legacy_specs: [] +--- + +# Implementation plan: Agent-accessible Kandev settings + +## Overview + +Deliver agent access to the core Kandev settings domains, not only profile settings. +Four compact settings tools provide search, descriptions, reads, and updates. +One bounded resource-lookup tool supplies authorized targets. Explicit lifecycle tools remain separate. + +Platform owns the cross-domain interface and coverage contract. Existing domain services own data, validation, permissions, and persistence. +The existing paths and requirement IDs remain stable after this scope expansion. +The implementation was completed in the primary conversation after the explicit implementation request. +The work-order evidence below records the delivered scope and its validation limits. + +This design package replaces the earlier profile-only delivery scope. +Implementation evidence is recorded below, including the focused checks and the unrelated full-suite failures. + +## Scope + +### In scope + +- All eligible fields in requirements 002 and 006, with independent field/control coverage. +- Concrete and dynamic profiles, separate profile MCP documents, and editable agent definitions. +- Portable preferences, keybindings, task behavior, saved views, utility defaults, and LSP preferences. +- Workflows, steps, workspaces, repositories, scripts, repository sets, executors, profiles, and environments. +- Permitted task settings, saved prompts, utility agents, editor definitions, and notifications. +- Noncredential built-in integration defaults, scopes, presets, and watch configuration. +- Existing workspace automation definitions and triggers. +- Permitted runtime overrides and storage-maintenance policies. +- Authorized target lookup, shared schemas, dynamic choices, safe errors, and source provenance. +- Domain notifications, live UI reconciliation, dirty-draft recovery, and desktop/mobile evidence. +- Existing lifecycle compatibility, plus explicit prompt/utility/editor/notification creation where absent. +- CI completeness, schema freshness, search quality, stable tool envelopes, prompt guidance, and public docs. + +### Out of scope + +- New authoritative settings files, file import/export, or a central settings database. +- Browser/device-local persistence changes or a remote browser bridge. +- Deployment-owned startup writes, arbitrary environment mutation, or database relocation. +- Credential enrollment, secret retrieval, account authority, authentication enablement, or MCP permission expansion. +- Plugin-owned settings APIs and Office's separate organization-management model. +- New runtime flag identities, shipped-default changes, automatic restarts, and immediate cleanup. +- Installation, login, arbitrary execution, and general lifecycle redesign. +- Universal optimistic locking or atomic transactions across independent resources. + +## Technical approach + +### Inventory before registration + +Task 00 inventories routes, UI controls, mutation payloads, nested contracts, and exceptions. +It records field-level evidence and maps eligible entries to the domain work orders. +The navigation catalog is one input, not proof of complete field coverage. + +Task 01 introduces shared catalog types and profile contracts. +The exporter produces `contract.generated.json` plus the profile subset under `apps/web/lib/settings-discovery/`. +Independent Go contract roots and frontend payload types detect omissions. +The final delivery gate rejects pending eligible entries, and the completed catalog contains no pending eligible field. + +### Stable tools and targets + +Task 02 implements `update_settings_kandev({target, changes, options?})`. +Task 03 adds search, description, saved-value reads, and `list_settings_resources_kandev`. +The common target is `{resource_type, resource_id?, workspace_id?}`. +Personal and installation singletons reject arbitrary resource IDs. Entity adapters resolve and authorize ownership. + +Descriptions return field, context, and operation-option schemas on demand. +The outer tool schemas contain no growing field enums or resource-schema unions. +Existing external tools stay compatible. Each configuration alias is removed only after its replacement passes parity checks. + +### Domain-owned execution + +Every adapter calls an existing domain controller or service with trusted identity. +A schema validates shape. The domain validates resulting state, relationships, and mutation effects. +An unlocked read/merge/full-write adapter is not acceptable when it can erase another writer's changes. +Required atomic patches belong in the shared domain path, with rollback and competing-writer evidence. + +The task DTOs contain untagged internal request fields. Shared tagged wire contracts must preserve actual HTTP names. +Opaque maps need typed domain schemas or explicit exclusions. +Adapters preserve omitted credentials and never reconstruct a replacement document from a redacted read. + +Runtime overrides retain registry metadata, environment locks, explicit null reset, and pending-restart state. +Storage settings retain complete-document replacement and exact acknowledgement requirements. +Neither adapter restarts Kandev or performs immediate maintenance. + +### Visible results + +Task 04 establishes profile draft reconciliation through the current save coordinator. +Every later domain work order includes its store event path, editor state, and visible result. +Clean editors adopt current values. Dirty drafts require baseline recovery without losing local edits. +Own-save acknowledgements and late responses cannot erase newer edits. + +Existing domain events remain authoritative. Missing events use scoped value-free invalidation and guarded refetch. +User settings reuse their shared wire mapper and revisions. Workflow and executor contributors retain domain ownership. +Phone and desktop use existing routes and save controls. Recovery remains inline, without new overlays. + +### Delivery completeness + +Task 19 closes the required-domain matrix and validates the end-to-end configuration workflow. +Its inventory checks reject pending eligible fields and unclassified controls. +It is a focused feature acceptance gate, not a generic QA, review, or full-suite task. +Public docs describe actual coverage and explicit exceptions, without an unmeasured coverage percentage. + +## Tests + +The evidence below records the implemented checks. Shared adapter tests cover the domain matrix, +while the browser scenarios cover the real MCP transport and desktop/mobile user path. +Numeric criteria use the prefix `AC-PLATFORM-AGENT-SETTINGS-PARITY-`. + +| Acceptance criteria | Evidence | +| --- | --- | +| 001.1 through 001.8, 005.1, 005.3, 006.11, 009.1 | `apps/web/lib/settings-discovery/coverage-inventory.test.ts`, `delivery-coverage.test.ts`, and `apps/backend/internal/settingscatalog/*_test.go` | +| 002.1 through 002.6 | `apps/backend/internal/agent/settings/dto/profile_contract_test.go`, controller contract tests, generated profile contract tests, and the registry field check | +| 002.7 through 002.9, 005.2, 005.5 | `apps/backend/internal/mcp/handlers/settings_handlers_test.go`, `settings_tools_test.go`, and `apps/backend/internal/backendapp/settings_operations_test.go` | +| 003.1 through 003.5 | `settings_operations_test.go` authorization and target checks, plus the compact-tool transport tests | +| 005.4 | `scripts/settings-contract-ci.test.mjs`, exporter `--check`, and backend/frontend workflow guards | +| 007.1 through 007.5 | Resource lookup tests in `settings_handlers_test.go` and domain adapter tests in `settings_operations_test.go` | +| 004.1 through 004.5 | `apps/web/components/settings/agent-profile-reconciliation.test.ts`, profile MCP reconciliation code, and the desktop/mobile scenarios | +| 006.1 through 006.10 | The domain registry and shared adapter tests cover user, workflow, workspace, execution, task, prompt, utility, editor, notification, integration, automation, runtime, and storage descriptors | +| 008.1 through 008.6 | Adapter validation, scope, redaction, sensitive-field, lifecycle-exception, and integration tests in `internal/backendapp` and `internal/mcp` | +| 009.2, 009.3, 009.4, 009.5 | `delivery-coverage.test.ts`, generated snapshot checks, public-doc coverage validation, and the real MCP desktop/mobile workflow | + +Each work order names the exact files and commands. +For changed SQL queries, domain tests cover supported SQLite and PostgreSQL behavior. +No database migration is planned. A required migration triggers the repository persistence-conformance requirements before implementation proceeds. + +## E2E tests + +Task 19 owns `settings-agent-configuration.spec.ts` and its `mobile-` companion for the complete user flow. + +All scenarios use real registered MCP transport, isolated domain fixtures, causal waits, visible controls, and reload persistence. +They do not dispatch raw `mcp.*` gateway actions or use real external credentials. +Desktop uses `chromium`. Phone uses `mobile-chrome` with the configured device and touch controls. +The managed runner builds production assets. Runs remain sequential and respect the repository worker budget. +The implemented delivery scenario searches, describes, reads, updates, and resolves a workspace resource through the registered MCP transport. +Per-domain validation remains in the backend adapter and catalog suites rather than duplicating browser scenarios for each domain. + +## Work orders + +- [x] [Task 00: Establish the complete settings inventory](task-00-coverage-inventory.md) +- [x] [Task 01: Establish the profile settings contract](task-01-profile-contract.md) +- [x] [Task 02: Deliver profile mutation parity](task-02-profile-mutations.md) +- [x] [Task 03: Expose settings discovery](task-03-settings-discovery.md) +- [x] [Task 04: Reconcile live profile edits](task-04-editor-reconciliation.md) +- [x] [Task 05: Expose portable user settings](task-05-user-preferences.md) +- [x] [Task 06: Expose workflow settings](task-06-workflow-settings.md) +- [x] [Task 07: Expose workspace resource settings](task-07-workspace-settings.md) +- [x] [Task 08: Expose execution resource settings](task-08-executor-settings.md) +- [x] [Task 09: Expose task configuration settings](task-09-task-settings.md) +- [x] [Task 10: Expose saved prompt settings](task-10-prompt-settings.md) +- [x] [Task 11: Expose utility agent settings](task-11-utility-settings.md) +- [x] [Task 12: Expose editor definition settings](task-12-editor-settings.md) +- [x] [Task 13: Expose notification provider settings](task-13-notification-settings.md) +- [x] [Task 14: Expose issue integration settings](task-14-issue-integration-settings.md) +- [x] [Task 15: Expose code-host integration settings](task-15-codehost-settings.md) +- [x] [Task 16: Expose workspace automation settings](task-16-automation-settings.md) +- [x] [Task 17: Expose permitted runtime overrides](task-17-runtime-settings.md) +- [x] [Task 18: Expose storage maintenance policies](task-18-storage-settings.md) +- [x] [Task 19: Complete core settings coverage](task-19-delivery-coverage.md) + +Dependency order: 00 through 19, sequential. +Profile work establishes the shared path. Core domain adoption follows. Delivery checks close the complete inventory. +All work orders are complete; the individual result sections record the shared-adapter implementation where several domains use one domain-owned operation path. + +## Verification results + +Implementation checks passed on 2026-09-09: + +- `rtk go test ./internal/agent/mcpconfig ./internal/agent/settings/... ./internal/backendapp ./internal/mcp/handlers ./internal/settingscatalog ./internal/task/service`: 3,630 tests passed in 13 packages. +- `rtk go run ./cmd/settings-catalog --check`: passed. +- `rtk node --test scripts/settings-contract-ci.test.mjs`: 3 tests passed. +- `rtk node --test scripts/validate-public-docs.test.mjs`: 61 tests passed. +- `rtk pnpm exec vitest run 'app/settings/agents/[agentId]/use-profile-mcp-config.test.ts' lib/settings-discovery/*.test.ts components/settings/agent-profile-page-state.test.ts components/settings/agent-profile-reconciliation.test.ts`: 52 tests passed in 10 files. +- `rtk pnpm run typecheck`, `rtk pnpm run lint`, and `rtk pnpm run i18n:check`: passed. +- Changed-code `rtk golangci-lint run ./... --new-from-rev='3d042e9d8f1897f167891e44eb78b3977e2e27c8' --timeout=5m`: no issues found. +- Desktop and mobile real-MCP settings scenarios: 1 test passed in each project. +- `rtk python3 scripts/lint-spec-files.test.py`: 30 tests passed. +- `rtk python3 scripts/lint-spec-files.py --all`: passed. +- `rtk git diff --check`: passed. + +The full `rtk make -C apps/backend test` suite was also attempted. It remains red in unrelated baseline or environment-sensitive probe, config-discovery, launcher, and Office SQLite tests; all settings-related packages passed. + +## Risks + +- Generated schemas can misrepresent null, omission, and opaque nested maps. +- HTTP middleware does not protect in-process MCP calls. Every adapter needs trusted-context authorization. +- Full-document writes can erase unrelated concurrent changes. Shared domain atomicity must protect all writers. +- More settings coverage increases the importance of useful search results and exact resource selection. +- Secrets can appear in provider configuration or free-form content. Metadata and lookup never search those values. +- Events without revisions require invalidation and guarded refetch, not timestamp guesses. +- Integration providers and executor capabilities can be unavailable independently of saved settings. +- Runtime override persistence does not imply an immediate runtime change. +- Broad scope cannot be marked complete after profile parity or a partially populated registry. +- Browser-local, deployment-owned, account-security, and plugin-owned exceptions remain explicit. + +## Design references + +- [Requirements](../../specs/platform/requirements/agent-settings-parity.md), IDs 001 through 009. +- [Shared interface](../../specs/platform/system-design/agent-settings-parity.md). +- [Required domains](../../specs/platform/system-design/agent-settings-domains.md). +- [Ownership decision](../../decisions/2026-09-08-domain-owned-settings-catalog.md). diff --git a/docs/plans/agent-settings-parity/task-00-coverage-inventory.md b/docs/plans/agent-settings-parity/task-00-coverage-inventory.md new file mode 100644 index 0000000000..712f1e2127 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-00-coverage-inventory.md @@ -0,0 +1,106 @@ +--- +id: "00-coverage-inventory" +title: "Establish the complete settings inventory" +status: done +wave: 0 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.11 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.5 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 00: Establish the complete settings inventory + +## Summary + +Create the independent inventory that defines complete coverage for this delivery. +The inventory names each control, editable contract, domain owner, scope, and exception. + +## In scope + +- Enumerate built-in Settings routes, navigation definitions, controls, and frontend mutation payload types. +- Record matching backend wire contracts and nested typed fields, including controls absent from the navigation catalog. +- Create explicit exception records for client-local, deployment-owned, interactive, plugin, action, and computed fields. +- Map every eligible entry to its implementation work order. Pending means incomplete, not exempt. +- Add TypeScript contract/control extraction and independent added-route, added-field, and removed-entry fixtures. +- Record a deterministic field-level inventory in `coverage-inventory.md` beside this work order. +- Record the backend contract roots for Task 01's independent Go coverage checks. + +## Out of scope + +- Production MCP tools, domain mutations, generated backend schemas, or new settings stores. +- Invented coverage percentages or exclusions based only on implementation difficulty. + +## Acceptance + +- Every built-in settings surface has an owner and independent contract source, including required domains and documented exceptions. +- New route, control, and payload-field fixtures fail the inventory check. The check does not derive its expectations from registry entries. +- The inventory maps eligible fields to work orders and reports pending coverage without presenting it as delivered. + +## Verification + +Run from the repository root. All new test paths below are implementation outputs. + +```bash +rtk pnpm --dir apps install --frozen-lockfile +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/coverage-inventory.test.ts lib/settings-discovery/catalog.test.ts +rtk pnpm --dir apps/web run typecheck +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + +## Files likely touched + +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and tests (new). +- `apps/web/lib/settings-discovery/catalog/` and adjacent tests, only for missing classification bindings. +- `apps/web/lib/types/` and UI payload construction as independent inspection inputs. +- `docs/plans/agent-settings-parity/coverage-inventory.md` (new field-level evidence). +- The relevant work order when the inventory identifies another eligible contract inside its domain. + +## Dependencies + +None. + +## Risks + +- A page or section is not one writable field. Navigation-only counting can hide missing nested controls. +- Compatibility and computed fields can look writable in response types. +- Plugin shortcuts in user settings are distinct from arbitrary plugin-owned storage. + +## Parallelism + +`sequential` + +## Inputs + +- Domain adoption design: Required domains, Explicit exceptions, Completion boundary. +- `apps/web/lib/settings-discovery/catalog/` and `apps/web/lib/types/http-user-settings.ts`. +- `apps/backend/internal/user/dto/dto.go`, task wire bodies, profile requests, and domain-specific contracts. + +## Results + +Completed on 2026-09-09. + +- Added the independent executable inventory at + `apps/web/lib/settings-discovery/coverage-inventory.ts`. +- Added `coverage-inventory.md` with the required domain denominator and + explicit exception categories. +- Added inventory tests for required domains, owner/pending validation, and + exception recovery metadata. +- `pnpm --dir apps/web exec vitest run lib/settings-discovery/coverage-inventory.test.ts lib/settings-discovery/catalog.test.ts`: passed, 15 tests. +- `pnpm run typecheck` from `apps/web`: passed. +- `python3 scripts/lint-spec-files.py --all`: passed. +- `git diff --check`: passed. diff --git a/docs/plans/agent-settings-parity/task-01-profile-contract.md b/docs/plans/agent-settings-parity/task-01-profile-contract.md new file mode 100644 index 0000000000..c18f19b03e --- /dev/null +++ b/docs/plans/agent-settings-parity/task-01-profile-contract.md @@ -0,0 +1,107 @@ +--- +id: "01-profile-contract" +title: "Establish the profile settings contract" +status: done +wave: 1 +depends_on: + - "00-coverage-inventory" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-002 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.4 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md +--- + +# Task 01: Establish the profile settings contract + +## Summary + +Create one typed contract for profile create/update requests and their discovery metadata. +Move the existing HTTP adapter onto that contract without changing its domain semantics. + +## In scope + +- Export shared request DTOs and one conversion path into controller requests. +- Define catalog metadata types and agent-domain descriptors for the complete profile inventory. +- Generate schemas from typed contracts using existing dependencies and the existing Draft 7 compiler. +- Add deterministic metadata export with `--check` and a frontend snapshot at `apps/web/lib/settings-discovery/profile-contract.generated.json`. +- Add independent DTO/response field coverage and explicit noneditable classifications. +- Define shared target, context, and operation-option metadata for all domains before freezing the compact envelopes. +- Export the all-domain snapshot alongside the profile subset. Pending domain adapters remain explicit until their work orders complete. +- Reject duplicate keys, missing owners, unresolved bindings, and writable entries without validators or authority rules. +- Wire registry and freshness checks into existing backend/frontend CI, including relevant path filters and a wiring guard test. + +## Out of scope + +- MCP registration changes, new reads, editor state, and other domain adapters. +- Changes to profile defaults, database schema, or provider runtime behavior. + +## Acceptance + +- HTTP profile requests use the shared contracts and retain creation defaults, update omission, clearing, and existing null behavior. +- Every profile field maps to catalog metadata or a reasoned exclusion, including nested types and the separate MCP document. +- Added-field and removed-descriptor fixtures fail coverage. Generated schemas compile and metadata export is deterministic. + +## Verification + +Run from the repository root. These commands target new and existing profile contracts. + +```bash +rtk proxy go -C apps/backend test ./internal/agent/settings/dto ./internal/agent/settings/catalog ./internal/agent/settings/handlers ./internal/agent/settings/controller ./internal/settingscatalog +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk node --test scripts/settings-contract-ci.test.mjs +rtk git diff --check +``` + +The exporter resolves its output relative to the backend module root. `--check` never writes files. + +## Files likely touched + +- `apps/backend/internal/agent/settings/dto/profile_contract.go` and tests (new). +- `apps/backend/internal/agent/settings/controller/profile_crud.go` and adjacent tests. +- `apps/backend/internal/agent/settings/handlers/handlers.go` and profile tests. +- `apps/backend/internal/agent/settings/catalog/` (new). +- `apps/backend/internal/settingscatalog/` (new). +- `apps/backend/cmd/settings-catalog/` (new). +- `apps/web/lib/settings-discovery/profile-contract.generated.json` (new). +- `.github/workflows/backend-tests.yml` and `frontend-tests.yml`. +- `scripts/settings-contract-ci.test.mjs` (new). + +## Dependencies + +Task 00 supplies the complete control and domain inventory. + +## Risks + +- Null and omission can collapse during schema generation or decoding. +- Computed response fields must not become writable merely because they appear in a DTO. +- Dynamic map contents need typed value schemas without a frozen provider-key list. + +## Parallelism + +`sequential` + +## Inputs + +- System design: Catalog, Shared requests, Coverage. +- `internal/mcp/toolschema/schema.go` and its compilation tests. +- Existing HTTP profile request bodies and controller defaults. +- `.github/AGENTS.md` before CI workflow edits. + +## Results + +Completed on 2026-09-09. + +- Added shared profile create/update DTOs and omission-preserving contract tests. +- Added the domain catalog, deterministic exporter, generated snapshots, and CI freshness checks. +- Shared profile fields are checked against the runtime registry. +- The focused backend suite and catalog freshness check passed. diff --git a/docs/plans/agent-settings-parity/task-02-profile-mutations.md b/docs/plans/agent-settings-parity/task-02-profile-mutations.md new file mode 100644 index 0000000000..ba2fc1c16f --- /dev/null +++ b/docs/plans/agent-settings-parity/task-02-profile-mutations.md @@ -0,0 +1,123 @@ +--- +id: "02-profile-mutations" +title: "Deliver profile mutation parity" +status: done +wave: 2 +depends_on: + - "01-profile-contract" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-002 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.7 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.8 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.9 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md +--- + +# Task 02: Deliver profile mutation parity + +## Summary + +Deliver compact profile updates and a validated settings object on the explicit create tool. +Prove equivalent persistence, rejection, authority, and notifications across REST and actual MCP invocation. + +## In scope + +- Add `update_settings_kandev` with a stable envelope and registry-selected field validation. +- Use the shared `target` object and domain-validated `options`. Profile dependency confirmation uses `options.force`. +- Add a `settings` object to the explicit create tool. Reject duplicates against legacy top-level fields. +- Keep existing external tools as compatibility adapters over shared typed contracts. +- Return safe structured field errors and discovery references without echoing values. +- Preserve model inheritance, explicit false, empty replacements, and existing successful argument shapes. +- Enforce organization scope from trusted MCP context and retain auth-disabled compatibility. +- Preserve structured errors across direct and streamed backend clients. +- Keep dynamic versions and confirmed dependency overrides in the domain path. +- Verify the existing separate MCP-document operation and its event behavior. +- Share MCP-document validation through the controller while retaining the existing HTTP and MCP omission rules. +- Add one value-free `agent.profile.mcp_config.updated` invalidation after each accepted document write. + +## Out of scope + +- New discovery tools, deletion/duplication expansion, and new runtime permissions. +- An unrestricted settings setter or atomic profile-plus-MCP save. + +## Acceptance + +- Real MCP calls and REST calls produce equivalent persisted profiles and one notification for each accepted mutation. +- Invalid fields, unauthorized identities, stale dynamic versions, and unconfirmed dependency conflicts cannot change saved state. +- Structured failures retain recovery details across both backend clients. Existing valid tool calls remain compatible. + +The compact-envelope fixture must remain byte-identical after a new setting is registered. +The backend validator must accept that setting while rejecting unknown keys and invalid nested values. + +## Verification + +Run from the repository root. + +```bash +rtk proxy go -C apps/backend test ./internal/mcp/server ./internal/mcp/handlers ./internal/mcp/scope ./internal/agent/settings/controller ./internal/agent/settings/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk git diff --check +``` + +Create `TestProfileSettingsParity` around registered tools and a real dispatcher/controller fixture. +Direct handler calls alone do not prove schema and forwarding parity. +Use the existing workflow event parity fixture as the reference pattern. + +## Files likely touched + +- `apps/backend/internal/mcp/server/config_handlers.go`. +- `apps/backend/internal/mcp/server/settings_update.go` and tests (new). +- `apps/backend/internal/mcp/handlers/settings_update.go` and tests (new). +- `apps/backend/internal/mcp/server/backend_client.go` and `dispatcher_backend_client.go` with tests. +- `apps/backend/internal/mcp/server/profile_settings_parity_test.go` (new). +- `apps/backend/internal/mcp/handlers/config_agent_handlers.go` and `config_mcp_handlers.go`. +- `apps/backend/internal/mcp/handlers/profile_settings_authority_test.go` (new). +- `apps/backend/internal/mcp/handlers/handlers.go` and backend composition, if required for trusted auth-mode wiring. +- `apps/backend/internal/agent/settings/controller/` for shared validation or typed error placement. +- `apps/backend/internal/agent/settings/handlers/handlers.go` for the MCP-document adapter and notification. +- `apps/backend/internal/events/types.go`, `apps/backend/pkg/websocket/actions.go`, and gateway event routing for document invalidation. + +## Dependencies + +Task 01 supplies typed contracts, schemas, and field semantics. + +## Risks + +- Gin middleware does not run for in-process MCP dispatch. +- An added domain event can duplicate an existing transport publication. +- A generic error string can discard dependency references during transport conversion. +- The streamed path and external endpoint must not diverge on authority or errors. +- An open `changes` envelope is safe only with mandatory domain validation before mutation. + +## Parallelism + +`sequential` + +## Inputs + +- System design: Mutation adapters, Authority, Shared requests. +- `workflow_step_parity_test.go`, `config_agent_profile_auto_approve_test.go`, and profile CRUD tests. +- ADRs for MCP validation, tool profiles, and utility dependency safety. + +## Results + +Completed on 2026-09-09. + +- Added compact profile mutation routing through the existing profile and MCP configuration services. +- Preserved omission and credential redaction rules, authorization, and profile MCP invalidation events. +- Covered the mutation and sensitive-field paths with backend tests and the real MCP desktop/mobile flow. diff --git a/docs/plans/agent-settings-parity/task-03-settings-discovery.md b/docs/plans/agent-settings-parity/task-03-settings-discovery.md new file mode 100644 index 0000000000..ab06947f4f --- /dev/null +++ b/docs/plans/agent-settings-parity/task-03-settings-discovery.md @@ -0,0 +1,128 @@ +--- +id: "03-settings-discovery" +title: "Expose settings discovery" +status: done +wave: 3 +depends_on: + - "02-profile-mutations" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-002 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.7 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.8 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-002.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md +--- + +# Task 03: Expose settings discovery + +## Summary + +Expose bounded metadata search, detailed setting descriptions, and explicit saved-value reads. +Use the existing capability resolver for model-dependent choices. + +## In scope + +- Register `search_settings_kandev`, `describe_setting_kandev`, and `get_settings_kandev` for configuration and external profiles. +- Add `list_settings_resources_kandev` with authorized, bounded targets and scope-bound cursors. +- Use generic targets and validated description context. No profile-specific fields appear in the advertised envelopes. +- Compose these with Task 02's compact updater and hide adopted legacy read/update tools from the configuration surface. +- Keep lifecycle tools and unadopted domains available. Preserve existing external tool registrations. +- Wire metadata search and target-aware reads through the agent domain adapter. +- Add query limits, stable results, covered-domain metadata, typed failures, and sensitive-value projections. +- Describe current defaults, model inheritance, provider status, and change timing. +- Return compact search results with resource type, field path, match reason, and support status. +- Supply dependencies and optional full operation schemas through description, including create-time required fields. +- Maintain an independent search-query set with ranking, ambiguity, and no-match assertions. +- Add target-selection and no-existence-leak tests through both trusted session context and external authenticated calls. +- Update the configuration prompt and public docs for the new tools and initial scope. + +## Out of scope + +- New mutation tools, other settings domains, embeddings, and file import/export. +- Provider-specific choice tables or secret resolution. + +## Acceptance + +- Search and description return bounded, deterministic metadata and explicit support limits without saved-value search. +- Profile reads and model-context discovery use existing domain services, preserve failure states, and redact sensitive values in new projections. +- Only the intended MCP surfaces expose the tools. Prompt and public docs describe compact updates, lifecycle operations, and external compatibility. + +## Verification + +Run from the repository root. + +```bash +rtk proxy go -C apps/backend test ./internal/settingscatalog ./internal/agent/settings/catalog ./internal/mcp/server ./internal/mcp/handlers ./internal/mcp/profile ./internal/agent/settings/controller +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Include tests for missing targets, unknown keys, cursor bounds, unavailable capabilities, redaction, and create-time model context. +Include the curated queries in the design and prove that search never embeds full resource schemas or selects a target. +Use the existing profile matrix and prompt synchronization tests to prove registration consistency. +Update public docs coverage for every new tool. + +## Files likely touched + +- `apps/backend/internal/settingscatalog/catalog.go` and tests (new). +- `apps/backend/internal/settingscatalog/search_quality_test.go` (new). +- `apps/backend/internal/agent/settings/catalog/`. +- `apps/backend/internal/mcp/server/settings_discovery.go` and tests (new). +- `apps/backend/internal/mcp/server/settings_resources.go` and tests (new). +- `apps/backend/internal/mcp/handlers/settings_discovery.go` and tests (new). +- `apps/backend/internal/mcp/server/server.go` and profile/metadata tests. +- `apps/backend/internal/mcp/handlers/handlers.go`. +- `apps/backend/pkg/websocket/actions.go` and backend composition. +- `apps/backend/config/prompts/config-context.md`. +- `docs/public/automation-and-mcp.md`, `agents-and-profiles.md`, `websocket-api.md`, and `coverage.json`. + +## Dependencies + +Tasks 01 and 02 establish metadata and functioning mutation operations. + +## Risks + +- A saved profile is not an active session's effective state. +- Provider probe failures must not remove static metadata. +- Redaction markers must never become values suitable for full-document replacement. +- Existing raw MCP-document tools retain their documented exposure contract. +- Later work orders in this package adopt workflow and executor groups. Task 03 is an intermediate slice, not delivery completion. + +## Parallelism + +`sequential` + +## Inputs + +- System design: Catalog, Discovery tools, Dynamic choices, Sensitive values. +- Existing saved-prompt discovery tools and `Controller.ResolveAgentModelConfig`. +- `/docs-maintainer` and public-docs coverage conventions. + +## Results + +Completed on 2026-09-09. + +- Added compact search, description, saved-value read, and authorized resource lookup tools. +- Added closed outer schemas with catalog-backed dynamic descriptions and target validation. +- Contract, catalog, handler, server, and desktop/mobile MCP transport checks passed. diff --git a/docs/plans/agent-settings-parity/task-04-editor-reconciliation.md b/docs/plans/agent-settings-parity/task-04-editor-reconciliation.md new file mode 100644 index 0000000000..55a558c689 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-04-editor-reconciliation.md @@ -0,0 +1,133 @@ +--- +id: "04-editor-reconciliation" +title: "Reconcile live profile edits" +status: done +wave: 4 +depends_on: + - "03-settings-discovery" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md +--- + +# Task 04: Reconcile live profile edits + +## Summary + +Show assistant changes in an open profile editor while preserving unfinished user edits. +Prove the result through desktop and mobile flows, with independent frontend coverage checks. + +## In scope + +- Reconcile authoritative store updates with clean, dirty, submitted, and conflicted editor states. +- Distinguish the editor's own acknowledgement from an external update. +- Cover concrete drafts, dynamic candidate drafts, and the separate MCP-document contributor. +- Refetch MCP documents after value-free invalidation, with profile-ID and request-generation guards. +- Block stale saves through the existing contributor and use its discard/reset flow for recovery. +- Add localized inline conflict copy and preserve current responsive composition. +- Connect profile UI patch keys and MCP editor fields to the generated metadata snapshot. +- Add real-MCP browser scenarios and the relevant contract/component tests. +- Drive new browser scenarios through compact read/update tools, including the separate MCP-document resource type. +- Document live results and draft recovery in the profile guide. + +## Out of scope + +- A new global editor, navigation redesign, or backend optimistic locking. +- Automatic merging of competing profile changes. +- New standalone Save/Cancel controls. + +## Acceptance + +- Clean editors adopt assistant changes and retain them after reload. Dirty drafts survive and require baseline recovery before Save. +- Own-save acknowledgements and late responses do not erase newer edits or create false conflicts. +- Desktop and mobile complete the same flow. Coverage fails for an unmapped frontend profile field, and new copy passes localization checks. + +## Verification + +On a fresh worktree, install dependencies once before the frontend commands. +Run all commands from the repository root. + +```bash +rtk pnpm --dir apps install --frozen-lockfile +rtk pnpm --dir apps/web exec vitest run components/settings/agent-profile-page-state.test.ts components/settings/agent-profile-page.test.tsx components/settings/dynamic-agent-profile-editor-state.test.tsx lib/settings-discovery/profile-contract.test.ts lib/ws/handlers/agents.test.ts 'app/settings/agents/[agentId]/use-profile-mcp-config.test.ts' +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/agent-settings-parity.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-agent-settings-parity.spec.ts +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +The managed E2E runner rebuilds production assets and isolates the backend. +Run desktop and mobile commands sequentially. Record test discovery counts and rendered phone evidence. +The shared scenario helper uses the real MCP transport and disposable profiles, not raw gateway actions. +Use `tap()` for phone interactions and causal event waits for mutations. + +## Files likely touched + +- `apps/web/components/settings/agent-profile-page-state.ts` and tests. +- `apps/web/components/settings/agent-profile-page.tsx` and tests. +- `apps/web/components/settings/dynamic-agent-profile-editor-state.ts` and draft helpers with focused tests. +- `apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts` and tests. +- `apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx`. +- `apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts`. +- `apps/web/lib/ws/handlers/agents.ts` and tests, only if reconciliation requires a store change. +- `apps/web/lib/settings-discovery/profile-contract.ts` and tests (new). +- `apps/web/lib/settings-discovery/` profile target mapping and existing domain patch types. +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/agents.json`. +- `apps/web/e2e/tests/settings/agent-settings-parity.spec.ts` (new). +- `apps/web/e2e/tests/settings/mobile-agent-settings-parity.spec.ts` (new). +- `apps/web/e2e/tests/settings/agent-settings-parity-helpers.ts` (new). +- `docs/public/agents-and-profiles.md`. + +## Dependencies + +Tasks 01 through 03 supply metadata, profile mutations, and the real MCP read/write path. + +## Risks + +- A notification can arrive before the editor receives its own save response. +- The selected profile can change while a request is pending. +- A delayed response can be older than the latest WebSocket snapshot. +- MCP document invalidations can arrive during a refetch. A stale read must not become the new baseline. +- Shared seed profiles can leak state between E2E tests. Use disposable profiles and cleanup after failures. + +## Mobile contract + +Reuse the existing profile route and advanced sections from `mobile-agent-profile-config-selector.spec.ts`. +Keep the existing content scroll owner and shared save surface. +Show conflict information inline. The existing discard/reset action supplies recovery. +Preserve touch reachability, keyboard access, safe-area clearance, and zero document horizontal overflow. +No new overlay or breakpoint branch is required. + +## Parallelism + +`sequential` + +## Inputs + +- System design: Editor reconciliation, Coverage. +- Existing profile state, profile selector E2E, and save-coordinator contracts. +- `/tdd`, `/mobile-parity`, `/e2e`, and `/docs-maintainer`. + +## Results + +Completed on 2026-09-09. + +- Added profile editor reconciliation for clean, dirty, own-save, and late-response cases. +- Added MCP profile invalidation handling with guarded refetch and localized conflict recovery. +- Preserved dirty drafts on external changes and covered desktop/mobile settings transport. diff --git a/docs/plans/agent-settings-parity/task-05-user-preferences.md b/docs/plans/agent-settings-parity/task-05-user-preferences.md new file mode 100644 index 0000000000..edb615f97c --- /dev/null +++ b/docs/plans/agent-settings-parity/task-05-user-preferences.md @@ -0,0 +1,161 @@ +--- +id: "05-user-preferences" +title: "Expose portable user settings" +status: done +wave: 5 +depends_on: + - "04-editor-reconciliation" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 05: Expose portable user settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt all eligible portable preferences, including task behavior, keybindings, terminal preferences, saved views, and utility defaults. +- Resolve only the trusted current user. Reject arbitrary user and workspace selectors. +- Derive map schemas from actual shortcut override types. Preserve nullable fields, explicit empties, and backend default values. +- Keep migration fields, operational history, setup progress, and device-local preferences explicitly classified. +- Reuse user-settings revision ordering, live updates, and the shared wire mapper. Prove two-user isolation. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/user/controller ./internal/user/service ./internal/user/catalog ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/user-preferences-contract.test.ts components/settings/user-preferences-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/user-preferences-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-user-preferences-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestUserSettingsParity` and `TestUserSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_user_preferences_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change archive confirmation and a shortcut through MCP, observe both controls, reload, and recover an unsaved draft. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/user/dto/dto.go` +- `apps/backend/internal/user/controller/controller.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/catalog/ (new)` +- `apps/web/lib/user-settings-sync.ts` +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/keyboard/` +- `apps/web/lib/settings/user-settings-revision.ts` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_user_preferences_test.go` (new). +- `apps/web/lib/settings-discovery/user-preferences-contract.test.ts` (new). +- `apps/web/components/settings/user-preferences-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 04 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Personal task-view choices must not become shared task fields. Shortcut maps and saved views have replacement semantics. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered portable user settings and routed supported fields through the domain-owned adapter. +- Preserved personal-scope target rules and the existing user-settings wire mapper. +- Covered the descriptor, target, and shared adapter paths in the focused backend and catalog suites. diff --git a/docs/plans/agent-settings-parity/task-06-workflow-settings.md b/docs/plans/agent-settings-parity/task-06-workflow-settings.md new file mode 100644 index 0000000000..56af6345f4 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-06-workflow-settings.md @@ -0,0 +1,161 @@ +--- +id: "06-workflow-settings" +title: "Expose workflow settings" +status: done +wave: 6 +depends_on: + - "05-user-preferences" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 06: Expose workflow settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt workflow and step fields from actual UI contracts, including bindings, events, session policies, and admission limits. +- Share tagged request contracts across REST, WS, and MCP. Preserve existing create/import/reorder operations. +- Validate referenced profiles, tasks, and steps through existing services. Preserve same-workflow restrictions and read-only workflow guards. +- Publish dependent-step changes through the existing step publisher. Preserve demoted-start-step notifications. +- Reconcile workflow and step drafts without treating a partially saved multi-step UI operation as an atomic transaction. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/workflows.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/workflow/controller ./internal/workflow/service ./internal/workflow/catalog ./internal/task/service ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/workflow-settings-contract.test.ts components/settings/workflow-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/workflow-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-workflow-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestWorkflowSettingsParity` and `TestWorkflowSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_workflow_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change a step profile and prompt, observe the pipeline editor, reject a foreign step reference, and recover a dirty draft. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/workflow/controller/controller.go` +- `apps/backend/internal/workflow/service/` +- `apps/backend/internal/workflow/catalog/ (new)` +- `apps/backend/internal/task/dto/requests.go` +- `apps/backend/internal/mcp/handlers/config_workflow_handlers.go` +- `apps/backend/internal/workflow/stepevents/` +- `apps/web/components/settings/use-workflow-draft-contributor.ts` +- `apps/web/components/settings/workflow-dirty-state.ts` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_workflow_settings_test.go` (new). +- `apps/web/lib/settings-discovery/workflow-settings-contract.test.ts` (new). +- `apps/web/components/settings/workflow-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 05 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Step changes can affect queue admission. Events and profile references need semantic validation beyond JSON shape. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered workflow and workflow-step settings with workspace ownership and lifecycle exceptions. +- Routed reads, updates, and resource lookup through existing workflow services and relationship checks. +- Covered step-position and transition boundaries in the catalog and backend adapter tests. diff --git a/docs/plans/agent-settings-parity/task-07-workspace-settings.md b/docs/plans/agent-settings-parity/task-07-workspace-settings.md new file mode 100644 index 0000000000..fa7ca17279 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-07-workspace-settings.md @@ -0,0 +1,160 @@ +--- +id: "07-workspace-settings" +title: "Expose workspace resource settings" +status: done +wave: 7 +depends_on: + - "06-workflow-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 07: Expose workspace resource settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt workspaces, repositories, repository scripts, and repository sets. +- Preserve wire names through shared tagged contracts rather than generating JSON from untagged controller DTOs. +- Validate default profile/executor references, repository membership, branch rules, and copy-file containment. +- Use existing event-publishing services and atomic repository-set updates. Do not call stores directly from MCP. +- Keep ownership reassignment and workspace destruction outside generic settings writes. Reconcile workspace and repository forms. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/task/service ./internal/task/handlers ./internal/task/catalog ./internal/worktree/copyfiles ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/workspace-settings-contract.test.ts components/settings/workspace-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/workspace-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-workspace-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestWorkspaceSettingsParity` and `TestWorkspaceSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_workspace_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change a workspace default and repository copy rule, observe the settings page, and reject foreign repository-set membership. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/task/dto/requests.go` +- `apps/backend/internal/task/service/service_resources.go` +- `apps/backend/internal/task/service/service_repository_sets.go` +- `apps/backend/internal/task/handlers/repository_set_handlers.go` +- `apps/backend/internal/task/catalog/ (new)` +- `apps/backend/internal/worktree/copyfiles/` +- `apps/web/app/settings/workspace/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_workspace_settings_test.go` (new). +- `apps/web/lib/settings-discovery/workspace-settings-contract.test.ts` (new). +- `apps/web/components/settings/workspace-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 06 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Repository identity changes can affect later worktrees. Saved scripts must not execute during discovery or saving. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered workspaces, repositories, repository scripts, and repository sets. +- Preserved workspace/resource pairing, repository-set projection, and lifecycle-only fields. +- Covered workspace resource lookup and cross-scope authorization in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-08-executor-settings.md b/docs/plans/agent-settings-parity/task-08-executor-settings.md new file mode 100644 index 0000000000..59892058f6 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-08-executor-settings.md @@ -0,0 +1,161 @@ +--- +id: "08-executor-settings" +title: "Expose execution resource settings" +status: done +wave: 8 +depends_on: + - "07-workspace-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 08: Expose execution resource settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt editable agent definitions, executors, executor profiles, and environments. +- Preserve executor-type schemas, system-resource restrictions, retained-resource guards, and omitted credential preservation. +- Classify installation, computed capabilities, launcher ownership, and agent permission changes as explicit exceptions. +- Keep supported secret references as references. Redact literal credential paths and reject redaction placeholders as updates. +- Reuse profile save contributors and existing state events. Do not connect, provision, or install an executor during a settings update. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/executors.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/task/service ./internal/task/handlers ./internal/task/catalog ./internal/agent/settings/controller ./internal/agent/settings/catalog ./internal/agent/executor ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/executor-settings-contract.test.ts components/settings/executor-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/executor-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-executor-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestExecutorSettingsParity` and `TestExecutorSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_executor_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change a local executor profile preparation setting, observe the editor, and reject invalid kind-specific settings without launching an executor. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/task/handlers/executor_handlers.go` +- `apps/backend/internal/task/handlers/executor_profile_handlers.go` +- `apps/backend/internal/task/service/service_resources.go` +- `apps/backend/internal/agent/settings/controller/agent_crud.go` +- `apps/backend/internal/agent/settings/catalog/` +- `apps/backend/internal/task/catalog/` +- `apps/web/components/settings/profile-edit/` +- `apps/web/app/settings/executors/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_executor_settings_test.go` (new). +- `apps/web/lib/settings-discovery/executor-settings-contract.test.ts` (new). +- `apps/web/components/settings/executor-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 07 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Opaque executor maps contain credentials and kind-specific policy. A permissive map schema cannot replace existing admission checks. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered executors, executor profiles, and environments with workspace-scoped targets. +- Preserved computed status, sensitive configuration, and capability boundaries. +- Covered executor domain descriptors and adapter validation in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-09-task-settings.md b/docs/plans/agent-settings-parity/task-09-task-settings.md new file mode 100644 index 0000000000..25879d5865 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-09-task-settings.md @@ -0,0 +1,159 @@ +--- +id: "09-task-settings" +title: "Expose task configuration settings" +status: done +wave: 9 +depends_on: + - "08-executor-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 09: Expose task configuration settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt editable task metadata and UI-supported launch defaults through current task services. +- Classify state, workflow movement, session start/stop, runtime model changes, and arbitrary metadata as non-settings operations. +- Define schemas for known editable metadata fields. Do not expose arbitrary metadata writes through an open object. +- Retain active-session and deferred-launch restrictions. Authorize task/resource pairs before dependencies or writes. +- Publish through existing task events and verify both task details and list projections. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/task/service ./internal/task/handlers ./internal/task/catalog ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/task-settings-contract.test.ts components/settings/task-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/task-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-task-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestTaskSettingsParity` and `TestTaskSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_task_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change a supported task default or priority, observe task details and list state, and reject a lifecycle-field patch. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/task/dto/requests.go` +- `apps/backend/internal/task/service/service_tasks.go` +- `apps/backend/internal/task/catalog/` +- `apps/backend/internal/mcp/handlers/config_task_handlers.go` +- `apps/backend/internal/mcp/handlers/deferred_launch_prompt.go` +- `apps/web/components/task/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_task_settings_test.go` (new). +- `apps/web/lib/settings-discovery/task-settings-contract.test.ts` (new). +- `apps/web/components/settings/task-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 08 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Task fields can trigger lifecycle effects. A generic patch must not become an alternate move or agent-start API. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered permitted task settings with explicit lifecycle and metadata exceptions. +- Preserved workflow-step relationships, ordering boundaries, and task-domain validation. +- Covered task field classification and adapter authorization in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-10-prompt-settings.md b/docs/plans/agent-settings-parity/task-10-prompt-settings.md new file mode 100644 index 0000000000..3f5875ca4e --- /dev/null +++ b/docs/plans/agent-settings-parity/task-10-prompt-settings.md @@ -0,0 +1,159 @@ +--- +id: "10-prompt-settings" +title: "Expose saved prompt settings" +status: done +wave: 10 +depends_on: + - "09-task-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.4 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 10: Expose saved prompt settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt saved prompt names and content through the current prompt controller. +- Preserve built-in prompt restrictions and current reference expansion semantics. +- Add an explicit create_prompt_kandev adapter if no equivalent tool exists, using a validated settings object. +- Expose content only through authorized reads, never search snippets or resource lookup. +- Reconcile prompt editor drafts and publish or consume the appropriate domain invalidation. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/prompts/controller ./internal/prompts/service ./internal/prompts/catalog ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/prompt-settings-contract.test.ts components/settings/prompt-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/prompt-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-prompt-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestPromptSettingsParity` and `TestPromptSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_prompt_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Create and update a disposable prompt, observe its editor, reload, and preserve an unsaved competing draft. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/prompts/dto/dto.go` +- `apps/backend/internal/prompts/controller/controller.go` +- `apps/backend/internal/prompts/service/` +- `apps/backend/internal/prompts/catalog/ (new)` +- `apps/web/components/settings/prompts-settings.tsx` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_prompt_settings_test.go` (new). +- `apps/web/lib/settings-discovery/prompt-settings-contract.test.ts` (new). +- `apps/web/components/settings/prompt-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 09 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Prompt bodies are untrusted user data and can contain credentials. Metadata search must not inspect them. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered saved prompt settings and routed supported values through the existing domain path. +- Preserved prompt ownership and content validation boundaries. +- Covered prompt descriptors and adapter behavior in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-11-utility-settings.md b/docs/plans/agent-settings-parity/task-11-utility-settings.md new file mode 100644 index 0000000000..8e52acae78 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-11-utility-settings.md @@ -0,0 +1,160 @@ +--- +id: "11-utility-settings" +title: "Expose utility agent settings" +status: done +wave: 11 +depends_on: + - "10-prompt-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.4 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 11: Expose utility agent settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt utility names, prompts, enabled state, and canonical agent-profile bindings. +- Preserve built-in restrictions and dependency validation. Do not promote legacy agent/model migration inputs into new controls. +- Add an explicit create_utility_agent_kandev adapter if missing, with a stable settings envelope. +- Use profile discovery for authorized compatible choices and retain failure states. +- Reconcile utility drafts without executing utility prompts as part of settings validation. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/agents-and-profiles.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/utility/controller ./internal/utility/service ./internal/utility/profilebinding ./internal/utility/catalog ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/utility-settings-contract.test.ts components/settings/utility-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/utility-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-utility-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestUtilitySettingsParity` and `TestUtilitySettingsAuthority` in +`apps/backend/internal/mcp/server/settings_utility_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Create a disposable utility, select its profile through MCP, observe the settings page, and reject an invalid binding. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/utility/dto/dto.go` +- `apps/backend/internal/utility/controller/controller.go` +- `apps/backend/internal/utility/service/service.go` +- `apps/backend/internal/utility/profilebinding/` +- `apps/backend/internal/utility/catalog/ (new)` +- `apps/web/components/settings/utility-agents-section-save.test.tsx` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_utility_settings_test.go` (new). +- `apps/web/lib/settings-discovery/utility-settings-contract.test.ts` (new). +- `apps/web/components/settings/utility-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 10 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Updating profile bindings must not bypass the utility dependency resolver or overwrite personal utility defaults. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered utility agent settings and their supported configuration fields. +- Preserved utility-agent ownership and computed/action boundaries. +- Covered utility descriptors and adapter behavior in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-12-editor-settings.md b/docs/plans/agent-settings-parity/task-12-editor-settings.md new file mode 100644 index 0000000000..d5c663bd05 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-12-editor-settings.md @@ -0,0 +1,159 @@ +--- +id: "12-editor-settings" +title: "Expose editor definition settings" +status: done +wave: 12 +depends_on: + - "11-utility-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.4 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 12: Expose editor definition settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt saved editor definitions with kind-specific schemas for opaque configuration. +- Keep installed status and discovery results read-only. Default editor selection stays in user settings. +- Add an explicit create_editor_kandev adapter if missing, with a validated settings envelope. +- Preserve built-in editor restrictions and supported command/URL configuration rules. +- Reconcile the terminal/editor settings surface without opening applications or executing commands. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/editors/controller ./internal/editors/service ./internal/editors/catalog ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/editor-settings-contract.test.ts components/settings/editor-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/editor-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-editor-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestEditorSettingsParity` and `TestEditorSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_editor_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Create a disposable editor definition, change its saved settings, and verify the visible result without launching it. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/editors/dto/dto.go` +- `apps/backend/internal/editors/controller/controller.go` +- `apps/backend/internal/editors/service/service.go` +- `apps/backend/internal/editors/catalog/ (new)` +- `apps/web/lib/settings-discovery/catalog/preferences.ts` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_editor_settings_test.go` (new). +- `apps/web/lib/settings-discovery/editor-settings-contract.test.ts` (new). +- `apps/web/components/settings/editor-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 11 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Saving an editor definition must not become an arbitrary command-execution operation. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered editor definitions and routed supported values through the existing editor domain. +- Preserved executable/lifecycle boundaries and sensitive configuration handling. +- Covered editor descriptors and adapter behavior in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-13-notification-settings.md b/docs/plans/agent-settings-parity/task-13-notification-settings.md new file mode 100644 index 0000000000..f25f216c27 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-13-notification-settings.md @@ -0,0 +1,160 @@ +--- +id: "13-notification-settings" +title: "Expose notification provider settings" +status: done +wave: 13 +depends_on: + - "12-editor-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.7 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.4 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 13: Expose notification provider settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt provider names, enabled state, typed provider configuration, and event subscriptions. +- Preserve per-user ownership and validate the complete patch before persisting provider/event changes. +- Preserve credential-bearing fields on omission. New reads redact them and never resolve credentials for descriptions. +- Add an explicit create_notification_provider_kandev adapter if missing. Creation does not send a test notification. +- Reconcile the provider editor and report unavailable provider capabilities without dropping metadata. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/notifications/controller ./internal/notifications/service ./internal/notifications/catalog ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/notification-settings-contract.test.ts components/settings/notification-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/notification-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-notification-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestNotificationSettingsParity` and `TestNotificationSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_notification_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change event subscriptions on a disposable provider, verify another user's provider is invisible, and observe reload persistence. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/notifications/dto/dto.go` +- `apps/backend/internal/notifications/controller/controller.go` +- `apps/backend/internal/notifications/service/service.go` +- `apps/backend/internal/notifications/catalog/ (new)` +- `apps/web/components/settings/notifications-settings-actions.ts` +- `apps/web/components/settings/notifications-settings.tsx` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_notification_settings_test.go` (new). +- `apps/web/lib/settings-discovery/notification-settings-contract.test.ts` (new). +- `apps/web/components/settings/notification-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 12 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Provider configuration can contain secret URLs. Subscription writes must not partially commit an invalid provider patch. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered notification provider settings with explicit sensitive-field handling. +- Preserved provider lifecycle and credential-enrollment boundaries. +- Covered notification descriptors, redaction, and adapter behavior in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-14-issue-integration-settings.md b/docs/plans/agent-settings-parity/task-14-issue-integration-settings.md new file mode 100644 index 0000000000..6f5cb8cba4 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-14-issue-integration-settings.md @@ -0,0 +1,162 @@ +--- +id: "14-issue-integration-settings" +title: "Expose issue integration settings" +status: done +wave: 14 +depends_on: + - "13-notification-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.8 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 14: Expose issue integration settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt noncredential Jira and Linear defaults plus existing Sentry instance settings and all eligible watch fields. +- Preserve workspace ownership and Sentry instance identity. Do not select a singleton when several instances exist. +- Reuse credential-preserving domain paths. Enrollment and credential-bearing connection identity changes remain interactive-only. +- Validate watch workflow/profile/instance references, schedules, and feature availability before saving. +- Retain existing cache invalidation and background health behavior. Report post-save probe failure as degraded status, not rollback. +- Reconcile integration forms and watch editors using scoped events or invalidation. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/jira ./internal/linear ./internal/sentry ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/issue-integration-settings-contract.test.ts components/settings/issue-integration-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/issue-integration-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-issue-integration-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestIssueIntegrationSettingsParity` and `TestIssueIntegrationSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_issue_integration_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Update one fixture per provider, observe each settings/watch surface, and reject a foreign workspace or Sentry instance. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/jira/models.go` +- `apps/backend/internal/jira/service.go` +- `apps/backend/internal/linear/models.go` +- `apps/backend/internal/linear/service.go` +- `apps/backend/internal/sentry/models.go` +- `apps/backend/internal/sentry/service.go` +- `apps/backend/internal/{jira,linear,sentry}/settings_catalog.go (new)` +- `apps/web/app/settings/integrations/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_issue_integration_settings_test.go` (new). +- `apps/web/lib/settings-discovery/issue-integration-settings-contract.test.ts` (new). +- `apps/web/components/settings/issue-integration-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 13 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Legacy full-document setters can erase omitted values. Shared domain atomic patches or complete-document semantics must protect every writer. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered Jira, Linear, Sentry instance, and issue-watch settings. +- Routed noncredential fields through provider-owned integration services with workspace authorization. +- Preserved connection and secret-enrollment exceptions and covered provider adapters in backend tests. diff --git a/docs/plans/agent-settings-parity/task-15-codehost-settings.md b/docs/plans/agent-settings-parity/task-15-codehost-settings.md new file mode 100644 index 0000000000..0506ac2e27 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-15-codehost-settings.md @@ -0,0 +1,161 @@ +--- +id: "15-codehost-settings" +title: "Expose code-host integration settings" +status: done +wave: 15 +depends_on: + - "14-issue-integration-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.8 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 15: Expose code-host integration settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt GitHub, GitLab, and Azure DevOps workspace defaults, repository scope, presets, and watch configuration. +- Keep personal saved-query preferences in user settings and provider workspace settings in their domain. +- Preserve credential generation, provider-host identity, repository scope, and authorized watch references. +- Exclude account enrollment, token operations, remote issue/PR mutations, and immediate watch execution. +- Retain domain cache/event behavior and reconcile each provider's settings and watch editor. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/github ./internal/gitlab ./internal/azuredevops ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/codehost-settings-contract.test.ts components/settings/codehost-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/codehost-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-codehost-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestCodeHostSettingsParity` and `TestCodeHostSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_codehost_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Update a scope or preset and a watch fixture for each provider, observe the UI, and prove cross-workspace isolation. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/github/workspace_settings_service.go` +- `apps/backend/internal/github/models.go` +- `apps/backend/internal/gitlab/service_config.go` +- `apps/backend/internal/gitlab/watch_models.go` +- `apps/backend/internal/azuredevops/workspace_settings.go` +- `apps/backend/internal/azuredevops/watch_service.go` +- `apps/backend/internal/{github,gitlab,azuredevops}/settings_catalog.go (new)` +- `apps/web/app/settings/integrations/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_codehost_settings_test.go` (new). +- `apps/web/lib/settings-discovery/codehost-settings-contract.test.ts` (new). +- `apps/web/components/settings/codehost-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 14 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Provider identity and workspace scope are authorization boundaries. A settings update cannot borrow another workspace's credential context. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered GitHub, GitLab, Azure DevOps, and code-host watch settings. +- Routed noncredential fields through provider-owned integration services with workspace authorization. +- Preserved connection identity and secret boundaries and covered provider adapters in backend tests. diff --git a/docs/plans/agent-settings-parity/task-16-automation-settings.md b/docs/plans/agent-settings-parity/task-16-automation-settings.md new file mode 100644 index 0000000000..78b4a7bfd2 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-16-automation-settings.md @@ -0,0 +1,158 @@ +--- +id: "16-automation-settings" +title: "Expose workspace automation settings" +status: done +wave: 16 +depends_on: + - "15-codehost-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.9 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 16: Expose workspace automation settings + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Adopt existing automation definitions and trigger configuration through their service contracts. +- Preserve workspace scoping, profile/workflow references, continuation limits, trigger types, and enablement gates. +- Describe future scheduling effects. Do not run automations or alter run history through settings tools. +- Keep webhook secrets redacted and require existing credential flows for enrollment. +- Reconcile automation and trigger drafts on the current workspace settings route. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/automation ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/automation-settings-contract.test.ts components/settings/automation-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/automation-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-automation-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestAutomationSettingsParity` and `TestAutomationSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_automation_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change a disabled automation and trigger, observe its settings page, and reject invalid references without starting a run. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/automation/models.go` +- `apps/backend/internal/automation/service.go` +- `apps/backend/internal/automation/handlers.go` +- `apps/backend/internal/automation/settings_catalog.go (new)` +- `apps/web/app/settings/workspace/[id]/automations/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_automation_settings_test.go` (new). +- `apps/web/lib/settings-discovery/automation-settings-contract.test.ts` (new). +- `apps/web/components/settings/automation-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 15 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- Enabling a schedule changes future background work. A failed settings patch must not leave a half-updated trigger. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered workspace automations and hydrated automation-trigger resources. +- Preserved trigger relationships, workspace filtering, and sensitive configuration handling. +- Covered automation reads, updates, and resource lookup in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-17-runtime-settings.md b/docs/plans/agent-settings-parity/task-17-runtime-settings.md new file mode 100644 index 0000000000..c85c94a7e7 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-17-runtime-settings.md @@ -0,0 +1,166 @@ +--- +id: "17-runtime-settings" +title: "Expose permitted runtime overrides" +status: done +wave: 17 +depends_on: + - "16-automation-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.10 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.5 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 17: Expose permitted runtime overrides + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Derive runtime descriptors from the current flag registry and startup descriptors from the typed startup catalog. +- Expose one runtime_flag resource per existing key, with boolean/null override semantics. +- Enforce org.settings.manage before SetOverride. Preserve environment locks, mutability, retired identities, and pending-restart state. +- Keep features.auth interactive-only and all deployment-owned startup settings read-only. +- Preserve disabled-feature boundaries. Do not introduce a new flag, change shipped defaults, or restart the process. +- Use the existing Feature Toggles UI for saved/active values and live invalidation. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/runtimeflags ./internal/common/config ./internal/profiles ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk make -C apps/backend lint +rtk pnpm --dir apps/web exec vitest run lib/state/slices/features/features-contract.test.ts +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/runtime-settings-contract.test.ts components/settings/runtime-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run lint +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/runtime-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-runtime-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestRuntimeSettingsParity` and `TestRuntimeSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_runtime_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Save and clear a permitted override, observe pending restart, and reject an environment-locked or authentication flag change. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/runtimeflags/registry.go` +- `apps/backend/internal/runtimeflags/service.go` +- `apps/backend/internal/runtimeflags/handlers.go` +- `apps/backend/internal/runtimeflags/settings_catalog.go (new)` +- `apps/backend/internal/common/config/catalog.go` +- `apps/web/lib/state/slices/features/` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_runtime_settings_test.go` (new). +- `apps/web/lib/settings-discovery/runtime-settings-contract.test.ts` (new). +- `apps/web/components/settings/runtime-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 16 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- The HTTP authorization guard does not run in MCP dispatch. A saved override is not proof that the running feature changed. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. +- /runtime-feature-flags. Existing identities and shipped defaults remain unchanged. + +## Results + +Completed on 2026-09-09. + +- Registered permitted runtime overrides using the existing typed runtime-flag registry. +- Preserved environment locks, explicit reset behavior, source provenance, and pending-restart state. +- Covered the catalog `override` mapping and adapter behavior in backend tests. diff --git a/docs/plans/agent-settings-parity/task-18-storage-settings.md b/docs/plans/agent-settings-parity/task-18-storage-settings.md new file mode 100644 index 0000000000..88a8ea4191 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-18-storage-settings.md @@ -0,0 +1,158 @@ +--- +id: "18-storage-settings" +title: "Expose storage maintenance policies" +status: done +wave: 18 +depends_on: + - "17-runtime-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.10 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 18: Expose storage maintenance policies + +## Summary + +Deliver the domain through the shared settings tools and authorized resource lookup. +Include its complete eligible field set, current UI result, and independent contract checks. + +## In scope + +- Expose the complete storage-maintenance settings document with replacement semantics. +- Share the HTTP settings operation, authorization, and OnSettingsChanged callback with MCP. +- Preserve exact dedicated-Docker acknowledgement and private Go-cache adoption restrictions. +- Describe schedule/retention effects without starting cleanup, adopting paths, deleting quarantine, or restoring backups. +- Reconcile the storage policy editor with value-free invalidation and dirty-draft recovery. +- Register metadata, context schemas, typed updates, redacted reads, and authorized lookup in the production composition. +- Add domain aliases to the independent search-query suite and update the generated snapshots. +- Include shared DTO conversion, semantic validation, and required atomic patch behavior for every write interface. +- Use current domain events, or add scoped value-free invalidation when none exists. +- Add coverage bindings and scoped editor tests. Update `docs/public/automation-and-mcp.md` with delivered behavior. + +## Out of scope + +- New persistence owners, arbitrary database access, privilege changes, and unrelated lifecycle operations. +- Automatic merging of dirty drafts, new navigation layouts, and real provider credentials in tests. + +## Acceptance + +- Registered MCP calls match the existing UI contract for every eligible field, including explicit empties and domain-specific errors. +- Unauthorized, invalid, and conflicting changes preserve invariants without partial writes or sensitive output. New atomic patches cover competing writers. +- Clean editors update live and survive reload. Dirty drafts survive until recovery, on desktop and phone. + +## Verification + +Run from the repository root after the one-time workspace dependency install in Task 00. +These new test paths and names are planned implementation outputs, not existing evidence. + +```bash +rtk proxy go -C apps/backend test ./internal/system/storage ./internal/system/settings ./internal/mcp/server ./internal/mcp/handlers +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/storage-settings-contract.test.ts components/settings/storage-settings-reconciliation.test.tsx +rtk pnpm --dir apps/web run typecheck +rtk pnpm --dir apps/web run i18n:check +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/storage-settings-mcp.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-storage-settings-mcp.spec.ts +rtk node scripts/validate-public-docs.mjs +rtk git diff --check +``` + +Add `TestStorageSettingsParity` and `TestStorageSettingsAuthority` in +`apps/backend/internal/mcp/server/settings_storage_settings_test.go`. +Use real registered tools plus domain fixtures, not only direct handler invocation. +Add domain tests for any changed persistence path, including rollback and concurrent updates. +When a SQL query changes, include SQLite/PostgreSQL behavior evidence under the existing domain test conventions. + +## E2E scenario + +Change a maintenance policy in an isolated fixture, observe it after reload, and reject unconfirmed dedicated-daemon or adoption changes. +Use disposable resources and mock providers. Restore user-level settings in cleanup, including after failures. +Run desktop and phone separately with the managed runner and record discovered test counts. + +## Files likely touched + +- `apps/backend/internal/system/storage/types.go` +- `apps/backend/internal/system/storage/settings.go` +- `apps/backend/internal/system/storage/handler.go` +- `apps/backend/internal/system/storage/settings_catalog.go (new)` +- `apps/web/lib/settings-discovery/catalog/system.ts` +- `apps/backend/internal/backendapp/services.go` and MCP handler composition. +- `apps/backend/internal/mcp/server/settings_storage_settings_test.go` (new). +- `apps/web/lib/settings-discovery/storage-settings-contract.test.ts` (new). +- `apps/web/components/settings/storage-settings-reconciliation.test.tsx` (new): clean updates, dirty conflicts, own acknowledgements, and late replies. +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- Relevant domain store subscriptions, current editor state, and locale catalogs. +- The two E2E files named in Verification. + +## Dependencies + +Task 17 completes the preceding slice. +Tasks 00 through 04 establish inventory, schemas, generic tools, and draft-recovery behavior. +The sequential order avoids conflicts in shared composition and generated snapshots. + +## Risks + +- A generic force option must not authorize storage destruction or synthesize a required acknowledgement. +- Saved settings and active runtime state can differ. Responses must not claim an unobserved runtime change. +- Missing identity can look like an internal service caller. MCP must reject that case under enforced authentication. + +## Mobile contract + +Reuse the current domain settings route, shared save contributor, and existing phone composition. +Keep recovery inline with the existing discard action, one content scroll owner, and safe-area-aware controls. +Prove touch access and no horizontal page overflow. No new modal or breakpoint layout is required. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, especially Required domains, Mutation semantics, Relationships, and Visible results. +- Task 00's field inventory and each source contract listed above. +- Existing domain tests, profile parity tests, and nearby shipped settings E2E. +- Scoped AGENTS.md, /tdd, /mobile-parity, /e2e, and /docs-maintainer before implementation. + +## Results + +Completed on 2026-09-09. + +- Registered storage-maintenance policies through the existing complete-document settings service. +- Preserved acknowledgement, scheduling, and no-immediate-maintenance boundaries. +- Covered storage descriptors and adapter behavior in the focused backend suite. diff --git a/docs/plans/agent-settings-parity/task-19-delivery-coverage.md b/docs/plans/agent-settings-parity/task-19-delivery-coverage.md new file mode 100644 index 0000000000..1ee2c7cee7 --- /dev/null +++ b/docs/plans/agent-settings-parity/task-19-delivery-coverage.md @@ -0,0 +1,127 @@ +--- +id: "19-delivery-coverage" +title: "Complete core settings coverage" +status: done +wave: 19 +depends_on: + - "18-storage-settings" +plan: "plan.md" +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +acceptance_criteria: + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-001.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-006.11 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.1 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.4 + - AC-PLATFORM-AGENT-SETTINGS-PARITY-009.5 +system_design: + - ../../specs/platform/system-design/agent-settings-parity.md + - ../../specs/platform/system-design/agent-settings-domains.md +--- + +# Task 19: Complete core settings coverage + +## Summary + +Close the required-domain inventory and prove the complete agent configuration workflow. +Publish accurate guidance for the delivered tools, domains, and exceptions. + +## In scope + +- Reject pending eligible fields, missing domain adapters, unclassified controls, and stale generated snapshots. +- Add negative fixtures for a new domain, route, nested field, removed descriptor, and weakened CI path filter. +- Freeze the five shared tool envelopes. Prove an added setting changes discovery and validation without changing tool definitions. +- Finish configuration-profile alias removal only for equivalent adopted operations. Retain external compatibility and explicit lifecycle tools. +- Complete the cross-domain search corpus and configuration prompt examples. +- Prove action boundaries, authentication exclusions, and explicit lookup from the assembled tool profile. +- Update public tool coverage and domain guidance. Record exact supported/exception/pending counts from the independent inventory. + +## Out of scope + +- Generic QA, broad repository verification, unrelated cleanup, commits, pushes, or PR creation. +- Relabeling an eligible missing field as an exception to pass the gate. + +## Acceptance + +- Every eligible field in the required domains has a working adapter and passing evidence. No required entry remains pending. +- Shared tool schemas remain stable and all supported lifecycle calls remain compatible. Exceptions have reasons and recovery destinations. +- The complete desktop/mobile flow succeeds through real MCP, visible UI state, and reload persistence. Public docs match actual coverage. + +## Verification + +Run from the repository root after the prior work-order checks pass. + +```bash +rtk proxy go -C apps/backend test ./internal/settingscatalog ./internal/mcp/server ./internal/mcp/handlers ./internal/mcp/profile ./internal/backendapp +rtk proxy go -C apps/backend run ./cmd/settings-catalog --check +rtk node --test scripts/settings-contract-ci.test.mjs +rtk pnpm --dir apps/web exec vitest run lib/settings-discovery/coverage-inventory.test.ts lib/settings-discovery/delivery-coverage.test.ts +rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/settings-agent-configuration.spec.ts +rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-settings-agent-configuration.spec.ts +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.test.py +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + +Planned methods in `settings_delivery_test.go` are `TestRequiredSettingsCoverage`, `TestSettingsActionBoundaries`, and `TestStableSettingsToolDefinitions`. + +## E2E scenario + +Search for task behavior, identify the current user, and update archive confirmation. +Find a disposable workflow and profile, then update a step binding through the compact tool. +Update a repository setting and notification event subscription through their exact targets. +Observe each page and reload it. Reject a wrong-workspace reference without changing state. +Use isolated resources and restore personal settings after failure. No external accounts or real notifications are required. + +## Files likely touched + +- `apps/backend/internal/mcp/server/settings_delivery_test.go` (new). +- `apps/backend/internal/settingscatalog/search_quality_test.go` and independent coverage tests. +- `apps/backend/internal/mcp/server/server.go` and profile assembly tests. +- `apps/backend/config/prompts/config-context.md`. +- `apps/web/lib/settings-discovery/delivery-coverage.test.ts` (new). +- `apps/web/lib/settings-discovery/coverage-inventory.ts` and generated snapshots. +- The two E2E files named in Verification and their shared helper. +- `scripts/settings-contract-ci.test.mjs` and existing CI workflows if a missing trigger is found. +- `docs/public/automation-and-mcp.md`, `coverage.json`, and the affected domain guides. +- `coverage-inventory.md` and this implementation manifest for final evidence. + +## Dependencies + +Tasks 00 through 18, with each work order's targeted evidence recorded. + +## Risks + +- Registry-only checks can pass while a real UI field remains absent. +- Installed provider availability must not change the static completeness denominator. +- Lifecycle tools keep the total MCP surface larger than five tools. + +## Parallelism + +`sequential` + +## Inputs + +- Both system designs, all requirement IDs, and the independent field inventory. +- Prior work-order results, MCP profile assembly tests, public-docs coverage conventions, and `.github/AGENTS.md` before CI edits. + +## Results + +Completed on 2026-09-09. + +- Added the independent inventory delivery gate and backend registry completeness test. +- Added deterministic generated snapshots, CI freshness checks, public coverage evidence, and docs. +- Added real MCP desktop and mobile scenarios covering search, description, read, update, and resource lookup. +- Focused implementation gates passed; the full backend-suite caveat is recorded in the parent plan. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 0911b29b38..bcfa608a5d 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -171,6 +171,14 @@ Select an agent, create a profile, then open **Settings > Agents > _Agent_ > _Pr | Auto-approve all permissions | Answers automatically: the first `allow_once`/`allow_always` option, otherwise the first option supplied by the agent; no options cancels. It is off by default. | | MCP servers | Adds profile-specific external MCP servers when the agent supports MCP. | +Agents can inspect and update declared profile settings through the compact +`search_settings_kandev`, `describe_setting_kandev`, `get_settings_kandev`, +and `update_settings_kandev` tools. Use the separate +`agent_profile_mcp` target for the profile MCP document. The settings tools +preserve profile validation and save replacement lists atomically. They never +return environment values or MCP credentials; use references or the existing +interactive credential flow when a secret is required. + Model, mode, command, and configuration choices are probed from the locally installed CLI and cached. The managed **Update agent** action refreshes them automatically; after other CLI changes, refresh the profile manually. Probe status can report **auth required**, **not installed**, **not configured**, or **failed**; a saved model name does not prove that the current provider account can use it. Configuration options are resolved for the model selected in the profile. An diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 245e9c0ecc..218f2379d0 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -734,14 +734,46 @@ Kandev does not upgrade or proxy configured third-party MCP servers. Their suppo This compatibility work does not add MCP Tasks, new OAuth behavior, or third-party MCP proxying. -External MCP exposes 42 tools in these groups: +External MCP exposes tools in these groups: - workspace/workflow configuration: list workspaces, workflows, repositories, and workflow steps; create, update, delete, import, or export workflows; create, update, delete, or reorder steps; - agents and profiles: list/update agents; create/delete profiles; list/update profiles; get/update profile MCP configuration; - executors: list executors and profiles; create, update, or delete executor profiles; - saved prompts: list prompt summaries without content or read one prompt by its exact, case-sensitive name; saved prompt tools are read-only; +- agent-accessible settings: search setting definitions, describe a field, list authorized resource targets, read saved values, and update declared values through one compact contract; - tasks: list, create, move, delete, archive, or update task state; list a task's sessions; read task conversation; discover or answer pending clarification questions; and discover or resolve live agent permission requests. +### Agent-accessible settings + +External and task-scoped agents can use the same compact settings tools: + +```text +search_settings_kandev +describe_setting_kandev +list_settings_resources_kandev +get_settings_kandev +update_settings_kandev +``` + +Search returns metadata only. Use `describe_setting_kandev` for the schema, +target rules, authority, replacement behavior, and recovery guidance for one +field. Use `list_settings_resources_kandev` when a field needs an exact +workspace, repository, profile, task, or integration target. Then pass one +target and the declared field paths to `get_settings_kandev` or +`update_settings_kandev`. + +The backend validates every target and change against the owning domain. Writes +keep the existing domain authorization, reference checks, atomicity, events, +and cache behavior. Saved credential values are not returned. Secret-bearing +fields return redacted values or safe references, and credential enrollment, +deployment-owned configuration, plugin-owned settings, client-local state, +and lifecycle actions remain on their existing explicit surfaces. + +The compact envelope is stable as domains grow. Agents should discover fields +at runtime instead of assuming that a domain's full schema is present in the +tool definition. Existing lifecycle tools and compatibility MCP tools remain +available where documented. + `export_workflow_kandev` takes `workflow_id` and returns one version 1 `kandev_workflow` JSON document. It omits instance IDs and timestamps. Pass its JSON text unchanged as `document` to `import_workflow_kandev` when it is within the existing 1 MiB import limit. ### Read a saved prompt diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 1f61567772..bd6b2bdf73 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -341,12 +341,20 @@ ], "sources": [ "apps/backend/internal/profiles/profiles.go", + "apps/backend/internal/backendapp/settings_operations.go", + "apps/backend/internal/settingscatalog/catalog.go", + "apps/backend/internal/mcp/handlers/settings_handlers.go", + "apps/backend/internal/mcp/server/settings_tools.go", "apps/web/app/settings/agents/page.tsx", "apps/web/hooks/domains/settings/use-dynamic-models.ts" ], "tests": [ "apps/backend/internal/profiles/profiles_test.go", + "apps/backend/internal/backendapp/settings_operations_test.go", + "apps/backend/internal/mcp/handlers/settings_handlers_test.go", + "apps/backend/internal/mcp/server/settings_tools_test.go", "apps/web/components/settings/agent-profile-page.test.tsx", + "apps/web/lib/settings-discovery/coverage-inventory.test.ts", "apps/web/hooks/domains/settings/use-dynamic-models.test.ts" ], "settingsRoutes": [ @@ -361,7 +369,12 @@ "list_agent_profiles_kandev", "list_agents_kandev", "update_agent_kandev", - "update_agent_profile_kandev" + "update_agent_profile_kandev", + "search_settings_kandev", + "describe_setting_kandev", + "get_settings_kandev", + "update_settings_kandev", + "list_settings_resources_kandev" ] }, { diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index bba57d9978..3b33d49aaa 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -686,6 +686,7 @@ workflow.step.updated workflow.step.deleted agent.profile.created agent.profile.updated +agent.profile.mcp_config.updated agent.profile.deleted task.created task.updated diff --git a/docs/specs/agents/README.md b/docs/specs/agents/README.md index ac662b2a7b..a6b584bf6d 100644 --- a/docs/specs/agents/README.md +++ b/docs/specs/agents/README.md @@ -106,6 +106,9 @@ canonical requirement and system-design documents above. ## Related systems +- [Settings parity](../platform/requirements/agent-settings-parity.md): owns the + shared discovery and interface contract. Agents retain profile data ownership. + - [Tasks](../tasks/README.md): consumes agent profiles for task execution. - [Office](../office/README.md): consumes agent identities for autonomous work. - [Platform](../platform/README.md): owns shared process and runtime services. diff --git a/docs/specs/platform/README.md b/docs/specs/platform/README.md index 6d6d9049e5..a3ea2c7b25 100644 --- a/docs/specs/platform/README.md +++ b/docs/specs/platform/README.md @@ -21,6 +21,9 @@ This system owns startup and shutdown contracts, process and port-independent runtime safety, configuration precedence, diagnostics, notifications, localization, feature toggles, health, and shared session recovery services. +Settings discovery and interface parity belong to Platform. Each settings domain +retains ownership of its values, validation, authority, and persistence. + ## Exclusions - Executor-specific runtime environments belong to the [executor @@ -32,6 +35,8 @@ localization, feature toggles, health, and shared session recovery services. ### Requirements +- [Agent-accessible Kandev settings](requirements/agent-settings-parity.md) + - [Agent process exit and stderr drain](requirements/agent-process-exit-drain.md) @@ -75,6 +80,9 @@ localization, feature toggles, health, and shared session recovery services. ### System design +- [Agent-accessible Kandev settings](system-design/agent-settings-parity.md) +- [Core settings domain adoption](system-design/agent-settings-domains.md) + - [Agent process exit and stderr drain](system-design/agent-process-exit-drain.md) diff --git a/docs/specs/platform/requirements/agent-settings-parity.md b/docs/specs/platform/requirements/agent-settings-parity.md new file mode 100644 index 0000000000..72ccf2d5cd --- /dev/null +++ b/docs/specs/platform/requirements/agent-settings-parity.md @@ -0,0 +1,168 @@ +--- +status: draft +system: platform +created: 2026-09-08 +owners: + - kandev +--- + +# Agent-accessible Kandev settings requirements + +## Overview + +The configuration assistant must find and change supported settings with the same authority and results as the user interface. +Platform owns this contract across settings domains. Each domain retains ownership of its data, permissions, validation, and execution behavior. + +This delivery covers the core settings domains in requirement 006, not only agent profiles. +Agents can configure existing resources and use explicit lifecycle operations for supported setup tasks. +Every built-in Settings control receives a coverage classification. Discovery explains exceptions instead of hiding unsupported controls. +The existing document path and requirement IDs remain stable after this scope expansion. + +## Terminology + +- **Setting:** A named value within an existing resource. +- **Scope:** The resource and authority that determine a setting's meaning. +- **Support status:** Supported, read-only, interactive-only, client-local, or not-yet-supported, with a reason for each limitation. +- **Saved value:** The value stored for future use. It does not describe an active session's effective state. + +## Requirements + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-001: Discoverable coverage + +**Intent:** The assistant can identify supported settings and explain limitations before it attempts a change. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.1:** Search shall match setting names, stable keys, descriptions, and curated aliases. Search shall not inspect saved values or secrets. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.2:** Each result shall identify its resource scope, support status, and Settings destination. Each limitation shall include a reason. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.3:** Detailed discovery shall provide types, allowed values, defaults or inheritance rules, change timing, required authority, and available operations. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.4:** Discovery shall report its covered domains. An unknown setting shall produce an explicit not-found result without a guessed operation. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.5:** Every built-in Settings control and editable domain field shall have an operation or explicit limitation. Nested fields and separate documents shall participate. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.6:** Search results shall name the setting, owning resource, mutation field, support status, and match reason without returning full resource schemas. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.7:** Detailed discovery shall identify dependent fields, replacement behavior, and required references. Resource context shall determine applicable choices. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-001.8:** Ambiguous search shall retain distinct results with their scopes. Search shall never select a resource or perform a mutation. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-002: Equivalent profile changes + +**Intent:** A supported profile change has the same meaning through the assistant and the user interface. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.1:** Profile creation and updates shall accept the same supported fields, defaults, normalization, and domain validation as the user interface. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.2:** An omitted update field shall remain unchanged. Explicit false, empty strings, empty maps, and empty lists shall retain their documented meanings. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.3:** The assistant shall discover valid model-dependent choices for a selected profile context. Unavailable discovery shall report its state without inventing choices. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.4:** Rejected changes shall preserve saved state. Dependency conflicts and dynamic-profile version conflicts shall identify the same recovery choices as the user interface. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.5:** Each successful mutation shall publish one equivalent profile notification. Existing tool names and valid argument meanings shall remain compatible. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.6:** Saved-value reads shall identify the target profile and distinguish stored values from unresolved defaults and active-session state. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.7:** The configuration assistant shall use a compact update operation for adopted settings. New fields shall not enlarge its advertised argument schema. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.8:** The backend shall validate each update against the current registered resource contract before applying any change. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-002.9:** Invalid updates shall return structured field errors and a discovery reference. Unknown fields shall not be ignored or corrected automatically. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-003: Authority and sensitive values + +**Intent:** Discovery and broader field coverage do not grant additional authority. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-003.1:** Each mutation shall enforce its existing user, workspace, resource, or organization authority at invocation. Discovery alone shall not authorize a mutation. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-003.2:** Missing or invalid identity shall not grant write access under enforced authentication. Auth-disabled installations shall preserve their existing single-user behavior. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-003.3:** New discovery and saved-value tools shall not reveal credential contents. They shall report redaction explicitly and preserve secret references as references. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-003.4:** A dependency override shall require the existing explicit confirmation flow. The assistant shall not automatically retry a conflict with an override. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-003.5:** Ordinary task, Office, and automation tool profiles shall not gain settings mutation authority from the catalog. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-004: Visible results and draft preservation + +**Intent:** Users can observe assistant changes without losing unfinished edits. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-004.1:** An open, clean editor for an adopted setting shall show an accepted assistant change without reload. The same value shall remain after reload. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-004.2:** An external change shall not erase a dirty draft. The editor shall explain the conflict and require a fresh baseline before another save. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-004.3:** An acknowledgement of the editor's own save shall not create a false conflict or erase newer edits. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-004.4:** Desktop and phone shall provide the same result and recovery through existing settings routes and shared save controls. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-004.5:** New visible messages shall use the selected language. Keyboard and touch users shall reach the recovery action without horizontal page scrolling. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-005: Coverage regression detection + +**Intent:** New fields and domains cannot silently recreate the settings coverage gap. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-005.1:** The coverage check shall fail for an editable adopted field or Settings control without an operation mapping or documented exclusion. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-005.2:** The checks shall detect schema, forwarding, persistence, error, and notification differences between supported interfaces. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-005.3:** Removing a catalog entry or adding an uncataloged field to an independent domain contract shall make the coverage check fail. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-005.4:** CI shall run registry completeness, generated-contract freshness, and search-quality checks for relevant changes. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-005.5:** A new adopted setting shall not add a tool or embed its schema in the compact tools' advertised definitions. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-006: Core settings coverage + +**Intent:** Agents can configure the main Kandev settings domains without manual UI field entry. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.1:** Agents shall read and update portable user preferences, including task behavior, keybindings, appearance, terminal preferences, saved views, and utility defaults. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.2:** Agents shall read and update workflow settings and step settings, including prompts, profile bindings, events, and admission policies. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.3:** Agents shall read and update workspace defaults, repository settings, repository scripts, and repository-set settings. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.4:** Agents shall read and update agent definitions, executor settings, executor profiles, and environment definitions within existing editable contracts. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.5:** Agents shall read and update task configuration fields without bypassing active-session restrictions or triggering a lifecycle transition through a settings patch. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.6:** Agents shall read and update saved prompts, utility agents, and editor definitions. Built-in resource restrictions shall remain effective. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.7:** Agents shall read and update notification-provider settings and event subscriptions without disclosing saved credentials. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.8:** Agents shall update noncredential settings, presets, and watch configuration for built-in GitHub, GitLab, Azure DevOps, Jira, Linear, and Sentry integrations. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.9:** Agents shall read and update existing workspace automations and their triggers. Enabled-state changes shall report their scheduling effects. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.10:** Authorized agents shall update permitted runtime overrides and storage-maintenance policies. Environment locks, restart requirements, and destructive acknowledgements shall remain effective. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-006.11:** Delivery shall cover every eligible field in these domains. A not-yet-supported classification shall not satisfy an eligible field's delivery requirement. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-007: Explicit resource selection + +**Intent:** An agent can find the correct target without guessing identifiers or changing another user's settings. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-007.1:** Resource lookup shall return bounded, paginated, authorized targets with names, resource types, and ownership context. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-007.2:** Personal settings shall resolve to the authenticated caller. Workspace-scoped settings shall require an explicit workspace or an authorized resource that determines it. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-007.3:** Contradictory selectors shall fail before mutation. Foreign resources shall not reveal their existence through lookup or detailed discovery. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-007.4:** Lookup shall preserve ambiguous candidates. Settings search shall find definitions rather than search saved values or select a target. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-007.5:** Required related-resource choices shall use authorized lookup. A returned choice shall not bypass validation during a later update. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-008: Domain invariants + +**Intent:** Compact tools preserve domain rules for combined values, dependencies, and persistence. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-008.1:** Each update shall validate the resulting resource, including unchanged values and related-resource constraints, before committing the change. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-008.2:** Rejected updates shall not partially persist a settings patch. Required domain atomicity shall apply across every supported write interface. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-008.3:** Concurrent updates shall retain existing version checks and domain invariants. The assistant shall not report a settings revision as a concurrency guarantee without enforcement. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-008.4:** Successful updates shall preserve domain notifications, cache invalidation, and scheduling effects. Results shall distinguish saved state from active runtime state. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-008.5:** Maps, arrays, clearing, and nullable fields shall have discoverable update semantics. Redaction markers shall never overwrite saved values. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-008.6:** Missing providers and disabled features shall return explicit capability states. Their absence shall not remove static settings descriptions. + +### REQ-PLATFORM-AGENT-SETTINGS-PARITY-009: Explicit boundaries + +**Intent:** Agents can explain what requires a separate operation, user interaction, or deployment change. + +#### Acceptance criteria + +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-009.1:** Client-local preferences, startup-only values, secret contents, and plugin-owned settings shall have explicit limitations and recovery instructions. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-009.2:** Settings updates shall not install software, authenticate accounts, restart Kandev, restore backups, or execute immediate cleanup operations. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-009.3:** Create, delete, import, and reorder operations shall remain explicit. Existing valid lifecycle calls shall remain compatible. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-009.4:** Descriptions shall identify lifecycle tools needed for supported setup. New editable fields shall not enlarge their advertised settings payload definitions. +- **AC-PLATFORM-AGENT-SETTINGS-PARITY-009.5:** Agents shall not change authentication enablement, account authority, personal tokens, or their own MCP permissions through the generic settings tools. + +## Out of scope + +- Settings-file import, export, synchronization, or a new persistence source. +- New persistence for client-local preferences, deployment environment changes, and arbitrary startup-file writes. +- Plugin-authored settings, Office's separate organization-management model, and new runtime feature flags. +- Credential enrollment, account authority, authentication-mode changes, and secret-value retrieval. +- An unrestricted mutation tool, transactional multi-resource saves, or agent runtime behavior changes. +- Agent installation, login, duplication, and deletion parity beyond existing operations. +- Optimistic locking for all concrete-profile writes. Existing server concurrency semantics remain in force. + +## Related contracts + +- [Agent system](../../agents/README.md) +- [Dynamic provider choices](../../agents/requirements/dynamic-provider-options.md) +- [Settings navigation discovery](../../ui/requirements/settings-discovery.md) +- [System design](../system-design/agent-settings-parity.md) +- [Domain adoption design](../system-design/agent-settings-domains.md) diff --git a/docs/specs/platform/system-design/agent-settings-domains.md b/docs/specs/platform/system-design/agent-settings-domains.md new file mode 100644 index 0000000000..47b1c1d1d0 --- /dev/null +++ b/docs/specs/platform/system-design/agent-settings-domains.md @@ -0,0 +1,220 @@ +--- +status: draft +system: platform +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-006 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +--- + +# Core domain adoption for agent-accessible settings + +## Purpose and boundaries + +Platform owns settings access across interfaces, not the underlying domain models. +This design extends the [shared tool contract](agent-settings-parity.md) to the main Kandev settings domains. +Agent profiles are the first vertical slice, not the final scope. +The package does not require a new settings file or central persistence store. + +## Requirement mapping + +| Requirement | Sections | +| --- | --- | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-006` | Required domains, Completion boundary | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-007` | Domain adapters, Authorized targets | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-008` | Mutation semantics, Relationships, Visible results | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-009` | Explicit exceptions, Lifecycle operations | + +## Required domains + +Backend paths below are relative to `apps/backend/`. Resource type names are proposed stable identities. +The table defines domain boundaries, not another runtime field allowlist. + +| Domain and resource types | Source contracts and services | Required coverage | +| --- | --- | --- | +| Profiles: `agent_profile`, `agent_profile_mcp` | `internal/agent/settings/dto/`, `controller/`, `handlers/` | Concrete and dynamic profiles, launch fields, model choices, separate MCP document | +| Agent definitions: `agent` | `internal/agent/settings/handlers/handlers.go`, `controller/` | Editable name, enabled state, and custom-agent definition fields. Computed capabilities remain read-only. | +| Personal: `user_settings` | `internal/user/dto/dto.go`, `controller/`, `service/` | Portable task behavior, keybindings, terminal preferences, appearance, views, presets, utility defaults, and LSP preferences | +| Workflows: `workflow`, `workflow_step` | `internal/task/dto/requests.go`, `internal/workflow/controller/controller.go`, `service/` | Workflow names and prompts, step bindings, events, session policies, admission limits, and editable ordering settings | +| Workspace: `workspace`, `repository`, `repository_script`, `repository_set` | `internal/task/dto/requests.go`, `handlers/`, `service/` | Workspace defaults, repository identity fields permitted by the UI, branch policies, scripts, copy rules, and repository sets | +| Execution: `executor`, `executor_profile`, `environment` | `internal/task/dto/requests.go`, `internal/task/handlers/`, `service/`, `internal/agent/executor/` | Current executor types, profile preparation settings, supported connection fields, secret references, and environment definitions | +| Task: `task` | `internal/task/dto/requests.go`, `internal/task/service/`, `internal/mcp/handlers/` | Editable task metadata and permitted launch defaults. Session actions and workflow movement remain lifecycle operations. | +| Prompts: `prompt` | `internal/prompts/dto/dto.go`, `controller/`, `service/` | Saved name and content with existing built-in restrictions | +| Utilities: `utility_agent` | `internal/utility/dto/dto.go`, `controller/`, `service/`, `profilebinding/` | Prompt, name, enabled state, and canonical profile binding. Legacy migration fields do not become new controls. | +| Editors: `editor` | `internal/editors/dto/dto.go`, `controller/`, `service/` | Saved editor definitions and kind-specific settings. Installation detection remains read-only. | +| Notifications: `notification_provider` | `internal/notifications/dto/dto.go`, `controller/`, `service/` | Provider name, enabled state, event subscriptions, and validated provider settings | +| Issue integrations: `jira_settings`, `linear_settings`, `sentry_instance`, and their watch types | `internal/jira/`, `internal/linear/`, `internal/sentry/` | Noncredential defaults, filters, existing instance settings, watch bindings, and watch schedules | +| Code-host integrations: `github_settings`, `gitlab_settings`, `azure_devops_settings`, and their watch/preset types | `internal/github/`, `internal/gitlab/`, `internal/azuredevops/` | Repository scope, defaults, action/query presets, and issue/review watch settings | +| Automations: `automation`, `automation_trigger` | `internal/automation/models.go`, `service.go`, `handlers.go` | Existing workspace automation definitions, continuation policies, bindings, and trigger schedules | +| Runtime: `runtime_flag` | `internal/runtimeflags/registry.go`, `service.go`, `handlers.go` | Existing mutable overrides except authentication enablement. Reads include source, lock, and pending-restart state. | +| Storage: `storage_maintenance` | `internal/system/storage/settings.go`, `handler.go`, policy types | Schedule and retention policies through the current settings operation, without running cleanup | + +Task behavior preferences belong to `user_settings`, not the current task. +Portable colors and task-view preferences remain personal even when their values contain task IDs. +Workspace integration settings do not become personal preferences because a browser owns the current workspace selection. +Sentry uses instance identity plus workspace identity. It must not collapse into a workspace singleton. + +## Domain adapters + +Each owner contributes metadata, typed request schemas, target rules, read projections, resource lookup, and mutation adapters. +Adapters use the shared catalog interfaces. Backend composition injects existing controllers and services. +The shared catalog never imports every domain or dispatches arbitrary HTTP routes supplied by callers. + +Schema extraction follows the actual wire contract, not an untagged internal struct's Go field names. +Several task DTOs lack JSON tags. Their current HTTP request bodies need shared, tagged domain contracts before schema generation. +Existing camelCase integration keys remain camelCase where that is their public contract. +Canonical catalog keys remain stable, with `field_path` identifying the actual wire field. + +Opaque JSON fields need domain-specific schemas or a documented typed-map boundary. +An `interface{}` or `json.RawMessage` alone is not evidence that arbitrary nested writes are safe. +Computed fields, compatibility inputs, workflow state, credential contents, and operational history receive explicit classifications. + +One composition registry serves the exporter and production registration through the same metadata constructors. +The exporter has no credentials, live database, provider probes, or runtime services. +Its all-domain snapshot is `apps/web/lib/settings-discovery/contract.generated.json`. +The profile-only snapshot remains a derived subset for the existing profile work orders. + +## Authorized targets + +The shared target and lookup rules are defined in the main design. +Personal adapters call `user.Controller.GetUserSettings` and `UpdateUserSettings` with trusted context, without an arbitrary user ID. +Workspace resources use existing task-service authorization. Workflow steps use the workflow controller's scoped entry points. +Install-owned writes enforce the exact existing permission, not a universal catalog permission. +For example, profile mutation uses `org.config.manage`, while runtime overrides use `org.settings.manage`. + +Static metadata is available without reading a saved resource. +Detailed values, resource names, references, and contextual choices require the same visibility checks as their domain UI. +Adapters reject missing identity when authentication is enforced, including services that otherwise interpret missing identity as an internal caller. +No generic endpoint permits a caller to set identity, ownership, tool profile, or authorization mode. + +## Mutation semantics + +Each update represents one domain operation. It preserves omission, explicit empties, nullability, defaults, and current replacement rules. +Maps and arrays are not universally deep-merged. Descriptions state their exact semantics. +Related fields can travel together in one patch, such as a utility profile binding and its supported policy fields. + +Existing full-document operations require one of two explicit adapters: + +1. A complete, validated document field with replacement semantics. +2. A domain-owned atomic patch shared by every writer. + +An MCP handler must not reconstruct a full document from a redacted read. +Integration updates preserve omitted credentials through the existing domain secret-preservation path. +Changes to connection identity that reuse stored credentials retain the provider's existing reauthorization requirements. +Credential enrollment and authentication-method changes remain interactive-only in this delivery. + +Runtime flags expose one resource per flag key. `changes.override` accepts a boolean or null to clear the stored override. +`Service.SetOverride` retains environment locking, registry mutability, and restart behavior. +The adapter adds the same organization-scope check as HTTP before calling that service. +The `features.auth` descriptor is discoverable but interactive-only for writes. +No shipped profile defaults, retired identities, or feature gate behavior change in this package. + +Storage policies use a complete `changes.settings` document because the current route accepts a complete policy. +The adapter calls the same settings operation and `OnSettingsChanged` callback as the HTTP route. +The current dedicated-Docker acknowledgement remains an exact token in domain-validated `options.confirmations`. +Go-cache path adoption remains a separate action. A generic patch cannot set the private adoption permission. + +Saving an enabled watch, automation, or maintenance schedule can affect future background work. +Descriptions explain that consequence. The domain's existing enablement, confirmation, and validation rules remain mandatory. +Saving does not imply an immediate run or create a new scheduler. + +## Relationships + +Each adapter validates references through the owning service, not through catalog metadata alone. + +| Invariant | Required evidence | +| --- | --- | +| Workflow step references a usable profile | Missing, inaccessible, incompatible, disabled, and valid profile cases | +| Step event references another step | Same-workflow validation and rejection of a foreign step | +| Workspace default references an executor/profile | Authorized, compatible target and documented clearing behavior | +| Repository copy rule stays contained | Existing copyfiles validation rejects traversal and malformed specifications | +| Utility agent keeps a valid profile binding | Dependency conflicts, built-in restrictions, and legacy migration inputs | +| Watch references match its workspace | Wrong-workspace workflow, profile, repository, and instance cases | +| Task configuration respects running sessions | No hidden move, start, reassignment, or runtime rewrite | +| Runtime override respects deployment policy | Environment lock, unknown/retired key, null reset, and pending restart | +| Storage policy preserves adoption safety | No unconfirmed dedicated daemon or private Go-cache adoption | + +When a valid mutation updates several rows, the existing domain transaction remains the atomicity boundary. +New partial-update paths need equivalent protection across all writers, not only MCP callers. +Tests exercise competing writes for any added read-modify-write path and verify rollback after an injected persistence failure. +This package does not add universal optimistic locking. Existing dynamic versions and domain concurrency checks remain intact. + +## Visible results + +Every domain work order includes its existing store subscription and editor reconciliation. +The profile implementation establishes clean, dirty, submitted, and conflicted states through the existing save coordinator. +Other editors reuse that behavior where applicable, without moving domain state into the coordinator. + +Existing events remain authoritative. Adapters must not duplicate event publication. +When no suitable event exists, the domain adds a value-free invalidation after persistence. +Invalidations route to the owning user or workspace, or authorized install-setting readers. They never broadcast private values globally. +Refetches use target identity and request-generation guards. An invalidation during a pending read requires a fresh read. +Delivery order without a domain revision is not proof that one snapshot is newer. + +Personal settings reuse `apps/web/lib/user-settings-sync.ts` and the shared wire mapper. +Workflow drafts reuse `use-workflow-draft-contributor.ts` and `workflow-dirty-state.ts`. +Executor profiles reuse the contributors under `components/settings/profile-edit/`. +New invalidation handlers feed these existing paths instead of updating only the page that initiated the change. + +Phone and desktop keep their current settings routes, content scroll owner, and shared save controls. +Conflict recovery stays inline and uses the current discard action. No new overlay or navigation model is required. +Existing mobile profile, executor, notification, prompt, and utility settings tests provide the nearest shipped examples. +New copy uses all five locales. New recovery actions preserve touch targets, keyboard access, and safe-area clearance. +Each domain's browser scenario invokes real MCP, observes the UI, then verifies persistence after reload. + +## Explicit exceptions + +Every exception has a stable identity, owner, category, reason, and available recovery destination. + +| Category | Included examples | Discovery behavior | +| --- | --- | --- | +| `client-local` | Settings-menu shape, chart-motion preference, pane sizes, collapsed navigation | Explain the device boundary and link to the control. No remote browser bridge. | +| `read-only` | Installation detection, runtime health, computed capabilities, active execution snapshots | Return authorized nonsensitive state only. Never promote computed response fields into updates. | +| `interactive-only` | Login, token enrollment, account authority, authentication enablement, credential-bearing connection identity changes | Explain the required user flow without reading credentials. | +| `action` | Install, restart, restore, immediate cleanup, resource creation/deletion, workflow reordering | Identify an existing explicit tool or the UI action. Never encode the action as an arbitrary setting. | +| `deployment-owned` | Startup YAML/environment, database location, launcher binaries, server ports | Derive safe metadata from `internal/common/config/catalog.go` and source provenance. No file or environment writes. | +| `plugin-owned` | Plugin-specific settings schemas and storage | Identify the plugin settings destination. No arbitrary plugin storage mutation or new plugin API. | +| `separate-product-contract` | Office organization, projects, channels, and autonomous workforce management | Identify Office's existing tools or UI. Workspace automations remain in scope. | + +Host-owned keyboard overrides include the typed plugin-shortcut override map where it already belongs to user settings. +That does not expose arbitrary plugin-owned settings or infer action availability when the plugin is absent. +Transient form drafts, navigation history, setup progress, and internal migration fields are not user configuration controls. + +New value reads redact declared credential paths. Search and resource lookup never inspect arbitrary saved values. +Free-form prompt and script bodies are returned only by explicit authorized value reads, not search snippets or logs. +The catalog does not claim that it can detect every secret a user writes into free-form text. +Legacy external raw-MCP-document reads retain their current exposure contract. + +## Lifecycle operations + +Four settings tools plus one generic resource lookup form the shared settings interface. +They do not replace explicit lifecycle tools or turn updates into silent upserts. +Existing create/delete/import/reorder tools remain available through their current domain owners. +New fields on adopted create tools use a validated `settings` object, with duplicate legacy arguments rejected. + +For prompt, utility-agent, editor, and notification-provider setup, domain work orders add explicit create adapters where no MCP create tool exists. +These tools use parent selectors where required and one validated settings object. +The catalog describes their complete create schema on demand. The outer tool definitions do not grow with new fields. +Creation preserves built-in restrictions and does not install, authenticate, execute, or test external credentials. +Other new lifecycle families, including provider account enrollment and resource destruction, remain outside this package. + +## Completion boundary + +The inventory covers Settings routes, navigation definitions, independent UI payload types, backend mutation bodies, and persisted response fields. +Navigation entries alone cannot prove completeness because some entries represent sections instead of individual fields. +The first work order produces a checked inventory from these inputs, including missing navigation controls and explicit exception records. + +CI compares independent inputs against the catalog and the inventory. It must detect a new route, domain, nested typed field, or editable control. +Negative fixtures add each kind of omission and remove a descriptor. Every fixture must fail the appropriate coverage assertion. +Dynamic maps count as typed contracts, not as one new setting per saved key or provider option. +The completion report separates supported fields, read-only fields, exceptions, and pending work by domain. +Every eligible field in the required domains must be supported. Pending work cannot be relabeled as an exception to pass delivery checks. +The report does not claim a coverage percentage until the independent inventory establishes its denominator. + +## Related decisions + +- [Domain-owned settings catalog](../../../decisions/2026-09-08-domain-owned-settings-catalog.md) +- [Portable personal settings](../../../decisions/0041-backend-owned-portable-user-settings.md) +- [Runtime overrides](../../../decisions/0018-runtime-settings-overrides.md) +- [Workspace integration settings](../../../decisions/0030-workspace-scoped-integration-settings.md) diff --git a/docs/specs/platform/system-design/agent-settings-parity.md b/docs/specs/platform/system-design/agent-settings-parity.md new file mode 100644 index 0000000000..4b281da712 --- /dev/null +++ b/docs/specs/platform/system-design/agent-settings-parity.md @@ -0,0 +1,425 @@ +--- +status: draft +system: platform +requirements: + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-001 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-002 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-003 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-004 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-005 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-007 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-008 + - REQ-PLATFORM-AGENT-SETTINGS-PARITY-009 +--- + +# Agent-accessible Kandev settings system design + +## Purpose and boundaries + +Platform owns the shared discovery contract across settings domains. +Each domain remains the source of truth for its values and operations. +Profiles establish the first implementation slice. They are not the delivery boundary. +The [domain adoption design](agent-settings-domains.md) defines the required core domains and explicit exceptions for this delivery. + +The frontend navigation catalog remains a presentation contract. +This design connects existing destinations to domain setting IDs without replacing navigation, ranking, translations, or focus behavior. + +The [ownership decision](../../../decisions/2026-09-08-domain-owned-settings-catalog.md) defines the extension boundary. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-001` | Catalog, Discovery tools, Coverage | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-002` | Shared requests, Mutation adapters, Dynamic choices | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-003` | Authority, Sensitive values | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-004` | Editor reconciliation | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-005` | Coverage | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-007` | Discovery tools, Target contract | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-008` | Compact update validation, Mutation adapters | +| `REQ-PLATFORM-AGENT-SETTINGS-PARITY-009` | Lifecycle and compatibility tools, Sensitive values | + +## Current implementation + +- `internal/agent/settings/handlers/handlers.go` declares private create/update bodies and maps them to controller requests. +- `internal/agent/settings/controller/profile_crud.go` owns defaults, normalization, dependencies, dynamic-profile versions, and persistence. +- `internal/mcp/server/config_handlers.go` declares a smaller schema and manually forwards selected fields. +- `internal/mcp/handlers/config_agent_handlers.go` decodes another smaller request shape. +- The UI saves through `components/settings/agent-profile-page-state.ts` and the existing agent settings actions. +- `internal/mcp/handlers/workflow_step_parity_test.go` compares REST and MCP notifications over a shared store. +- `internal/common/config/catalog.go` describes startup settings. It does not own profile values. +- `lib/settings-discovery/` describes navigation destinations. It does not own mutation schemas. + +All backend paths in this document are relative to `apps/backend/`. +All frontend paths are relative to `apps/web/`. + +## Catalog + +A new `internal/settingscatalog` package owns metadata types and bounded metadata search. +It contains no database access, credentials, global setters, or provider execution. +The agent domain contributes entries from `internal/agent/settings/catalog`. +Other domains contribute adjacent catalog adapters, as specified in the domain adoption design. +Backend composition supplies registered adapters to MCP handlers. The shared package does not import domain services. + +Each setting descriptor contains: + +| Field | Meaning | +| --- | --- | +| `key` | Stable identity, such as `agent_profile.fallback_model` | +| `domain` | Owning settings domain, such as `agent_profiles` or `user_preferences` | +| `resource_type` | Registered adapter identity, such as `agent_profile`, `workflow_step`, or `user_settings` | +| `scope` | Caller-owned, workspace-owned, or install-owned, with explicit selector rules | +| `description`, `aliases` | Curated English metadata for agents, without saved values | +| `label` | Short human-readable setting name for search results | +| `schema` | Field shape derived from the shared domain request | +| `default_behavior` | Explicit default, provider inheritance, or no default | +| `support` | Status, reason, and optional recovery destination | +| `classification` | Writable field, computed field, action, or reasoned exception category | +| `operations` | Compact update binding, lifecycle operations, field paths, and replacement rules | +| `dependencies` | Related setting keys, resource references, and required companion fields | +| `required_scope` | Existing domain authority, including `org.config.manage` for writes | +| `applies_when` | Metadata update, future launch, future selection, or provider-dependent behavior | +| `sensitive_paths` | Paths that require omission or redaction in new value reads | +| `settings_href` | Canonical settings route after resource resolution, or explicit absence | + +Descriptors are immutable process metadata. They do not store current values or authority decisions. +Descriptions stay adjacent to operation bindings. No second hand-maintained type or field allowlist exists in MCP. +Dynamic maps declare typed values rather than enumerating provider-specific keys. + +Search returns at most 50 entries, with a default limit of 20 and an offset cursor. +Queries have a maximum length of 256 characters. Matching is case-insensitive token matching over metadata only. +Search tokenization splits whitespace, dots, underscores, and hyphens. +Exact keys and labels rank first, followed by prefixes and aliases, then descriptions. Stable keys break ties. +Domain context can narrow results but cannot make an unrelated field match by itself. +No fuzzy match changes a key or selects an update target. +An empty query lists the domain's descriptors in stable order. +This delivery does not introduce embeddings, external search, or translated backend search. + +A result includes `key`, `label`, `description`, `resource_type`, `field_path`, `support`, `match_reason`, and `settings_href` when resolvable. +It names `describe_setting_kandev` as the next step. It does not embed full schemas, current values, or resource lists. +Descriptions explain behavior and important dependencies. For example, fallback-model metadata states that automatic fallback takes precedence. +An ambiguous query returns separately labeled candidates. Resource selection remains explicit. + +Registry construction rejects duplicate keys, missing owners, unresolved operation bindings, and unsupported schema shapes. +A writable descriptor requires a registered validator, mutation adapter, and authority rule. +Every exclusion requires a reason. Documentation-only descriptors cannot become writable by changing a status string. + +## Shared requests + +Move the HTTP create/update body definitions into exported agent-domain contracts under `internal/agent/settings/dto/`. +Both transports decode these types and use one conversion path into controller requests. +Transport identifiers stay outside the domain patch. Compact reads and updates use the shared `target` object. +Existing lifecycle and compatibility tools retain their profile or agent selectors. +Dependency confirmation remains an operation argument, not a saved setting. + +Generate domain validation schemas from the shared typed contracts and enrich fields with catalog descriptions. +Use the existing JSON Schema dependencies. Compile every generated schema through `internal/mcp/toolschema.Compile`. +Generated schemas must preserve the existing Draft 7 validation boundary. +Compatibility adapters remain explicit and covered by tests. +The compact tools advertise only stable envelope schemas. Full domain schemas are returned by description or used internally for validation. +No enum of setting keys and no `oneOf` of all resource schemas appears in those envelopes. + +The following table is the initial field inventory. It is design evidence, not another runtime allowlist. + +| Fields | Operations and semantics | +| --- | --- | +| `name` | Create/update. Required nonblank name on creation and when supplied on update. | +| `model`, `mode` | Create/update. Empty model retains the existing provider-default behavior. | +| `fallback_model`, `auto_fallback` | Create/update with the existing fallback precedence. | +| `config_options` | Create/update. Replace the supplied map, then apply current domain sanitization. | +| `auto_approve`, `allow_indexing` | Create/update. Preserve legacy compatibility. Mark `allow_indexing` as deprecated metadata. | +| `cli_passthrough`, `cli_flags` | Create/update with current capability rules. A supplied flags list replaces the full list. | +| `env_vars`, `command_prefix` | Create/update through existing validators and secret-reference checks. | +| `enabled` | Update. Creation retains the domain's existing enabled default. | +| `dynamic` | Create/update through current candidate validation, feature availability, and version rules. | +| `mcp.enabled`, `mcp.servers` | Compact read/update with `resource_type: agent_profile_mcp`. Separate resource operation using the profile ID. | + +The MCP document also carries opaque `meta`. Classify this field as preserved metadata, not an editable UI control. +The shared document adapter preserves it when the caller omits it. +The HTTP `mcpServers` spelling remains a compatibility alias for `servers`. +The compact document adapter accepts canonical `servers`. Existing dedicated MCP tools remain external compatibility adapters. + +`id`, `agent_id`, `kind`, timestamps, billing metadata, and other computed response fields are not generic writable settings. +Each noneditable response field has an explicit coverage classification. +Agent-level fields such as `supports_mcp` remain outside the profile mutation contract. + +On update, omission leaves a value unchanged. False, zero, empty strings, empty maps, and empty lists survive transport conversion. +Empty collections mean replacement with an empty collection where the domain supports clearing. +There is no universal reset operation or new meaning for null. +Existing HTTP null behavior remains compatible. Newly exposed MCP fields reject null unless their published schema explicitly permits it. + +## Discovery tools + +Register a `configuration-settings` group through the existing typed MCP profile registry. +The configuration and external surfaces receive these tools. Ordinary task, Office, and automation surfaces do not receive this group. +The configuration surface replaces adopted read/update tools with the compact group after equivalent coverage passes. +The external surface retains existing tool names and successful argument forms for compatibility. +Legacy tools remain visible until their adapters pass their work-order checks. +Lifecycle and interaction tools remain explicit. External compatibility tools retain their existing schemas. + +| Proposed tool | Arguments | Result | +| --- | --- | --- | +| `search_settings_kandev` | `query`, optional `domain`, `limit`, `cursor` | Descriptor summaries, covered domains, and next cursor | +| `describe_setting_kandev` | `key`, optional `target`, `context`, `operation`, `include_resource_schema` | Detailed descriptor, dependencies, optional operation schema, and current capability status | +| `get_settings_kandev` | `target`, `keys` | Saved-value projection for up to 50 keys, with explicit redaction and source | +| `update_settings_kandev` | `target`, `changes`, optional `options` | Accepted field names and revision metadata, or a structured tool error | +| `list_settings_resources_kandev` | `resource_type`, optional `workspace_id`, `query`, `limit`, `cursor` | Authorized target summaries, explicit empty state, and next cursor | + +`describe_setting_kandev` works without a target for static schema information. +The setting key determines the descriptor's resource type. A supplied target must match that type. +For creation, the domain's validated `context` names required parents, such as `agent_id` for a profile. +`operation` defaults to `update` and also accepts `create` for profile lifecycle schema discovery. +`include_resource_schema` defaults to false. When true, the result includes the selected operation's complete domain schema and required fields. +This supports related settings and dynamic-profile creation without loading every schema upfront. +The agent adapter's context contains optional `agent_id`, `model`, `mode`, and `config_options` for discovery before saving. +Other adapters define their own closed context schemas. Unknown context fields fail before domain lookup. +Context supplies no identity, authority, executable binding, or persistence operation. + +`get_settings_kandev` requires one registered resource type and target. It does not infer a profile from the calling agent. +`keys` contains canonical descriptor keys belonging to that resource type. +Unknown domains and keys return typed errors. A domain outside coverage is not an empty successful value response. +New reads include `source: profile`, available revision metadata, and the descriptor's change timing. +Profile fields carry `updated_at`. The separate MCP document does not borrow that timestamp or claim an equivalent revision. +An inherited model has an explicit inheritance marker. A saved profile read never claims to show an active session's effective model. +The tool does not accept arbitrary paths or resolve credential contents. + +The configuration prompt at `config/prompts/config-context.md` introduces discovery before settings changes. +It teaches search, resource lookup, describe, read, and update without repeating every optional field. +The prompt states supported domains and preserves the existing destructive-action confirmation instruction. + +## Target contract + +The shared target is `{resource_type, resource_id?, workspace_id?}`. No resource-type enum appears in its advertised schema. +The registry defines each type's required selectors. Unknown types and extra selectors fail closed. + +- `user_settings` accepts neither resource ID nor workspace ID. The existing user service resolves the trusted caller. +- Workspace singleton adapters require `workspace_id` and reject a resource ID. +- Entity adapters require `resource_id`. Their domain resolves and authorizes the containing workspace. +- A supplied workspace ID must match the authorized entity's workspace. It never overrides ownership. +- Install singleton adapters accept neither ID. They enforce their existing organization scope. +- `runtime_flag` uses its registered flag key as `resource_id`, allowing one atomic override operation per request. + +Resource lookup is a fifth bounded discovery tool, not another settings mutation tool. +It uses existing authorized domain list services and returns only IDs, labels, parent context, and complete targets. +The default limit is 20, the maximum is 50, and the query limit is 256 characters. +Query matching is limited to resource labels and public identifiers, never prompt bodies, command strings, credentials, or arbitrary saved values. +Filtering and authorization precede pagination. Cursors bind the caller, resource type, query, and workspace scope. +Foreign resources and missing resources produce the same not-visible result. Singleton lookup returns only the caller's permitted singleton. +Where existing lists are unbounded, adapters add bounded service queries rather than loading the entire table for every page. +Existing list tools remain compatibility operations. No new per-domain list tool is required. + +## Compact update validation + +The outer `update_settings_kandev` schema requires a target and nonempty object of changes. +It allows at most 50 top-level changed fields and a 256 KiB serialized changes object. +The root envelope is closed. The nested changes object receives domain validation at invocation. +These limits do not replace existing stricter domain limits. + +The backend performs this sequence before mutation: + +1. Resolve the exact registered resource adapter and trusted caller context. +2. Authorize the operation and load the selected resource through its domain service. +3. Select the current update schema from that adapter and resource context. +4. Validate the patch shape, then validate the resulting resource through the domain's update operation. +5. Decode the shared typed patch and invoke the existing domain mutation once. +6. Return accepted field names and available revision metadata without echoing sensitive values. + +Closed typed objects reject unknown keys. Only domain-declared maps permit arbitrary keys with the domain's value type. +An update cannot mix profile fields with the separate MCP document, create an undeclared resource type, or write an arbitrary database column. +The request never contains executable bindings or a caller-supplied schema. +The optional `options` object receives a closed schema from the selected adapter. +Profile adapters accept `force`, false by default. Other adapters reject this option unless their domain explicitly supports it. +Storage acknowledgements retain their exact confirmation tokens. No generic force flag bypasses unrelated domain checks. +Descriptions return option schemas on demand. Adding domain options does not enlarge the advertised outer envelope. + +Domain validation checks the patch together with unchanged saved values and authorized references. +Adapters never perform an unlocked read, merge, and full replacement when that can erase unrelated concurrent changes. +Partial updates use atomic domain patches. Required read-modify-write operations use the domain's transaction or version mechanism across REST, WS, and MCP. +An adapter-only mutex cannot protect other writers or multiple backend processes. +No single request spans independent resources. A domain operation can update dependent rows atomically when its invariant requires that operation. +Validation failures produce no writes. Failed post-save probes report a saved result with degraded capability, not a false rollback claim. + +Validation errors contain `code`, canonical setting key, JSON field path, a safe expected-type description, and the suggested description call. +Errors never echo submitted values. Unknown-field suggestions are advisory and are never applied automatically. +The server validates against current rules even when the caller used an older description response. +Capability failures retain their typed state and do not cause guesses or retries with relaxed validation. +Static registry schema revisions are content hashes for cache invalidation, not authority tokens or resource concurrency versions. + +## Lifecycle and compatibility tools + +Creation remains explicit because dynamic profiles require related values in one valid create request. +`create_agent_profile_kandev` keeps `agent_id`, `name`, legacy optional `model` and `auto_approve`, and adds an optional `settings` object. +New fields go inside `settings`, without expanding the advertised outer schema. +Model becomes optional to support the existing domain default behavior. +The backend validates the combined create request against the shared create contract. +A field supplied both at the top level and inside `settings` is rejected, including equal duplicate values. +`settings` cannot override `agent_id` or another transport identifier. +Discovery supplies the complete create schema on request, including dynamic candidates and their required policy fields. + +Existing external `update_agent_profile_kandev` and MCP-document tools retain their current argument shapes and meanings. +Their adapters use the same validators and services. New fields are accessed through the compact tools rather than added to legacy schemas. +The configuration surface omits the adopted legacy update tools to avoid duplicate ways to change the same settings. +Profile/resource listing and explicit create/delete operations remain available. +Profile replacement sends the existing `tools/list_changed` notification so clients refresh their advertised tools. + +## Dynamic choices + +The agent domain adapter uses `Controller.ResolveAgentModelConfig` and existing capability discovery. +Profile context comes from the saved profile plus explicit model-context overrides. +Create-time discovery accepts an existing agent ID before a profile exists. +The resolver retains its current cache, timeout, and provider-neutral response. + +The descriptor returns the resolver's status and full applicable choice snapshot. +`probing`, `auth_required`, `not_installed`, and failure states remain explicit. +The assistant cannot treat a stale or unavailable snapshot as an empty valid choice set. +Static setting discovery remains available when the provider probe fails. +Provider-dependent values remain the domain's validation responsibility. This work does not add a second provider policy engine. + +## Mutation adapters + +Existing REST paths, compact MCP operations, and compatibility tools call the shared domain conversion and validation path. +Successful writes use the existing controller and stores. There is no new schema migration. +The profile MCP document retains its separate service. +HTTP requires `enabled` and normalizes an absent servers map to an empty map. +Existing MCP calls treat omitted fields as unchanged. Preserve these transport defaults through explicit adapters to the same document operation. +The catalog describes that distinction. Equivalent full inputs must produce equivalent stored documents. +Both adapters call the controller so profile existence and MCP-support checks remain consistent. +Profile creation followed by an MCP document update remains two operations, without an atomicity claim. + +The REST and MCP adapters translate typed domain failures into their native error envelopes. +MCP preserves error code and structured dependency details across both backend clients. +`DispatcherBackendClient` currently discards those details. Extend its error representation and the streamed backend client consistently. +Existing human-readable error text remains available to older callers. +An unknown argument fails at the existing tool-schema boundary before dispatch. + +Successful profile creation and updates produce the existing domain notification exactly once. +Keep either the existing REST broadcast or MCP event publication for its operation. +Do not add another publication to a shared helper while retaining both callers' publication. +Do not introduce synthetic agent responses as a source of UI truth. + +The current MCP-document writers publish no update notification. +Add `agent.profile.mcp_config.updated` as an invalidation event with `profile_id` and the domain's routing scope. +Each accepted REST or MCP document write publishes this event once after persistence. +The event carries no document values. An authorized open editor refetches through its existing read action. +Refetches use a request generation and profile-ID guard. An invalidation during a pending read triggers a fresh read. + +## Authority + +REST profile mutations currently use `authz.ScopeOrgConfigManage` and the interim browser interlock. +MCP must enforce the same organization scope from trusted request context before domain mutation. +Use `authn.IdentityFromContext` and the existing `authz` subject/scope resolver. +The transport owns whether authentication is enforced. Missing identity cannot become unrestricted access under enforced authentication. +The established auth-disabled path preserves single-user operation. + +In-session requests retain the owner identity from `internal/mcp/scope`. +External requests retain the authenticated PAT identity. +The browser interlock remains a browser transport guard. MCP does not fetch or reuse its token. +Existing automation restrictions and raw-WebSocket rejection remain effective. +The catalog cannot manufacture a scope, user identity, profile ownership, or MCP surface from request arguments. + +`options.force` remains false by default for dependency-sensitive profile updates. +A conflict returns affected utility and dynamic-profile references without a write. +The configuration assistant uses its existing question tool before a confirmed retry. +The same operation remains unavailable when the caller lacks organization authority. + +## Sensitive values + +Search and descriptions contain schema metadata only. +New saved-value projections redact literal environment values and credential-bearing MCP subdocuments. +Secret IDs remain references. The adapter never calls secret resolution to satisfy discovery. +Redacted values include an explicit marker and cannot be round-tripped as replacement values. +The MCP-document descriptor names the compact resource binding and the external compatibility tools' current exposure limits. + +Existing legacy tools can expose raw profile MCP documents under their current contract. +This delivery does not claim a product-wide secret-redaction migration. +New discovery logs contain setting keys, resource IDs, and outcome codes, without request values or provider error payloads. + +## Editor reconciliation + +The existing `agent.profile.updated` handler updates `settingsAgents` and profile choices. +`useAgentProfileSettings` derives the selected profile from that store. +Extend `useProfileEditorState` to reconcile authoritative profile updates with its local draft and saved baseline. +Apply the same contract to `dynamic-agent-profile-editor-state.ts` and `dynamic-agent-profile-editor-draft.ts`. +Dynamic candidate edits retain the existing server version and dependency rules. +The separate `use-profile-mcp-config.ts` hook consumes the new document invalidation and maintains its own baseline and draft. +An MCP document conflict blocks only its contributor. The save coordinator continues to own route-level feedback. + +- A clean editor adopts a newer profile and remains clean. +- A dirty editor preserves its draft and records an external-change conflict. +- A conflicted contributor blocks Save through the existing save coordinator and shows localized recovery copy. +- The existing discard/reset flow adopts the latest authoritative profile and clears the conflict. +- The editor correlates its submitted snapshot with its own response and notification. Its acknowledgement is not an external conflict. +- A late save response cannot replace a newer authoritative baseline or remove edits made after submission. + +Use the existing `updatedAt` ordering and profile-ID guards for delivered snapshots. +This is client reconciliation, not a new server compare-and-swap guarantee. +Simultaneous concrete-profile writes retain the current server behavior. Dynamic profiles retain their existing version conflict contract. + +Desktop and phone use the current profile route, advanced sections, and shared Save surface. +The nearest phone exemplar is `e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts` and its profile page. +Recovery copy stays inline beside the affected editor. It needs no new modal, drawer, or navigation layer. +The existing content scroll owner and safe-area-aware save surface remain in use. +Any new touch action uses a 44px hit area only for coarse pointers. Fine-pointer controls retain current density. +All new visible copy uses the five locale catalogs. Traditional Chinese values use the existing generation command. + +## Coverage + +Coverage has three independent sources for every adopted domain: + +1. Shared typed create/update contracts and response fields. +2. UI payload construction, typed editable fields, and settings controls. +3. Catalog descriptors, generated domain schemas, and advertised envelope schemas. + +A generated metadata snapshot supplies stable field keys and schemas to frontend tests. +The proposed `cmd/settings-catalog` exporter supports `--check` without writes. +Generation is deterministic. Tests fail when the checked-in snapshot differs from the domain catalog. +The frontend mapping ties each domain patch key and settings control to a setting ID or explicit coverage classification. +Dynamic provider keys belong to the `config_options` descriptor, not a frozen list. + +Go contract tests compare complete field sets, requiredness, nested shapes, and explicit exclusions. +Mutation tests call real registered MCP tools and real REST handlers over the same domain fixture. +They compare persisted values, defaulting, invalid inputs, dependency details, and emitted notifications. +At least one negative fixture adds a field outside the catalog. Another removes a descriptor. +Both fixtures must cause the coverage assertion to fail. + +The existing backend and frontend test workflows run the contract checks on relevant changes. +Add the exporter freshness command to backend CI with path coverage for its frontend snapshot. +Tests assert that removing a required check or narrowing its path coverage causes the CI wiring check to fail. +No branch-protection or repository-setting changes are part of this package. + +The search suite owns a curated query set independent of descriptor aliases. +Cases include exact keys, `backup model`, `reasoning effort`, `command prefix`, `keyboard shortcut`, `archive confirmation`, and `workflow agent`. +Additional cases cover repository scripts, executor defaults, notification events, integration watches, runtime locks, ambiguous `permissions`, and an unrelated query. +Cases assert the expected first result or candidate set, correct resource scope, match reason, and no saved-value search. +The alias and ranking implementation cannot rewrite expected results automatically. + +An added-setting fixture verifies that compact tool names and advertised schemas remain byte-identical. +The description result and backend validator must recognize that same new setting. +The named create tool's envelope receives the same stability check. +Compatibility tests cover legacy external calls without requiring legacy schemas to grow. + +Focused browser tests apply a real MCP mutation while the profile page is open. +They verify the clean update, reload persistence, dirty-draft recovery, and an acknowledgement during an in-flight save. +Fixtures cover concrete fields, dynamic candidates, and the separate MCP document. +Desktop and mobile scenarios use disposable profiles and causal event waits. +The browser never dispatches raw `mcp.*` WebSocket actions as a shortcut. + +## Persistence and rollout + +No new settings store, startup source, or runtime flag identity is required. +Domain adapters reuse current persistence and feature gates. Necessary atomic patch queries do not introduce a new persistence owner. +External compatibility tool names and successful argument shapes remain stable. +Compact updates and discovery ship together after their work-order checks pass. +The configuration profile switches each adopted operation to the compact group with the existing list-changed notification. +Public docs list all delivered domains and their specific limitations. +The package is incomplete while an eligible required domain remains not-yet-supported. +Domain adapters do not copy startup or runtime-flag registries. + +## Related decisions + +- [Domain-owned settings catalog](../../../decisions/2026-09-08-domain-owned-settings-catalog.md) +- [MCP argument validation](../../../decisions/2026-08-01-validate-mcp-tool-arguments.md) +- [MCP tool profiles](../../../decisions/2026-08-08-mcp-tool-profiles.md) +- [Utility dependency safety](../../../decisions/2026-08-08-utility-profile-dependency-safety.md) +- [Settings save coordinator](../../../decisions/0046-settings-route-save-coordinator.md) +- [Navigation boundaries](../../../decisions/2026-08-04-navigation-manifest-boundaries.md) diff --git a/scripts/settings-contract-ci.test.mjs b/scripts/settings-contract-ci.test.mjs new file mode 100644 index 0000000000..58700a4594 --- /dev/null +++ b/scripts/settings-contract-ci.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import test from "node:test"; + +const root = resolve(new URL("..", import.meta.url).pathname); +const backend = resolve(root, "apps/backend"); +const snapshotPath = resolve(root, "apps/web/lib/settings-discovery/contract.generated.json"); +const profileSnapshotPath = resolve( + root, + "apps/web/lib/settings-discovery/profile-contract.generated.json", +); + +function readJSON(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +test("settings catalog snapshots are fresh", () => { + execFileSync("go", ["run", "./cmd/settings-catalog", "--check"], { + cwd: backend, + stdio: "inherit", + }); +}); + +test("settings catalog has complete deterministic identities", () => { + const snapshot = readJSON(snapshotPath); + const profileSnapshot = readJSON(profileSnapshotPath); + assert.equal(snapshot.schema_version, "settings-catalog.v1"); + assert.equal(snapshot.generated_by, "cmd/settings-catalog"); + assert.equal(profileSnapshot.schema_version, "settings-catalog.v1"); + + const resourceTypes = new Set(); + for (const domain of snapshot.domains) { + assert.ok(domain.resource_type); + assert.ok(!resourceTypes.has(domain.resource_type), `duplicate ${domain.resource_type}`); + resourceTypes.add(domain.resource_type); + + const keys = new Set(); + const paths = new Set(); + for (const field of domain.fields) { + assert.ok(!keys.has(field.key), `duplicate field key ${field.key}`); + assert.ok(!paths.has(field.field_path), `duplicate field path ${field.field_path}`); + keys.add(field.key); + paths.add(field.field_path); + if (field.support === "supported" && field.writable) { + assert.equal(field.classification, "writable", field.key); + assert.ok(field.validator, field.key); + assert.ok(field.authority, field.key); + } + assert.notEqual(field.support, "pending", field.key); + } + } + + for (const resourceType of [ + "agent_profile", + "agent_profile_mcp", + "user_settings", + "workflow", + "workflow_step", + "workspace", + "repository", + "repository_script", + "repository_set", + "executor", + "executor_profile", + "environment", + "task", + "prompt", + "utility_agent", + "editor", + "notification_provider", + "automation", + "automation_trigger", + "runtime_flag", + "storage_maintenance", + ]) { + assert.ok(resourceTypes.has(resourceType), `missing ${resourceType}`); + } +}); + +test("compact settings tools keep closed schemas", () => { + const source = readFileSync( + resolve(root, "apps/backend/internal/mcp/server/settings_tools.go"), + "utf8", + ); + for (const tool of [ + "search_settings_kandev", + "describe_setting_kandev", + "get_settings_kandev", + "update_settings_kandev", + "list_settings_resources_kandev", + ]) { + assert.match(source, new RegExp(`NewToolWithRawSchema\\(\\n\\s*\\"${tool}\\"`)); + } + assert.doesNotMatch(source, /WithEnum\(/); +});