diff --git a/crates/tracedecay-api/src/configuration.rs b/crates/tracedecay-api/src/configuration.rs index 5e7c81dcd6..92e75faa41 100644 --- a/crates/tracedecay-api/src/configuration.rs +++ b/crates/tracedecay-api/src/configuration.rs @@ -42,6 +42,8 @@ pub struct ProjectSettingsPatch { pub telemetry: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_scout: Option, } /// Nested synchronization settings patch. diff --git a/crates/tracedecay-dashboard-api/src/settings_api.rs b/crates/tracedecay-dashboard-api/src/settings_api.rs index 6b7c72342b..4bb5f96073 100644 --- a/crates/tracedecay-dashboard-api/src/settings_api.rs +++ b/crates/tracedecay-dashboard-api/src/settings_api.rs @@ -30,7 +30,8 @@ use crate::application::configuration::{ }; use crate::application::settings_control::{ ProjectSettingsPatchV1, ProjectSettingsPreviewErrorV1, SyncSettingsPatchV1, - TelemetrySettingsPatchV1, preview_project_settings, + TelemetrySettingsPatchV1, context_scout_settings_are_enabled, effective_context_scout_settings, + preview_project_settings, }; use crate::config::TraceDecayConfig; use crate::request_identity::{GlobalRequestSurface, mint_global_request_id}; @@ -94,25 +95,32 @@ struct ProjectEditableSettingsV1 { git_ignore: bool, telemetry: TelemetrySettingsV1, sync: SyncSettingsV1, -} - -impl From<&TraceDecayConfig> for ProjectEditableSettingsV1 { - fn from(config: &TraceDecayConfig) -> Self { - Self { - include: config.include.clone(), - exclude: config.exclude.clone(), - max_file_size: config.max_file_size, - extract_docstrings: config.extract_docstrings, - track_call_sites: config.track_call_sites, - git_ignore: config.git_ignore, - telemetry: TelemetrySettingsV1 { - timings: config.telemetry.timings, - }, - sync: SyncSettingsV1 { - auto_track_pr_branches: config.sync.auto_track_pr_branches, - auto_track_pr_poll_secs: config.sync.auto_track_pr_poll_secs, - }, - } + /// Rendered from the effective Plan 20 `context_scout.settings.v1` value; + /// the dashboard holds no Scout state of its own. + context_scout: bool, +} + +fn project_editable_settings( + configuration: &crate::config::PinnedRuntimeConfiguration, +) -> ProjectEditableSettingsV1 { + let config: &TraceDecayConfig = &configuration.config; + ProjectEditableSettingsV1 { + include: config.include.clone(), + exclude: config.exclude.clone(), + max_file_size: config.max_file_size, + extract_docstrings: config.extract_docstrings, + track_call_sites: config.track_call_sites, + git_ignore: config.git_ignore, + telemetry: TelemetrySettingsV1 { + timings: config.telemetry.timings, + }, + sync: SyncSettingsV1 { + auto_track_pr_branches: config.sync.auto_track_pr_branches, + auto_track_pr_poll_secs: config.sync.auto_track_pr_poll_secs, + }, + context_scout: context_scout_settings_are_enabled(&effective_context_scout_settings( + &configuration.snapshot, + )), } } @@ -274,6 +282,7 @@ pub async fn patch_project_settings( auto_track_pr_branches: sync.auto_track_pr_branches, auto_track_pr_poll_secs: sync.auto_track_pr_poll_secs, }), + context_scout: patch.context_scout, }, ) .map_err(project_preview_error)?; @@ -407,7 +416,7 @@ async fn settings_envelope( .as_str() .to_owned(), configuration_revision_id: project_configuration.revision_id.as_str().to_owned(), - config: ProjectEditableSettingsV1::from(&project_configuration.config), + config: project_editable_settings(&project_configuration), tracedecay_dir_gitignored: crate::config::is_in_gitignore(&state.project_root), pr_autotrack: pr_autotrack_payload(state), }, diff --git a/crates/tracedecay-usecases/src/settings_control.rs b/crates/tracedecay-usecases/src/settings_control.rs index 1a01c00f83..303a2b16aa 100644 --- a/crates/tracedecay-usecases/src/settings_control.rs +++ b/crates/tracedecay-usecases/src/settings_control.rs @@ -8,11 +8,12 @@ use serde::Deserialize; use tracedecay_domain::ProjectId; use tracedecay_domain::configuration::{ - ConfigurationLayerIdV1, ConfigurationRevisionId, ConfigurationValueV1, - INDEX_EXCLUDE_SETTING_KEY, INDEX_EXTRACT_DOCSTRINGS_SETTING_KEY, INDEX_GIT_IGNORE_SETTING_KEY, - INDEX_INCLUDE_SETTING_KEY, INDEX_MAX_FILE_SIZE_SETTING_KEY, INDEX_TRACK_CALL_SITES_SETTING_KEY, - SYNC_AUTO_TRACK_PR_BRANCHES_SETTING_KEY, SYNC_AUTO_TRACK_PR_POLL_SECS_SETTING_KEY, SettingKey, - TELEMETRY_TIMINGS_SETTING_KEY, + CONTEXT_SCOUT_SETTINGS_SETTING_KEY, ConfigurationLayerIdV1, ConfigurationRevisionId, + ConfigurationSnapshotV1, ConfigurationValueV1, ContextScoutConfigurationStateV1, + ContextScoutSettingsV1, INDEX_EXCLUDE_SETTING_KEY, INDEX_EXTRACT_DOCSTRINGS_SETTING_KEY, + INDEX_GIT_IGNORE_SETTING_KEY, INDEX_INCLUDE_SETTING_KEY, INDEX_MAX_FILE_SIZE_SETTING_KEY, + INDEX_TRACK_CALL_SITES_SETTING_KEY, SYNC_AUTO_TRACK_PR_BRANCHES_SETTING_KEY, + SYNC_AUTO_TRACK_PR_POLL_SECS_SETTING_KEY, SettingKey, TELEMETRY_TIMINGS_SETTING_KEY, }; use tracedecay_application::{ProjectSettingsPatchInputV1, validate_project_settings_patch}; @@ -42,6 +43,27 @@ pub struct ProjectSettingsPatchV1 { pub telemetry: Option, #[serde(default)] pub sync: Option, + #[serde(default)] + pub context_scout: Option, +} + +/// The effective Plan 20 Context Scout value in one configuration snapshot. +/// The registry always resolves this key; a snapshot predating the key reads +/// as the canonical stock state, which is disabled. +pub fn effective_context_scout_settings( + snapshot: &ConfigurationSnapshotV1, +) -> ContextScoutSettingsV1 { + SettingKey::new(CONTEXT_SCOUT_SETTINGS_SETTING_KEY) + .ok() + .and_then(|key| match snapshot.effective_values.get(&key) { + Some(ConfigurationValueV1::ContextScoutSettings(settings)) => Some(settings.clone()), + _ => None, + }) + .unwrap_or_else(ContextScoutSettingsV1::disabled) +} + +pub fn context_scout_settings_are_enabled(settings: &ContextScoutSettingsV1) -> bool { + settings.state != ContextScoutConfigurationStateV1::Disabled } #[derive(Debug, Clone, Deserialize, Default)] @@ -110,6 +132,7 @@ pub fn preview_project_settings( let layer = ConfigurationLayerIdV1::Project { project_id: project_id.clone(), }; + let current_context_scout = effective_context_scout_settings(¤t.snapshot); let expected_is_current = expected_revision == current.revision_id; let supplied_values_are_current = patch .include @@ -142,6 +165,9 @@ pub fn preview_project_settings( && sync .auto_track_pr_poll_secs .is_none_or(|value| value == current.config.sync.auto_track_pr_poll_secs) + }) + && patch.context_scout.is_none_or(|value| { + value == context_scout_settings_are_enabled(¤t_context_scout) }); let mut mutations = Vec::new(); push( @@ -204,6 +230,22 @@ pub fn preview_project_settings( .map(ConfigurationValueV1::Unsigned), )?; } + // The dashboard flag is only a state toggle: mode, limits, and model + // selection stay exactly as configured, and disabling never erases them. + push( + &mut mutations, + &layer, + CONTEXT_SCOUT_SETTINGS_SETTING_KEY, + patch.context_scout.map(|enabled| { + let mut settings = current_context_scout.clone(); + settings.state = if enabled { + ContextScoutConfigurationStateV1::Active + } else { + ContextScoutConfigurationStateV1::Disabled + }; + ConfigurationValueV1::ContextScoutSettings(settings) + }), + )?; if mutations.is_empty() && !expected_is_current { return Err(ProjectSettingsPreviewErrorV1::RevisionConflict { expected: expected_revision.as_str().to_owned(), @@ -303,4 +345,82 @@ mod tests { assert_eq!(mutations.len(), 2); assert_eq!(current.config.max_file_size, 1_048_576); } + + #[test] + fn context_scout_flag_toggles_only_the_state_of_the_effective_value() { + let project_id = ProjectId::new("project.settings.scout").unwrap(); + let revision = ConfigurationRevisionId::new("configuration.revision.scout").unwrap(); + let snapshot = ConfigurationSnapshotV1::new( + BTreeMap::new(), + BTreeMap::>::new(), + ) + .unwrap(); + let current = PinnedRuntimeConfiguration { + target: crate::config::RuntimeConfigurationTarget { + project_id: project_id.clone(), + project_root: PathBuf::from("/project"), + }, + revision_id: revision.clone(), + snapshot, + config: crate::config::TraceDecayConfig::default(), + }; + // A snapshot without the key renders the canonical stock state: off. + let current_settings = effective_context_scout_settings(¤t.snapshot); + assert_eq!(current_settings, ContextScoutSettingsV1::disabled()); + assert!(!context_scout_settings_are_enabled(¤t_settings)); + + // Re-submitting the current state plans no mutation. + let unchanged = preview_project_settings( + &project_id, + ¤t, + ProjectSettingsPatchV1 { + expected_revision_id: revision.as_str().to_owned(), + context_scout: Some(false), + ..ProjectSettingsPatchV1::default() + }, + ) + .unwrap(); + assert!(!unchanged.changed); + + let preview = preview_project_settings( + &project_id, + ¤t, + ProjectSettingsPatchV1 { + expected_revision_id: revision.as_str().to_owned(), + context_scout: Some(true), + ..ProjectSettingsPatchV1::default() + }, + ) + .unwrap(); + assert!(preview.changed); + let DirectConfigurationMutation::Batch { mutations } = preview.mutation else { + panic!("project settings must be atomic") + }; + let [DirectConfigurationMutation::Set { key, value, .. }] = mutations.as_slice() else { + panic!("the flag must plan exactly one typed Set") + }; + assert_eq!(key.as_str(), CONTEXT_SCOUT_SETTINGS_SETTING_KEY); + let ConfigurationValueV1::ContextScoutSettings(settings) = value.as_ref() else { + panic!("the flag must write the typed Context Scout value") + }; + assert_eq!(settings.state, ContextScoutConfigurationStateV1::Active); + // Only the state toggles; mode, limits, and model fields are kept. + assert_eq!( + ( + settings.mode, + settings.limits, + settings.model_path, + settings.model_id.as_deref(), + settings.model_timeout_secs, + ), + ( + ContextScoutSettingsV1::disabled().mode, + ContextScoutSettingsV1::disabled().limits, + None, + None, + None, + ) + ); + settings.validate().expect("planned value stays canonical"); + } } diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 4bb7dcec48..3b09f93396 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -15284,6 +15284,10 @@ }, "ProjectEditableSettingsV1": { "properties": { + "context_scout": { + "description": "Rendered from the effective Plan 20 `context_scout.settings.v1` value;\nthe dashboard holds no Scout state of its own.", + "type": "boolean" + }, "exclude": { "items": { "type": "string" @@ -15325,7 +15329,8 @@ "track_call_sites", "git_ignore", "telemetry", - "sync" + "sync", + "context_scout" ], "type": "object" }, @@ -15481,6 +15486,12 @@ "additionalProperties": false, "description": "Project-scoped settings patch accepted by `PATCH /api/settings/project`.", "properties": { + "context_scout": { + "type": [ + "boolean", + "null" + ] + }, "exclude": { "items": { "type": "string" diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 8669e70d19..214748c653 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -3554,6 +3554,7 @@ export const ProjectContextPayloadV1Schema = z.object({ export type ProjectContextPayloadV1 = z.infer; export const ProjectEditableSettingsV1Schema = z.object({ + context_scout: z.boolean(), exclude: z.array(z.string()), extract_docstrings: z.boolean(), git_ignore: z.boolean(), @@ -3611,6 +3612,7 @@ export type ProjectRepoGroup = z.infer; /** Project-scoped settings patch accepted by `PATCH /api/settings/project`. */ export const ProjectSettingsPatchSchema = z.object({ + context_scout: z.boolean().nullable().optional(), exclude: z.array(z.string()).nullable().optional(), expected_revision_id: z.string(), extract_docstrings: z.boolean().nullable().optional(), diff --git a/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx b/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx index 2473c7d9ce..14c4216df6 100644 --- a/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/AnalyticsControls.dom.test.tsx @@ -290,6 +290,7 @@ function settingsPayload() { exclude: [], extract_docstrings: true, git_ignore: true, + context_scout: false, include: [], max_file_size: 1_048_576, sync: { auto_track_pr_branches: false, auto_track_pr_poll_secs: 300 }, diff --git a/dashboard/src/workspaces/settings/SettingsFields.tsx b/dashboard/src/workspaces/settings/SettingsFields.tsx index f71e744f02..50029b217b 100644 --- a/dashboard/src/workspaces/settings/SettingsFields.tsx +++ b/dashboard/src/workspaces/settings/SettingsFields.tsx @@ -109,6 +109,12 @@ export function ProjectSettingsFields({ error={errorFor(errors, 'auto_track_pr_branches')} onChange={(checked) => onChange({ ...values, auto_track_pr_branches: checked })} /> + onChange({ ...values, context_scout: checked })} + /> {writable.state === 'writable' ? (