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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/tracedecay-api/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub struct ProjectSettingsPatch {
pub telemetry: Option<TelemetrySettingsPatch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync: Option<SyncSettingsPatch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_scout: Option<bool>,
}

/// Nested synchronization settings patch.
Expand Down
51 changes: 30 additions & 21 deletions crates/tracedecay-dashboard-api/src/settings_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
)),
}
}

Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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),
},
Expand Down
130 changes: 125 additions & 5 deletions crates/tracedecay-usecases/src/settings_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -42,6 +43,27 @@ pub struct ProjectSettingsPatchV1 {
pub telemetry: Option<TelemetrySettingsPatchV1>,
#[serde(default)]
pub sync: Option<SyncSettingsPatchV1>,
#[serde(default)]
pub context_scout: Option<bool>,
}

/// 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)]
Expand Down Expand Up @@ -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(&current.snapshot);
let expected_is_current = expected_revision == current.revision_id;
let supplied_values_are_current = patch
.include
Expand Down Expand Up @@ -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(&current_context_scout)
});
let mut mutations = Vec::new();
push(
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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::<SettingKey, Vec<ConfigurationCandidateV1>>::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(&current.snapshot);
assert_eq!(current_settings, ContextScoutSettingsV1::disabled());
assert!(!context_scout_settings_are_enabled(&current_settings));

// Re-submitting the current state plans no mutation.
let unchanged = preview_project_settings(
&project_id,
&current,
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,
&current,
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");
}
}
13 changes: 12 additions & 1 deletion dashboard/codegen/schemas/dashboard-contracts.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions dashboard/src/contracts/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/workspaces/settings/SettingsFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export function ProjectSettingsFields({
error={errorFor(errors, 'auto_track_pr_branches')}
onChange={(checked) => onChange({ ...values, auto_track_pr_branches: checked })}
/>
<SettingsCheckbox
label="Context Scout suggestions"
checked={values.context_scout}
error={errorFor(errors, 'context_scout')}
onChange={(checked) => onChange({ ...values, context_scout: checked })}
/>
</div>
{writable.state === 'writable' ? (
<button type="button" className={`${settingsButtonClass} mt-3`} onClick={onReview}>
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/workspaces/settings/settingsEditorMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ function sameProjectValues(
left.extract_docstrings === right.extract_docstrings &&
left.track_call_sites === right.track_call_sites &&
left.git_ignore === right.git_ignore &&
left.context_scout === right.context_scout &&
left.telemetry_timings === right.telemetry_timings &&
left.auto_track_pr_branches === right.auto_track_pr_branches &&
left.auto_track_pr_poll_secs === right.auto_track_pr_poll_secs
Expand Down
7 changes: 4 additions & 3 deletions dashboard/src/workspaces/settings/settingsModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ describe('Settings read model', () => {
const config = project?.rows.find((row) => row.id === 'config');
expect(config?.kind).toBe('group');
// include, exclude, max_file_size, extract_docstrings, track_call_sites,
// git_ignore, telemetry.timings, sync.auto_track_pr_branches,
// sync.auto_track_pr_poll_secs
expect(config?.count).toBe(9);
// git_ignore, context_scout, telemetry.timings,
// sync.auto_track_pr_branches, sync.auto_track_pr_poll_secs
expect(config?.count).toBe(10);
expect(countSettings(project?.rows ?? [])).toBe(project?.settingCount);
});

Expand Down Expand Up @@ -325,6 +325,7 @@ describe('Settings authorized changes', () => {
extract_docstrings: true,
track_call_sites: true,
git_ignore: true,
context_scout: false,
telemetry_timings: false,
auto_track_pr_branches: true,
auto_track_pr_poll_secs: '120',
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/workspaces/settings/settingsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export interface ProjectSettingsValues {
readonly extract_docstrings: boolean;
readonly track_call_sites: boolean;
readonly git_ignore: boolean;
readonly context_scout: boolean;
readonly telemetry_timings: boolean;
readonly auto_track_pr_branches: boolean;
readonly auto_track_pr_poll_secs: string;
Expand Down Expand Up @@ -168,6 +169,7 @@ export interface ProjectSettingsChangeSet {
extract_docstrings?: boolean;
track_call_sites?: boolean;
git_ignore?: boolean;
context_scout?: boolean;
telemetry?: { timings?: boolean };
sync?: {
auto_track_pr_branches?: boolean;
Expand Down Expand Up @@ -384,6 +386,7 @@ export function buildSettingsEditor(payload: SettingsPayloadV1): SettingsEditor
extract_docstrings: config.extract_docstrings,
track_call_sites: config.track_call_sites,
git_ignore: config.git_ignore,
context_scout: config.context_scout,
telemetry_timings: config.telemetry.timings,
auto_track_pr_branches: config.sync.auto_track_pr_branches,
auto_track_pr_poll_secs: pollSecs,
Expand Down Expand Up @@ -446,7 +449,12 @@ export function planProjectChangeAgainst(
if (maxFileSize !== Number(current.project.max_file_size)) {
patch.max_file_size = maxFileSize;
}
for (const field of ['extract_docstrings', 'track_call_sites', 'git_ignore'] as const) {
for (const field of [
'extract_docstrings',
'track_call_sites',
'git_ignore',
'context_scout',
] as const) {
if (values[field] !== current.project[field]) patch[field] = values[field];
}
if (values.telemetry_timings !== current.project.telemetry_timings) {
Expand Down
Loading
Loading