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
19 changes: 14 additions & 5 deletions docs/SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ File: `%LOCALAPPDATA%/BuildMonitor/settings.json`
## Settings UI tabs

- **Projects** — per-project folder, csproj/sln, launch profile, run/watch options, **start build when app launches**, and **active in session** checkbox (left of each project name). Unchecked projects remain in the list but are not built or run until checked and settings are saved.
- **Monitor** — concurrency, debounce, **batch watch-mode rebuilds**, health refresh, auto-open log on failure, **auto-open Build Monitor Health on startup**, max log bytes.
- **Monitor** — concurrency, debounce, **batch watch-mode rebuilds**, health refresh, **auto-open Build Monitor Health on startup**, max log bytes.
- **App** — theme (`System`, `Light`, `Dark`) and startup behavior. **Run when Windows starts** adds/removes an entry under `HKCU\...\Run` named `LocalBuildMonitor`.

Per project (**Projects** tab → **File watching**):

- **`autoOpenLog`**: `Never` (default), `Errors`, `Warnings`, or `Always` — when to open the log viewer automatically after a build or test. `Warnings` opens on amber health (build succeeded with warnings) as well as failures. `Always` opens after every build or test completes. Replaces the old global `monitor.autoOpenLogOnFailure` flag (schema v11).

## Health colors

- **Green (Success)** — build/run healthy; no errors in the active context.
Expand All @@ -69,6 +73,9 @@ See [features/health-and-logs.md](features/health-and-logs.md) for how build vs
- **`fileChangeDebounceMode`**: `Manual` (default) or `Auto`. **Auto** learns per project from save burst length (time from first to last file change before rebuild), using p90 × 1.25 smoothed into **1500–12000 ms**. The manual ms value is used until **five** bursts are recorded. Stats persist in `%LOCALAPPDATA%/BuildMonitor/debounce-stats.json`.
- **Agent session coalescing** — after the first file-triggered build in a 90-second window, further saves wait for a full quiet period since the **last** change (not a fixed 3 s post-build cooldown). Debounce increases up to **2×** when multiple file-triggered builds happen in that window. Turn on **Auto** debounce mode for longer agent sessions.
- **`coalesceWatchRebuilds`** (default **true**) — in **Watch** run mode, BuildMonitor watches the project folder, waits for edits to settle, then runs one `dotnet build` and restarts the app. This replaces per-save `dotnet watch` rebuilds during agent sessions. Turn off to use `dotnet watch` hot reload instead (more rebuilds, faster feedback on single-file edits).
- **`deferStartupBuildUntilQuiet`** (default **true**) — when starting Build Monitor while an agent is still saving, wait for the quiet period before the first `dotnet build`.
- **`cancelSupersededBuilds`** (default **true**) — cancel in-flight **startup** or **file-change** builds when newer saves arrive; coalesce into one rebuild after edits settle. Manual tray rebuilds are never cancelled.
- **`useAgentTranscriptActivity`** (default **true**) — treat writes under `agent-transcripts` / `.cursor` as “agent still active” for gating (signal only; does not trigger rebuilds).

Restart the project from the tray after changing this option so the run process switches between `dotnet run` and `dotnet watch`.

Expand Down Expand Up @@ -110,13 +117,13 @@ Output uses `--verbosity normal` and a detailed console logger (per-test pass/fa

## Build diagnostics

Tray → **Build diagnostics…** opens **one tab per project**: a compact **rebuild timing** panel (wait bar, learning progress, save-burst chart) and today's build triggers for that project.
Tray → **Build diagnostics…** opens **one tab per active project**: a compact **rebuild timing** panel (metric tiles, save-burst and build-duration charts, rebuild countdown) and today's build triggers for that project.

| Column | Meaning |
|--------|---------|
| **Kind** | Session start, file watcher, manual rebuild, hot reload, `dotnet watch`, etc. |
| **Files** | Paths that triggered a debounced file-watcher rebuild (relative to project root) |
| **Detail** | Extra context (e.g. a `dotnet watch` output line) |
| **Detail** | Extra context — file-watcher debounce/hold timing, or a `dotnet watch` / hot-reload output line |
| **Verdict** | Mark **Expected** or **Unexpected** to track spurious rebuilds |

Persisted at `%LOCALAPPDATA%/BuildMonitor/diagnostics/build-triggers.jsonl` (**today's entries only**, local calendar day; up to 500 per day). Mark **Unexpected** triggers to spot spurious rebuilds during agent sessions.
Expand All @@ -125,12 +132,13 @@ Persisted at `%LOCALAPPDATA%/BuildMonitor/diagnostics/build-triggers.jsonl` (**t

**Likely cause** is a heuristic from trigger kind and changed file paths (e.g. Cursor/agent tooling folders vs source edits). **Your note** is free text — use it to record what you were doing (e.g. “Cursor ask mode chat”) when marking unexpected rebuilds.

Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics, and status panel width — height auto-fits content up to 520 px).
Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layout.json` (Settings, build log, diagnostics — including trigger grid column widths — and status panel width — height auto-fits content up to 460 px).

## Watch / file-watcher excludes

- **`watchExcludeSegments`** — semicolon-separated folder names ignored by BuildMonitor’s debounced file watcher (`TriggerRebuild` mode). Defaults include `.cursor`, `agent-transcripts`, `logs`, `bin`, `obj`, and similar tooling/output folders.
- Noisy file types (`.log`, `.dll`, `.pdb`, `.tmp`, etc.) are also ignored so build output and tooling writes are less likely to trigger rebuilds.
- Noisy file types (`.log`, `.dll`, `.pdb`, `.tmp`, common image formats under `wwwroot`, etc.) are also ignored so build output and static assets are less likely to trigger rebuilds.
- **`wwwroot/Images`** — image saves are ignored by default (`.png`, `.jpg`, `.gif`, `.webp`, `.svg`, `.ico`, …). **`wwwroot/Files`** (PDFs, Office docs, etc.) still trigger rebuilds unless you add `Files` or `wwwroot` to **`watchExcludeSegments`**.
- For **dotnet watch**, also add `<Watch Remove="**/.cursor/**" />` (and similar) to the monitored `.csproj`. Defaults and behaviour: [features/health-and-logs.md](features/health-and-logs.md).

## Developer environment (not in settings UI)
Expand All @@ -154,3 +162,4 @@ Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layou
- **Restart app after rebuild** — when run mode is Watch or Run, start (or restart) the app after a successful rebuild, including the first successful build after a prior failure.
- **Restart app** — stop and start run/watch with `--no-build` (no full rebuild).
- **Rebuild & restart** — full `dotnet build`, then start run/watch (shows build progress in status panel).
- **Show status panel while building** (default **off**) — per project. Opens the hover status panel when a build starts and hides it when the build finishes. Does not auto-hide if you already had the panel open before the build started.
13 changes: 10 additions & 3 deletions docs/features/health-and-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ How BuildMonitor decides what failed and what the tray, status panel, and log vi
| 3 | Tray receives immutable snapshot list at bounded rate | `ProjectOrchestrator.HealthUpdated` → `App.OnHealthUpdated` (`ApplicationIdle`, coalesced) |
| 4 | Run/watch output updates `runErrorCount` / `runWarningCount` | `DotNetRunOutputParser`, `OnRunProcessOutputLine` (coalesced same as build) |
| 5 | Snapshot picks display counts by lifecycle state | `HealthIssueCountsFormatter.SelectPrimaryCounts` |
| 6 | Tray tooltip uses headline project + failure phase + error preview | `App.FormatTrayTooltip` |
| 5b | Incremental file builds with 0/0 MSBuild summary reuse counts from metadata, `.prev`, or the BuildMonitor incremental note | `IncrementalBuildDetector`, `BuildIssueCountResolver`, `BuildLogStore` |
| 5c | **Startup**, **Rebuild**, and **Rebuild & restart** pass `--no-incremental` so MSBuild recompiles and reports real warning/error counts | `DotNetBuildArguments` |
| 5d | Post-build tests no longer clear build warning/error counts used for tray health | `ProjectRuntime.TestAsync` |
| 5e | **Agent-aware build suppression** — defer startup until edits settle; cancel superseded startup/file-change builds; optional agent-transcript activity signal | `EditActivityEvaluator`, `BuildSuppressionPolicy`, `ProjectRuntime` |
| 5f | Edit gating auto-shows status panel with hold detail + countdown | `App.AutoShowStatusPanelForEditGating`, `HoverStatusPanel` |
| 6 | Tray hover opens the status panel (project health, counts, gating detail, actions); native shell tooltip is suppressed | `HoverStatusPanel`, `TrayIconShellInterop` |
| 7 | Status panel shows `IssueCountsText` (build vs run context) | `HoverStatusPanel` |
| 8 | Log viewer parses Build / Run / Test tabs with matching parsers | `BuildLogViewerWindow.ParseIssuesForCurrentLog` |
| 9 | Failure auto-opens log on correct tab + Errors filter | `App.AutoOpenLogsOnFailureTransition` |
| 8b | Log viewer footer and issues summary use the same resolved counts (metadata, `.prev`, incremental note); incremental 0/0 builds show a carry-forward note in the issues pane | `BuildLogViewerWindow.RefreshResolvedIssueCounts` |
| 9 | Auto-open log per project (`Never` / `Errors` / `Warnings` / `Always`) | `App.AutoOpenLogsOnTransition`, `AutoOpenLogTransitionEvaluator` |
| 9b | Auto-show status panel while building (per project, default off) | `App.AutoShowStatusPanelWhileBuilding`, `StatusPanelBuildVisibilityEvaluator` |
| 10 | **Restart app** stops run/watch and starts with `--no-build` | `ProjectRuntime.RestartAppCoreAsync(rebuildFirst: false)` |
| 11 | **Rebuild & restart** runs full build then starts app | `ProjectRuntime.RestartAppCoreAsync(rebuildFirst: true)` |
| 12 | Hot-reload “requires restart/rebuild” lines trigger auto-restart when enabled | `HotReloadRestartDetector`, `ProjectRuntime.TryHandleHotReloadRestartRequest` |
Expand All @@ -38,7 +45,7 @@ Per-project dirty flags; up to `MaxConcurrentActiveProjects` (default 3) share o

**Extension points:** add run-error heuristics in `DotNetRunOutputParser`; adjust status formatting in `HealthIssueCountsFormatter`.

**Failure / fallback:** tray tooltip truncated to 63 characters; issue scroll uses text-match fallback when line index drifts after log truncation.
**Failure / fallback:** native shell tooltip is suppressed via empty `NotifyIcon.Text` (custom hint only). `TrayIconShellInterop` resolves icon bounds for hint dismiss. Issue scroll uses text-match fallback when line index drifts after log truncation.

## Watch excludes (dotnet watch)

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

namespace BuildMonitor.Tests;

public sealed class AutoOpenLogTransitionEvaluatorTests
{
[Theory]
[InlineData(AutoOpenLogMode.Never, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.BuildFailed, false)]
[InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Watching, ProjectLifecycleState.BuildFailed, false)]
[InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.BuildFailed, true)]
[InlineData(AutoOpenLogMode.Errors, MonitorHealth.Red, MonitorHealth.Red, ProjectLifecycleState.BuildFailed, ProjectLifecycleState.BuildFailed, false)]
[InlineData(AutoOpenLogMode.Errors, MonitorHealth.Green, MonitorHealth.Red, ProjectLifecycleState.Building, ProjectLifecycleState.Building, false)]
[InlineData(AutoOpenLogMode.Warnings, MonitorHealth.Green, MonitorHealth.Amber, ProjectLifecycleState.Watching, ProjectLifecycleState.Watching, true)]
[InlineData(AutoOpenLogMode.Warnings, MonitorHealth.Amber, MonitorHealth.Red, ProjectLifecycleState.Watching, ProjectLifecycleState.BuildFailed, true)]
[InlineData(AutoOpenLogMode.Always, MonitorHealth.Green, MonitorHealth.Green, ProjectLifecycleState.Building, ProjectLifecycleState.Watching, true)]
[InlineData(AutoOpenLogMode.Always, MonitorHealth.Green, MonitorHealth.Green, ProjectLifecycleState.Testing, ProjectLifecycleState.TestOk, true)]
public void ShouldOpen_respects_mode_and_transition(
AutoOpenLogMode mode,
MonitorHealth previousHealth,
MonitorHealth currentHealth,
ProjectLifecycleState previousState,
ProjectLifecycleState currentState,
bool expected) =>
Assert.Equal(
expected,
AutoOpenLogTransitionEvaluator.ShouldOpen(
mode,
previousHealth,
currentHealth,
previousState,
currentState,
errorCount: 0));

[Fact]
public void ShouldOpen_errors_mode_on_run_crash()
{
Assert.True(
AutoOpenLogTransitionEvaluator.ShouldOpen(
AutoOpenLogMode.Errors,
MonitorHealth.Green,
MonitorHealth.Red,
ProjectLifecycleState.Running,
ProjectLifecycleState.Crashed));
}

[Fact]
public void ResolveIssueFilters_selects_errors_for_failed_build_without_parsed_count()
{
var snapshot = new ProjectHealthSnapshot(
"p1",
"Demo",
MonitorHealth.Red,
"Failed",
ProjectLifecycleState.BuildFailed,
LastExitCode: 1,
LastDuration: TimeSpan.FromSeconds(3),
LastErrorPreview: "targets(269,5): error : asset missing",
ErrorCount: 0,
WarningCount: 1065,
LastChangedUtc: DateTimeOffset.UtcNow,
LastBuildFinishedAtUtc: DateTimeOffset.UtcNow,
IsActive: true,
ProgressSteps: []);

var (errors, warnings) = AutoOpenLogTransitionEvaluator.ResolveIssueFilters(
AutoOpenLogMode.Errors,
snapshot);

Assert.True(errors);
Assert.False(warnings);
}

[Fact]
public void PreferWarningsFilter_only_when_no_errors()
{
Assert.True(AutoOpenLogTransitionEvaluator.PreferWarningsFilter(0, 3));
Assert.False(AutoOpenLogTransitionEvaluator.PreferWarningsFilter(1, 3));
}
}
61 changes: 59 additions & 2 deletions src/BuildMonitor.Tests/BuildIntelligenceSnapshotTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using BuildMonitor.Core.Models;
using BuildMonitor.Core.Settings;
using BuildMonitor.Infrastructure.Diagnostics;
using BuildMonitor.Infrastructure.LocalBuild;
Expand Down Expand Up @@ -115,13 +116,65 @@ public void BurstBars_scale_relative_to_largest_sample()
Assert.Equal(1.0, snapshot.BurstBars.Last().HeightRatio, 3);
}

[Fact]
public void NextRebuildReasonText_timer_reset_mentions_files_and_restart()
{
var snapshot = CreateSnapshot(
pendingFileChangeRebuild: true,
rebuildQuietUntilUtc: DateTimeOffset.UtcNow.AddSeconds(3),
holdReason: PendingRebuildHoldReason.EditsStillArriving,
pendingRebuildFileCount: 3,
pendingRebuildSamplePaths: ["Foo.cs", "Bar.cs"],
rebuildTimerResetCount: 2);

Assert.Contains("Wait timer reset", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("3 file(s)", snapshot.NextRebuildReasonText, StringComparison.Ordinal);
Assert.Contains("Quiet period restarted", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Foo.cs", snapshot.NextRebuildReasonText, StringComparison.Ordinal);
}

[Fact]
public void NextRebuildReasonText_build_in_progress_explains_wait()
{
var snapshot = CreateSnapshot(
pendingFileChangeRebuild: true,
holdReason: PendingRebuildHoldReason.BuildInProgress);

Assert.Contains("current build", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void NextRebuildReasonText_startup_deferred_mentions_quiet_period()
{
var snapshot = CreateSnapshot(
pendingFileChangeRebuild: true,
holdReason: PendingRebuildHoldReason.StartupDeferred);

Assert.Contains("Startup build deferred", snapshot.NextRebuildReasonText, StringComparison.OrdinalIgnoreCase);
}

[Theory]
[InlineData(new[] { "src/A.cs" }, 1, " (src/A.cs)")]
[InlineData(new[] { "A.cs", "B.cs" }, 4, " (A.cs, B.cs +2 more)")]
public void FormatPendingFileSample_formats_path_suffix(
string[] paths,
int totalCount,
string expectedSuffix) =>
Assert.EndsWith(
expectedSuffix,
BuildIntelligenceSnapshot.FormatPendingFileSample(paths, totalCount));

private static BuildIntelligenceSnapshot CreateSnapshot(
FileChangeDebounceMode debounceMode = FileChangeDebounceMode.Auto,
int baseEffectiveDebounceMs = 3000,
int liveEffectiveDebounceMs = 3000,
int recentFileChangeBuildsIn90s = 0,
bool pendingFileChangeRebuild = false,
DateTimeOffset? rebuildQuietUntilUtc = null) =>
DateTimeOffset? rebuildQuietUntilUtc = null,
PendingRebuildHoldReason holdReason = PendingRebuildHoldReason.None,
int pendingRebuildFileCount = 0,
IReadOnlyList<string>? pendingRebuildSamplePaths = null,
int rebuildTimerResetCount = 0) =>
BuildIntelligenceSnapshot.Create(
SampleProject(),
new GlobalMonitorSettings { FileChangeDebounceMode = debounceMode },
Expand All @@ -134,7 +187,11 @@ private static BuildIntelligenceSnapshot CreateSnapshot(
coalesceWatchRebuilds: true,
lastMeaningfulFileChangeUtc: null,
pendingFileChangeRebuild: pendingFileChangeRebuild,
rebuildQuietUntilUtc: rebuildQuietUntilUtc);
rebuildQuietUntilUtc: rebuildQuietUntilUtc,
holdReason: holdReason,
pendingRebuildFileCount: pendingRebuildFileCount,
pendingRebuildSamplePaths: pendingRebuildSamplePaths,
rebuildTimerResetCount: rebuildTimerResetCount);

private static LocalProjectDefinition SampleProject() => new()
{
Expand Down
45 changes: 45 additions & 0 deletions src/BuildMonitor.Tests/BuildIssueCountResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using BuildMonitor.Infrastructure.LocalBuild;

namespace BuildMonitor.Tests;

public sealed class BuildIssueCountResolverTests
{
private const string FullLog = """
C:\app\Pages\Index.razor(1,1): warning CS8618: Field required [C:\app\app.csproj]

Build succeeded.
1066 Warning(s)
0 Error(s)
""";

private const string IncrementalLog = """
WitherbyConnect -> C:\app\bin\Debug\net9.0\WitherbyConnect.dll

Build succeeded.
0 Warning(s)
0 Error(s)
""";

[Fact]
public void Resolve_incremental_output_uses_previous_log_counts()
{
var dir = Path.Combine(Path.GetTempPath(), $"bm-resolver-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
var logPath = Path.Combine(dir, "last-build.log");
var prevPath = logPath + ".prev";

try
{
File.WriteAllText(prevPath, FullLog);

var (errors, warnings) = BuildIssueCountResolver.Resolve(IncrementalLog, logPath);

Assert.Equal(0, errors);
Assert.Equal(1066, warnings);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
Loading
Loading