diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 229adf0..04bec85 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -254,7 +254,7 @@ Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layou ## Projects — start on launch -- **`startOnLaunch`** (default **true** for new projects; migrated from global `monitor.autoStartActiveProjectsOnLaunch` in schema v10) — per project. When **true** and **active in session**, the project builds and runs automatically when the app starts or after you save settings. When **false**, the project stays monitored but idle until you use **Rebuild** / **Restart** from the tray. Settings → **Projects** → select project → *Start build when app launches*. +- **`startOnLaunch`** (default **true** for new projects; migrated from global `monitor.autoStartActiveProjectsOnLaunch` in schema v10) — per project. When **true** and **active in session**, the project builds and runs automatically on **cold BuildMonitor/session startup**. It does **not** trigger a build when Settings Save remounts a Local runtime (HardRestart settings remount watcher/process without compiling). When **false**, the project stays monitored but idle until you use **Rebuild** / **Restart** from the tray. Settings → **Projects** → select project → *Start build when app launches*. - **`buildControlMode`** (schema v19; default **FileWatching**) — per project. `FileWatching` = debounced auto-build on source changes (held while control-plane busy). `AiControlled` = file watcher observes/counts only; builds require tray Rebuild or `POST /run/rebuild` / `/run/ship-check`. Settings → **Projects** → **Build control**. Wire API uses `file-watching` / `ai-controlled`. - **`preferredSiteUrlScheme`** (schema v20; default **Auto**) — per project. When the launch profile lists both HTTP and HTTPS, which URL to show/open: `Auto` (prefer HTTPS), `Https`, or `Http`. Settings → **Projects** → **Preferred site URL**. Also waits briefly for the preferred scheme before locking onto the first open port. diff --git a/docs/features/health-and-logs.md b/docs/features/health-and-logs.md index 4c4815f..38a61d8 100644 --- a/docs/features/health-and-logs.md +++ b/docs/features/health-and-logs.md @@ -38,16 +38,16 @@ During large builds MSBuild can emit thousands of lines. Parsing issue counts an Settings Save is classified by `SettingsApplyImpactClassifier` using the exhaustive `SettingsApplyImpactCatalog` (every persisted leaf path under `AppSettings`): -| Impact | Example | StopAll + StartActive (may build) | -|--------|---------|-------------------------------------| +| Impact | Example | Local action | +|--------|---------|--------------| | 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 | +| HardRestart | Local Id/active, RootFolder/ProjectFile/launch/args, RunMode, WatchExcludeSegments | Remount **affected** Local runtimes **without** `BuildAsync` | | 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). +**HardRestart invalidates live Local process/watcher context for the changed project(s) only** when practical. Remount recreates the watcher and may restart the app with `--no-build`; it never compiles solely because Settings were saved. Cold BuildMonitor startup (`before == null`) still uses `StartAsync` / StartOnLaunch startup builds. Policy knobs read on the next crash/build/test (RunTests, TestProjectFile, restart flags, BuildControlMode, FileChanges, lock/repair) remain SoftRuntime. 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. diff --git a/docs/ops/ai-controlled-build-isolation.md b/docs/ops/ai-controlled-build-isolation.md index 23bc738..a52f36f 100644 --- a/docs/ops/ai-controlled-build-isolation.md +++ b/docs/ops/ai-controlled-build-isolation.md @@ -19,7 +19,7 @@ When `buildControlMode` is **AI Controlled**, source-file changes must never ini | `TryHandleHotReloadRestartRequest` | May rebuild/restart | Blocked unless explicit agent rebuild/ship-check in progress | | Busy timeout → idle | May resume auto-build | Policy still false; no schedule started | | `/session/idle` | May resume debounce build | Does not schedule in AI Controlled | -| Startup / `StartOnLaunch` | Initial build | Allowed (not a source-change schedule) | +| Startup / `StartOnLaunch` | Initial build on **cold** BuildMonitor/session start | Allowed (not a source-change schedule). Settings HardRestart remounts never compile, including AI Controlled + StartOnLaunch. | | Manual Rebuild / Restart / Tests | Explicit | Allowed | | `/run/rebuild`, `/run/ship-check`, `/run/tests`, `/run/stop` | Explicit | Allowed | diff --git a/src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs b/src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs new file mode 100644 index 0000000..7c7e729 --- /dev/null +++ b/src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs @@ -0,0 +1,160 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Infrastructure.AzureDevOps; + +namespace BuildMonitor.Tests; + +/// Azure BUILDS row deep-link and column semantics (presentation layer). +public sealed class BuildSourcePresentationNavigationTests +{ + private static readonly DateTimeOffset Now = new(2026, 8, 28, 6, 0, 0, TimeSpan.Zero); + private const string RunUrl = "https://dev.azure.com/org/project/_build/results?buildId=491&view=results"; + + [Fact] + public void Azure_primary_row_deep_link_targets_PrimaryRun_build_results() + { + var primary = AzureRun(runId: 491, buildNumber: "20260828.3", pullRequestNumber: 185); + var facet = Facet(primary); + var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now)); + + Assert.Equal("#491", row.RunDisplay); + Assert.Equal("20260828.3", row.BuildNumberDisplay); + Assert.Equal("#185", row.PullRequestDisplay); + Assert.Equal(RunUrl, row.DeepLinkUrl); + Assert.NotEqual(row.RunDisplay, row.BuildNumberDisplay); + } + + [Fact] + public void Azure_primary_uses_PrimaryRun_not_attention_or_previous_run() + { + var primary = AzureRun(runId: 491, buildNumber: "20260828.3", pullRequestNumber: 185); + var attentionFailed = AzureRun( + runId: 488, + buildNumber: "20260828.1", + pullRequestNumber: null, + state: PipelineRunState.Completed, + result: PipelineRunResult.Failed); + var facet = Facet(primary, attentionFailed); + var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now)); + + Assert.Equal("#491", row.RunDisplay); + Assert.Equal(RunUrl, row.DeepLinkUrl); + Assert.DoesNotContain("488", row.DeepLinkUrl, StringComparison.Ordinal); + } + + [Fact] + public void Azure_non_pr_run_has_dash_pull_request_display_and_same_run_deep_link() + { + var primary = AzureRun(runId: 452, buildNumber: "20260825.13", pullRequestNumber: null, branch: "master"); + var facet = Facet(primary); + var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now)); + + Assert.Equal("—", row.PullRequestDisplay); + Assert.Contains("buildId=452", row.DeepLinkUrl!, StringComparison.Ordinal); + } + + [Fact] + public void Azure_message_mode_has_no_fabricated_deep_link() + { + var facet = new ProjectAzureHealthFacet( + AzureMonitoringAvailability.AuthRequired, + AzureCiMonitoringState.NotMonitored, + FocusBranch: "master", + PrimaryRun: null, + AttentionRuns: [], + PolledAtUtc: Now, + HasSelectedPipelines: true, + StatusMessage: "Sign in required"); + + var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now)); + + Assert.Null(row.DeepLinkUrl); + Assert.Equal("—", row.RunDisplay); + Assert.Equal("—", row.PullRequestDisplay); + } + + [Fact] + public void Local_row_has_no_azure_deep_link() + { + var snapshot = new ProjectHealthSnapshot( + ProjectId: "p1", + DisplayName: "Demo", + Health: MonitorHealth.Green, + HealthLabel: "Healthy", + State: ProjectLifecycleState.Watching, + LastExitCode: 0, + LastDuration: TimeSpan.FromSeconds(4), + LastErrorPreview: null, + ErrorCount: 0, + WarningCount: 0, + LastChangedUtc: Now, + LastBuildFinishedAtUtc: Now.AddMinutes(-1), + IsActive: true, + ProgressSteps: [], + LastBuildExitCode: 0, + Azure: Facet(AzureRun(491, "20260828.3", 185))); + var controlPlane = ControlPlaneStatusFormatter.Format(snapshot, Now); + var local = BuildSourcePresentationBuilder.TryBuildLocal(snapshot, controlPlane, Now); + + Assert.NotNull(local); + Assert.Null(local!.DeepLinkUrl); + Assert.Equal("—", local.RunDisplay); + Assert.Equal("—", local.PullRequestDisplay); + } + + [Fact] + public void Deep_link_builder_uses_build_id_not_build_number() + { + var url = AzureDevOpsDeepLinkBuilder.BuildRunResultsUrl( + "https://dev.azure.com/org", + "project", + 491); + + Assert.Contains("buildId=491", url, StringComparison.Ordinal); + Assert.DoesNotContain("20260828", url, StringComparison.Ordinal); + } + + [Fact] + public void Azure_table_row_run_url_matches_primary_run() + { + var run = AzureRun(491, "20260828.3", 185); + var tableRow = AzureStatusPresentationBuilder.ToTableRow(run, Now); + + Assert.Equal("#491", tableRow.RunDisplay); + Assert.Equal(RunUrl, tableRow.RunUrl); + Assert.Equal("#185", tableRow.PullRequestDisplay); + } + + private static AzurePipelineRunInfo AzureRun( + long runId, + string buildNumber, + int? pullRequestNumber, + string branch = "PR #185", + PipelineRunState state = PipelineRunState.InProgress, + PipelineRunResult? result = null) => + new( + DefinitionId: 8, + PipelineDisplayName: "WitherbyConnect", + RunId: runId, + BuildNumber: buildNumber, + State: state, + Result: result ?? PipelineRunResult.Unknown, + Branch: branch, + QueuedAtUtc: Now.AddMinutes(-5), + StartedAtUtc: Now.AddMinutes(-4), + FinishedAtUtc: null, + RunUrl: $"https://dev.azure.com/org/project/_build/results?buildId={runId}&view=results", + PullRequestNumber: pullRequestNumber); + + private static ProjectAzureHealthFacet Facet( + AzurePipelineRunInfo primary, + params AzurePipelineRunInfo[] attention) => + new( + AzureMonitoringAvailability.Available, + AzureCiMonitoringState.Activity, + FocusBranch: "master", + primary, + attention, + Now, + HasSelectedPipelines: true); +} diff --git a/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs b/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs index 22feeea..6246e2f 100644 --- a/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs +++ b/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs @@ -44,7 +44,7 @@ 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.TouchesLocalRuntimes); Assert.False(plan.ApplyOrchestratorSettings); Assert.False(plan.ShowProjectsStartingToast); } @@ -58,7 +58,7 @@ public void TrayMenuLayout_only_is_presentation_with_zero_restarts() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.Presentation, plan.Impact); - Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.TouchesLocalRuntimes); Assert.False(plan.ApplyOrchestratorSettings); Assert.False(plan.ResetHealthTransitionState); Assert.False(plan.ShowProjectsStartingToast); @@ -92,7 +92,7 @@ public void Azure_attachment_only_is_soft_runtime_without_local_restart() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); - Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.TouchesLocalRuntimes); Assert.True(plan.ApplyOrchestratorSettings); Assert.False(plan.ShowProjectsStartingToast); } @@ -132,7 +132,7 @@ public void Monitor_debounce_only_is_soft_runtime() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); - Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.TouchesLocalRuntimes); } [Fact] @@ -144,9 +144,9 @@ public void Local_project_file_change_is_hard_restart() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact); - Assert.True(plan.StopAllAndRestartActiveProjects); + Assert.True(plan.TouchesLocalRuntimes); Assert.True(plan.ApplyOrchestratorSettings); - Assert.True(plan.ShowProjectsStartingToast); + Assert.False(plan.ShowProjectsStartingToast); Assert.True(plan.RemountAffectedLocalProjectsWithoutBuild); } [Fact] @@ -209,7 +209,7 @@ public void Ai_controlled_mode_change_is_soft_runtime() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); - Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.TouchesLocalRuntimes); Assert.True(plan.ApplyOrchestratorSettings); } @@ -240,7 +240,7 @@ public void Restart_policy_flags_are_soft_runtime() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); - Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.TouchesLocalRuntimes); } [Fact] @@ -254,7 +254,10 @@ public void RunMode_change_is_hard_restart() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact); - Assert.True(plan.StopAllAndRestartActiveProjects); + Assert.True(plan.TouchesLocalRuntimes); + Assert.True(plan.RemountAffectedLocalProjectsWithoutBuild); + Assert.False(plan.ColdStartActiveProjectsWithBuild); + Assert.False(plan.ShowProjectsStartingToast); } [Fact] @@ -270,11 +273,14 @@ public void Watch_exclude_segments_change_is_hard_restart() } [Fact] - public void Null_before_is_hard_restart_like_cold_start() + public void Null_before_cold_start_plan_uses_startup_build_not_remount() { - Assert.Equal( - SettingsApplyImpact.HardRestart, - SettingsApplyImpactClassifier.Classify(null, SampleSettings())); + var plan = SettingsApplyImpactClassifier.CreatePlan(null, SampleSettings()); + Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact); + Assert.True(plan.ColdStartActiveProjectsWithBuild); + Assert.False(plan.RemountAffectedLocalProjectsWithoutBuild); + Assert.True(plan.ShowProjectsStartingToast); + Assert.Empty(plan.LocalRemounts); } [Fact] @@ -286,7 +292,7 @@ public void Display_name_only_is_soft_runtime() var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact); - Assert.False(plan.StopAllAndRestartActiveProjects); + Assert.False(plan.TouchesLocalRuntimes); } [Fact] @@ -312,7 +318,7 @@ public static TheoryData CatalogMutationCases() continue; } - // SchemaVersion alone → None (catalog Impact None) + // SchemaVersion alone → None (catalog Impact None) data.Add(entry.Path, entry.Impact); } @@ -497,3 +503,4 @@ private static void SetAlteredValue(object target, PropertyInfo prop) prop.SetValue(target, next); } } + diff --git a/src/BuildMonitor.Tests/SettingsLocalRemountPlannerTests.cs b/src/BuildMonitor.Tests/SettingsLocalRemountPlannerTests.cs new file mode 100644 index 0000000..3c6a06f --- /dev/null +++ b/src/BuildMonitor.Tests/SettingsLocalRemountPlannerTests.cs @@ -0,0 +1,290 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; +using BuildMonitor.Infrastructure.Diagnostics; +using BuildMonitor.Infrastructure.LocalBuild; +using BuildMonitor.Infrastructure.Services; + +namespace BuildMonitor.Tests; + +public sealed class SettingsLocalRemountPlannerTests +{ + [Fact] + public void Watch_exclude_change_is_watcher_only_for_affected_project() + { + var before = TwoProjects(); + var after = Clone(before); + after.Projects[0].Local!.RunOptions.WatchExcludeSegments = "bin;obj;custom"; + + var plans = SettingsLocalRemountPlanner.Plan(before, after); + Assert.Single(plans); + Assert.Equal(before.Projects[0].Id, plans[0].ProjectId); + Assert.Equal(LocalRemountKind.WatcherOnly, plans[0].Kind); + Assert.DoesNotContain(plans, p => p.ProjectId == before.Projects[1].Id); + } + + [Fact] + public void Launch_profile_and_args_and_run_mode_are_process_and_watcher() + { + var before = Sample(); + var afterProfile = Clone(before); + afterProfile.Projects[0].Local!.LaunchProfile = "https"; + Assert.Equal( + LocalRemountKind.ProcessAndWatcher, + Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterProfile)).Kind); + + var afterArgs = Clone(before); + afterArgs.Projects[0].Local!.ExtraDotNetArgs = "--verbosity minimal"; + Assert.Equal( + LocalRemountKind.ProcessAndWatcher, + Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterArgs)).Kind); + + var afterMode = Clone(before); + afterMode.Projects[0].Local!.RunOptions.RunMode = ProjectRunMode.None; + Assert.Equal( + LocalRemountKind.ProcessAndWatcher, + Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterMode)).Kind); + } + + [Fact] + public void Root_and_project_file_are_source_identity() + { + var before = Sample(); + var afterRoot = Clone(before); + afterRoot.Projects[0].Local!.RootFolder = @"C:\src\Other"; + Assert.Equal( + LocalRemountKind.SourceIdentity, + Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterRoot)).Kind); + + var afterProj = Clone(before); + afterProj.Projects[0].Local!.ProjectFile = "Other.csproj"; + Assert.Equal( + LocalRemountKind.SourceIdentity, + Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterProj)).Kind); + } + + [Fact] + public void Active_toggle_stop_and_mount_fresh() + { + var before = Sample(); + var afterOff = Clone(before); + afterOff.Projects[0].IsActiveInSession = false; + Assert.Equal( + LocalRemountKind.StopOnly, + Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterOff)).Kind); + + var afterOn = Clone(afterOff); + afterOn.Projects[0].IsActiveInSession = true; + Assert.Equal( + LocalRemountKind.MountFresh, + Assert.Single(SettingsLocalRemountPlanner.Plan(afterOff, afterOn)).Kind); + } + + [Fact] + public void Hard_plan_lists_only_changed_project() + { + var before = TwoProjects(); + var after = Clone(before); + after.Projects[0].Local!.LaunchProfile = "http"; + + var plan = SettingsApplyImpactClassifier.CreatePlan(before, after); + Assert.True(plan.RemountAffectedLocalProjectsWithoutBuild); + Assert.False(plan.ColdStartActiveProjectsWithBuild); + Assert.Single(plan.LocalRemounts); + Assert.Equal(before.Projects[0].Id, plan.LocalRemounts[0].ProjectId); + } + + private static AppSettings Sample() => new() + { + Projects = + [ + TestProjectFactory.LocalOnly( + displayName: "A", + id: "a", + rootFolder: @"C:\src\A", + projectFile: "A.csproj", + runOptions: new ProjectRunOptions { RunMode = ProjectRunMode.Watch }) + ] + }; + + private static AppSettings TwoProjects() + { + var s = Sample(); + s.Projects.Add(TestProjectFactory.LocalOnly( + displayName: "B", + id: "b", + rootFolder: @"C:\src\B", + projectFile: "B.csproj", + runOptions: new ProjectRunOptions { RunMode = ProjectRunMode.Watch })); + return s; + } + + private static AppSettings Clone(AppSettings settings) => + System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(settings))!; +} + +public sealed class ProjectRuntimeRemountWithoutBuildTests +{ + [Fact] + public async Task Watcher_only_remount_creates_watcher_without_build() + { + await using var scope = await RemountTestScope.CreateAsync(ProjectRunMode.None, ProjectBuildControlMode.FileWatching); + var beforeWatchers = scope.Runtime.WatcherCreateCount; + + await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.WatcherOnly, CancellationToken.None); + + Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount); + Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount); + Assert.True(scope.Runtime.WatcherCreateCount > beforeWatchers); + } + + [Fact] + public async Task Ai_controlled_source_identity_remount_with_start_on_launch_does_not_build() + { + await using var scope = await RemountTestScope.CreateAsync( + ProjectRunMode.Watch, + ProjectBuildControlMode.AiControlled); + scope.Runtime.UpdateDefinition( + TestProjectFactory.LocalOnly( + id: scope.Runtime.ProjectId, + rootFolder: scope.Root, + projectFile: "App.csproj", + buildControlMode: ProjectBuildControlMode.AiControlled, + runOptions: new ProjectRunOptions { RunMode = ProjectRunMode.Watch }), + new GlobalMonitorSettings()); + GetDefinition(scope.Runtime).Local!.StartOnLaunch = true; + + await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.SourceIdentity, CancellationToken.None); + + Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount); + Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount); + } + + [Fact] + public async Task Process_and_watcher_remount_restarts_process_path_without_build() + { + await using var scope = await RemountTestScope.CreateAsync( + ProjectRunMode.Run, + ProjectBuildControlMode.FileWatching); + SetPrivate(scope.Runtime, "lastBuildExitCode", 0); + + await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.ProcessAndWatcher, CancellationToken.None); + + Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount); + Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount); + Assert.True(scope.Runtime.ProcessStartCount >= 1); + Assert.True(scope.Runtime.WatcherCreateCount >= 1); + } + + [Fact] + public async Task Mount_fresh_does_not_build() + { + await using var scope = await RemountTestScope.CreateAsync( + ProjectRunMode.Watch, + ProjectBuildControlMode.AiControlled); + + await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.MountFresh, CancellationToken.None); + + Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount); + Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount); + } + + private static MonitoredProjectSettings GetDefinition(ProjectRuntime runtime) + { + var field = typeof(ProjectRuntime).GetField( + "projectSettings", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (MonitoredProjectSettings)field!.GetValue(runtime)!; + } + + private static void SetPrivate(object target, string name, object value) + { + var field = target.GetType().GetField( + name, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + field!.SetValue(target, value); + } + + private sealed class RemountTestScope : IAsyncDisposable + { + private RemountTestScope(ProjectRuntime runtime, string root, string logsRoot, string dataRoot) + { + Runtime = runtime; + Root = root; + this.logsRoot = logsRoot; + this.dataRoot = dataRoot; + } + + public ProjectRuntime Runtime { get; } + public string Root { get; } + private readonly string logsRoot; + private readonly string dataRoot; + + public static Task CreateAsync( + ProjectRunMode runMode, + ProjectBuildControlMode mode) + { + var root = CreateTempDir(); + var logsRoot = CreateTempDir(); + var dataRoot = CreateTempDir(); + var definition = TestProjectFactory.LocalOnly( + displayName: "RemountProbe", + rootFolder: root, + projectFile: "App.csproj", + buildControlMode: mode, + runOptions: new ProjectRunOptions + { + RunMode = runMode, + FileChanges = FileChangeMode.TriggerRebuild + }); + definition.Local!.StartOnLaunch = true; + + var runtime = new ProjectRuntime( + definition, + new BuildLogStore(logsRoot), + new DotNetCliRunner(), + new BuildTriggerJournal(dataRoot), + new FileChangeBurstStatsStore(dataRoot), + new BuildTrainingStore(dataRoot)); + + return Task.FromResult(new RemountTestScope(runtime, root, logsRoot, dataRoot)); + } + + public async ValueTask DisposeAsync() + { + try + { + await Runtime.StopAsync().ConfigureAwait(false); + } + catch + { + // ignore + } + + Runtime.Dispose(); + TryDelete(Root); + TryDelete(logsRoot); + TryDelete(dataRoot); + } + + private static string CreateTempDir() + { + var path = Path.Combine(Path.GetTempPath(), "bm-remount-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch + { + // ignore + } + } + } +} diff --git a/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs b/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs index db07550..2c2b1fa 100644 --- a/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs +++ b/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs @@ -77,6 +77,100 @@ public void RequiresUrgentCardRebuild_when_progress_steps_advance() Assert.True(StatusPanelPresentationChangeDetector.RequiresUrgentCardRebuild(previous, current)); } + [Fact] + public void RequiresUrgentCardRebuild_false_when_only_build_source_age_ticks() + { + var now = new DateTimeOffset(2026, 8, 28, 6, 0, 0, TimeSpan.Zero); + var completed = AzureRunAt( + now.AddMinutes(-5), + runId: 491, + buildNumber: "20260828.3", + state: PipelineRunState.Completed, + result: PipelineRunResult.Succeeded, + finishedAtUtc: now.AddMinutes(-1)); + var previous = StatusPanelPresentationBuilder.Build( + [SnapshotWithAzure(completed, now)], + null, + now); + var azureRow = Assert.Single(previous.Cards[0].BuildSourceRows!); + var agedRow = azureRow with { AgeDisplay = "42m · 5m0s" }; + var agedCard = previous.Cards[0] with { BuildSourceRows = new[] { agedRow } }; + var current = previous with { Cards = new[] { agedCard } }; + + Assert.Equal("42m · 5m0s", agedRow.AgeDisplay); + Assert.Equal(azureRow.DeepLinkUrl, agedRow.DeepLinkUrl); + Assert.False(StatusPanelPresentationChangeDetector.RequiresUrgentCardRebuild(previous, current)); + } + + [Fact] + public void RequiresUrgentCardRebuild_when_build_source_run_id_changes() + { + var now = new DateTimeOffset(2026, 8, 28, 6, 0, 0, TimeSpan.Zero); + var started = now.AddMinutes(-4); + var previous = StatusPanelPresentationBuilder.Build( + [SnapshotWithAzure(AzureRunAt(started, runId: 491, buildNumber: "20260828.3"), now)], + null, + now); + var current = StatusPanelPresentationBuilder.Build( + [SnapshotWithAzure(AzureRunAt(started, runId: 492, buildNumber: "20260828.4"), now)], + null, + now); + + Assert.True(StatusPanelPresentationChangeDetector.RequiresUrgentCardRebuild(previous, current)); + } + + private static AzurePipelineRunInfo AzureRunAt( + DateTimeOffset startedUtc, + long runId = 491, + string buildNumber = "20260828.3", + PipelineRunState state = PipelineRunState.InProgress, + PipelineRunResult result = PipelineRunResult.Unknown, + DateTimeOffset? finishedAtUtc = null) => + new( + DefinitionId: 8, + PipelineDisplayName: "WitherbyConnect", + RunId: runId, + BuildNumber: buildNumber, + State: state, + Result: result, + Branch: "PR #185", + QueuedAtUtc: startedUtc.AddMinutes(-1), + StartedAtUtc: startedUtc, + FinishedAtUtc: finishedAtUtc, + RunUrl: $"https://dev.azure.com/org/project/_build/results?buildId={runId}&view=results", + PullRequestNumber: 185); + + private static AzurePipelineRunInfo AzureRun( + long runId = 491, + string buildNumber = "20260828.3", + PipelineRunState state = PipelineRunState.InProgress) => + AzureRunAt(DateTimeOffset.UtcNow.AddMinutes(-4), runId, buildNumber, state); + + private static ProjectHealthSnapshot SnapshotWithAzure(AzurePipelineRunInfo primary, DateTimeOffset utcNow) => + new( + ProjectId: "p1", + DisplayName: "Demo", + Health: MonitorHealth.Amber, + HealthLabel: "Building", + State: ProjectLifecycleState.Idle, + LastExitCode: 0, + LastDuration: null, + LastErrorPreview: null, + ErrorCount: 0, + WarningCount: 0, + LastChangedUtc: utcNow, + LastBuildFinishedAtUtc: null, + IsActive: true, + ProgressSteps: [], + Azure: new ProjectAzureHealthFacet( + AzureMonitoringAvailability.Available, + AzureCiMonitoringState.Activity, + "master", + primary, + [], + utcNow, + HasSelectedPipelines: true)); + private static ProjectHealthSnapshot Snapshot( ProjectLifecycleState state, DateTimeOffset? rebuildQuietUntilUtc = null, diff --git a/src/Core/Rules/SettingsApplyImpactCatalog.cs b/src/Core/Rules/SettingsApplyImpactCatalog.cs index 23369e7..c832bd8 100644 --- a/src/Core/Rules/SettingsApplyImpactCatalog.cs +++ b/src/Core/Rules/SettingsApplyImpactCatalog.cs @@ -34,30 +34,30 @@ public sealed record Entry(string Path, SettingsApplyImpact Impact, string Ratio new("Projects[].DisplayName", SettingsApplyImpact.SoftRuntime, "UI label only."), new("Projects[].IsActiveInSession", SettingsApplyImpact.HardRestart, - "Activating/deactivating a Local project must start/stop its runtime. " + + "Activating/deactivating a Local project must remount/stop its runtime without compiling. " + "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."), + "Changes watched working directory; remount without automatic compile."), new("Projects[].Local.ProjectFile", SettingsApplyImpact.HardRestart, - "Changes which project/solution is built."), + "Changes which project/solution is monitored; remount without automatic compile."), new("Projects[].Local.LaunchProfile", SettingsApplyImpact.HardRestart, - "Changes run environment / launch profile."), + "Changes run environment / launch profile; remount process without compile."), new("Projects[].Local.ExtraDotNetArgs", SettingsApplyImpact.HardRestart, - "Changes CLI args for build/run."), + "Changes CLI args for run/next build; remount process without compile."), 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."), + "Cold BuildMonitor/session startup only; Settings remount never builds from this flag."), 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)."), + "Watch/Run/None changes the live child process model; remount without compile."), new("Projects[].Local.RunOptions.RestartOnCrash", SettingsApplyImpact.SoftRuntime, "Crash-restart policy is read live from definition on exit."), new("Projects[].Local.RunOptions.MaxRestartRetries", SettingsApplyImpact.SoftRuntime, @@ -79,7 +79,7 @@ public sealed record Entry(string Path, SettingsApplyImpact Impact, string Ratio 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."), + "Watcher ignore set remounts without compile; Soft add-only refresh cannot remove segments."), new("Projects[].Local.RunOptions.AutoOpenLog", SettingsApplyImpact.SoftRuntime, "UI auto-open preference; App reads settings without restarting Local."), new("Projects[].Local.RunOptions.ShowStatusPanelWhileBuilding", SettingsApplyImpact.SoftRuntime, diff --git a/src/Core/Rules/SettingsApplyImpactClassifier.cs b/src/Core/Rules/SettingsApplyImpactClassifier.cs index 30f9a3f..19b94af 100644 --- a/src/Core/Rules/SettingsApplyImpactClassifier.cs +++ b/src/Core/Rules/SettingsApplyImpactClassifier.cs @@ -20,14 +20,14 @@ public enum SettingsApplyImpact /// /// Monitor, connections, Azure, display names, Local policy/UI prefs (tests, restart flags, - /// build-control mode, etc.) — refresh orchestrator/Azure without StopAll + StartActive. + /// build-control mode, etc.) — refresh orchestrator/Azure without remounting Local process/watcher. /// 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. + /// active-session membership — remount affected Local runtimes without compiling. + /// Cold start (before == null) still uses startup build when StartOnLaunch is enabled. /// HardRestart = 3 } @@ -35,10 +35,26 @@ public enum SettingsApplyImpact /// Actions derived from for the tray apply path. public sealed record SettingsApplyPlan( SettingsApplyImpact Impact, - bool StopAllAndRestartActiveProjects, bool ApplyOrchestratorSettings, bool ResetHealthTransitionState, - bool ShowProjectsStartingToast); + bool ShowProjectsStartingToast, + /// + /// Cold BuildMonitor/session start: StopAll + StartActive with startup build when StartOnLaunch. + /// + bool ColdStartActiveProjectsWithBuild, + /// + /// Hard Settings Save: remount only — never BuildAsync. + /// + bool RemountAffectedLocalProjectsWithoutBuild, + IReadOnlyList LocalRemounts) +{ + /// + /// Legacy name used by older tests/docs: true when either cold-start-with-build or + /// remount-without-build will touch Local runtimes. + /// + public bool TouchesLocalRuntimes => + ColdStartActiveProjectsWithBuild || RemountAffectedLocalProjectsWithoutBuild; +} /// /// Classifies Settings Save diffs so presentation/Azure/monitor updates are not treated as launch. @@ -91,28 +107,44 @@ public static SettingsApplyPlan CreatePlan(AppSettings? before, AppSettings afte { SettingsApplyImpact.None => new SettingsApplyPlan( Impact: impact, - StopAllAndRestartActiveProjects: false, ApplyOrchestratorSettings: false, ResetHealthTransitionState: false, - ShowProjectsStartingToast: false), + ShowProjectsStartingToast: false, + ColdStartActiveProjectsWithBuild: false, + RemountAffectedLocalProjectsWithoutBuild: false, + LocalRemounts: []), SettingsApplyImpact.Presentation => new SettingsApplyPlan( Impact: impact, - StopAllAndRestartActiveProjects: false, ApplyOrchestratorSettings: false, ResetHealthTransitionState: false, - ShowProjectsStartingToast: false), + ShowProjectsStartingToast: false, + ColdStartActiveProjectsWithBuild: false, + RemountAffectedLocalProjectsWithoutBuild: false, + LocalRemounts: []), SettingsApplyImpact.SoftRuntime => new SettingsApplyPlan( Impact: impact, - StopAllAndRestartActiveProjects: false, ApplyOrchestratorSettings: true, ResetHealthTransitionState: false, - ShowProjectsStartingToast: false), + ShowProjectsStartingToast: false, + ColdStartActiveProjectsWithBuild: false, + RemountAffectedLocalProjectsWithoutBuild: false, + LocalRemounts: []), + _ when before is null => new SettingsApplyPlan( + Impact: SettingsApplyImpact.HardRestart, + ApplyOrchestratorSettings: true, + ResetHealthTransitionState: true, + ShowProjectsStartingToast: true, + ColdStartActiveProjectsWithBuild: true, + RemountAffectedLocalProjectsWithoutBuild: false, + LocalRemounts: []), _ => new SettingsApplyPlan( Impact: SettingsApplyImpact.HardRestart, - StopAllAndRestartActiveProjects: true, ApplyOrchestratorSettings: true, ResetHealthTransitionState: true, - ShowProjectsStartingToast: true) + ShowProjectsStartingToast: false, + ColdStartActiveProjectsWithBuild: false, + RemountAffectedLocalProjectsWithoutBuild: true, + LocalRemounts: SettingsLocalRemountPlanner.Plan(before!, after)) }; } diff --git a/src/Core/Rules/SettingsLocalRemountPlanner.cs b/src/Core/Rules/SettingsLocalRemountPlanner.cs new file mode 100644 index 0000000..fa5acff --- /dev/null +++ b/src/Core/Rules/SettingsLocalRemountPlanner.cs @@ -0,0 +1,138 @@ +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Core.Rules; + +/// +/// How a Local project should be remounted after a HardRestart Settings diff. +/// Never implies BuildAsync — Settings Save is not a build trigger. +/// +public enum LocalRemountKind +{ + /// No remount work for this id (should not appear in plans). + None = 0, + + /// Project deactivated — orchestrator disposes the runtime. + StopOnly = 1, + + /// Newly activated — mount watcher only; do not compile or start the app. + MountFresh = 2, + + /// Watch ignore / root-adjacent watcher config — recreate watcher; leave process running. + WatcherOnly = 3, + + /// Launch profile, extra args, or RunMode — remount watcher and refresh process without build. + ProcessAndWatcher = 4, + + /// RootFolder / ProjectFile — remount against new source; do not auto-compile or start process. + SourceIdentity = 5 +} + +/// Per-project remount instruction derived from a Settings HardRestart diff. +public sealed record LocalProjectRemountPlan(string ProjectId, LocalRemountKind Kind); + +/// +/// Identifies which Local projects a HardRestart Settings save must remount, and how. +/// Used so unrelated projects are not bounced. +/// +public static class SettingsLocalRemountPlanner +{ + public static IReadOnlyList Plan(AppSettings before, AppSettings after) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + + var beforeLocal = before.Projects + .Where(p => p.Local is not null) + .ToDictionary(p => p.Id, StringComparer.Ordinal); + var afterLocal = after.Projects + .Where(p => p.Local is not null) + .ToDictionary(p => p.Id, StringComparer.Ordinal); + + var ids = beforeLocal.Keys + .Union(afterLocal.Keys, StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal); + + var plans = new List(); + foreach (var id in ids) + { + beforeLocal.TryGetValue(id, out var b); + afterLocal.TryGetValue(id, out var a); + + if (b is null && a is not null) + { + // New Local id (including Id rename target). + if (a.IsActiveInSession) + { + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.MountFresh)); + } + + continue; + } + + if (b is not null && a is null) + { + // Local id removed (including Id rename source). + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.StopOnly)); + continue; + } + + if (b is null || a is null) + { + continue; + } + + var wasActive = b.IsActiveInSession; + var isActive = a.IsActiveInSession; + if (wasActive && !isActive) + { + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.StopOnly)); + continue; + } + + if (!wasActive && isActive) + { + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.MountFresh)); + continue; + } + + if (!wasActive && !isActive) + { + // Inactive Local hard-field edits do not touch a live runtime. + continue; + } + + // Both active — classify field deltas. + var bl = b.Local!; + var al = a.Local!; + var sourceChanged = + !string.Equals(bl.RootFolder, al.RootFolder, StringComparison.OrdinalIgnoreCase) + || !string.Equals(bl.ProjectFile, al.ProjectFile, StringComparison.OrdinalIgnoreCase); + if (sourceChanged) + { + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.SourceIdentity)); + continue; + } + + var processChanged = + !string.Equals(bl.LaunchProfile, al.LaunchProfile, StringComparison.Ordinal) + || !string.Equals(bl.ExtraDotNetArgs, al.ExtraDotNetArgs, StringComparison.Ordinal) + || bl.RunOptions.RunMode != al.RunOptions.RunMode; + if (processChanged) + { + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.ProcessAndWatcher)); + continue; + } + + var watcherChanged = !string.Equals( + bl.RunOptions.WatchExcludeSegments ?? "", + al.RunOptions.WatchExcludeSegments ?? "", + StringComparison.Ordinal); + if (watcherChanged) + { + plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.WatcherOnly)); + } + } + + return plans; + } +} diff --git a/src/Core/Rules/StatusPanelPresentationChangeDetector.cs b/src/Core/Rules/StatusPanelPresentationChangeDetector.cs index 859f670..19c70e5 100644 --- a/src/Core/Rules/StatusPanelPresentationChangeDetector.cs +++ b/src/Core/Rules/StatusPanelPresentationChangeDetector.cs @@ -76,7 +76,7 @@ public static bool RequiresUrgentCardRebuild( || prev.ShowProgressChart != card.ShowProgressChart || !ProgressStepsEqual(prev.ProgressSteps, card.ProgressSteps) || prev.Azure != card.Azure - || !BuildSourceRowsEqual(prev.BuildSourceRows, card.BuildSourceRows)) + || !BuildSourceRowsUrgentEqual(prev.BuildSourceRows, card.BuildSourceRows)) { return true; } @@ -85,7 +85,11 @@ public static bool RequiresUrgentCardRebuild( return previous.Cards.Count != current.Cards.Count; } - private static bool BuildSourceRowsEqual( + /// + /// Urgent BUILDS rebuild when identity, status, or navigation metadata changes — not when only Age ticks. + /// Age-only updates defer while the pointer is over the panel so Azure hyperlinks stay clickable. + /// + private static bool BuildSourceRowsUrgentEqual( IReadOnlyList? left, IReadOnlyList? right) { @@ -106,7 +110,7 @@ private static bool BuildSourceRowsEqual( for (var i = 0; i < left.Count; i++) { - if (left[i] != right[i]) + if (!BuildSourceRowUrgentEqual(left[i], right[i])) { return false; } @@ -115,6 +119,19 @@ private static bool BuildSourceRowsEqual( return true; } + private static bool BuildSourceRowUrgentEqual(BuildSourcePresentationRow left, BuildSourcePresentationRow right) => + left.Source == right.Source + && left.StatusGlyph == right.StatusGlyph + && left.StatusText == right.StatusText + && left.BranchDisplay == right.BranchDisplay + && left.RunDisplay == right.RunDisplay + && left.BuildNumberDisplay == right.BuildNumberDisplay + && left.PullRequestDisplay == right.PullRequestDisplay + && left.IssuesDisplay == right.IssuesDisplay + && left.DeepLinkUrl == right.DeepLinkUrl + && left.Emphasis == right.Emphasis + && left.AttentionNote == right.AttentionNote; + private static bool StatusRowsEqual( IReadOnlyList left, IReadOnlyList right) diff --git a/src/Infrastructure/Services/ProjectOrchestrator.cs b/src/Infrastructure/Services/ProjectOrchestrator.cs index 43722ba..1255564 100644 --- a/src/Infrastructure/Services/ProjectOrchestrator.cs +++ b/src/Infrastructure/Services/ProjectOrchestrator.cs @@ -285,6 +285,49 @@ public async Task StartActiveProjectsAsync(CancellationToken cancellationToken) } } + /// + /// Hard Settings Save remount: apply per-project remount kinds without compiling. + /// Call after so definitions are current. + /// + public async Task RemountLocalProjectsWithoutBuildAsync( + IReadOnlyList remounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(remounts); + foreach (var plan in remounts) + { + if (plan.Kind == LocalRemountKind.StopOnly) + { + continue; // ApplySettings already disposed inactive runtimes. + } + + ProjectRuntime? runtime; + lock (sync) + { + runtimes.TryGetValue(plan.ProjectId, out runtime); + } + + if (runtime is null) + { + continue; + } + + try + { + await runtime.RemountWithoutBuildAsync(plan.Kind, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + RaiseUserNotification( + runtime.ProjectId, + $"Failed to remount {runtime.DisplayName}", + ExceptionDetailFormatter.Format(ex), + UserNotificationKind.Error, + UserNotificationCategory.Error); + } + } + } + private bool ShouldStartOnLaunch(string projectId) { var project = settings.Projects.FirstOrDefault(p => p.Id == projectId); diff --git a/src/Infrastructure/Services/ProjectRuntime.Build.cs b/src/Infrastructure/Services/ProjectRuntime.Build.cs index e92e513..161bb2b 100644 --- a/src/Infrastructure/Services/ProjectRuntime.Build.cs +++ b/src/Infrastructure/Services/ProjectRuntime.Build.cs @@ -24,6 +24,7 @@ private void TryStartFileWatcher() fileChangeDebounceMs, GetEffectiveWatchIgnoreSegments()); fileWatcher.Changed += OnFileWatcherChanged; + Interlocked.Increment(ref watcherCreateCount); } catch (Exception ex) { @@ -57,6 +58,7 @@ private void BeginRebuildDisplayReset() public async Task BuildAsync(CancellationToken cancellationToken) { + Interlocked.Increment(ref buildAsyncInvocationCount); if (Interlocked.CompareExchange(ref buildInProgress, 1, 0) != 0) { // Rejected rebuild: force a user-visible refresh now so the status panel diff --git a/src/Infrastructure/Services/ProjectRuntime.Run.cs b/src/Infrastructure/Services/ProjectRuntime.Run.cs index d2bafd9..a844b5b 100644 --- a/src/Infrastructure/Services/ProjectRuntime.Run.cs +++ b/src/Infrastructure/Services/ProjectRuntime.Run.cs @@ -18,6 +18,7 @@ internal sealed partial class ProjectRuntime private void StartRunProcess(bool skipEmbeddedBuild = false) { + Interlocked.Increment(ref processStartCount); SetProjectCurrentAction(skipEmbeddedBuild ? "Starting app (dotnet run --no-build)" : "Starting app (dotnet run)"); diff --git a/src/Infrastructure/Services/ProjectRuntime.cs b/src/Infrastructure/Services/ProjectRuntime.cs index e49c29c..17fa5a2 100644 --- a/src/Infrastructure/Services/ProjectRuntime.cs +++ b/src/Infrastructure/Services/ProjectRuntime.cs @@ -85,6 +85,12 @@ internal sealed partial class ProjectRuntime : IDisposable private int healthDirty; + // Lifecycle probes for #90 remount-without-build regression tests. + private int buildAsyncInvocationCount; + private int remountWithoutBuildCount; + private int watcherCreateCount; + private int processStartCount; + private readonly List registeredWorkerIds = []; private readonly Dictionary lastWorkerHeartbeatUtc = new(StringComparer.OrdinalIgnoreCase); @@ -95,6 +101,18 @@ internal sealed partial class ProjectRuntime : IDisposable public bool IsRunProcessActive => runProcess?.IsRunning == true; public bool RestartAppAfterRebuild => Local.RunOptions.RestartAppAfterRebuild; + /// Test probe: how many times was entered. + public int BuildAsyncInvocationCount => Volatile.Read(ref buildAsyncInvocationCount); + + /// Test probe: Settings remount-without-build entries. + public int RemountWithoutBuildCount => Volatile.Read(ref remountWithoutBuildCount); + + /// Test probe: file watcher constructions. + public int WatcherCreateCount => Volatile.Read(ref watcherCreateCount); + + /// Test probe: supervised process start attempts. + public int ProcessStartCount => Volatile.Read(ref processStartCount); + public ProjectHealthSnapshot Snapshot => BuildSnapshot(); public ProjectHealthSnapshot BuildSnapshot() @@ -484,6 +502,88 @@ public async Task StartAsync(CancellationToken cancellationToken) TryStartFileWatcher(); } + + /// + /// Remount watcher/process after a Hard Settings Save. Structurally does not call + /// — Settings remount is never a build trigger. + /// + public async Task RemountWithoutBuildAsync(LocalRemountKind kind, CancellationToken cancellationToken) + { + if (kind is LocalRemountKind.None or LocalRemountKind.StopOnly) + { + return; + } + + Interlocked.Increment(ref remountWithoutBuildCount); + SetProjectCurrentAction("Remounting runtime (no build)"); + + switch (kind) + { + case LocalRemountKind.WatcherOnly: + RemountFileWatcherOnly(); + break; + + case LocalRemountKind.MountFresh: + await StopRunProcessAsync(cancellationToken).ConfigureAwait(false); + RemountFileWatcherOnly(); + SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching); + break; + + case LocalRemountKind.SourceIdentity: + await StopRunProcessAsync(cancellationToken).ConfigureAwait(false); + lastBuildExitCode = -1; + lastBuildFinishedAtUtc = null; + RemountFileWatcherOnly(); + SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching); + break; + + case LocalRemountKind.ProcessAndWatcher: + await StopRunProcessAsync(cancellationToken).ConfigureAwait(false); + RemountFileWatcherOnly(); + if (Local.RunOptions.RunMode == ProjectRunMode.None) + { + SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching); + break; + } + + if (lastBuildExitCode == 0) + { + StartRunProcess(skipEmbeddedBuild: true); + } + else + { + SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching); + } + + break; + + default: + break; + } + + RefreshHealth(); + HealthCoalesceRequested?.Invoke(true); + } + + private void RemountFileWatcherOnly() + { + var wasRunning = runProcess?.IsRunning == true; + fileWatcher?.Dispose(); + fileWatcher = null; + TryStartFileWatcher(); + if (wasRunning && runProcess?.IsRunning == true) + { + // Process intentionally left running across watcher-only remount. + SetState(Local.RunOptions.RunMode == ProjectRunMode.Watch + ? ProjectLifecycleState.Watching + : ProjectLifecycleState.Running); + } + else if (runProcess?.IsRunning != true && fileWatcher is not null + && Local.RunOptions.RunMode == ProjectRunMode.None) + { + SetState(ProjectLifecycleState.Watching); + } + } private void SetState(ProjectLifecycleState newState) { state = newState; diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs index 05ecd2b..8d94703 100644 --- a/src/TrayApp/App.xaml.cs +++ b/src/TrayApp/App.xaml.cs @@ -203,7 +203,7 @@ private async Task ApplySettingsAndStartAsync(SettingsApplyPlan plan) ToastNotificationService.ApplySettings(currentSettings.AppBehavior); - if (plan.StopAllAndRestartActiveProjects) + if (plan.ColdStartActiveProjectsWithBuild) { await orchestrator.StopAllAsync(); } @@ -214,11 +214,17 @@ private async Task ApplySettingsAndStartAsync(SettingsApplyPlan plan) ApplyControlPlaneHost(); } - if (plan.StopAllAndRestartActiveProjects + if (plan.ColdStartActiveProjectsWithBuild && AppLaunchPolicy.ShouldAutoStartAnyProjectsOnLaunch(currentSettings)) { await orchestrator.StartActiveProjectsAsync(CancellationToken.None).ConfigureAwait(false); } + else if (plan.RemountAffectedLocalProjectsWithoutBuild) + { + await orchestrator.RemountLocalProjectsWithoutBuildAsync( + plan.LocalRemounts, + CancellationToken.None).ConfigureAwait(false); + } } finally { @@ -1475,7 +1481,9 @@ private async Task ShowSettingsAsync() RebuildTrayMenu(); var plan = SettingsApplyImpactClassifier.CreatePlan(previousSettings, currentSettings); - if (plan.StopAllAndRestartActiveProjects || plan.ApplyOrchestratorSettings) + if (plan.ColdStartActiveProjectsWithBuild + || plan.RemountAffectedLocalProjectsWithoutBuild + || plan.ApplyOrchestratorSettings) { var applyVersion = Interlocked.Increment(ref settingsApplyVersion); _ = ApplySettingsAndStartInBackgroundAsync(applyVersion, plan); @@ -1498,6 +1506,8 @@ private async Task ShowSettingsAsync() SettingsApplyImpact.None => "No changes to apply.", SettingsApplyImpact.Presentation => "Presentation settings updated.", SettingsApplyImpact.SoftRuntime => "Runtime settings updated without rebuilding.", + SettingsApplyImpact.HardRestart => + "Local runtime remounted without rebuilding.", _ => "Settings updated." }, ToastKind.Success,