diff --git a/docs/features/health-and-logs.md b/docs/features/health-and-logs.md index f0db77f..4c4815f 100644 --- a/docs/features/health-and-logs.md +++ b/docs/features/health-and-logs.md @@ -33,7 +33,23 @@ How BuildMonitor decides what failed and what the tray, status panel, and log vi During large builds MSBuild can emit thousands of lines. Parsing issue counts and refreshing the tray on every line starves the UI thread. -**Builds and runs execute on thread-pool / process output threads**, not the WPF dispatcher. Auto-start and settings apply call `ApplySettingsAndStartAsync` via `Task.Run` so `dotnet build` continuations do not marshal back to the UI thread (manual **Rebuild** from the tray already used this pattern). +**Builds and runs execute on thread-pool / process output threads**, not the WPF dispatcher. Auto-start and **Local-affecting** settings apply call `ApplySettingsAndStartAsync` via `Task.Run` so `dotnet build` continuations do not marshal back to the UI thread (manual **Rebuild** from the tray already used this pattern). + +Settings Save is classified by `SettingsApplyImpactClassifier` using the exhaustive +`SettingsApplyImpactCatalog` (every persisted leaf path under `AppSettings`): + +| Impact | Example | StopAll + StartActive (may build) | +|--------|---------|-------------------------------------| +| Presentation | Tray menu layout, theme, toasts, VD follow | No | +| SoftRuntime | Monitor, Azure, display name, test/restart/build-control policies, Local UI prefs | No (orchestrator `UpdateDefinition` only) | +| HardRestart | Local Id/active, RootFolder/ProjectFile/launch/args, RunMode, WatchExcludeSegments | Yes | +| None | Identical save / schema version only | No | + +Azure-only project add/active toggles are **SoftRuntime** (not HardRestart). Presentation-only saves still refresh the tray menu immediately; they must not schedule a Local rebuild. + +**HardRestart is reserved for settings that invalidate the live Local process/watcher context.** Policy knobs read on the next crash/build/test (RunTests, TestProjectFile, restart flags, BuildControlMode, FileChanges, lock/repair) are SoftRuntime. There is no separate “restart process without rebuild” apply path yet — changing RootFolder/ProjectFile/RunMode still uses StopAll + StartActive (may build when StartOnLaunch is on). + +Coverage: `SettingsApplyImpactClassifierTests.Catalog_covers_every_discovered_persisted_leaf_path` fails if a new persisted property is added without a catalog entry. Mutation theories assert each catalog path yields its declared impact. The tray uses WinForms `NotifyIcon` with `ContextMenuStrip` assigned directly (same as `main`). Health snapshots are coalesced in `HealthCoalescer` (~250 ms) on a background thread. The UI applies them via a single coalesced `Dispatcher.BeginInvoke(Normal)` pass so the tray stays in step with build toasts: tray icon always updates; hover panel updates only when visible; toasts and sounds are skipped while the tray menu is open. `HealthCoalescer` also pauses publish while the menu is open. Agent-tooling folder activity marks health dirty for the next coalesce tick (not an immediate publish) so Cursor writes do not flood the UI; lifecycle and meaningful source saves still request immediate coalesce. diff --git a/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs b/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs new file mode 100644 index 0000000..22feeea --- /dev/null +++ b/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs @@ -0,0 +1,499 @@ +using System.Collections; +using System.Reflection; +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Tests; + +public sealed class SettingsApplyImpactClassifierTests +{ + [Fact] + public void Catalog_covers_every_discovered_persisted_leaf_path() + { + var discovered = SettingsPersistedPropertyDiscovery.DiscoverLeafPaths(); + var catalogued = SettingsApplyImpactCatalog.Paths; + + var missingFromCatalog = discovered.Where(p => !catalogued.Contains(p)).ToList(); + var extraInCatalog = catalogued.Where(p => !discovered.Contains(p)).ToList(); + + Assert.True( + missingFromCatalog.Count == 0, + "Persisted settings missing from SettingsApplyImpactCatalog (add an Entry):\n" + + string.Join("\n", missingFromCatalog)); + Assert.True( + extraInCatalog.Count == 0, + "Catalog paths not discovered on AppSettings (remove or fix path):\n" + + string.Join("\n", extraInCatalog)); + } + + [Fact] + public void Catalog_has_no_duplicate_paths() + { + var dupes = SettingsApplyImpactCatalog.All + .GroupBy(e => e.Path, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + Assert.True(dupes.Count == 0, "Duplicate catalog paths: " + string.Join(", ", dupes)); + } + + [Fact] + public void Identical_settings_are_none_and_do_not_restart() + { + var settings = SampleSettings(); + var plan = SettingsApplyImpactClassifier.CreatePlan(settings, Clone(settings)); + Assert.Equal(SettingsApplyImpact.None, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.ApplyOrchestratorSettings); + Assert.False(plan.ShowProjectsStartingToast); + } + + [Fact] + public void TrayMenuLayout_only_is_presentation_with_zero_restarts() + { + var before = SampleSettings(); + var after = Clone(before); + after.AppBehavior.TrayMenuLayout = TrayMenuLayout.ByProject; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.Presentation, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.ApplyOrchestratorSettings); + Assert.False(plan.ResetHealthTransitionState); + Assert.False(plan.ShowProjectsStartingToast); + } + + [Fact] + public void Theme_only_is_presentation() + { + var before = SampleSettings(); + var after = Clone(before); + after.AppBehavior.Theme = AppThemePreference.Dark; + + Assert.Equal( + SettingsApplyImpact.Presentation, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + [Fact] + public void Azure_attachment_only_is_soft_runtime_without_local_restart() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].Azure = new AzureDevOpsProjectAttachment + { + ConnectionId = "c1", + AdoProjectId = "p1", + AdoProjectName = "P", + RepositoryId = "r1", + RepositoryName = "Repo" + }; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.True(plan.ApplyOrchestratorSettings); + Assert.False(plan.ShowProjectsStartingToast); + } + + [Fact] + public void Adding_azure_only_project_is_soft_runtime_not_hard_restart() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects.Add(new MonitoredProjectSettings + { + Id = "azure-only", + DisplayName = "Azure only", + IsActiveInSession = true, + Local = null, + Azure = new AzureDevOpsProjectAttachment + { + ConnectionId = "c1", + AdoProjectId = "p1", + AdoProjectName = "P", + RepositoryId = "r1", + RepositoryName = "Repo" + } + }); + + Assert.Equal( + SettingsApplyImpact.SoftRuntime, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + [Fact] + public void Monitor_debounce_only_is_soft_runtime() + { + var before = SampleSettings(); + var after = Clone(before); + after.Monitor.FileChangeDebounceMs = 9_000; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + } + + [Fact] + public void Local_project_file_change_is_hard_restart() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].Local!.ProjectFile = "Other.csproj"; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact); + Assert.True(plan.StopAllAndRestartActiveProjects); + Assert.True(plan.ApplyOrchestratorSettings); + Assert.True(plan.ShowProjectsStartingToast); + } + + [Fact] + public void Active_session_toggle_on_local_project_is_hard_restart() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].IsActiveInSession = false; + + Assert.Equal( + SettingsApplyImpact.HardRestart, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + [Fact] + public void Active_session_toggle_on_azure_only_project_is_soft_runtime() + { + var before = SampleSettings(); + before.Projects.Add(new MonitoredProjectSettings + { + Id = "azure-only", + DisplayName = "Azure only", + IsActiveInSession = true, + Local = null, + Azure = new AzureDevOpsProjectAttachment + { + ConnectionId = "c1", + AdoProjectId = "p1", + AdoProjectName = "P", + RepositoryId = "r1", + RepositoryName = "Repo" + } + }); + var after = Clone(before); + after.Projects.Single(p => p.Id == "azure-only").IsActiveInSession = false; + + Assert.Equal( + SettingsApplyImpact.SoftRuntime, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + [Fact] + public void Local_ui_preference_auto_open_log_is_soft_runtime() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].Local!.RunOptions.AutoOpenLog = AutoOpenLogMode.Errors; + + Assert.Equal( + SettingsApplyImpact.SoftRuntime, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + [Fact] + public void Ai_controlled_mode_change_is_soft_runtime() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].Local!.BuildControlMode = ProjectBuildControlMode.AiControlled; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.True(plan.ApplyOrchestratorSettings); + } + + [Fact] + public void RunTests_and_TestProjectFile_are_soft_runtime_without_local_rebuild() + { + var before = SampleSettings(); + var afterTests = Clone(before); + afterTests.Projects[0].Local!.RunOptions.RunTests = TestRunTrigger.OnBuildSuccess; + Assert.Equal( + SettingsApplyImpact.SoftRuntime, + SettingsApplyImpactClassifier.Classify(before, afterTests)); + + var afterTarget = Clone(before); + afterTarget.Projects[0].Local!.TestProjectFile = "Other.Tests.csproj"; + Assert.Equal( + SettingsApplyImpact.SoftRuntime, + SettingsApplyImpactClassifier.Classify(before, afterTarget)); + } + + [Fact] + public void Restart_policy_flags_are_soft_runtime() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].Local!.RunOptions.RestartOnCrash = true; + after.Projects[0].Local!.RunOptions.MaxRestartRetries = 9; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + } + + [Fact] + public void RunMode_change_is_hard_restart() + { + var before = SampleSettings(); + // Default RunMode is Watch — flip to Run so the hard fingerprint changes. + Assert.Equal(ProjectRunMode.Watch, before.Projects[0].Local!.RunOptions.RunMode); + var after = Clone(before); + after.Projects[0].Local!.RunOptions.RunMode = ProjectRunMode.Run; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact); + Assert.True(plan.StopAllAndRestartActiveProjects); + } + + [Fact] + public void Watch_exclude_segments_change_is_hard_restart() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].Local!.RunOptions.WatchExcludeSegments = "bin;obj;custom"; + + Assert.Equal( + SettingsApplyImpact.HardRestart, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + [Fact] + public void Null_before_is_hard_restart_like_cold_start() + { + Assert.Equal( + SettingsApplyImpact.HardRestart, + SettingsApplyImpactClassifier.Classify(null, SampleSettings())); + } + + [Fact] + public void Display_name_only_is_soft_runtime() + { + var before = SampleSettings(); + var after = Clone(before); + after.Projects[0].DisplayName = "Renamed"; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); + Assert.False(plan.StopAllAndRestartActiveProjects); + } + + [Fact] + public void Schema_version_only_is_none() + { + var before = SampleSettings(); + var after = Clone(before); + after.SchemaVersion = 99; + + Assert.Equal( + SettingsApplyImpact.None, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + public static TheoryData CatalogMutationCases() + { + var data = new TheoryData(); + foreach (var entry in SettingsApplyImpactCatalog.All) + { + // IsActiveInSession is Hard when Local exists; Soft for Azure-only — tested separately. + if (entry.Path == "Projects[].IsActiveInSession") + { + continue; + } + + // SchemaVersion alone → None (catalog Impact None) + data.Add(entry.Path, entry.Impact); + } + + return data; + } + + [Theory] + [MemberData(nameof(CatalogMutationCases))] + public void Mutating_each_catalog_path_yields_declared_impact(string path, SettingsApplyImpact expected) + { + var before = RichSampleSettings(); + var after = Clone(before); + SettingsPathMutator.Mutate(after, path); + + var actual = SettingsApplyImpactClassifier.Classify(before, after); + Assert.Equal(expected, actual); + } + + [Fact] + public void Every_hard_restart_catalog_entry_has_non_empty_rationale() + { + foreach (var entry in SettingsApplyImpactCatalog.All.Where(e => e.Impact == SettingsApplyImpact.HardRestart)) + { + Assert.False(string.IsNullOrWhiteSpace(entry.Rationale), entry.Path); + } + } + + private static AppSettings SampleSettings() => new() + { + Projects = + [ + new MonitoredProjectSettings + { + Id = "proj1", + DisplayName = "WitherbyConnect (main)", + IsActiveInSession = true, + Local = new LocalProjectAttachment + { + RootFolder = @"C:\src\WitherbyConnectDotNet9", + ProjectFile = "WitherbyConnect.csproj", + BuildControlMode = ProjectBuildControlMode.FileWatching, + StartOnLaunch = true + } + } + ], + Monitor = new GlobalMonitorSettings(), + AppBehavior = new AppBehaviorSettings + { + TrayMenuLayout = TrayMenuLayout.ByOperation + } + }; + + /// Fixture with Local + Azure + connection so every catalog path can be mutated. + private static AppSettings RichSampleSettings() + { + var settings = SampleSettings(); + settings.Connections = + [ + new AzureDevOpsConnectionSettings + { + Id = "conn1", + DisplayName = "Org", + OrganizationUrl = "https://dev.azure.com/org" + } + ]; + settings.Projects[0].Azure = new AzureDevOpsProjectAttachment + { + ConnectionId = "conn1", + AdoProjectId = "p1", + AdoProjectName = "P", + RepositoryId = "r1", + RepositoryName = "Repo", + RepositoryRemoteUrl = "https://dev.azure.com/org/P/_git/Repo", + DefaultBranch = "main", + ExtraWatchedBranches = ["develop"], + Pipelines = + [ + new AzurePipelineSelection + { + DefinitionId = 1, + DisplayName = "CI", + IncludedBranches = ["main"], + NotificationMode = NotificationMode.FailuresAndRecovery, + Priority = 1 + } + ] + }; + return settings; + } + + private static AppSettings Clone(AppSettings source) => + System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(source)) + ?? new AppSettings(); +} + +/// Mutates a single catalog path on an graph for coverage tests. +internal static class SettingsPathMutator +{ + public static void Mutate(AppSettings settings, string path) + { + var segments = path.Split('.'); + MutateAt(settings, segments, 0); + } + + private static void MutateAt(object target, string[] segments, int index) + { + var raw = segments[index]; + var isList = raw.EndsWith("[]", StringComparison.Ordinal); + var name = isList ? raw[..^2] : raw; + var prop = target.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing property {name} on {target.GetType().Name} for path {string.Join('.', segments)}"); + + if (index == segments.Length - 1) + { + if (isList) + { + MutateStringList(prop.GetValue(target)); + return; + } + + SetAlteredValue(target, prop); + return; + } + + if (isList) + { + var list = prop.GetValue(target) as IList + ?? throw new InvalidOperationException($"Expected list at {name}"); + if (list.Count == 0) + { + throw new InvalidOperationException($"Empty list at {name}; enrich fixture."); + } + + MutateAt(list[0]!, segments, index + 1); + return; + } + + var child = prop.GetValue(target) + ?? throw new InvalidOperationException($"Null child at {name}; enrich fixture."); + MutateAt(child, segments, index + 1); + } + + private static void MutateStringList(object? listObj) + { + if (listObj is not IList list) + { + throw new InvalidOperationException("Expected string list."); + } + + list.Add("mutated-branch-" + Guid.NewGuid().ToString("N")[..8]); + } + + private static void SetAlteredValue(object target, PropertyInfo prop) + { + var type = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; + object? next; + if (type == typeof(string)) + { + next = (prop.GetValue(target) as string ?? "") + "-x"; + } + else if (type == typeof(bool)) + { + next = !(bool)(prop.GetValue(target) ?? false); + } + else if (type == typeof(int)) + { + next = (int)(prop.GetValue(target) ?? 0) + 1; + } + else if (type.IsEnum) + { + var values = Enum.GetValues(type); + var current = prop.GetValue(target) ?? values.GetValue(0)!; + var i = Array.IndexOf(values, current); + next = values.GetValue((i + 1) % values.Length)!; + } + else + { + throw new InvalidOperationException($"Unsupported leaf type {type.Name}"); + } + + prop.SetValue(target, next); + } +} diff --git a/src/BuildMonitor.Tests/SettingsPersistedPropertyDiscovery.cs b/src/BuildMonitor.Tests/SettingsPersistedPropertyDiscovery.cs new file mode 100644 index 0000000..17be389 --- /dev/null +++ b/src/BuildMonitor.Tests/SettingsPersistedPropertyDiscovery.cs @@ -0,0 +1,122 @@ +using System.Collections; +using System.Reflection; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Tests; + +/// +/// Discovers persisted settings leaf paths for coverage against . +/// +public static class SettingsPersistedPropertyDiscovery +{ + private static readonly HashSet SkipTypeNames = + [ + nameof(LegacyAppSettingsV20), + nameof(LegacyFlatProjectSettings) + ]; + + public static IReadOnlyList DiscoverLeafPaths() + { + var paths = new List(); + Walk(typeof(AppSettings), prefix: "", paths); + return paths.OrderBy(p => p, StringComparer.Ordinal).ToList(); + } + + private static void Walk(Type type, string prefix, List paths) + { + if (SkipTypeNames.Contains(type.Name)) + { + return; + } + + foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (prop.GetIndexParameters().Length > 0 || prop.GetMethod is null) + { + continue; + } + + // Computed / non-persisted + if (!prop.CanWrite && prop.Name is nameof(MonitoredProjectSettings.ListLabel)) + { + continue; + } + + if (!prop.CanWrite && prop.PropertyType != typeof(string) && !IsCollection(prop.PropertyType)) + { + // get-only non-collection (e.g. ListLabel already skipped) + continue; + } + + var path = string.IsNullOrEmpty(prefix) ? prop.Name : $"{prefix}.{prop.Name}"; + var propType = prop.PropertyType; + + if (IsSimple(propType)) + { + paths.Add(path); + continue; + } + + if (IsStringList(propType)) + { + paths.Add(path + "[]"); + continue; + } + + if (TryGetListElementType(propType, out var elementType)) + { + var elementPrefix = path + "[]"; + if (IsSimple(elementType!)) + { + paths.Add(elementPrefix); + } + else + { + Walk(elementType!, elementPrefix, paths); + } + + continue; + } + + Walk(propType, path, paths); + } + } + + private static bool IsSimple(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsEnum + || type == typeof(string) + || type == typeof(bool) + || type == typeof(int) + || type == typeof(long) + || type == typeof(double) + || type == typeof(float) + || type == typeof(decimal); + } + + private static bool IsCollection(Type type) => + type != typeof(string) && typeof(IEnumerable).IsAssignableFrom(type); + + private static bool IsStringList(Type type) => + TryGetListElementType(type, out var el) && el == typeof(string); + + private static bool TryGetListElementType(Type type, out Type? elementType) + { + elementType = null; + if (type.IsArray) + { + elementType = type.GetElementType(); + return elementType is not null; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + { + elementType = type.GetGenericArguments()[0]; + return true; + } + + return false; + } +} diff --git a/src/Core/Rules/SettingsApplyImpactCatalog.cs b/src/Core/Rules/SettingsApplyImpactCatalog.cs new file mode 100644 index 0000000..23369e7 --- /dev/null +++ b/src/Core/Rules/SettingsApplyImpactCatalog.cs @@ -0,0 +1,189 @@ +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Core.Rules; + +/// +/// Exhaustive map of persisted leaf paths → apply impact. +/// Adding a persisted property without an entry here fails the coverage test. +/// +public static class SettingsApplyImpactCatalog +{ + /// + /// Dot path under . Collections use [] + /// (e.g. Projects[].Local.RootFolder). + /// + public sealed record Entry(string Path, SettingsApplyImpact Impact, string Rationale); + + public static IReadOnlyList All { get; } = + [ + // --- root --- + new("SchemaVersion", SettingsApplyImpact.None, + "Schema marker only; not a runtime input."), + + // --- connections (Azure org; PAT is outside settings.json) --- + new("Connections[].Id", SettingsApplyImpact.SoftRuntime, + "Connection identity; Azure poller refresh only."), + new("Connections[].DisplayName", SettingsApplyImpact.SoftRuntime, + "Label for Azure UI; no Local process."), + new("Connections[].OrganizationUrl", SettingsApplyImpact.SoftRuntime, + "Azure discovery/poll target; no Local rebuild."), + + // --- projects --- + new("Projects[].Id", SettingsApplyImpact.HardRestart, + "ProjectRuntime dictionary is keyed by Id; renaming Id invalidates the live runtime mapping."), + new("Projects[].DisplayName", SettingsApplyImpact.SoftRuntime, + "UI label only."), + new("Projects[].IsActiveInSession", SettingsApplyImpact.HardRestart, + "Activating/deactivating a Local project must start/stop its runtime. " + + "Azure-only active toggles are applied via SoftRuntime when Local is null " + + "(see classifier Local vs Soft fingerprints)."), + + // --- Local attachment (build/run defining) --- + new("Projects[].Local.RootFolder", SettingsApplyImpact.HardRestart, + "Changes watched/build working directory."), + new("Projects[].Local.ProjectFile", SettingsApplyImpact.HardRestart, + "Changes which project/solution is built."), + new("Projects[].Local.LaunchProfile", SettingsApplyImpact.HardRestart, + "Changes run environment / launch profile."), + new("Projects[].Local.ExtraDotNetArgs", SettingsApplyImpact.HardRestart, + "Changes CLI args for build/run."), + new("Projects[].Local.TestProjectFile", SettingsApplyImpact.SoftRuntime, + "Test target for subsequent RunTests; UpdateDefinition adopts without Local rebuild."), + new("Projects[].Local.StartOnLaunch", SettingsApplyImpact.SoftRuntime, + "Affects next StartActive only; does not invalidate an already-running runtime."), + new("Projects[].Local.BuildControlMode", SettingsApplyImpact.SoftRuntime, + "File Watching vs AI Controlled is read live (same as control-plane mode updates)."), + new("Projects[].Local.PreferredSiteUrlScheme", SettingsApplyImpact.SoftRuntime, + "Status URL preference only; no process restart required."), + + new("Projects[].Local.RunOptions.RunMode", SettingsApplyImpact.HardRestart, + "Watch/Run/None changes the live child process model (watch host vs run vs none)."), + new("Projects[].Local.RunOptions.RestartOnCrash", SettingsApplyImpact.SoftRuntime, + "Crash-restart policy is read live from definition on exit."), + new("Projects[].Local.RunOptions.MaxRestartRetries", SettingsApplyImpact.SoftRuntime, + "Restart budget read live on crash path."), + new("Projects[].Local.RunOptions.AutoRestartOnWatchChanges", SettingsApplyImpact.SoftRuntime, + "Watch rude-edit restart policy read live."), + new("Projects[].Local.RunOptions.AutoRestartOnHotReloadRequest", SettingsApplyImpact.SoftRuntime, + "Hot-reload restart policy read live."), + new("Projects[].Local.RunOptions.RestartAppAfterRebuild", SettingsApplyImpact.SoftRuntime, + "Post-build run preference read live after the next rebuild."), + new("Projects[].Local.RunOptions.RunTests", SettingsApplyImpact.SoftRuntime, + "When tests auto-run relative to builds; next build/test uses UpdateDefinition value."), + new("Projects[].Local.RunOptions.FileChanges", SettingsApplyImpact.SoftRuntime, + "File-change rebuild trigger mode is evaluated live on each change."), + new("Projects[].Local.RunOptions.ReleaseOutputLocksBeforeBuild", SettingsApplyImpact.SoftRuntime, + "Pre-build lock release; next build path reads definition."), + new("Projects[].Local.RunOptions.ForceCompleteWarningCounts", SettingsApplyImpact.SoftRuntime, + "Log/count presentation preference; UpdateDefinition can adopt without restart."), + new("Projects[].Local.RunOptions.AutoRepairCorruptedOutput", SettingsApplyImpact.SoftRuntime, + "Build repair preference; next build path reads definition."), + new("Projects[].Local.RunOptions.WatchExcludeSegments", SettingsApplyImpact.HardRestart, + "Watcher ignore set is mounted at watcher create; Soft refresh can only add segments, not remount/remove."), + new("Projects[].Local.RunOptions.AutoOpenLog", SettingsApplyImpact.SoftRuntime, + "UI auto-open preference; App reads settings without restarting Local."), + new("Projects[].Local.RunOptions.ShowStatusPanelWhileBuilding", SettingsApplyImpact.SoftRuntime, + "Status panel visibility preference only."), + + // --- Azure attachment --- + new("Projects[].Azure.ConnectionId", SettingsApplyImpact.SoftRuntime, + "Azure poller association."), + new("Projects[].Azure.AdoProjectId", SettingsApplyImpact.SoftRuntime, + "Azure project identity for polling."), + new("Projects[].Azure.AdoProjectName", SettingsApplyImpact.SoftRuntime, + "Azure display metadata."), + new("Projects[].Azure.RepositoryId", SettingsApplyImpact.SoftRuntime, + "Azure repo identity for polling."), + new("Projects[].Azure.RepositoryName", SettingsApplyImpact.SoftRuntime, + "Azure display metadata."), + new("Projects[].Azure.RepositoryRemoteUrl", SettingsApplyImpact.SoftRuntime, + "Azure remote metadata."), + new("Projects[].Azure.DefaultBranch", SettingsApplyImpact.SoftRuntime, + "Azure focus metadata; not Local."), + new("Projects[].Azure.ExtraWatchedBranches[]", SettingsApplyImpact.SoftRuntime, + "Azure attention branches."), + new("Projects[].Azure.Pipelines[].DefinitionId", SettingsApplyImpact.SoftRuntime, + "Which pipelines are polled."), + new("Projects[].Azure.Pipelines[].DisplayName", SettingsApplyImpact.SoftRuntime, + "Pipeline label."), + new("Projects[].Azure.Pipelines[].IncludedBranches[]", SettingsApplyImpact.SoftRuntime, + "Pipeline branch filter for Azure."), + new("Projects[].Azure.Pipelines[].NotificationMode", SettingsApplyImpact.SoftRuntime, + "Azure notification preference (deferred); no Local rebuild."), + new("Projects[].Azure.Pipelines[].Priority", SettingsApplyImpact.SoftRuntime, + "Azure pipeline ordering."), + + // --- monitor --- + new("Monitor.HealthRefreshSeconds", SettingsApplyImpact.SoftRuntime, + "Health coalesce cadence."), + new("Monitor.FileChangeDebounceMs", SettingsApplyImpact.SoftRuntime, + "UpdateDefinition applies debounce to the live watcher."), + new("Monitor.FileChangeDebounceMode", SettingsApplyImpact.SoftRuntime, + "Auto/manual debounce mode via UpdateDefinition."), + new("Monitor.CoalesceWatchRebuilds", SettingsApplyImpact.SoftRuntime, + "Watch coalesce flag via UpdateDefinition."), + new("Monitor.MaxConcurrentActiveProjects", SettingsApplyImpact.SoftRuntime, + "Caps concurrent starts; does not require stopping healthy runtimes."), + new("Monitor.AutoOpenLogOnFailure", SettingsApplyImpact.SoftRuntime, + "Obsolete migrated field; retained for load compatibility."), + new("Monitor.AutoOpenBuildMonitorHealthOnStartup", SettingsApplyImpact.SoftRuntime, + "Startup UI preference for next launch; no live rebuild."), + new("Monitor.PlaySoundOnBuildError", SettingsApplyImpact.SoftRuntime, + "Toast sound preference (Monitor-hosted); Soft avoids Local restart."), + new("Monitor.PlaySoundOnBuildSuccess", SettingsApplyImpact.SoftRuntime, + "Toast sound preference; Soft avoids Local restart."), + new("Monitor.MaxLogDisplayBytes", SettingsApplyImpact.SoftRuntime, + "Log viewer display cap."), + new("Monitor.DeferStartupBuildUntilQuiet", SettingsApplyImpact.SoftRuntime, + "Suppression settings applied on UpdateDefinition / next start."), + new("Monitor.CancelSupersededBuilds", SettingsApplyImpact.SoftRuntime, + "Build cancellation policy via UpdateDefinition."), + new("Monitor.UseAgentTranscriptActivity", SettingsApplyImpact.SoftRuntime, + "Edit-gating input; UpdateDefinition / session path."), + new("Monitor.LearnFromDiagnosticsVerdicts", SettingsApplyImpact.SoftRuntime, + "Learning flag via UpdateDefinition."), + new("Monitor.ControlPlaneEnabled", SettingsApplyImpact.SoftRuntime, + "Control-plane host enable; ApplyControlPlaneHost."), + new("Monitor.ControlPlanePort", SettingsApplyImpact.SoftRuntime, + "Control-plane bind port."), + new("Monitor.ControlPlaneBusyTimeoutSeconds", SettingsApplyImpact.SoftRuntime, + "Session busy timeout defaults."), + new("Monitor.SuppressAutoBuildTests", SettingsApplyImpact.SoftRuntime, + "Test suppression default for sessions."), + + // --- app behaviour (presentation) --- + new("AppBehavior.RunOnLogon", SettingsApplyImpact.Presentation, + "Windows startup registration only."), + new("AppBehavior.StartMinimizedToTray", SettingsApplyImpact.Presentation, + "Launch UI preference."), + new("AppBehavior.Theme", SettingsApplyImpact.Presentation, + "WPF theme."), + new("AppBehavior.ToastPosition", SettingsApplyImpact.Presentation, + "Toast placement."), + new("AppBehavior.ToastDurationSeconds", SettingsApplyImpact.Presentation, + "Toast duration."), + new("AppBehavior.TrayMenuLayout", SettingsApplyImpact.Presentation, + "Tray context-menu layout."), + new("AppBehavior.FollowStatusPanelToVirtualDesktop", SettingsApplyImpact.Presentation, + "Status panel VD follow."), + new("AppBehavior.FollowBuildLogToVirtualDesktop", SettingsApplyImpact.Presentation, + "Log viewer VD follow."), + new("AppBehavior.Toasts.BuildStart", SettingsApplyImpact.Presentation, + "Toast category toggle."), + new("AppBehavior.Toasts.BuildSuccess", SettingsApplyImpact.Presentation, + "Toast category toggle."), + new("AppBehavior.Toasts.BuildFailure", SettingsApplyImpact.Presentation, + "Toast category toggle."), + new("AppBehavior.Toasts.FileChangeDetected", SettingsApplyImpact.Presentation, + "Toast category toggle."), + new("AppBehavior.Toasts.Warnings", SettingsApplyImpact.Presentation, + "Toast category toggle."), + new("AppBehavior.Toasts.Errors", SettingsApplyImpact.Presentation, + "Toast category toggle."), + new("AppBehavior.Toasts.Info", SettingsApplyImpact.Presentation, + "Toast category toggle.") + ]; + + public static IReadOnlySet Paths { get; } = + All.Select(e => e.Path).ToHashSet(StringComparer.Ordinal); +} diff --git a/src/Core/Rules/SettingsApplyImpactClassifier.cs b/src/Core/Rules/SettingsApplyImpactClassifier.cs new file mode 100644 index 0000000..30f9a3f --- /dev/null +++ b/src/Core/Rules/SettingsApplyImpactClassifier.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Core.Rules; + +/// +/// How aggressively Settings Save must touch Local project runtimes. +/// Saving Settings is never itself a reason to build — only Local-affecting diffs are. +/// +public enum SettingsApplyImpact +{ + /// No meaningful difference (or identity-only fields such as schema version). + None = 0, + + /// + /// AppBehavior / presentation-only (tray layout, theme, toasts, virtual-desktop follow, etc.). + /// Persist + refresh UI; do not stop/start Local runtimes. + /// + Presentation = 1, + + /// + /// Monitor, connections, Azure, display names, Local policy/UI prefs (tests, restart flags, + /// build-control mode, etc.) — refresh orchestrator/Azure without StopAll + StartActive. + /// + SoftRuntime = 2, + + /// + /// Local process/watcher identity (paths, launch/args, RunMode, watch excludes) or Local + /// active-session membership — stop/restart Local runtimes (may build via StartAsync when + /// StartOnLaunch is enabled). There is no restart-without-build apply path yet. + /// + HardRestart = 3 +} + +/// Actions derived from for the tray apply path. +public sealed record SettingsApplyPlan( + SettingsApplyImpact Impact, + bool StopAllAndRestartActiveProjects, + bool ApplyOrchestratorSettings, + bool ResetHealthTransitionState, + bool ShowProjectsStartingToast); + +/// +/// Classifies Settings Save diffs so presentation/Azure/monitor updates are not treated as launch. +/// Field groupings follow . +/// +public static class SettingsApplyImpactClassifier +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public static SettingsApplyImpact Classify(AppSettings? before, AppSettings after) + { + ArgumentNullException.ThrowIfNull(after); + if (before is null) + { + return SettingsApplyImpact.HardRestart; + } + + var beforeLocal = SerializeLocalHardFingerprint(before); + var afterLocal = SerializeLocalHardFingerprint(after); + if (!string.Equals(beforeLocal, afterLocal, StringComparison.Ordinal)) + { + return SettingsApplyImpact.HardRestart; + } + + var beforeSoft = SerializeSoftRuntimeFingerprint(before); + var afterSoft = SerializeSoftRuntimeFingerprint(after); + if (!string.Equals(beforeSoft, afterSoft, StringComparison.Ordinal)) + { + return SettingsApplyImpact.SoftRuntime; + } + + var beforeUi = Serialize(before.AppBehavior); + var afterUi = Serialize(after.AppBehavior); + if (!string.Equals(beforeUi, afterUi, StringComparison.Ordinal)) + { + return SettingsApplyImpact.Presentation; + } + + return SettingsApplyImpact.None; + } + + public static SettingsApplyPlan CreatePlan(AppSettings? before, AppSettings after) + { + var impact = Classify(before, after); + return impact switch + { + SettingsApplyImpact.None => new SettingsApplyPlan( + Impact: impact, + StopAllAndRestartActiveProjects: false, + ApplyOrchestratorSettings: false, + ResetHealthTransitionState: false, + ShowProjectsStartingToast: false), + SettingsApplyImpact.Presentation => new SettingsApplyPlan( + Impact: impact, + StopAllAndRestartActiveProjects: false, + ApplyOrchestratorSettings: false, + ResetHealthTransitionState: false, + ShowProjectsStartingToast: false), + SettingsApplyImpact.SoftRuntime => new SettingsApplyPlan( + Impact: impact, + StopAllAndRestartActiveProjects: false, + ApplyOrchestratorSettings: true, + ResetHealthTransitionState: false, + ShowProjectsStartingToast: false), + _ => new SettingsApplyPlan( + Impact: SettingsApplyImpact.HardRestart, + StopAllAndRestartActiveProjects: true, + ApplyOrchestratorSettings: true, + ResetHealthTransitionState: true, + ShowProjectsStartingToast: true) + }; + } + + /// + /// Local projects only: active flag + build/run-defining Local fields. + /// Azure-only projects are excluded so attaching Azure does not HardRestart. + /// + private static string SerializeLocalHardFingerprint(AppSettings settings) + { + var rows = settings.Projects + .Where(p => p.Local is not null) + .OrderBy(p => p.Id, StringComparer.Ordinal) + .Select(p => new + { + p.Id, + p.IsActiveInSession, + Local = SliceLocalHard(p.Local!) + }); + return Serialize(rows); + } + + /// + /// Monitor, connections, Azure, display names, Azure-only active flag, Local UI preferences. + /// + private static string SerializeSoftRuntimeFingerprint(AppSettings settings) + { + var rows = settings.Projects + .OrderBy(p => p.Id, StringComparer.Ordinal) + .Select(p => new + { + p.Id, + p.DisplayName, + AzureOnlyActive = p.Local is null ? p.IsActiveInSession : (bool?)null, + LocalUi = p.Local is null ? null : SliceLocalSoft(p.Local), + p.Azure + }); + return Serialize(new + { + settings.Monitor, + settings.Connections, + Projects = rows + }); + } + + private static object SliceLocalHard(LocalProjectAttachment local) => new + { + local.RootFolder, + local.ProjectFile, + local.LaunchProfile, + local.ExtraDotNetArgs, + RunOptions = new + { + local.RunOptions.RunMode, + local.RunOptions.WatchExcludeSegments + } + }; + + private static object SliceLocalSoft(LocalProjectAttachment local) => new + { + local.TestProjectFile, + local.StartOnLaunch, + local.BuildControlMode, + local.PreferredSiteUrlScheme, + RunOptions = new + { + local.RunOptions.RestartOnCrash, + local.RunOptions.MaxRestartRetries, + local.RunOptions.AutoRestartOnWatchChanges, + local.RunOptions.AutoRestartOnHotReloadRequest, + local.RunOptions.RestartAppAfterRebuild, + local.RunOptions.RunTests, + local.RunOptions.FileChanges, + local.RunOptions.ReleaseOutputLocksBeforeBuild, + local.RunOptions.AutoRepairCorruptedOutput, + local.RunOptions.AutoOpenLog, + local.RunOptions.ShowStatusPanelWhileBuilding, + local.RunOptions.ForceCompleteWarningCounts + } + }; + + private static string Serialize(T value) => + JsonSerializer.Serialize(value, JsonOptions); +} diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs index a647901..05ecd2b 100644 --- a/src/TrayApp/App.xaml.cs +++ b/src/TrayApp/App.xaml.cs @@ -163,7 +163,8 @@ protected override async void OnStartup(StartupEventArgs e) await WaitForUiIdleAsync(); } - await Task.Run(async () => await ApplySettingsAndStartAsync().ConfigureAwait(false)); + await Task.Run(async () => await ApplySettingsAndStartAsync( + SettingsApplyImpactClassifier.CreatePlan(before: null, currentSettings)).ConfigureAwait(false)); } private async Task OpenBuildMonitorHealthWhenReadyAsync() @@ -175,7 +176,7 @@ private async Task OpenBuildMonitorHealthWhenReadyAsync() } } - private async Task ApplySettingsAndStartAsync() + private async Task ApplySettingsAndStartAsync(SettingsApplyPlan plan) { if (Volatile.Read(ref exitRequested) != 0) { @@ -190,18 +191,31 @@ private async Task ApplySettingsAndStartAsync() return; } - previousProjectHealth.Clear(); - buildLifecycleToastNotifier.Reset(); - autoOpenLogSession.Reset(); - previousProjectLifecycleState.Clear(); - statusPanelAutoShownForBuild = false; - fileChangeBuildStarts.Clear(); + if (plan.ResetHealthTransitionState) + { + previousProjectHealth.Clear(); + buildLifecycleToastNotifier.Reset(); + autoOpenLogSession.Reset(); + previousProjectLifecycleState.Clear(); + statusPanelAutoShownForBuild = false; + fileChangeBuildStarts.Clear(); + } + ToastNotificationService.ApplySettings(currentSettings.AppBehavior); - await orchestrator.StopAllAsync(); - orchestrator.ApplySettings(currentSettings); - ApplyControlPlaneHost(); - if (AppLaunchPolicy.ShouldAutoStartAnyProjectsOnLaunch(currentSettings)) + if (plan.StopAllAndRestartActiveProjects) + { + await orchestrator.StopAllAsync(); + } + + if (plan.ApplyOrchestratorSettings) + { + orchestrator.ApplySettings(currentSettings); + ApplyControlPlaneHost(); + } + + if (plan.StopAllAndRestartActiveProjects + && AppLaunchPolicy.ShouldAutoStartAnyProjectsOnLaunch(currentSettings)) { await orchestrator.StartActiveProjectsAsync(CancellationToken.None).ConfigureAwait(false); } @@ -1451,6 +1465,7 @@ private async Task ShowSettingsAsync() return; } + var previousSettings = currentSettings; currentSettings = window.Settings; ThemeService.ApplyTheme(currentSettings.AppBehavior.Theme); ToastNotificationService.ApplySettings(currentSettings.AppBehavior); @@ -1459,21 +1474,43 @@ private async Task ShowSettingsAsync() ApplyThemeToUi(); RebuildTrayMenu(); - var applyVersion = Interlocked.Increment(ref settingsApplyVersion); - _ = ApplySettingsAndStartInBackgroundAsync(applyVersion); + var plan = SettingsApplyImpactClassifier.CreatePlan(previousSettings, currentSettings); + if (plan.StopAllAndRestartActiveProjects || plan.ApplyOrchestratorSettings) + { + var applyVersion = Interlocked.Increment(ref settingsApplyVersion); + _ = ApplySettingsAndStartInBackgroundAsync(applyVersion, plan); + } - ToastNotificationService.ShowIfEnabled( - "Settings saved", - "Projects with start on launch enabled are starting in the background.", - ToastKind.Success, - UserNotificationCategory.Info); + if (plan.ShowProjectsStartingToast) + { + ToastNotificationService.ShowIfEnabled( + "Settings saved", + "Projects with start on launch enabled are starting in the background.", + ToastKind.Success, + UserNotificationCategory.Info); + } + else + { + ToastNotificationService.ShowIfEnabled( + "Settings saved", + plan.Impact switch + { + SettingsApplyImpact.None => "No changes to apply.", + SettingsApplyImpact.Presentation => "Presentation settings updated.", + SettingsApplyImpact.SoftRuntime => "Runtime settings updated without rebuilding.", + _ => "Settings updated." + }, + ToastKind.Success, + UserNotificationCategory.Info); + } } - private async Task ApplySettingsAndStartInBackgroundAsync(int applyVersion) + private async Task ApplySettingsAndStartInBackgroundAsync(int applyVersion, SettingsApplyPlan plan) { try { - await Task.Run(ApplySettingsAndStartAsync).ConfigureAwait(false); + await Task.Run(async () => await ApplySettingsAndStartAsync(plan).ConfigureAwait(false)) + .ConfigureAwait(false); } catch (Exception ex) {