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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/features/health-and-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/ops/ai-controlled-build-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
160 changes: 160 additions & 0 deletions src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using BuildMonitor.Core.Models;
using BuildMonitor.Core.Rules;
using BuildMonitor.Infrastructure.AzureDevOps;

namespace BuildMonitor.Tests;

/// <summary>Azure BUILDS row deep-link and column semantics (presentation layer).</summary>
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);
}
37 changes: 22 additions & 15 deletions src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -312,7 +318,7 @@ public static TheoryData<string, SettingsApplyImpact> CatalogMutationCases()
continue;
}

// SchemaVersion alone None (catalog Impact None)
// SchemaVersion alone → None (catalog Impact None)
data.Add(entry.Path, entry.Impact);
}

Expand Down Expand Up @@ -497,3 +503,4 @@ private static void SetAlteredValue(object target, PropertyInfo prop)
prop.SetValue(target, next);
}
}

Loading
Loading