diff --git a/.cursor/context/architecture.md b/.cursor/context/architecture.md index 11d9b41..9602ed2 100644 --- a/.cursor/context/architecture.md +++ b/.cursor/context/architecture.md @@ -20,6 +20,12 @@ Human-facing detail: [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md). | **BuildMonitor.Infrastructure** | `src/Infrastructure/` | Orchestrator, process config, log store, port probe | | **BuildMonitor.Tests** | `src/BuildMonitor.Tests/` | Unit tests for Core + Infrastructure | +## Engineering rules + +- **No god-classes:** `.cursor/rules/code-structure.mdc` — extract from `ProjectOrchestrator` / `App.xaml.cs`; line limits. +- **Orchestration tests:** `.cursor/rules/testing.mdc` Tier 2 — orchestrator/runtime changes require tests in the same PR. +- **CI:** `.github/workflows/ci.yml` — build + test on every PR to `main`. + ## Goals - Monitor multiple local dotnet projects from the system tray diff --git a/.cursor/context/coding-standards.md b/.cursor/context/coding-standards.md index 3f6efe7..a2ef38b 100644 --- a/.cursor/context/coding-standards.md +++ b/.cursor/context/coding-standards.md @@ -5,6 +5,6 @@ C# style for this solution. Layout: `.cursor/rules/architecture.mdc`. - Use file-scoped namespaces. - Use nullable reference types. - Prefer explicit types when clarity improves. -- Keep methods short. +- Keep methods short; keep files short — see `.cursor/rules/code-structure.mdc` (≤400 lines target, no god-class growth). - Avoid magic strings for settings keys — use `LocalAppSettings` properties. -- Test pure logic in `BuildMonitor.Tests`. +- Test pure logic in `BuildMonitor.Tests`; orchestration changes need Tier 2 tests — see `.cursor/rules/testing.mdc`. diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index 1c61e06..f85d34a 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -15,4 +15,6 @@ alwaysApply: false - Infrastructure must not depend on TrayApp. - TrayApp references Infrastructure and Core only. - Prefer small, testable helpers in Infrastructure/LocalBuild over logic in code-behind. +- **Class size:** see `code-structure.mdc` — no new god-classes; legacy files shrink on touch. +- **Orchestration tests:** see `testing.mdc` Tier 2 — orchestrator changes require tests in the same PR. - Stack detail: `.cursor/context/architecture.md` and `docs/ARCHITECTURE.md`. diff --git a/.cursor/rules/code-structure.mdc b/.cursor/rules/code-structure.mdc new file mode 100644 index 0000000..f27eb9c --- /dev/null +++ b/.cursor/rules/code-structure.mdc @@ -0,0 +1,59 @@ +--- +description: Class size limits and god-class prevention +globs: + - "src/**/*.cs" +alwaysApply: false +--- + +# Code structure — no god-classes + +Large monolith files are how regressions hide. **Do not grow them.** Extract first, then add behaviour. + +## Line limits + +| Threshold | Rule | +|-----------|------| +| **≤ 400 lines** | Target maximum for any `.cs` file | +| **≤ 500 lines** | Hard cap for **new** files | +| **> 500 lines (legacy)** | Files already over limit (e.g. `ProjectOrchestrator.cs`, `App.xaml.cs`) — **no net line increase** in a feature PR unless the same PR extracts code to a smaller type and overall line count in those files **decreases** | + +Check before commit: + +```powershell +(Get-Content path\to\File.cs).Count +``` + +## Where logic belongs + +| Layer | Allowed in file | Move elsewhere | +|-------|-----------------|----------------| +| **TrayApp** (`App.xaml.cs`, `*Window.xaml.cs`) | UI wiring, `Dispatcher` calls, event handlers that delegate | Business rules, parsing, process orchestration → `TrayApp/Services/*` or Infrastructure | +| **Infrastructure** (`ProjectOrchestrator.cs`) | Start/stop coordination, subscribing runtimes, applying settings | Build/test/watch steps, output handling, lock release, debounce → `ProjectRuntime`, `LocalBuild/*`, `Infrastructure/Services/*` | +| **Core** | Models, pure rules, settings shapes | I/O, processes, file system | + +## Extraction triggers (mandatory) + +Before adding **~30+ lines** of non-trivial logic to any file already **> 400 lines**: + +1. Identify a cohesive unit (parser, policy, planner, health publisher, menu builder). +2. Add a new type in the correct project (prefer `Infrastructure/LocalBuild` or `TrayApp/Services`). +3. Add unit tests for the extracted type (`testing.mdc`). +4. Wire the thin caller from the original file. + +## Naming and shape + +- One primary type per file when practical. +- Prefer `sealed` helpers and static parsers over private regions hundreds of lines long. +- New orchestration types: `*Coordinator`, `*Planner`, `*Handler`, `*Publisher` — not more methods on an existing 2k-line class. + +## PR rejection (agent) + +Reject or split a PR that: + +- Adds feature logic only inside `App.xaml.cs` or `ProjectOrchestrator.cs` without extraction. +- Increases line count in a legacy god-class without a paired extraction in the same PR. +- Introduces a new file expected to exceed **500 lines**. + +## Legacy debt + +Existing oversized files are **not** fixed in one go. Every touch should **shrink or stabilise** them: extract at least one helper when you change behaviour, and add orchestration tests (`testing.mdc`). diff --git a/.cursor/rules/core.mdc b/.cursor/rules/core.mdc index 4153789..48a7ecb 100644 --- a/.cursor/rules/core.mdc +++ b/.cursor/rules/core.mdc @@ -12,5 +12,7 @@ alwaysApply: true - Use async/await with `CancellationToken` for I/O in Infrastructure; do not block on asynchronous work. - Never hardcode secrets; user settings live under `%LocalAppData%/BuildMonitor/` — document keys in `docs/SETTINGS.md`, never commit real settings files. - **Layering:** **Core** (models, rules) ← **Infrastructure** (processes, logs, orchestration) ← **TrayApp** (WPF UI only). Keep business logic out of XAML code-behind where possible. +- **Structure:** See `code-structure.mdc` — line limits, no god-classes, extract from `App.xaml.cs` / `ProjectOrchestrator.cs`. +- **Tests:** See `testing.mdc` — orchestration changes require Tier 2 tests in the same PR; CI enforces build + test on every PR. - Follow existing solution layout and naming unless a change is explicitly requested. - **Document as you build.** When adding a feature, config key, or behaviour change, update the matching `docs/` page in the same change. See `documentation.mdc`, `adr.mdc`, and `feature-delivery.mdc`. diff --git a/.cursor/rules/feature-delivery.mdc b/.cursor/rules/feature-delivery.mdc index b0dfb9b..cb3f394 100644 --- a/.cursor/rules/feature-delivery.mdc +++ b/.cursor/rules/feature-delivery.mdc @@ -16,12 +16,14 @@ Use this checklist before declaring a feature complete. It complements `core.mdc 1. **Code** - Build succeeds; **no new compiler warnings** in files this change adds or modifies (`build-warnings.mdc`). - Async with `CancellationToken` on I/O in Infrastructure. - - Keep orchestration and parsing in Infrastructure/Core; TrayApp stays thin. + - Keep orchestration and parsing in Infrastructure/Core; TrayApp stays thin (`code-structure.mdc`). + - **No god-class growth** — extract before adding logic to oversized files. -2. **Tests** (default: include in the same change — see `testing.mdc`) - - New or changed logic in Core/Infrastructure has unit tests in `BuildMonitor.Tests`. - - UI-only XAML: document manual checks in the feature doc when tests are N/A. - - Existing tests still pass. +2. **Tests** (mandatory — see `testing.mdc`) + - Tier 1: parsers, rules, formatters in Core/Infrastructure. + - **Tier 2: any orchestrator/runtime/coalescer change includes new or updated tests in the same PR** — thin parser-only coverage alone is insufficient. + - UI-only XAML: manual checks in the feature doc when tests are N/A. + - CI build + test must pass on the PR (`.github/workflows/ci.yml`). 3. **Documentation** - `docs/features/.md` added or updated for user-facing changes. diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 91c1a56..ef8f270 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -1,5 +1,5 @@ --- -description: Unit tests for Core and Infrastructure logic +description: Unit and orchestration tests for Core and Infrastructure globs: - "src/**/*.cs" - "src/BuildMonitor.Tests/**/*.cs" @@ -8,19 +8,67 @@ alwaysApply: false # Testing rules -- Use xUnit in [`src/BuildMonitor.Tests/`](../../src/BuildMonitor.Tests/). -- Test Core rules and Infrastructure parsers/helpers — not WPF rendering. -- New or changed logic in Core/Infrastructure should have unit tests in the same PR unless genuinely UI-only. +**Thin integration coverage is not acceptable** for orchestration code. Parsers-only tests while `ProjectOrchestrator` grows untested is a defect, not a shortcut. -## Commands (user runs) +## Test project + +xUnit in [`src/BuildMonitor.Tests/`](../../src/BuildMonitor.Tests/). + +## Three tiers (all required where applicable) + +### Tier 1 — Pure logic (mandatory) + +Always unit-test when you add or change: + +- Core rules and evaluators +- Static parsers (`BuildLogParser`, `DotNetRunOutputParser`, `DotNetTestOutputParser`, …) +- Formatters, planners, detectors with no I/O + +### Tier 2 — Orchestration (mandatory — no exceptions) + +Any PR that changes behaviour in these areas **must** add or update tests in the same PR: + +| Area | Examples | +|------|----------| +| `ProjectOrchestrator.cs` | start/stop, rebuild, settings apply, health publish | +| `ProjectRuntime` (nested or extracted) | build/run/test lifecycle, output lines, state transitions | +| `HealthCoalescer` | coalesce timing, dirty flags, pause while menu open | +| `DotNetCliRunner` / `SupervisedProcess` | command construction, env sanitization (via test doubles) | +| `BuildLogStore` | persist/load/truncate semantics | +| New `Infrastructure/Services/*` or `LocalBuild/*` coordinators | lock release, repair, debounce application | + +**What “orchestration test” means here:** not a full UI or real `dotnet` E2E. Use: + +- Extracted pure functions (preferred) +- In-memory fakes (`FakeLogStore`, recording callbacks, stub `I*` interfaces) +- Table-driven tests over inputs → expected state / commands / events + +**Minimum bar per PR:** at least one new or updated test method that would **fail** if the orchestration change were reverted. + +### Tier 3 — TrayApp + +- Do **not** test WPF rendering or window layout in xUnit. +- New non-trivial logic in code-behind → **extract** to `TrayApp/Services/*` and test there (Tier 1/2). +- Pure XAML-only changes → manual test plan in the PR and feature doc. + +## Anti-patterns (reject the PR) + +| Anti-pattern | Fix | +|--------------|-----| +| Orchestrator change + only parser tests | Add orchestration tests for the changed path | +| “Too hard to test” without extraction | Extract a testable unit first (`code-structure.mdc`) | +| Manual-only verification for Infrastructure behaviour | Add Tier 2 tests or split the PR | + +## Commands (user runs locally; CI runs on PR) ```powershell +dotnet build BuildMonitor.slnx dotnet test src/BuildMonitor.Tests/BuildMonitor.Tests.csproj ``` -## When tests are N/A +## When tests are genuinely N/A -- Pure XAML layout with no new logic — document manual tray/status checks in `docs/features/`. -- Comment-only or docs-only changes. +- Comment-only, docs-only, or `.cursor` rules-only changes. +- Pure XAML with no new logic — document manual tray checks in `docs/features/`. -See `feature-delivery.mdc` and `feature-ship` skill for checklist enforcement. +See `feature-delivery.mdc`, `code-structure.mdc`, and CI workflow [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml). diff --git a/.cursor/rules/work-tracking.mdc b/.cursor/rules/work-tracking.mdc index bdd1f7b..1d5f1f5 100644 --- a/.cursor/rules/work-tracking.mdc +++ b/.cursor/rules/work-tracking.mdc @@ -44,6 +44,10 @@ Before `git commit`, `git push`, **ship it**, or **check in** when the user did **After linking:** set project Status to **In Progress** when starting substantial work; **Done** only when the user says ship/merge or work is complete on `main` (issue closed). +**Board backlog:** run `.\scripts\github\Sync-ProjectBoard.ps1` when retrospective or planned cards are missing; see [docs/ops/github-workflow.md](../../docs/ops/github-workflow.md). + +**Hooks (mandatory locally):** run `.\scripts\install-githooks.ps1` once per clone — `commit-msg` rejects commits without `#` (or `feature/-...` branch). PRs must reference an issue (CI workflow). + **Exception:** user explicitly says “no issue”, “chore only”, or “docs/rules only, skip issue”. ## Agent automation (`gh`) diff --git a/.cursor/skills/feature-ship/SKILL.md b/.cursor/skills/feature-ship/SKILL.md index 760b480..6e0aa4a 100644 --- a/.cursor/skills/feature-ship/SKILL.md +++ b/.cursor/skills/feature-ship/SKILL.md @@ -27,7 +27,8 @@ No commit, push, or merge unless the user triggered one of the phrases above. - **Performance (inline):** if diff touches orchestrator output handling, log saves, port probe — no obvious hot-path blocking. Else N/A. 3. **User gates** — agent does **not** run `dotnet build` or `dotnet test`. Print commands; require user confirmation or pasted output before PR/merge. 4. **Git + GitHub** — per `work-tracking.mdc`: resolve `#`; confirm issue is on **project #3** (add with `gh project item-add 3` if missing); commit `#: …`; push; PR body `Closes #`. -5. **Merge + Done** — only for full **ship it**: `gh pr merge --squash` after user confirms build/test; issue closes via `Closes #N`; project Status **Done** (automation or manual per [docs/ops/github-workflow.md](../../docs/ops/github-workflow.md)). +5. **CI** — PR should pass [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) (build + test on `windows-latest`). User confirms green checks before merge. +6. **Merge + Done** — only for full **ship it**: `gh pr merge --squash` after user confirms build/test; issue closes via `Closes #N`; project Status **Done** (automation or manual per [docs/ops/github-workflow.md](../../docs/ops/github-workflow.md)). ```powershell gh pr create --title "#42: Short title" --body "Closes #42`n`n## Summary`n- ...`n`n## Test plan`n- [x] dotnet build`n- [x] dotnet test" diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 0000000..5db9eb2 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,27 @@ +#!/bin/sh +# Require a GitHub issue reference in every commit (enforces work-tracking.mdc). +# Install: .\scripts\install-githooks.ps1 + +MSG_FILE="$1" +MSG=$(cat "$MSG_FILE") + +# Allow merge/revert/fixup commits +case "$MSG" in + Merge*|Revert*|fixup!*|squash!*) exit 0 ;; +esac + +# Commit message contains #N +if printf '%s' "$MSG" | grep -qE '#[0-9]+'; then + exit 0 +fi + +# Branch feature/N-... without message ref (branch still ties to issue) +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || true) +case "$BRANCH" in + feature/[0-9]*-*) exit 0 ;; +esac + +echo "commit-msg hook: commit must reference a GitHub issue (#N) in the message," >&2 +echo "or use branch feature/-short-name." >&2 +echo "Create an issue, add it to project board #3, then commit with: #: summary" >&2 +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..858a0c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + include-prerelease: true + + - name: Build solution + run: dotnet build BuildMonitor.slnx --configuration Release + + - name: Test + run: dotnet test src/BuildMonitor.Tests/BuildMonitor.Tests.csproj --configuration Release --no-build --verbosity normal diff --git a/.github/workflows/pr-issue-link.yml b/.github/workflows/pr-issue-link.yml new file mode 100644 index 0000000..5734c33 --- /dev/null +++ b/.github/workflows/pr-issue-link.yml @@ -0,0 +1,32 @@ +name: PR issue link + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + require-issue: + runs-on: ubuntu-latest + steps: + - name: Check PR links a GitHub issue + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + body="$PR_BODY" + title="$PR_TITLE" + combined="${title} + ${body}" + if echo "$combined" | grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'; then + echo "PR links an issue." + exit 0 + fi + if echo "$combined" | grep -qE '#[0-9]+'; then + echo "PR references an issue number." + exit 0 + fi + echo "::error::Pull request must reference a GitHub issue (Closes #N or #N in title/body)." + exit 1 diff --git a/docs/ops/github-workflow.md b/docs/ops/github-workflow.md index 72d8f62..5eba299 100644 --- a/docs/ops/github-workflow.md +++ b/docs/ops/github-workflow.md @@ -123,6 +123,57 @@ Full ship: commit → push → PR → squash merge → issue closed → project Load `.cursor/skills/feature-ship/SKILL.md` when the user says **ship** or **ship it**. +## Board sync (backlog) + +The board must list **all shipped work (Done)** and **planned work (Todo)**. Run after creating retrospective issues or when the board drifts: + +```powershell +.\scripts\github\Sync-ProjectBoard.ps1 +``` + +Dry run: `.\scripts\github\Sync-ProjectBoard.ps1 -WhatIf` + +The script is idempotent (skips issues whose titles already exist) and ensures closed issues **#2**, **#4**, **#6**–**#8**, **#10** are on project #3 with Status **Done**. + +### Planned work (Todo on board) + +| Theme | Issue title (created by sync script if missing) | +|-------|--------------------------------------------------| +| Tests | Run tests on file change (`OnFileChange` mode) | +| Tray UX | Open log viewer from tray context menu | +| Tray UX | WPF tray context menu (Phase 2, if #8 insufficient) | +| Optional module | Wire Azure DevOps polling module | +| Diagnostics | Verdict feedback loop for adaptive debounce | + +Update this table when adding new planned issues. + +## Enforce issue on every commit + +**Local (required for agents and developers):** + +```powershell +.\scripts\install-githooks.ps1 +``` + +The `commit-msg` hook rejects commits whose message lacks `#` unless the branch is `feature/-...` (merge/revert commits are exempt). + +**CI:** [`.github/workflows/pr-issue-link.yml`](../../.github/workflows/pr-issue-link.yml) fails PRs that do not reference an issue (`Closes #N` or `#N` in title/body). + +## CI build and test + +[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs on every push to `main` and on every pull request targeting `main`: + +| Step | Command | +|------|---------| +| Build | `dotnet build BuildMonitor.slnx --configuration Release` | +| Test | `dotnet test src/BuildMonitor.Tests/BuildMonitor.Tests.csproj --configuration Release --no-build` | + +Runner: **windows-latest** (WPF / `net10.0-windows`). SDK: **10.0.x** (`include-prerelease: true` until .NET 10 GA). + +**PRs should pass CI before merge.** Agents do not run build/test locally by default; use CI status on the PR. + +Optional: branch protection on `main` → require status check **build-and-test**. + ## Record of intent Chat and Cursor plans are not the system of record. The **project board (#3)**, **Issues**, **PR descriptions**, and **`docs/`** are. diff --git a/scripts/github/Sync-ProjectBoard.ps1 b/scripts/github/Sync-ProjectBoard.ps1 new file mode 100644 index 0000000..2ededa9 --- /dev/null +++ b/scripts/github/Sync-ProjectBoard.ps1 @@ -0,0 +1,324 @@ +# Syncs BuildMonitor project board #3 with retrospective (Done) and planned (Todo) issues. +# Run from repo root: .\scripts\github\Sync-ProjectBoard.ps1 +# Idempotent: skips issues whose titles already exist (open or closed). + +param( + [switch]$WhatIf +) + +$ErrorActionPreference = "Continue" +$gh = if (Test-Path "C:\Program Files\GitHub CLI\gh.exe") { "C:\Program Files\GitHub CLI\gh.exe" } else { "gh" } +$repo = "Unthred/BuildMonitor" +$projectOwner = "Unthred" +$projectNumber = 3 +$projectId = "PVT_kwHOAFM-l84BaXQL" +$statusFieldId = "PVTSSF_lAHOAFM-l84BaXQLzhVPZxY" +$statusDoneId = "98236657" +$statusTodoId = "f75ad846" + +function Get-ExistingIssueTitles { + $json = & $gh issue list --repo $repo --state all --limit 200 --json title 2>$null | ConvertFrom-Json + $set = @{} + foreach ($row in $json) { + $set[$row.title.ToLowerInvariant()] = $true + } + return $set +} + +function Add-IssueToProject { + param([string]$Url) + if ($WhatIf) { Write-Host "[WhatIf] project item-add $Url"; return $null } + & $gh project item-add $projectNumber --owner $projectOwner --url $Url 2>&1 | Out-Null + Start-Sleep -Milliseconds 400 + $items = & $gh project item-list $projectNumber --owner $projectOwner --format json --limit 200 | ConvertFrom-Json + foreach ($item in $items.items) { + if ($item.content.url -eq $Url) { return $item.id } + } + return $null +} + +function Set-ProjectItemStatus { + param([string]$ItemId, [string]$StatusOptionId) + if (-not $ItemId) { return } + if ($WhatIf) { Write-Host "[WhatIf] item-edit $ItemId -> $StatusOptionId"; return } + & $gh project item-edit ` + --project-id $projectId ` + --id $ItemId ` + --field-id $statusFieldId ` + --single-select-option-id $StatusOptionId 2>&1 | Out-Null +} + +function New-BoardIssue { + param( + [string]$Title, + [string]$Body, + [ValidateSet("Done", "Todo")] + [string]$BoardStatus + ) + + if ($existingTitles.ContainsKey($Title.ToLowerInvariant())) { + Write-Host "Skip (exists): $Title" + return + } + + Write-Host "Create: $Title [$BoardStatus]" + if ($WhatIf) { return } + + $url = (& $gh issue create --repo $repo --title $Title --body $Body --assignee "@me" | Out-String).Trim() + $existingTitles[$Title.ToLowerInvariant()] = $true + + if ($BoardStatus -eq "Done") { + $num = ($url -split "/")[-1] + & $gh issue close $num --repo $repo --reason completed 2>&1 | Out-Null + } + + $itemId = Add-IssueToProject -Url $url + $optionId = if ($BoardStatus -eq "Done") { $statusDoneId } else { $statusTodoId } + Set-ProjectItemStatus -ItemId $itemId -StatusOptionId $optionId +} + +function Ensure-IssueOnBoard { + param( + [int]$Number, + [ValidateSet("Done", "Todo")] + [string]$BoardStatus + ) + + $url = "https://github.com/$repo/issues/$Number" + Write-Host "Ensure on board: #$Number" + if ($WhatIf) { return } + + $items = & $gh project item-list $projectNumber --owner $projectOwner --format json --limit 200 | ConvertFrom-Json + $itemId = ($items.items | Where-Object { $_.content.number -eq $Number } | Select-Object -First 1).id + if (-not $itemId) { + $itemId = Add-IssueToProject -Url $url + } + $optionId = if ($BoardStatus -eq "Done") { $statusDoneId } else { $statusTodoId } + Set-ProjectItemStatus -ItemId $itemId -StatusOptionId $optionId +} + +$existingTitles = Get-ExistingIssueTitles + +# --- Retrospective (shipped) --- + +New-BoardIssue -Title "Foundation: GitHub workflow, tests, and no-downtime test runs" -BoardStatus Done -Body @" +## Summary +Initial delivery after repo bootstrap: Cursor rules/skills, GitHub workflow docs, xUnit test project, manual and automatic ``dotnet test``, ``--no-build`` tests while watch/run stays up, test project discovery, restart-app settings. + +## Shipped +- PR #1 +- Initial commit: local build monitor tray app + +## Acceptance criteria +- [x] ``BuildMonitor.Tests`` project with unit tests +- [x] Tray **Run tests** with live Test tab output +- [x] ``docs/ops/github-workflow.md`` and work-tracking rules +"@ + +$uxPlanShippedIn2 = @( + @{ + Title = "Bug: Status panel and log viewer disagree on error/warning counts" + Body = "Shipped in **#2** (PR #3). Context-aware build vs run counts via ``HealthIssueCountsFormatter``." + }, + @{ + Title = "Bug: Tray tooltip does not distinguish run failure or show error detail" + Body = "Shipped in **#2** (PR #3). Headline project, failure phase, and error preview in tray tooltip." + }, + @{ + Title = "Enhancement: Surface runtime errors in log viewer and auto-open Run tab" + Body = "Shipped in **#2** (PR #3). Auto-open correct tab and Errors filter on failure transition." + }, + @{ + Title = "Bug: Clicking an issue in log viewer scrolls to wrong line" + Body = "Shipped in **#2** (PR #3). Line index + text-match fallback after log truncation." + }, + @{ + Title = "Enhancement: Copy errors only in log viewer" + Body = "Shipped in **#2** (PR #3). **Copy errors** copies compiler errors only." + }, + @{ + Title = "Enhancement: Split Restart app vs Rebuild and restart with clear progress" + Body = "Shipped in **#2** (PR #3). Separate tray actions and progress during rebuild." + }, + @{ + Title = "Bug: Restart app sometimes does not restart watch/build as expected" + Body = "Shipped in **#2** (PR #3). Reliable watch restart and live log re-attach." + }, + @{ + Title = "Bug: dotnet watch rebuilds on Cursor/IDE file activity - limit watch scope" + Body = "Shipped in **#2** (PR #3). Watch exclude segments and documented ``Watch Remove`` defaults." + }, + @{ + Title = "Docs: Feature doc for health/log/restart behaviour" + Body = "Shipped in **#2** (PR #3). ``docs/features/health-and-logs.md`` and SETTINGS cross-links." + } +) + +foreach ($item in $uxPlanShippedIn2) { + New-BoardIssue -Title $item.Title -BoardStatus Done -Body $item.Body +} + +New-BoardIssue -Title "Enhancement: Per-project output lock release and MSB lock retry" -BoardStatus Done -Body @" +## Summary +Opt-in per-project **Stop processes locking build output** with graceful stop, delay, and single retry on MSB3021/3027. Non-elevated fallback when access denied. + +## Shipped +- PR #3 (#2 epic) + +## Acceptance criteria +- [x] Per-project toggle in Settings +- [x] Scoped process termination under project output paths +- [x] Toasts instead of blocking dialogs for lock-release failures +"@ + +New-BoardIssue -Title "Enhancement: Live log viewer with follow output and single window per project" -BoardStatus Done -Body @" +## Summary +Build log viewer streams live output during builds; **Follow output** checkbox; one log window per project (focus existing instead of opening duplicates). + +## Shipped +- PR #3 (#2 epic) + +## Acceptance criteria +- [x] Live tail while build/watch runs +- [x] Follow output persisted in window state +- [x] Reuse/focus existing viewer from tray and auto-open +"@ + +New-BoardIssue -Title "Enhancement: Toast lifecycle notifications and settings" -BoardStatus Done -Body @" +## Summary +Configurable toasts for build start/end/error, file-change rebuild, lock release; position and duration settings; click-to-copy on error toasts. Polished card UI in #4. + +## Shipped +- PR #3 (#2), PR #5 (#4) + +## Acceptance criteria +- [x] Per-event toast toggles in Settings +- [x] Position, duration, click-to-copy +- [x] Themed toast cards (#4) +"@ + +New-BoardIssue -Title "Enhancement: Listen URL discovery, HTTPS profile, and clickable status link" -BoardStatus Done -Body @" +## Summary +Detect listening URL from run/watch output; prefer HTTPS launch profile; clickable link in status panel; reduced run-log overhead for faster startup. + +## Shipped +- PR #3 (#2 epic) + +## Acceptance criteria +- [x] Launch profile resolution matches cmdline HTTPS usage +- [x] Clickable URL in hover status panel +- [x] Port/listen URL in project health snapshot +"@ + +New-BoardIssue -Title "Enhancement: Build progress, duplicate-build fixes, and hammer tray animation" -BoardStatus Done -Body @" +## Summary +Failed step in build progress list; prevent double build (watch + file watcher, failed rebuild restart); hammer overlay on traffic-light icon during builds; last build timestamp in status and log header. + +## Shipped +- PR #3 (#2 epic) + +## Acceptance criteria +- [x] Failed project marked in progress steps +- [x] No duplicate builds from overlapping triggers +- [x] Hammer animation on building state +"@ + +# --- Planned (Todo) --- + +New-BoardIssue -Title "Enhancement: Run tests on file change (OnFileChange mode)" -BoardStatus Todo -Body @" +## Problem +``RunTests`` only supports ``Off`` and ``OnBuildSuccess``; ``OnFileChange`` is documented as planned. + +## Acceptance criteria +- [ ] ``OnFileChange`` runs debounced ``dotnet test`` after file-triggered rebuild +- [ ] Respects same watcher debounce as builds +- [ ] Documented in ``docs/SETTINGS.md`` + +## Surfaces +Infrastructure, Settings, docs +"@ + +New-BoardIssue -Title "Enhancement: Open log viewer from tray context menu" -BoardStatus Todo -Body @" +## Problem +Log viewer is reachable from hover status panel; tray context menu does not yet expose **View log** per project. + +## Acceptance criteria +- [ ] **View log** under each project in tray menu (both layout modes) +- [ ] Reuses single window per project +- [ ] ``docs/LOGS.md`` updated + +## Surfaces +TrayApp, docs +"@ + +New-BoardIssue -Title "Enhancement: WPF tray context menu (Phase 2)" -BoardStatus Todo -Body @" +## Problem +Phase 1 health coalescing (#8) fixed most tray menu stalls. If menu remains sticky, migrate WinForms ``ContextMenuStrip`` to WPF ``ContextMenu``. + +## Acceptance criteria +- [ ] Only implement if Phase 1 insufficient in production use +- [ ] Menu responsive during heavy builds +- [ ] Exit and dismiss behave reliably + +## Surfaces +TrayApp + +## Related +#8 (Done) +"@ + +New-BoardIssue -Title "Enhancement: Wire optional Azure DevOps polling module" -BoardStatus Todo -Body @" +## Problem +``Infrastructure/AzureDevOps/`` exists but is not wired into tray startup (optional future module per ``BUILD_STATUS_MONITOR_PLAN.md``). + +## Acceptance criteria +- [ ] Settings schema for org URL and pipelines (or remove dead code) +- [ ] Tray integration behind explicit opt-in +- [ ] ADR if persistence/auth approach is chosen + +## Surfaces +Infrastructure, TrayApp, docs +"@ + +New-BoardIssue -Title "Enhancement: Diagnostics verdict feedback loop for adaptive debounce" -BoardStatus Todo -Body @" +## Problem +Adaptive debounce (#7) learns from save bursts; diagnostics verdicts could refine learning (noted out of scope in #7). + +## Acceptance criteria +- [ ] Verdict (helpful / noisy rebuild) stored per trigger journal entry +- [ ] Optional influence on debounce learning weights +- [ ] Unit tests for feedback calculator + +## Surfaces +Infrastructure, TrayApp, docs + +## Related +#7 (Done), #10 (Done) +"@ + +# --- Ensure existing issues are on the board --- + +foreach ($n in @(2, 4, 6, 7, 8, 10, 12)) { + Ensure-IssueOnBoard -Number $n -BoardStatus Done +} + +# Any other closed issue missing from the board (e.g. partial sync) → Done +if (-not $WhatIf) { + $allIssues = & $gh issue list --repo $repo --state all --limit 200 --json number,state | ConvertFrom-Json + $boardItems = (& $gh project item-list $projectNumber --owner $projectOwner --format json --limit 200 | ConvertFrom-Json).items + $onBoard = @{} + foreach ($item in $boardItems) { + if ($item.content.number) { $onBoard[$item.content.number] = $item.id } + } + foreach ($issue in $allIssues) { + if ($onBoard.ContainsKey($issue.number)) { continue } + $url = "https://github.com/$repo/issues/$($issue.number)" + Write-Host "Backfill board: #$($issue.number)" + $itemId = Add-IssueToProject -Url $url + $status = if ($issue.state -eq "OPEN") { $statusTodoId } else { $statusDoneId } + Set-ProjectItemStatus -ItemId $itemId -StatusOptionId $status + } +} + +Write-Host "" +Write-Host "Done. Board: https://github.com/users/Unthred/projects/3" diff --git a/scripts/install-githooks.ps1 b/scripts/install-githooks.ps1 new file mode 100644 index 0000000..8efb0c1 --- /dev/null +++ b/scripts/install-githooks.ps1 @@ -0,0 +1,9 @@ +# Point this repo at .githooks/ (commit-msg enforces issue #N on every commit). +# Run once from repo root: .\scripts\install-githooks.ps1 + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) + +git -C $root config core.hooksPath .githooks +Write-Host "Installed hooks from $root\.githooks (core.hooksPath=.githooks)" +Write-Host "Commits must include # or be on branch feature/-..." diff --git a/src/BuildMonitor.Tests/BuildLifecycleFormattingTests.cs b/src/BuildMonitor.Tests/BuildLifecycleFormattingTests.cs new file mode 100644 index 0000000..eaa00ad --- /dev/null +++ b/src/BuildMonitor.Tests/BuildLifecycleFormattingTests.cs @@ -0,0 +1,25 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class BuildLifecycleFormattingTests +{ + [Theory] + [InlineData(ProjectLifecycleState.BuildOk, true)] + [InlineData(ProjectLifecycleState.Watching, true)] + [InlineData(ProjectLifecycleState.Running, true)] + [InlineData(ProjectLifecycleState.BuildFailed, false)] + public void IsSuccessfulBuildEndState_recognises_success_paths( + ProjectLifecycleState state, + bool expected) + { + Assert.Equal(expected, BuildLifecycleFormatting.IsSuccessfulBuildEndState(state)); + } + + [Fact] + public void FormatBuildDuration_uses_seconds_for_short_runs() + { + Assert.Equal("3.5s", BuildLifecycleFormatting.FormatBuildDuration(TimeSpan.FromSeconds(3.5))); + } +} diff --git a/src/BuildMonitor.Tests/BuildLogParserTests.cs b/src/BuildMonitor.Tests/BuildLogParserTests.cs index 501b862..375013a 100644 --- a/src/BuildMonitor.Tests/BuildLogParserTests.cs +++ b/src/BuildMonitor.Tests/BuildLogParserTests.cs @@ -20,7 +20,7 @@ public void ParseWarningCount_counts_compiler_warnings() { const string log = """ Pages\Bar.cs(2,1): warning CS0168: unused variable - Build succeeded. + Build succeeded with 1 warning(s) in 1.0s """; Assert.Equal(1, BuildLogParser.ParseWarningCount(log)); diff --git a/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs b/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs new file mode 100644 index 0000000..9fb15ee --- /dev/null +++ b/src/BuildMonitor.Tests/TrayTooltipFormatterTests.cs @@ -0,0 +1,77 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.Tests; + +public sealed class TrayTooltipFormatterTests +{ + [Fact] + public void Format_building_uses_project_name() + { + var snapshot = new ProjectHealthSnapshot( + "p1", + "Alpha", + MonitorHealth.Green, + "OK", + ProjectLifecycleState.Building, + null, + null, + null, + 0, + 0, + DateTimeOffset.UtcNow, + null, + true, + [], + null, + false, + true, + null, + null, + false); + + var text = TrayTooltipFormatter.Format(snapshot, MonitorHealth.Green, isBuilding: true); + + Assert.Equal("Building — Alpha", text); + } + + [Fact] + public void Format_failure_includes_error_preview_truncated() + { + var longError = new string('x', 80); + var snapshot = new ProjectHealthSnapshot( + "p1", + "Beta", + MonitorHealth.Red, + "Failed", + ProjectLifecycleState.BuildFailed, + 1, + TimeSpan.FromSeconds(3), + longError, + 2, + 0, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + true, + [], + null, + false, + true, + "Build failed", + "Build", + false); + + var text = TrayTooltipFormatter.Format(snapshot, MonitorHealth.Red, isBuilding: false); + + Assert.StartsWith("Beta — Build: ", text); + Assert.True(text.Length <= TrayTooltipFormatter.MaxTooltipLength); + Assert.EndsWith("…", text); + } + + [Fact] + public void DescribeHealthTooltip_maps_rollup_colours() + { + Assert.Equal("Build monitor - Success", TrayTooltipFormatter.DescribeHealthTooltip(MonitorHealth.Green)); + Assert.Equal("Build monitor - Failed", TrayTooltipFormatter.DescribeHealthTooltip(MonitorHealth.Red)); + } +} diff --git a/src/Core/Rules/BuildLifecycleFormatting.cs b/src/Core/Rules/BuildLifecycleFormatting.cs new file mode 100644 index 0000000..c148445 --- /dev/null +++ b/src/Core/Rules/BuildLifecycleFormatting.cs @@ -0,0 +1,23 @@ +using BuildMonitor.Core.Models; + +namespace BuildMonitor.Core.Rules; + +public static class BuildLifecycleFormatting +{ + public static bool IsSuccessfulBuildEndState(ProjectLifecycleState state) => + state is ProjectLifecycleState.BuildOk + or ProjectLifecycleState.Watching + or ProjectLifecycleState.Running; + + public static string FormatBuildDuration(TimeSpan duration) + { + if (duration.TotalHours >= 1) + { + return duration.ToString(@"h\:mm\:ss"); + } + + return duration.TotalMinutes >= 1 + ? duration.ToString(@"m\:ss") + : $"{duration.TotalSeconds:F1}s"; + } +} diff --git a/src/Core/Rules/TrayTooltipFormatter.cs b/src/Core/Rules/TrayTooltipFormatter.cs new file mode 100644 index 0000000..82ebeda --- /dev/null +++ b/src/Core/Rules/TrayTooltipFormatter.cs @@ -0,0 +1,71 @@ +using BuildMonitor.Core.Models; + +namespace BuildMonitor.Core.Rules; + +public static class TrayTooltipFormatter +{ + public const int MaxTooltipLength = 63; + + public static string Format( + ProjectHealthSnapshot? headline, + MonitorHealth health, + bool isBuilding) + { + if (isBuilding) + { + var name = headline?.DisplayName ?? "project"; + return Truncate($"Building — {name}"); + } + + if (headline is null) + { + return DescribeHealthTooltip(health); + } + + if (headline.Health == MonitorHealth.Red) + { + var phase = string.IsNullOrWhiteSpace(headline.FailurePhase) + ? "Failed" + : headline.FailurePhase; + if (!string.IsNullOrWhiteSpace(headline.LastErrorPreview)) + { + return Truncate($"{headline.DisplayName} — {phase}: {headline.LastErrorPreview}"); + } + + return Truncate($"{headline.DisplayName} — {phase}"); + } + + if (headline.Health == MonitorHealth.Amber) + { + return Truncate($"{headline.DisplayName} — Warnings"); + } + + if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl)) + { + return Truncate($"{headline.DisplayName} — Site up · {headline.ListenUrl}"); + } + + return Truncate($"{headline.DisplayName} — OK"); + } + + public static string Truncate(string text, int maxLength = MaxTooltipLength) => + text.Length <= maxLength ? text : text[..(maxLength - 1)] + "…"; + + public static string DescribeHealth(MonitorHealth health) => + health switch + { + MonitorHealth.Green => "OK", + MonitorHealth.Amber => "Warnings", + MonitorHealth.Red => "Errors", + _ => "Unknown" + }; + + public static string DescribeHealthTooltip(MonitorHealth health) => + health switch + { + MonitorHealth.Green => "Build monitor - Success", + MonitorHealth.Amber => "Build monitor - Warnings", + MonitorHealth.Red => "Build monitor - Failed", + _ => "Build Monitor" + }; +} diff --git a/src/Infrastructure/BuildMonitor.Infrastructure.csproj b/src/Infrastructure/BuildMonitor.Infrastructure.csproj index 73f0eaf..ad6e30c 100644 --- a/src/Infrastructure/BuildMonitor.Infrastructure.csproj +++ b/src/Infrastructure/BuildMonitor.Infrastructure.csproj @@ -15,4 +15,8 @@ enable + + + + diff --git a/src/Infrastructure/Services/ProjectOrchestrator.cs b/src/Infrastructure/Services/ProjectOrchestrator.cs index 1376de6..381e93f 100644 --- a/src/Infrastructure/Services/ProjectOrchestrator.cs +++ b/src/Infrastructure/Services/ProjectOrchestrator.cs @@ -391,2308 +391,3 @@ public void Dispose() } } } - -internal sealed class ProjectRuntime : IDisposable -{ - private readonly BuildLogStore logStore; - private readonly BuildTriggerJournal triggerJournal; - private readonly FileChangeBurstStatsStore burstStatsStore; - private readonly DotNetCliRunner cliRunner; - private Action? notifyUser; - private SupervisedProcess? runProcess; - private DebouncedFileWatcher? fileWatcher; - private LocalProjectDefinition definition; - private ProjectLifecycleState state = ProjectLifecycleState.Idle; - private MonitorHealth health = MonitorHealth.Unknown; - private int restartCount; - private string? lastErrorPreview; - private int buildErrorCount; - private int buildWarningCount; - private int runErrorCount; - private int runWarningCount; - private bool isRestarting; - private readonly object liveOutputSync = new(); - private readonly StringBuilder liveBuildOutput = new(); - private readonly StringBuilder liveTestOutput = new(); - private int liveOutputRevision; - private int liveTestOutputRevision; - private int testInProgress; - private int testNumber; - private string pendingTestReason = "tests"; - private bool watchRebuildInProgress; - private int lastBuildExitCode = -1; - private int? lastExitCode; - private TimeSpan? lastDuration; - private DateTimeOffset? lastBuildFinishedAtUtc; - private DateTimeOffset lastChangedUtc = DateTimeOffset.UtcNow; - private DateTimeOffset lastLiveCountParseUtc = DateTimeOffset.MinValue; - private IReadOnlyList progressSteps = []; - private BuildProgressTracker? buildProgressTracker; - private int buildInProgress; - private int buildTriggeredByFileChange; - private bool pendingFileChangeRebuild; - private DateTimeOffset fileChangeBuildCooldownUntil = DateTimeOffset.MinValue; - private DateTimeOffset lastWatchFileChangeNotifyUtc = DateTimeOffset.MinValue; - private DateTimeOffset lastHotReloadRestartRequestUtc = DateTimeOffset.MinValue; - private int fileChangeDebounceMs = 3000; - private int manualFileChangeDebounceMs = 3000; - private FileChangeDebounceMode debounceMode = FileChangeDebounceMode.Manual; - private bool coalesceWatchRebuilds = true; - private int pendingHotReloadRestartRequest; - private int buildNumber; - private string pendingBuildReason = "startup"; - private DateTimeOffset lastMeaningfulFileChangeUtc = DateTimeOffset.MinValue; - private int fileChangeRebuildScheduleGeneration; - private readonly Queue recentFileChangeBuildStarts = new(); - private IReadOnlyList lastFileChangePaths = []; - private int runProcessGeneration; - private Action? runProcessExitedHandler; - private string? pendingListenUrl; - private IReadOnlyList candidateListenUrls = []; - private bool listenUrlReady; - private bool listenUrlNotified; - private int runOutputSaveRevision; - private Timer? listenUrlPollTimer; - private Timer? runLogSaveTimer; - - private int healthDirty; - - private readonly List registeredWorkerIds = []; - private readonly Dictionary lastWorkerHeartbeatUtc = new(StringComparer.OrdinalIgnoreCase); - - public event Action? HealthCoalesceRequested; - - public string ProjectId => definition.Id; - public string DisplayName => definition.DisplayName; - public bool IsRunProcessActive => runProcess?.IsRunning == true; - public bool RestartAppAfterRebuild => definition.RunOptions.RestartAppAfterRebuild; - - public ProjectHealthSnapshot Snapshot => BuildSnapshot(); - - public ProjectHealthSnapshot BuildSnapshot() - { - RefreshHealth(); - var (displayErrors, displayWarnings) = HealthIssueCountsFormatter.SelectPrimaryCounts( - state, - buildErrorCount, - buildWarningCount, - runErrorCount, - runWarningCount); - return new ProjectHealthSnapshot( - definition.Id, - definition.DisplayName, - health, - ProjectHealthEvaluator.ToLabel(health), - state, - lastExitCode, - lastDuration, - lastErrorPreview, - displayErrors, - displayWarnings, - lastChangedUtc, - lastBuildFinishedAtUtc, - definition.IsActiveInSession, - progressSteps, - ResolveDisplayListenUrl(), - listenUrlReady, - definition.RunOptions.RunMode != ProjectRunMode.None, - HealthIssueCountsFormatter.FormatStatusLine( - state, - buildErrorCount, - buildWarningCount, - runErrorCount, - runWarningCount), - HealthIssueCountsFormatter.FormatFailurePhase(state), - isRestarting); - } - - public void MarkHealthDirty() => Interlocked.Exchange(ref healthDirty, 1); - - public bool TryCoalesceHealth() - { - if (Interlocked.Exchange(ref healthDirty, 0) == 0) - { - return false; - } - - CoalesceHealthCore(); - return true; - } - - public void ForceCoalesceHealth() - { - Interlocked.Exchange(ref healthDirty, 0); - CoalesceHealthCore(); - } - - private void CoalesceHealthCore() - { - RefreshLiveIssueCounts(force: true); - RefreshHealth(); - } - - private void RequestHealthCoalesce(bool immediate = false) - { - MarkHealthDirty(); - HealthCoalesceRequested?.Invoke(immediate); - } - - public ProjectRuntime( - LocalProjectDefinition definition, - BuildLogStore logStore, - DotNetCliRunner cliRunner, - BuildTriggerJournal triggerJournal, - FileChangeBurstStatsStore burstStatsStore, - Action? notifyUser = null) - { - this.definition = definition; - this.logStore = logStore; - this.cliRunner = cliRunner; - this.triggerJournal = triggerJournal; - this.burstStatsStore = burstStatsStore; - this.notifyUser = notifyUser; - RegisterProjectWorkers(); - } - - public void UpdateDefinition(LocalProjectDefinition updated, GlobalMonitorSettings? monitor = null) - { - definition = updated; - baseOutputPathWarningShown = false; - if (monitor is null) - { - return; - } - - if (monitor.FileChangeDebounceMs > 0) - { - manualFileChangeDebounceMs = monitor.FileChangeDebounceMs; - } - - debounceMode = monitor.FileChangeDebounceMode; - fileChangeDebounceMs = ResolveFileChangeDebounceMs(); - fileWatcher?.SetDebounceMs(fileChangeDebounceMs); - coalesceWatchRebuilds = monitor.CoalesceWatchRebuilds; - } - - private int ResolveFileChangeDebounceMs() => - AdaptiveFileChangeDebounce.ResolveEffectiveDebounce( - debounceMode, - manualFileChangeDebounceMs, - burstStatsStore.GetOrDefault(definition.Id)); - - private int GetSessionAdjustedFileChangeDebounceMs() - { - PruneRecentFileChangeBuildStarts(); - return AdaptiveFileChangeDebounce.ApplySessionPressure( - ResolveFileChangeDebounceMs(), - recentFileChangeBuildStarts.Count); - } - - private void SyncFileWatcherDebounceMs() - { - var effective = GetSessionAdjustedFileChangeDebounceMs(); - if (effective != fileChangeDebounceMs) - { - fileChangeDebounceMs = effective; - fileWatcher?.SetDebounceMs(effective); - } - } - - private DateTimeOffset GetFileChangeQuietUntilUtc() => - lastMeaningfulFileChangeUtc == DateTimeOffset.MinValue - ? DateTimeOffset.UtcNow - : AdaptiveFileChangeDebounce.ComputeQuietUntilUtc( - lastMeaningfulFileChangeUtc, - GetSessionAdjustedFileChangeDebounceMs()); - - private void NoteFileChangeBuildStarted() - { - recentFileChangeBuildStarts.Enqueue(DateTimeOffset.UtcNow); - PruneRecentFileChangeBuildStarts(); - SyncFileWatcherDebounceMs(); - } - - private void PruneRecentFileChangeBuildStarts() - { - var cutoff = DateTimeOffset.UtcNow.AddSeconds(-90); - while (recentFileChangeBuildStarts.Count > 0 - && recentFileChangeBuildStarts.Peek() < cutoff) - { - recentFileChangeBuildStarts.Dequeue(); - } - } - - private bool IsAgentEditSessionActive() - { - PruneRecentFileChangeBuildStarts(); - return recentFileChangeBuildStarts.Count >= 1; - } - - public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings monitor) - { - PruneRecentFileChangeBuildStarts(); - var stats = burstStatsStore.GetOrDefault(definition.Id); - var liveDebounceMs = GetSessionAdjustedFileChangeDebounceMs(); - DateTimeOffset? rebuildQuietUntilUtc = pendingFileChangeRebuild - && lastMeaningfulFileChangeUtc != DateTimeOffset.MinValue - ? AdaptiveFileChangeDebounce.ComputeQuietUntilUtc( - lastMeaningfulFileChangeUtc, - liveDebounceMs) - : null; - - return BuildIntelligenceSnapshot.Create( - definition, - monitor, - stats, - manualFileChangeDebounceMs, - debounceMode, - ResolveFileChangeDebounceMs(), - liveDebounceMs, - recentFileChangeBuildStarts.Count, - coalesceWatchRebuilds, - lastMeaningfulFileChangeUtc == DateTimeOffset.MinValue ? null : lastMeaningfulFileChangeUtc, - pendingFileChangeRebuild, - rebuildQuietUntilUtc); - } - - private bool UsesCoalescedWatchRebuilds() => - definition.RunOptions.RunMode == ProjectRunMode.Watch && coalesceWatchRebuilds; - - private bool UsesDotNetWatchProcess() => - definition.RunOptions.RunMode == ProjectRunMode.Watch && !UsesCoalescedWatchRebuilds(); - - private bool ShouldStartFileWatcher() - { - if (definition.RunOptions.FileChanges == FileChangeMode.Off) - { - return false; - } - - if (UsesCoalescedWatchRebuilds()) - { - return true; - } - - return definition.RunOptions.FileChanges == FileChangeMode.TriggerRebuild - && definition.RunOptions.RunMode != ProjectRunMode.Watch; - } - - public void SetUserNotifier(Action? notifier) => - notifyUser = notifier; - - private static (int Errors, int Warnings) CountLiveIssues(BuildLogKind kind, string normalized) => - kind switch - { - BuildLogKind.Run => ( - DotNetRunOutputParser.ParseErrorCount(normalized), - DotNetRunOutputParser.ParseWarningCount(normalized)), - BuildLogKind.Test => CountTestIssues(normalized), - _ => ( - BuildLogParser.ParseErrorCount(normalized), - BuildLogParser.ParseWarningCount(normalized)) - }; - - private static (int Errors, int Warnings) CountTestIssues(string normalized) - { - var testIssues = DotNetTestOutputParser.ParseIssues(normalized); - return (testIssues.Count(i => i.IsError), testIssues.Count(i => !i.IsError)); - } - - public void PrepareBuild(string reason) => pendingBuildReason = reason; - - public void PrepareTest(string reason) => pendingTestReason = reason; - - public LiveBuildLogView? GetLiveBuildLogView(BuildLogKind kind) - { - var isDirectBuild = Volatile.Read(ref buildInProgress) != 0 - || state is ProjectLifecycleState.Building; - var isWatchRebuild = watchRebuildInProgress && runProcess?.IsRunning == true; - var isRunLive = kind == BuildLogKind.Run && runProcess?.IsRunning == true; - var isTestLive = kind == BuildLogKind.Test - && (Volatile.Read(ref testInProgress) != 0 || state is ProjectLifecycleState.Testing); - - if (kind == BuildLogKind.Run) - { - if (!isRunLive) - { - return null; - } - } - else if (kind == BuildLogKind.Test) - { - if (!isTestLive) - { - return null; - } - } - else if (kind != BuildLogKind.Build || (!isDirectBuild && !isWatchRebuild)) - { - return null; - } - - string text; - lock (liveOutputSync) - { - text = kind switch - { - BuildLogKind.Test => liveTestOutput.ToString(), - BuildLogKind.Build when isDirectBuild => liveBuildOutput.ToString(), - _ => runProcess?.Output ?? string.Empty - }; - } - - var normalized = BuildLogTextNormalizer.Normalize(text); - var revision = kind == BuildLogKind.Test - ? Volatile.Read(ref liveTestOutputRevision) - : Volatile.Read(ref liveOutputRevision); - var (liveErrors, liveWarnings) = CountLiveIssues(kind, normalized); - return new LiveBuildLogView( - normalized, - true, - state, - liveErrors, - liveWarnings, - revision); - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - SetProjectCurrentAction("Starting — loading saved build state"); - await HydrateLastBuildFromStoreAsync(cancellationToken); - await BuildAsync(cancellationToken); - - if (definition.RunOptions.RunMode == ProjectRunMode.None) - { - return; - } - - if (lastBuildExitCode != 0) - { - RefreshHealth(); - return; - } - - // Build already completed above — skip watch/run's embedded rebuild. - StartRunProcess(skipEmbeddedBuild: true); - TryStartFileWatcher(); - } - - private void TryStartFileWatcher() - { - if (!ShouldStartFileWatcher()) - { - return; - } - - try - { - fileChangeDebounceMs = ResolveFileChangeDebounceMs(); - fileWatcher = new DebouncedFileWatcher( - definition.RootFolder, - fileChangeDebounceMs, - WatchExcludeSegments.Parse(definition.RunOptions.WatchExcludeSegments)); - fileWatcher.Changed += OnFileWatcherChanged; - } - catch (Exception ex) - { - notifyUser?.Invoke( - definition.Id, - $"File watcher disabled — {definition.DisplayName}", - $"Could not watch '{definition.RootFolder}': {ex.Message}", - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - } - } - - public async Task BuildAsync(CancellationToken cancellationToken) - { - if (Interlocked.CompareExchange(ref buildInProgress, 1, 0) != 0) - { - return; - } - - var triggeredByFileChange = Volatile.Read(ref buildTriggeredByFileChange) != 0; - if (triggeredByFileChange) - { - NoteFileChangeBuildStarted(); - } - - var buildReason = triggeredByFileChange - ? pendingBuildReason switch - { - "file change (queued)" => "file change (queued)", - _ => "file change" - } - : pendingBuildReason; - pendingBuildReason = "startup"; - var fileChangePaths = triggeredByFileChange ? lastFileChangePaths : null; - lastFileChangePaths = []; - RecordBuildTrigger( - BuildTriggerKindFormatter.FromBuildReason(buildReason, triggeredByFileChange), - buildReason, - detail: null, - fileChangePaths); - - fileWatcher?.Suspend(); - - try - { - if (runProcess is not null) - { - SetProjectCurrentAction("Building — stopping app"); - await StopRunProcessAsync(cancellationToken); - await Task.Delay(500, cancellationToken); - } - - lock (liveOutputSync) - { - liveBuildOutput.Clear(); - } - - watchRebuildInProgress = false; - Interlocked.Exchange(ref liveOutputRevision, 0); - buildErrorCount = 0; - buildWarningCount = 0; - lastErrorPreview = null; - - var buildBanner = WriteBuildStartBanner(buildReason); - SetState(ProjectLifecycleState.Building); - SetProjectCurrentAction($"Building — {buildReason}"); - - buildProgressTracker = new BuildProgressTracker(); - buildProgressTracker.Reset(); - progressSteps = buildProgressTracker.Steps; - NotifyProgressChanged(force: true); - - var releaseLocks = definition.RunOptions.ReleaseOutputLocksBeforeBuild; - if (releaseLocks) - { - SetProjectCurrentAction("Building — releasing output locks"); - await ReleaseOutputLocksAsync(cancellationToken); - } - - SetProjectCurrentAction("Building — dotnet build"); - var args = BuildProjectArgs(); - var result = await RunBuildAttemptAsync(args, cancellationToken, buildBanner); - - if (releaseLocks - && result.ExitCode != 0 - && BuildLogParser.IsOutputLockError(result.Output)) - { - await ReleaseOutputLocksAsync(cancellationToken); - await Task.Delay(1000, cancellationToken); - - lock (liveOutputSync) - { - liveBuildOutput.Clear(); - } - - Interlocked.Exchange(ref liveOutputRevision, 0); - var retryBanner = WriteBuildStartBanner($"{buildReason} (lock retry)"); - buildProgressTracker = new BuildProgressTracker(); - buildProgressTracker.Reset(); - progressSteps = buildProgressTracker.Steps; - NotifyProgressChanged(force: true); - - result = await RunBuildAttemptAsync(args, cancellationToken, retryBanner); - } - - if (result.ExitCode != 0 - && definition.RunOptions.AutoRepairCorruptedOutput - && CorruptedOutputTreeDetector.IsCorruptedTreeFailure(result.Output, definition.RootFolder)) - { - SetProjectCurrentAction("Building — repairing output folders"); - var repair = await RepairBuildOutputInternalAsync(cancellationToken, restartAfter: false); - if (repair.Repaired) - { - notifyUser?.Invoke( - definition.Id, - $"Repaired build output — {definition.DisplayName}", - $"Removed {string.Join(", ", repair.RemovedFolders)}. Retrying build…", - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - - lock (liveOutputSync) - { - liveBuildOutput.Clear(); - } - - Interlocked.Exchange(ref liveOutputRevision, 0); - var repairBanner = WriteBuildStartBanner($"{buildReason} (output repair retry)"); - buildProgressTracker = new BuildProgressTracker(); - buildProgressTracker.Reset(); - progressSteps = buildProgressTracker.Steps; - NotifyProgressChanged(force: true); - result = await RunBuildAttemptAsync(args, cancellationToken, repairBanner); - } - } - - lastBuildExitCode = result.ExitCode; - lastExitCode = result.ExitCode; - lastDuration = result.Duration; - - var finishBanner = BuildMonitorLogBanner.FormatFinished(buildNumber, result.ExitCode); - var logText = result.Output + Environment.NewLine + finishBanner; - - var buildLog = await logStore.SaveAsync( - definition.Id, - BuildLogKind.Build, - result.CommandLine, - result.ExitCode, - DateTimeOffset.UtcNow - result.Duration, - logText, - cancellationToken); - - lastBuildFinishedAtUtc = buildLog.FinishedAtUtc; - buildErrorCount = buildLog.ErrorCount; - buildWarningCount = BuildLogParser.ParseWarningCount(result.Output); - lastErrorPreview = buildLog.ErrorLines.FirstOrDefault(); - if (result.Duration.TotalMilliseconds > 0) - { - burstStatsStore.RecordBuildDuration(definition.Id, (int)result.Duration.TotalMilliseconds); - } - - if (result.ExitCode == 0) - { - SetState(ProjectLifecycleState.BuildOk); - if (definition.RunOptions.RunTests == TestRunTrigger.OnBuildSuccess) - { - PrepareTest("build success"); - await TestAsync(cancellationToken); - } - } - else - { - SetState(ProjectLifecycleState.BuildFailed); - } - - if (buildProgressTracker is not null) - { - if (buildProgressTracker.FinalizeFromResult(result.ExitCode, result.Output)) - { - progressSteps = buildProgressTracker.Steps; - NotifyProgressChanged(force: true); - } - } - - buildProgressTracker = null; - - var restartedAfterBuild = false; - if (definition.RunOptions.RestartAppAfterRebuild - && definition.RunOptions.RunMode != ProjectRunMode.None - && result.ExitCode == 0 - && runProcess?.IsRunning != true) - { - if (triggeredByFileChange) - { - await Task.Delay(1500, cancellationToken); - } - - StartRunProcess(skipEmbeddedBuild: true); - restartedAfterBuild = true; - } - - ApplyPendingHotReloadRestartAfterBuild(result.ExitCode, restartedAfterBuild); - } - finally - { - Interlocked.Exchange(ref buildInProgress, 0); - Interlocked.Exchange(ref buildTriggeredByFileChange, 0); - fileWatcher?.Resume(); - - if (triggeredByFileChange) - { - fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddMilliseconds( - GetSessionAdjustedFileChangeDebounceMs()); - } - else - { - var quietUntil = DateTimeOffset.UtcNow.AddMilliseconds(Math.Min(fileChangeDebounceMs / 2, 2000)); - if (quietUntil > fileChangeBuildCooldownUntil) - { - fileChangeBuildCooldownUntil = quietUntil; - } - } - - if (pendingFileChangeRebuild && lastFileChangePaths.Count > 0) - { - pendingFileChangeRebuild = false; - _ = ScheduleCoalescedFileChangeRebuildAsync(); - } - } - } - - private async Task ScheduleCoalescedFileChangeRebuildAsync() - { - var generation = Interlocked.Increment(ref fileChangeRebuildScheduleGeneration); - - while (generation == Volatile.Read(ref fileChangeRebuildScheduleGeneration)) - { - var waitUntil = GetFileChangeQuietUntilUtc(); - if (fileChangeBuildCooldownUntil > waitUntil) - { - waitUntil = fileChangeBuildCooldownUntil; - } - - var delay = waitUntil - DateTimeOffset.UtcNow; - if (delay > TimeSpan.Zero) - { - await Task.Delay(delay); - continue; - } - - if (Volatile.Read(ref buildInProgress) != 0) - { - pendingFileChangeRebuild = true; - return; - } - - if (DateTimeOffset.UtcNow < GetFileChangeQuietUntilUtc()) - { - continue; - } - - break; - } - - if (generation != Volatile.Read(ref fileChangeRebuildScheduleGeneration)) - { - return; - } - - if (Volatile.Read(ref buildInProgress) != 0) - { - pendingFileChangeRebuild = true; - return; - } - - pendingFileChangeRebuild = false; - Interlocked.Exchange(ref buildTriggeredByFileChange, 1); - pendingBuildReason = "file change (queued)"; - - notifyUser?.Invoke( - definition.Id, - $"File change — {definition.DisplayName}", - "Source change detected. Rebuilding…", - UserNotificationKind.Info, - UserNotificationCategory.FileChangeDetected); - - await BuildAsync(CancellationToken.None); - } - - private async Task HydrateLastBuildFromStoreAsync(CancellationToken cancellationToken) - { - var metadata = await logStore.LoadMetadataAsync(definition.Id, BuildLogKind.Build, cancellationToken); - if (metadata is null) - { - return; - } - - lastBuildExitCode = metadata.ExitCode; - lastExitCode = metadata.ExitCode; - lastDuration = metadata.FinishedAtUtc - metadata.StartedAtUtc; - lastBuildFinishedAtUtc = metadata.FinishedAtUtc; - buildErrorCount = metadata.ErrorCount; - lastErrorPreview = metadata.ErrorLines.FirstOrDefault(); - var logText = await logStore.LoadLogTextAsync(metadata, maxBytes: 512_000, cancellationToken); - if (!string.IsNullOrWhiteSpace(logText)) - { - buildWarningCount = BuildLogParser.ParseWarningCount(logText); - if (buildErrorCount == 0) - { - buildErrorCount = BuildLogParser.ParseErrorCount(logText); - } - } - - RefreshHealth(); - HealthCoalesceRequested?.Invoke(true); - } - - private string WriteBuildStartBanner(string reason) - { - var banner = BuildMonitorLogBanner.Format(Interlocked.Increment(ref buildNumber), reason); - lock (liveOutputSync) - { - liveBuildOutput.AppendLine(banner); - liveBuildOutput.AppendLine(string.Empty); - } - - Interlocked.Increment(ref liveOutputRevision); - return banner; - } - - private async Task RunBuildAttemptAsync( - List args, - CancellationToken cancellationToken, - string? logBanner = null) => - await cliRunner.RunAsync( - definition.RootFolder, - args, - cancellationToken, - OnBuildOutputLine, - logBanner); - - private async Task ReleaseOutputLocksAsync(CancellationToken cancellationToken) - { - var releaseResult = await OutputLockReleaser.ReleaseAsync( - definition.RootFolder, - definition.ProjectFile, - cancellationToken); - - if (notifyUser is null) - { - return; - } - - if (releaseResult.Failures.Count > 0) - { - var lines = new List(); - if (releaseResult.ProcessesStopped > 0) - { - lines.Add($"Stopped {releaseResult.ProcessesStopped} process(es)."); - } - - lines.AddRange(releaseResult.Failures.Take(4)); - - var accessDeniedOnly = releaseResult.Failures.All(OutputLockReleaser.IsAccessDeniedFailure); - if (accessDeniedOnly) - { - lines.Add(string.Empty); - lines.Add("Build Monitor cannot stop some processes without permission."); - lines.Add("Close the running app yourself, or turn off \"Stop processes locking build output\" in Settings."); - } - - notifyUser( - definition.Id, - accessDeniedOnly - ? $"Couldn't release locks — {definition.DisplayName}" - : $"Lock release issues — {definition.DisplayName}", - string.Join(Environment.NewLine, lines), - accessDeniedOnly ? UserNotificationKind.Warning : UserNotificationKind.Error, - accessDeniedOnly ? UserNotificationCategory.Warning : UserNotificationCategory.Error); - return; - } - - if (releaseResult.ProcessesStopped > 0) - { - notifyUser( - definition.Id, - $"Released locks — {definition.DisplayName}", - string.Join(Environment.NewLine, releaseResult.StoppedDescriptions.Take(4)), - UserNotificationKind.Info, - UserNotificationCategory.Info); - } - } - - private void OnFileWatcherChanged(IReadOnlyList changedPaths, int burstDurationMs) - { - if (burstDurationMs > 0) - { - burstStatsStore.RecordBurst(definition.Id, burstDurationMs); - } - - var meaningful = WatchIgnoreRules.FilterMeaningfulPaths( - changedPaths, - WatchExcludeSegments.Parse(definition.RunOptions.WatchExcludeSegments)); - if (meaningful.Count == 0) - { - return; - } - - lastMeaningfulFileChangeUtc = DateTimeOffset.UtcNow; - HeartbeatProjectWorker("file-watcher", $"{meaningful.Count} file(s)"); - SetProjectCurrentAction($"File change — rebuild pending ({meaningful.Count} file(s))"); - - lastFileChangePaths = RelativizePaths(meaningful); - SyncFileWatcherDebounceMs(); - - if (DateTimeOffset.UtcNow < fileChangeBuildCooldownUntil) - { - pendingFileChangeRebuild = true; - return; - } - - if (Volatile.Read(ref testInProgress) != 0) - { - pendingFileChangeRebuild = true; - return; - } - - if (Volatile.Read(ref buildInProgress) != 0) - { - pendingFileChangeRebuild = true; - return; - } - - if (IsAgentEditSessionActive()) - { - pendingFileChangeRebuild = true; - _ = ScheduleCoalescedFileChangeRebuildAsync(); - return; - } - - Interlocked.Exchange(ref buildTriggeredByFileChange, 1); - pendingBuildReason = "file change"; - - notifyUser?.Invoke( - definition.Id, - $"File change — {definition.DisplayName}", - "Source change detected. Rebuilding…", - UserNotificationKind.Info, - UserNotificationCategory.FileChangeDetected); - - _ = BuildAsync(CancellationToken.None); - } - - private void OnRunProcessOutputLine(string line) - { - Interlocked.Increment(ref liveOutputRevision); - HeartbeatProjectWorker("run-output"); - - if (DotNetRunOutputParser.TryExtractListeningUrl(line, out var parsedUrl)) - { - var hadUrl = !string.IsNullOrWhiteSpace(pendingListenUrl); - pendingListenUrl = parsedUrl; - var wasReady = listenUrlReady; - RefreshListenUrlReady(); - if (!hadUrl || listenUrlReady != wasReady) - { - NotifyProgressChanged(force: true); - } - } - - if (DotNetRunOutputParser.IsHostTerminatedLine(line) - || DotNetRunOutputParser.IsFatalStartupLine(line)) - { - lastErrorPreview = line.Trim(); - runErrorCount = Math.Max(runErrorCount, 1); - SetState(ProjectLifecycleState.Crashed); - notifyUser?.Invoke( - definition.Id, - $"App failed to start — {definition.DisplayName}", - line.Trim(), - UserNotificationKind.Error, - UserNotificationCategory.Error); - SaveRunOutputIfChanged(force: true); - return; - } - - TryHandleHotReloadRestartRequest(line); - - if (UsesDotNetWatchProcess()) - { - HandleWatchProcessOutputLine(line); - } - - MarkHealthDirty(); - HealthCoalesceRequested?.Invoke(false); - } - - private void HandleWatchProcessOutputLine(string line) - { - if (DotNetWatchOutput.IsWatchBuildingLine(line)) - { - RecordBuildTrigger( - BuildTriggerKind.DotNetWatchCompile, - "dotnet watch compile started", - detail: line.Trim()); - watchRebuildInProgress = true; - return; - } - - if (DotNetWatchOutput.IsBuildFailedLine(line)) - { - watchRebuildInProgress = false; - lastBuildExitCode = 1; - lastErrorPreview = line.Trim(); - buildErrorCount = Math.Max(buildErrorCount, 1); - RefreshBuildIssueCountsFromWatchOutput(force: true); - if (runProcess?.IsRunning == true) - { - RefreshHealth(); - NotifyProgressChanged(force: true); - HealthCoalesceRequested?.Invoke(true); - } - else - { - SetState(ProjectLifecycleState.BuildFailed); - } - - return; - } - - if (DotNetWatchOutput.IsBuildSucceededLine(line)) - { - var wasWatchRebuild = watchRebuildInProgress; - watchRebuildInProgress = false; - lastBuildExitCode = 0; - RefreshBuildIssueCountsFromWatchOutput(force: true); - if (state is ProjectLifecycleState.BuildFailed) - { - SetState(ProjectLifecycleState.Watching); - } - - if (wasWatchRebuild) - { - notifyUser?.Invoke( - definition.Id, - $"Build succeeded — {definition.DisplayName}", - "Watch rebuild completed successfully.", - UserNotificationKind.Info, - UserNotificationCategory.BuildSuccess); - } - - RequestHealthCoalesce(immediate: true); - return; - } - - if (!DotNetWatchOutput.IsFileChangeLine(line)) - { - return; - } - - if (watchRebuildInProgress - || Volatile.Read(ref testInProgress) != 0 - || DateTimeOffset.UtcNow < fileChangeBuildCooldownUntil) - { - return; - } - - watchRebuildInProgress = true; - listenUrlReady = false; - listenUrlNotified = false; - RecordBuildTrigger( - BuildTriggerKind.DotNetWatchFileChange, - "dotnet watch detected a file change", - detail: line.Trim()); - - var now = DateTimeOffset.UtcNow; - var notifyCooldown = TimeSpan.FromMilliseconds(Math.Max(fileChangeDebounceMs, 2000)); - if (now - lastWatchFileChangeNotifyUtc < notifyCooldown) - { - return; - } - - lastWatchFileChangeNotifyUtc = now; - notifyUser?.Invoke( - definition.Id, - $"File change — {definition.DisplayName}", - "Source change detected. Rebuilding…", - UserNotificationKind.Info, - UserNotificationCategory.FileChangeDetected); - } - - private void StartRunLogSaveTimer() - { - StopRunLogSaveTimer(); - runLogSaveTimer = new Timer( - _ => SaveRunOutputIfChanged(), - null, - TimeSpan.FromSeconds(8), - TimeSpan.FromSeconds(8)); - } - - private void StopRunLogSaveTimer() - { - runLogSaveTimer?.Dispose(); - runLogSaveTimer = null; - } - - private void SaveRunOutputIfChanged(bool force = false) - { - var process = runProcess; - if (process is null) - { - return; - } - - var revision = Volatile.Read(ref liveOutputRevision); - if (!force && revision == runOutputSaveRevision) - { - return; - } - - var output = BuildLogTextNormalizer.Normalize(process.Output); - if (string.IsNullOrWhiteSpace(output)) - { - return; - } - - runOutputSaveRevision = revision; - var commandLine = process.CommandLine; - var exitCode = state is ProjectLifecycleState.Crashed ? 1 : 0; - - _ = Task.Run(async () => - { - try - { - await logStore.SaveAsync( - definition.Id, - BuildLogKind.Run, - commandLine, - exitCode, - DateTimeOffset.UtcNow, - output, - CancellationToken.None); - } - catch - { - // Best effort only — never block the hosted app on log I/O. - } - }); - } - - public void EnsureRunProcessStartedAfterBuild() - { - if (definition.RunOptions.RunMode == ProjectRunMode.None || lastBuildExitCode != 0) - { - return; - } - - if (runProcess?.IsRunning == true) - { - return; - } - - StartRunProcess(skipEmbeddedBuild: true); - } - - public Task RestartAppAsync(CancellationToken cancellationToken) => - RestartAppCoreAsync(rebuildFirst: false, cancellationToken); - - public Task RebuildAndRestartAsync(CancellationToken cancellationToken) => - RestartAppCoreAsync(rebuildFirst: true, cancellationToken, "rebuild & restart"); - - private async Task RestartAppCoreAsync( - bool rebuildFirst, - CancellationToken cancellationToken, - string? buildReason = null) - { - if (definition.RunOptions.RunMode == ProjectRunMode.None) - { - return; - } - - if (Volatile.Read(ref buildInProgress) != 0) - { - notifyUser?.Invoke( - definition.Id, - $"Restart skipped — {definition.DisplayName}", - "Wait for the current build to finish, then try again.", - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - return; - } - - isRestarting = true; - HealthCoalesceRequested?.Invoke(true); - - try - { - await StopRunProcessAsync(cancellationToken); - restartCount = 0; - runErrorCount = 0; - runWarningCount = 0; - - if (rebuildFirst) - { - PrepareBuild(buildReason ?? "rebuild & restart"); - await BuildAsync(cancellationToken); - } - else if (buildReason == "hot reload restart") - { - RecordBuildTrigger( - BuildTriggerKind.HotReloadRestart, - "Hot reload requested app restart (no rebuild)", - detail: null); - } - - EnsureRunProcessStartedAfterBuild(); - } - finally - { - isRestarting = false; - HealthCoalesceRequested?.Invoke(true); - } - } - - private void OnBuildOutputLine(string line) - { - lock (liveOutputSync) - { - liveBuildOutput.AppendLine(line); - } - - Interlocked.Increment(ref liveOutputRevision); - HeartbeatProjectWorker("build-output"); - - if (buildProgressTracker is not null && buildProgressTracker.OnOutputLine(line)) - { - progressSteps = buildProgressTracker.Steps; - RequestHealthCoalesce(immediate: false); - } - else - { - MarkHealthDirty(); - HealthCoalesceRequested?.Invoke(false); - } - - TryHandleHotReloadRestartRequest(line); - } - - private void TryHandleHotReloadRestartRequest(string line) - { - var request = HotReloadRestartDetector.Classify(line); - if (request == HotReloadRestartRequest.None) - { - return; - } - - if (!definition.RunOptions.AutoRestartOnHotReloadRequest - || definition.RunOptions.RunMode == ProjectRunMode.None) - { - return; - } - - if (ShouldDeferRestartToDotNetWatch(line, request)) - { - return; - } - - ScheduleHotReloadRestart(request); - } - - private bool ShouldDeferRestartToDotNetWatch(string line, HotReloadRestartRequest request) => - request == HotReloadRestartRequest.RestartApp - && UsesDotNetWatchProcess() - && definition.RunOptions.AutoRestartOnWatchChanges - && HotReloadRestartDetector.IsWatchAutoRestartMessage(line); - - private void ScheduleHotReloadRestart(HotReloadRestartRequest request) - { - if (isRestarting || Volatile.Read(ref testInProgress) != 0) - { - return; - } - - var now = DateTimeOffset.UtcNow; - if (now - lastHotReloadRestartRequestUtc < TimeSpan.FromSeconds(5)) - { - UpgradePendingHotReloadRestartRequest(request); - return; - } - - if (Volatile.Read(ref buildInProgress) != 0) - { - UpgradePendingHotReloadRestartRequest(request); - return; - } - - if (runProcess?.IsRunning != true) - { - if (request == HotReloadRestartRequest.RebuildAndRestart) - { - lastHotReloadRestartRequestUtc = now; - _ = ExecuteHotReloadRestartAsync(HotReloadRestartRequest.RebuildAndRestart); - } - - return; - } - - lastHotReloadRestartRequestUtc = now; - _ = ExecuteHotReloadRestartAsync(request); - } - - private void UpgradePendingHotReloadRestartRequest(HotReloadRestartRequest request) - { - if (request == HotReloadRestartRequest.RebuildAndRestart) - { - Volatile.Write(ref pendingHotReloadRestartRequest, (int)HotReloadRestartRequest.RebuildAndRestart); - } - else if (Volatile.Read(ref pendingHotReloadRestartRequest) == 0) - { - Volatile.Write(ref pendingHotReloadRestartRequest, (int)request); - } - } - - private void ApplyPendingHotReloadRestartAfterBuild(int exitCode, bool restartedAfterBuild) - { - var pending = (HotReloadRestartRequest)Interlocked.Exchange(ref pendingHotReloadRestartRequest, 0); - if (pending == HotReloadRestartRequest.None || exitCode != 0) - { - return; - } - - if (restartedAfterBuild) - { - return; - } - - if (definition.RunOptions.RunMode == ProjectRunMode.None) - { - return; - } - - StartRunProcess(skipEmbeddedBuild: true); - } - - private async Task ExecuteHotReloadRestartAsync(HotReloadRestartRequest request) - { - if (isRestarting - || Volatile.Read(ref buildInProgress) != 0 - || Volatile.Read(ref testInProgress) != 0) - { - UpgradePendingHotReloadRestartRequest(request); - return; - } - - notifyUser?.Invoke( - definition.Id, - request == HotReloadRestartRequest.RebuildAndRestart - ? $"Rebuild required — {definition.DisplayName}" - : $"Restart required — {definition.DisplayName}", - "Output indicated hot reload could not apply the latest changes. Restarting automatically.", - UserNotificationKind.Info, - UserNotificationCategory.Info); - - try - { - await RestartAppCoreAsync( - rebuildFirst: request == HotReloadRestartRequest.RebuildAndRestart, - CancellationToken.None, - request == HotReloadRestartRequest.RebuildAndRestart - ? "hot reload rebuild" - : "hot reload restart"); - } - catch (Exception ex) - { - notifyUser?.Invoke( - definition.Id, - $"Auto-restart failed — {definition.DisplayName}", - ex.Message, - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - } - } - - private bool RefreshBuildIssueCountsFromWatchOutput(bool force) - { - if (!UsesDotNetWatchProcess() || runProcess is null) - { - return false; - } - - var now = DateTimeOffset.UtcNow; - if (!force && (now - lastLiveCountParseUtc).TotalMilliseconds < 150) - { - return false; - } - - lastLiveCountParseUtc = now; - var output = BuildLogTextNormalizer.Normalize(runProcess.Output); - if (string.IsNullOrWhiteSpace(output)) - { - return false; - } - - var parsedErrors = BuildLogParser.ParseErrorCount(output); - var parsedWarnings = BuildLogParser.ParseWarningCount(output); - if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount) - { - return false; - } - - buildErrorCount = parsedErrors; - buildWarningCount = parsedWarnings; - return true; - } - - private bool RefreshLiveIssueCounts(bool force) - { - if (state is not (ProjectLifecycleState.Building or ProjectLifecycleState.Testing)) - { - return false; - } - - var now = DateTimeOffset.UtcNow; - if (!force && (now - lastLiveCountParseUtc).TotalMilliseconds < 150) - { - return false; - } - - lastLiveCountParseUtc = now; - string output; - lock (liveOutputSync) - { - output = state == ProjectLifecycleState.Testing - ? liveTestOutput.ToString() - : liveBuildOutput.ToString(); - } - - var parsedErrors = BuildLogParser.ParseErrorCount(output); - var parsedWarnings = BuildLogParser.ParseWarningCount(output); - if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount) - { - return false; - } - - buildErrorCount = parsedErrors; - buildWarningCount = parsedWarnings; - return true; - } - - private void NotifyProgressChanged(bool force = false) => - RequestHealthCoalesce(force); - - public async Task TestAsync(CancellationToken cancellationToken) - { - if (Interlocked.CompareExchange(ref testInProgress, 1, 0) != 0) - { - notifyUser?.Invoke( - definition.Id, - $"Tests skipped — {definition.DisplayName}", - "Tests are already running for this project.", - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - return; - } - - if (Volatile.Read(ref buildInProgress) != 0) - { - Interlocked.Exchange(ref testInProgress, 0); - notifyUser?.Invoke( - definition.Id, - $"Tests skipped — {definition.DisplayName}", - "Wait for the current build to finish, then try again.", - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - return; - } - - var testReason = pendingTestReason; - pendingTestReason = "tests"; - var wasRunProcessActive = runProcess?.IsRunning == true; - var releaseLocksSetting = definition.RunOptions.ReleaseOutputLocksBeforeBuild; - var stoppedAppForTests = false; - - fileWatcher?.Suspend(); - fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddMinutes(2); - - try - { - lock (liveOutputSync) - { - liveTestOutput.Clear(); - } - - Interlocked.Exchange(ref liveTestOutputRevision, 0); - buildErrorCount = 0; - buildWarningCount = 0; - lastErrorPreview = null; - - var resolution = TestProjectDiscovery.Resolve( - definition.RootFolder, - definition.ProjectFile, - definition.TestProjectFile); - - if (resolution.Targets.Count == 0) - { - WriteTestStartBanner(testReason, [], resolution.DiscoveryNote); - SetState(ProjectLifecycleState.TestFailed); - lastErrorPreview = resolution.DiscoveryNote; - buildErrorCount = 1; - return; - } - - WriteTestStartBanner(testReason, resolution); - SetState(ProjectLifecycleState.Testing); - NotifyProgressChanged(force: true); - - var startedAtUtc = DateTimeOffset.UtcNow; - var commandLines = new List(); - var exitCode = 0; - var wallDuration = TimeSpan.Zero; - - for (var i = 0; i < resolution.Targets.Count; i++) - { - var target = resolution.Targets[i]; - if (resolution.Targets.Count > 1) - { - AppendTestSectionHeader(i + 1, resolution.Targets.Count, target); - } - - var targetRun = await RunTestTargetWithRetryAsync( - target, - wasRunProcessActive, - releaseLocksSetting, - cancellationToken); - - stoppedAppForTests |= targetRun.StoppedApp; - commandLines.Add(targetRun.Result.CommandLine); - wallDuration += targetRun.Result.Duration; - if (targetRun.Result.ExitCode != 0) - { - exitCode = targetRun.Result.ExitCode; - } - } - - string logText; - lock (liveOutputSync) - { - logText = liveTestOutput.ToString(); - } - - var testsExecuted = DotNetTestOutputParser.LooksLikeTestsExecuted(logText); - var testSummary = DotNetTestOutputParser.TryParseSummary(logText); - var summaryLine = testSummary is not null - ? DotNetTestOutputParser.FormatSummaryLine(testSummary) - : DescribeMissingTestSummary(logText, testsExecuted); - var finishBanner = BuildMonitorLogBanner.FormatTestFinished( - testNumber, - testsExecuted ? exitCode : 1, - summaryLine, - wallDuration); - lock (liveOutputSync) - { - liveTestOutput.AppendLine(finishBanner); - } - - Interlocked.Increment(ref liveTestOutputRevision); - - lock (liveOutputSync) - { - logText = liveTestOutput.ToString(); - } - - var parsed = BuildLogParser.ParseErrors(logText); - var effectiveExitCode = testsExecuted ? exitCode : 1; - await logStore.SaveAsync( - definition.Id, - BuildLogKind.Test, - string.Join(" && ", commandLines), - effectiveExitCode, - startedAtUtc, - logText, - cancellationToken); - - if (effectiveExitCode == 0) - { - SetState(ProjectLifecycleState.TestOk); - } - else - { - buildErrorCount = Math.Max(parsed.ErrorCount, testsExecuted ? 0 : 1); - buildWarningCount = BuildLogParser.ParseWarningCount(logText); - lastErrorPreview = parsed.ErrorLines.FirstOrDefault() - ?? summaryLine - ?? "No tests were executed"; - SetState(ProjectLifecycleState.TestFailed); - } - } - finally - { - Interlocked.Exchange(ref testInProgress, 0); - fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddSeconds(10); - fileWatcher?.Resume(); - - if (stoppedAppForTests - && wasRunProcessActive - && definition.RunOptions.RunMode != ProjectRunMode.None) - { - _ = RestartRunProcessAfterTestsAsync(); - } - - HealthCoalesceRequested?.Invoke(true); - } - } - - private sealed record TestTargetRunResult(CliRunResult Result, bool StoppedApp); - - private async Task RestartRunProcessAfterTestsAsync() - { - await Task.Delay(2500); - - if (Volatile.Read(ref testInProgress) != 0 || Volatile.Read(ref buildInProgress) != 0) - { - return; - } - - StartRunProcess(skipEmbeddedBuild: true); - } - - private async Task RunTestTargetWithRetryAsync( - string target, - bool wasRunProcessActive, - bool releaseLocksSetting, - CancellationToken cancellationToken) - { - var stoppedApp = false; - CliRunResult result; - var usedNoBuild = false; - - if (TestRunPlanner.RequiresFullBuildFromStart(lastBuildExitCode)) - { - stoppedApp = await StopAppForTestBuildIfNeededAsync( - wasRunProcessActive, - "stopping run/watch to rebuild before tests", - cancellationToken); - await ReleaseLocksForTestBuildIfNeededAsync(releaseLocksSetting, stoppedApp, cancellationToken); - result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: false), cancellationToken); - } - else - { - AppendTestNote("running tests while app stays up (--no-build)"); - usedNoBuild = true; - result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: true), cancellationToken); - - if (!DotNetTestOutputParser.LooksLikeTestsExecuted(result.Output) - && DotNetTestOutputParser.LooksLikeNeedsFullBuildBeforeTest(result.Output)) - { - usedNoBuild = false; - stoppedApp = await StopAppForTestBuildIfNeededAsync( - wasRunProcessActive, - "test assemblies stale — stopping app briefly to rebuild", - cancellationToken); - await ReleaseLocksForTestBuildIfNeededAsync(releaseLocksSetting, stoppedApp, cancellationToken); - result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: false), cancellationToken); - } - } - - var shouldReleaseLocks = TestRunPlanner.ShouldReleaseLocksForTestBuild(releaseLocksSetting, stoppedApp); - var finalResult = await RetryTestOnLockErrorAsync( - result, - target, - usedNoBuild, - shouldReleaseLocks, - wasRunProcessActive, - cancellationToken); - - return new TestTargetRunResult(finalResult.Result, finalResult.StoppedApp || stoppedApp); - } - - private async Task StopAppForTestBuildIfNeededAsync( - bool wasRunProcessActive, - string note, - CancellationToken cancellationToken) - { - if (!wasRunProcessActive || runProcess?.IsRunning != true) - { - return false; - } - - AppendTestNote(note); - await StopRunProcessAsync(cancellationToken); - return true; - } - - private async Task ReleaseLocksForTestBuildIfNeededAsync( - bool releaseLocksSetting, - bool stoppedApp, - CancellationToken cancellationToken) - { - if (!TestRunPlanner.ShouldReleaseLocksForTestBuild(releaseLocksSetting, stoppedApp)) - { - return; - } - - await ReleaseOutputLocksAsync(cancellationToken); - } - - private async Task RetryTestOnLockErrorAsync( - CliRunResult result, - string target, - bool noBuild, - bool shouldReleaseLocks, - bool wasRunProcessActive, - CancellationToken cancellationToken) - { - if (result.ExitCode == 0 || !BuildLogParser.IsOutputLockError(result.Output)) - { - return new TestTargetRunResult(result, false); - } - - var stoppedApp = false; - if (shouldReleaseLocks || wasRunProcessActive) - { - stoppedApp = await StopAppForTestBuildIfNeededAsync( - wasRunProcessActive, - "output locked — stopping app before retrying tests", - cancellationToken); - AppendTestNote("output locked — releasing and retrying tests"); - await ReleaseOutputLocksAsync(cancellationToken); - await Task.Delay(1000, cancellationToken); - result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: false), cancellationToken); - } - - return new TestTargetRunResult(result, stoppedApp); - } - - private async Task RunTestAttemptAsync( - List args, - CancellationToken cancellationToken) => - await cliRunner.RunAsync( - definition.RootFolder, - args, - cancellationToken, - OnTestOutputLine); - - private void AppendTestNote(string note) - { - lock (liveOutputSync) - { - liveTestOutput.AppendLine($"[BuildMonitor] {note}"); - liveTestOutput.AppendLine(string.Empty); - } - - Interlocked.Increment(ref liveTestOutputRevision); - } - - private string WriteTestStartBanner(string reason, TestTargetResolution resolution) - { - var banner = BuildMonitorLogBanner.FormatTest(Interlocked.Increment(ref testNumber), reason); - lock (liveOutputSync) - { - liveTestOutput.AppendLine(banner); - liveTestOutput.AppendLine($"[BuildMonitor] {resolution.DiscoveryNote}"); - if (resolution.Targets.Count == 1) - { - var tryNoBuild = lastBuildExitCode == 0; - liveTestOutput.AppendLine( - $"dotnet {string.Join(' ', BuildTestArgs(resolution.Targets[0], tryNoBuild))}" - + (tryNoBuild ? " (app stays up; brief stop only if assemblies are stale)" : string.Empty)); - } - - liveTestOutput.AppendLine(string.Empty); - } - - Interlocked.Increment(ref liveTestOutputRevision); - return banner; - } - - private void WriteTestStartBanner(string reason, IReadOnlyList args, string note) - { - var banner = BuildMonitorLogBanner.FormatTest(Interlocked.Increment(ref testNumber), reason); - lock (liveOutputSync) - { - liveTestOutput.AppendLine(banner); - liveTestOutput.AppendLine($"[BuildMonitor] {note}"); - if (args.Count > 0) - { - liveTestOutput.AppendLine($"dotnet {string.Join(' ', args)}"); - } - - liveTestOutput.AppendLine(string.Empty); - } - - Interlocked.Increment(ref liveTestOutputRevision); - } - - private void AppendTestSectionHeader(int index, int total, string target) - { - lock (liveOutputSync) - { - liveTestOutput.AppendLine($"[BuildMonitor] --- Test target {index}/{total}: {target} ---"); - liveTestOutput.AppendLine($"dotnet {string.Join(' ', BuildTestArgs(target, lastBuildExitCode == 0))}"); - liveTestOutput.AppendLine(string.Empty); - } - - Interlocked.Increment(ref liveTestOutputRevision); - } - - private static string? DescribeMissingTestSummary(string logText, bool testsExecuted) - { - if (testsExecuted) - { - return null; - } - - if (BuildLogParser.IsOutputLockError(logText)) - { - return "build failed — app executable is locked; enable Stop processes locking build output in settings"; - } - - if (logText.Contains("No test is available", StringComparison.OrdinalIgnoreCase) - || logText.Contains("No tests found", StringComparison.OrdinalIgnoreCase)) - { - return "no tests discovered in target — set Test project / solution in settings"; - } - - if (DotNetTestOutputParser.LooksLikeRestoreOrBuildOnly(logText)) - { - return "no tests executed (build did not reach test host) — check build errors above"; - } - - return "no tests executed"; - } - - private void OnTestOutputLine(string line) - { - lock (liveOutputSync) - { - liveTestOutput.AppendLine(line); - } - - Interlocked.Increment(ref liveTestOutputRevision); - HeartbeatProjectWorker("test-output"); - RequestHealthCoalesce(immediate: false); - } - - private void StartRunProcess(bool skipEmbeddedBuild = false) - { - SetProjectCurrentAction(skipEmbeddedBuild - ? "Starting app (dotnet run --no-build)" - : "Starting app (dotnet run)"); - StopRunProcess(); - WarnIfRiskyBaseOutputPath(); - - runProcessGeneration++; - var generation = runProcessGeneration; - - runProcess = new SupervisedProcess(definition.Id); - runProcess.OutputLineReceived += OnRunProcessOutputLine; - - runProcessExitedHandler = (_, exitCode) => - { - if (generation != runProcessGeneration) - { - return; - } - - OnRunProcessExited(exitCode); - }; - runProcess.Exited += runProcessExitedHandler; - - var args = UsesDotNetWatchProcess() - ? BuildWatchArgs(skipEmbeddedBuild) - : BuildRunArgs(skipEmbeddedBuild); - - candidateListenUrls = LaunchProfileEnvironmentApplier.ResolveListenUrls( - definition.RootFolder, - definition.ProjectFile, - definition.LaunchProfile); - pendingListenUrl = candidateListenUrls.FirstOrDefault(); - listenUrlReady = false; - listenUrlNotified = false; - runOutputSaveRevision = 0; - StartListenUrlPolling(); - StartRunLogSaveTimer(); - - runProcess.Start( - definition.RootFolder, - args, - psi => - { - LaunchProfileEnvironmentApplier.ApplyTo( - psi, - definition.RootFolder, - definition.ProjectFile, - definition.LaunchProfile); - - if (UsesDotNetWatchProcess() - && !definition.RunOptions.AutoRestartOnWatchChanges) - { - psi.Environment["DOTNET_WATCH_RESTART_ON_RUDE_EDIT"] = "0"; - } - }); - - NotifyProgressChanged(force: true); - - SetState(definition.RunOptions.RunMode == ProjectRunMode.Watch - || UsesCoalescedWatchRebuilds() - ? ProjectLifecycleState.Watching - : ProjectLifecycleState.Running); - } - - private void OnRunProcessExited(int exitCode) - { - var exitedProcess = runProcess; - if (exitedProcess is null) - { - return; - } - - StopListenUrlPolling(); - StopRunLogSaveTimer(); - SaveRunOutputIfChanged(force: true); - listenUrlReady = false; - listenUrlNotified = false; - lastExitCode = exitCode; - var runOutput = exitedProcess.Output; - runErrorCount = DotNetRunOutputParser.ParseErrorCount(runOutput); - runWarningCount = DotNetRunOutputParser.ParseWarningCount(runOutput); - if (exitCode != 0 && runErrorCount == 0) - { - runErrorCount = 1; - } - - if (exitCode != 0 && definition.RunOptions.RestartOnCrash && restartCount < definition.RunOptions.MaxRestartRetries) - { - restartCount++; - SetState(ProjectLifecycleState.Crashed); - StartRunProcess(skipEmbeddedBuild: true); - return; - } - - if (exitCode != 0) - { - _ = logStore.SaveAsync( - definition.Id, - BuildLogKind.Run, - exitedProcess.CommandLine, - exitCode, - DateTimeOffset.UtcNow, - exitedProcess.Output, - CancellationToken.None); - SetState(ProjectLifecycleState.Crashed); - } - else - { - SetState(ProjectLifecycleState.Idle); - } - } - - private void StopRunProcess() - { - StopListenUrlPolling(); - StopRunLogSaveTimer(); - - if (runProcess is null) - { - return; - } - - SaveRunOutputIfChanged(force: true); - runProcessGeneration++; - DetachRunProcessHandlers(); - runProcess.Stop(); - runProcess = null; - } - - private async Task StopRunProcessAsync(CancellationToken cancellationToken) - { - if (runProcess is null) - { - return; - } - - runProcessGeneration++; - DetachRunProcessHandlers(); - await runProcess.StopGracefullyAsync(cancellationToken); - runProcess = null; - } - - private void DetachRunProcessHandlers() - { - if (runProcess is null) - { - return; - } - - runProcess.OutputLineReceived -= OnRunProcessOutputLine; - if (runProcessExitedHandler is not null) - { - runProcess.Exited -= runProcessExitedHandler; - runProcessExitedHandler = null; - } - } - - private List BuildProjectArgs() - { - var args = new List { "build", ResolveProjectFileArg() }; - AppendExtraArgs(args); - return args; - } - - private List BuildTestArgs(string testTargetPath, bool noBuild = false) - { - var args = new List - { - "test", - testTargetPath, - "--verbosity", - "normal", - "--logger", - "console;verbosity=detailed" - }; - - if (noBuild) - { - args.Add("--no-build"); - } - - AppendExtraArgs(args); - return args; - } - - private List BuildRunArgs(bool skipEmbeddedBuild = false) - { - var args = new List { "run", "--project", ResolveProjectFileArg() }; - if (skipEmbeddedBuild) - { - args.Add("--no-build"); - } - - if (!string.IsNullOrWhiteSpace(definition.LaunchProfile)) - { - args.AddRange(["--launch-profile", definition.LaunchProfile]); - } - - AppendExtraArgs(args); - return args; - } - - private List BuildWatchArgs(bool skipEmbeddedBuild = false) - { - var args = new List { "watch" }; - if (definition.RunOptions.AutoRestartOnWatchChanges) - { - // Tray host has no stdin for restart prompts — auto-restart when enabled per project. - args.Add("--non-interactive"); - } - - args.AddRange(["run", "--project", ResolveProjectFileArg()]); - if (skipEmbeddedBuild) - { - args.Add("--no-build"); - } - - if (!string.IsNullOrWhiteSpace(definition.LaunchProfile)) - { - args.AddRange(["--launch-profile", definition.LaunchProfile]); - } - - AppendExtraArgs(args); - return args; - } - - private void AppendExtraArgs(List args) - { - if (string.IsNullOrWhiteSpace(definition.ExtraDotNetArgs)) - { - return; - } - - args.AddRange(definition.ExtraDotNetArgs.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - } - - private string ResolveProjectFileArg() => - Path.IsPathRooted(definition.ProjectFile) - ? definition.ProjectFile - : Path.Combine(definition.RootFolder, definition.ProjectFile); - - private void SetState(ProjectLifecycleState newState) - { - state = newState; - lastChangedUtc = DateTimeOffset.UtcNow; - var action = FormatLifecycleAction(newState); - SetProjectCurrentAction(action); - HeartbeatProjectWorker("state", newState.ToString()); - RefreshHealth(); - HealthCoalesceRequested?.Invoke(true); - } - - private static string FormatLifecycleAction(ProjectLifecycleState state) => - state switch - { - ProjectLifecycleState.Idle => "Idle", - ProjectLifecycleState.Building => "Building", - ProjectLifecycleState.BuildOk => "Build succeeded", - ProjectLifecycleState.BuildFailed => "Build failed", - ProjectLifecycleState.Running => "App running", - ProjectLifecycleState.Watching => "Watching for file changes", - ProjectLifecycleState.Crashed => "App crashed", - ProjectLifecycleState.Testing => "Running tests", - ProjectLifecycleState.TestOk => "Tests passed", - ProjectLifecycleState.TestFailed => "Tests failed", - _ => state.ToString() - }; - - private void RefreshHealth() - { - var (displayErrors, displayWarnings) = HealthIssueCountsFormatter.SelectPrimaryCounts( - state, - buildErrorCount, - buildWarningCount, - runErrorCount, - runWarningCount); - health = ProjectHealthEvaluator.Evaluate( - state, - lastBuildExitCode, - displayErrors, - displayWarnings, - inProgress: isRestarting - || state is ProjectLifecycleState.Building - || state is ProjectLifecycleState.Testing); - } - - private string? ResolveDisplayListenUrl() - { - if (definition.RunOptions.RunMode == ProjectRunMode.None) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(pendingListenUrl)) - { - return pendingListenUrl; - } - - if (candidateListenUrls.Count > 0) - { - return candidateListenUrls[0]; - } - - return LaunchProfileEnvironmentApplier.ResolvePrimaryListenUrl( - definition.RootFolder, - definition.ProjectFile, - definition.LaunchProfile); - } - - private void RefreshListenUrlReady() - { - if (runProcess?.IsRunning != true - || state is not (ProjectLifecycleState.Running or ProjectLifecycleState.Watching)) - { - listenUrlReady = false; - return; - } - - var urlsToProbe = candidateListenUrls.Count > 0 - ? candidateListenUrls - : string.IsNullOrWhiteSpace(pendingListenUrl) ? [] : new[] { pendingListenUrl }; - - foreach (var url in urlsToProbe) - { - if (LocalPortProbe.IsHttpEndpointOpen(url)) - { - MarkListenUrlReady(url); - return; - } - } - - listenUrlReady = false; - } - - private void StartListenUrlPolling() - { - StopListenUrlPolling(); - listenUrlPollTimer = new Timer( - _ => PollListenUrl(), - null, - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(1)); - } - - private void StopListenUrlPolling() - { - listenUrlPollTimer?.Dispose(); - listenUrlPollTimer = null; - } - - private void PollListenUrl() - { - if (listenUrlReady) - { - return; - } - - RefreshListenUrlReady(); - } - - private void MarkListenUrlReady(string url) - { - pendingListenUrl = url; - if (listenUrlReady) - { - return; - } - - listenUrlReady = true; - StopListenUrlPolling(); - NotifyProgressChanged(force: true); - - if (listenUrlNotified) - { - return; - } - - listenUrlNotified = true; - var openUrl = LocalPortProbe.NormalizeBrowserUrl(url); - notifyUser?.Invoke( - definition.Id, - $"App running — {definition.DisplayName}", - $"Open {openUrl}", - UserNotificationKind.Info, - UserNotificationCategory.Info); - } - - public async Task RepairBuildOutputAsync( - CancellationToken cancellationToken, - bool restartAfter) - { - fileWatcher?.Suspend(); - try - { - if (runProcess is not null) - { - await StopRunProcessAsync(cancellationToken); - await Task.Delay(500, cancellationToken); - } - - var result = BuildOutputTreeRepairer.Repair(definition.RootFolder); - if (result.Repaired && restartAfter && definition.RunOptions.RunMode != ProjectRunMode.None) - { - StartRunProcess(skipEmbeddedBuild: false); - } - - return result; - } - finally - { - fileWatcher?.Resume(); - } - } - - private Task RepairBuildOutputInternalAsync( - CancellationToken cancellationToken, - bool restartAfter) => - RepairBuildOutputAsync(cancellationToken, restartAfter); - - private bool baseOutputPathWarningShown; - - private void WarnIfRiskyBaseOutputPath() - { - if (baseOutputPathWarningShown - || definition.RunOptions.RunMode != ProjectRunMode.Watch - || !CorruptedOutputTreeDetector.HasRiskyBaseOutputPath(definition.ExtraDotNetArgs)) - { - return; - } - - baseOutputPathWarningShown = true; - notifyUser?.Invoke( - definition.Id, - $"Risky build args — {definition.DisplayName}", - "Extra dotnet args include BaseOutputPath while watch mode is enabled. " - + "This can corrupt artifacts/bin/obj output trees. Remove BaseOutputPath for local watch.", - UserNotificationKind.Warning, - UserNotificationCategory.Warning); - } - - public Task StopAsync() - { - fileWatcher?.Dispose(); - fileWatcher = null; - StopListenUrlPolling(); - StopRunProcess(); - buildProgressTracker = null; - progressSteps = []; - SetState(ProjectLifecycleState.Idle); - return Task.CompletedTask; - } - - private void RecordBuildTrigger( - BuildTriggerKind kind, - string summary, - string? detail, - IReadOnlyList? changedPaths = null) - { - triggerJournal.Record(new BuildTriggerRecord( - Guid.NewGuid().ToString("N"), - definition.Id, - definition.DisplayName, - DateTimeOffset.UtcNow, - kind, - summary, - detail, - changedPaths is { Count: > 0 } ? changedPaths : null, - InferredCause: BuildTriggerInference.Infer(kind, detail, changedPaths))); - } - - private IReadOnlyList RelativizePaths(IReadOnlyList fullPaths) - { - if (fullPaths.Count == 0) - { - return []; - } - - var root = Path.GetFullPath(definition.RootFolder); - var results = new List(fullPaths.Count); - foreach (var path in fullPaths) - { - try - { - var full = Path.GetFullPath(path); - results.Add(Path.GetRelativePath(root, full)); - } - catch - { - results.Add(path); - } - } - - return results; - } - - public void Dispose() - { - UnregisterProjectWorkers(); - StopListenUrlPolling(); - StopRunLogSaveTimer(); - fileWatcher?.Dispose(); - runProcess?.Dispose(); - } - - private string ProjectWorkerId(string suffix) => $"project.{definition.Id}.{suffix}"; - - private void RegisterProjectWorkers() - { - var registry = WorkerHealthRegistry.Shared; - void Register(string suffix, string label, TimeSpan staleAfter) - { - var id = ProjectWorkerId(suffix); - registry.Register(id, $"{definition.DisplayName} — {label}", staleAfter, "Project"); - registeredWorkerIds.Add(id); - } - - Register("build-output", "build output", TimeSpan.FromSeconds(5)); - Register("run-output", "run output", TimeSpan.FromSeconds(10)); - Register("test-output", "test output", TimeSpan.FromSeconds(10)); - Register("file-watcher", "file watcher", TimeSpan.FromMinutes(30)); - Register("state", "lifecycle", TimeSpan.FromMinutes(10)); - } - - private void UnregisterProjectWorkers() - { - var registry = WorkerHealthRegistry.Shared; - foreach (var id in registeredWorkerIds) - { - registry.Unregister(id); - } - - registeredWorkerIds.Clear(); - lastWorkerHeartbeatUtc.Clear(); - } - - private void SetProjectCurrentAction(string action) - { - WorkerHealthRegistry.Shared.SetCurrentAction(ProjectWorkerId("state"), action); - } - - private void HeartbeatProjectWorker(string suffix, string? note = null) - { - var id = ProjectWorkerId(suffix); - var now = DateTimeOffset.UtcNow; - if (lastWorkerHeartbeatUtc.TryGetValue(id, out var last) - && (now - last).TotalMilliseconds < 500) - { - return; - } - - lastWorkerHeartbeatUtc[id] = now; - WorkerHealthRegistry.Shared.Heartbeat( - id, - note, - Environment.CurrentManagedThreadId); - } -} diff --git a/src/Infrastructure/Services/ProjectRuntime.cs b/src/Infrastructure/Services/ProjectRuntime.cs new file mode 100644 index 0000000..f9a099d --- /dev/null +++ b/src/Infrastructure/Services/ProjectRuntime.cs @@ -0,0 +1,2313 @@ +using System.Text; +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; +using BuildMonitor.Infrastructure.Diagnostics; +using BuildMonitor.Infrastructure.LocalBuild; + +namespace BuildMonitor.Infrastructure.Services; + +internal sealed class ProjectRuntime : IDisposable +{ + private readonly BuildLogStore logStore; + private readonly BuildTriggerJournal triggerJournal; + private readonly FileChangeBurstStatsStore burstStatsStore; + private readonly DotNetCliRunner cliRunner; + private Action? notifyUser; + private SupervisedProcess? runProcess; + private DebouncedFileWatcher? fileWatcher; + private LocalProjectDefinition definition; + private ProjectLifecycleState state = ProjectLifecycleState.Idle; + private MonitorHealth health = MonitorHealth.Unknown; + private int restartCount; + private string? lastErrorPreview; + private int buildErrorCount; + private int buildWarningCount; + private int runErrorCount; + private int runWarningCount; + private bool isRestarting; + private readonly object liveOutputSync = new(); + private readonly StringBuilder liveBuildOutput = new(); + private readonly StringBuilder liveTestOutput = new(); + private int liveOutputRevision; + private int liveTestOutputRevision; + private int testInProgress; + private int testNumber; + private string pendingTestReason = "tests"; + private bool watchRebuildInProgress; + private int lastBuildExitCode = -1; + private int? lastExitCode; + private TimeSpan? lastDuration; + private DateTimeOffset? lastBuildFinishedAtUtc; + private DateTimeOffset lastChangedUtc = DateTimeOffset.UtcNow; + private DateTimeOffset lastLiveCountParseUtc = DateTimeOffset.MinValue; + private IReadOnlyList progressSteps = []; + private BuildProgressTracker? buildProgressTracker; + private int buildInProgress; + private int buildTriggeredByFileChange; + private bool pendingFileChangeRebuild; + private DateTimeOffset fileChangeBuildCooldownUntil = DateTimeOffset.MinValue; + private DateTimeOffset lastWatchFileChangeNotifyUtc = DateTimeOffset.MinValue; + private DateTimeOffset lastHotReloadRestartRequestUtc = DateTimeOffset.MinValue; + private int fileChangeDebounceMs = 3000; + private int manualFileChangeDebounceMs = 3000; + private FileChangeDebounceMode debounceMode = FileChangeDebounceMode.Manual; + private bool coalesceWatchRebuilds = true; + private int pendingHotReloadRestartRequest; + private int buildNumber; + private string pendingBuildReason = "startup"; + private DateTimeOffset lastMeaningfulFileChangeUtc = DateTimeOffset.MinValue; + private int fileChangeRebuildScheduleGeneration; + private readonly Queue recentFileChangeBuildStarts = new(); + private IReadOnlyList lastFileChangePaths = []; + private int runProcessGeneration; + private Action? runProcessExitedHandler; + private string? pendingListenUrl; + private IReadOnlyList candidateListenUrls = []; + private bool listenUrlReady; + private bool listenUrlNotified; + private int runOutputSaveRevision; + private Timer? listenUrlPollTimer; + private Timer? runLogSaveTimer; + + private int healthDirty; + + private readonly List registeredWorkerIds = []; + private readonly Dictionary lastWorkerHeartbeatUtc = new(StringComparer.OrdinalIgnoreCase); + + public event Action? HealthCoalesceRequested; + + public string ProjectId => definition.Id; + public string DisplayName => definition.DisplayName; + public bool IsRunProcessActive => runProcess?.IsRunning == true; + public bool RestartAppAfterRebuild => definition.RunOptions.RestartAppAfterRebuild; + + public ProjectHealthSnapshot Snapshot => BuildSnapshot(); + + public ProjectHealthSnapshot BuildSnapshot() + { + RefreshHealth(); + var (displayErrors, displayWarnings) = HealthIssueCountsFormatter.SelectPrimaryCounts( + state, + buildErrorCount, + buildWarningCount, + runErrorCount, + runWarningCount); + return new ProjectHealthSnapshot( + definition.Id, + definition.DisplayName, + health, + ProjectHealthEvaluator.ToLabel(health), + state, + lastExitCode, + lastDuration, + lastErrorPreview, + displayErrors, + displayWarnings, + lastChangedUtc, + lastBuildFinishedAtUtc, + definition.IsActiveInSession, + progressSteps, + ResolveDisplayListenUrl(), + listenUrlReady, + definition.RunOptions.RunMode != ProjectRunMode.None, + HealthIssueCountsFormatter.FormatStatusLine( + state, + buildErrorCount, + buildWarningCount, + runErrorCount, + runWarningCount), + HealthIssueCountsFormatter.FormatFailurePhase(state), + isRestarting); + } + + public void MarkHealthDirty() => Interlocked.Exchange(ref healthDirty, 1); + + public bool TryCoalesceHealth() + { + if (Interlocked.Exchange(ref healthDirty, 0) == 0) + { + return false; + } + + CoalesceHealthCore(); + return true; + } + + public void ForceCoalesceHealth() + { + Interlocked.Exchange(ref healthDirty, 0); + CoalesceHealthCore(); + } + + private void CoalesceHealthCore() + { + RefreshLiveIssueCounts(force: true); + RefreshHealth(); + } + + private void RequestHealthCoalesce(bool immediate = false) + { + MarkHealthDirty(); + HealthCoalesceRequested?.Invoke(immediate); + } + + public ProjectRuntime( + LocalProjectDefinition definition, + BuildLogStore logStore, + DotNetCliRunner cliRunner, + BuildTriggerJournal triggerJournal, + FileChangeBurstStatsStore burstStatsStore, + Action? notifyUser = null) + { + this.definition = definition; + this.logStore = logStore; + this.cliRunner = cliRunner; + this.triggerJournal = triggerJournal; + this.burstStatsStore = burstStatsStore; + this.notifyUser = notifyUser; + RegisterProjectWorkers(); + } + + public void UpdateDefinition(LocalProjectDefinition updated, GlobalMonitorSettings? monitor = null) + { + definition = updated; + baseOutputPathWarningShown = false; + if (monitor is null) + { + return; + } + + if (monitor.FileChangeDebounceMs > 0) + { + manualFileChangeDebounceMs = monitor.FileChangeDebounceMs; + } + + debounceMode = monitor.FileChangeDebounceMode; + fileChangeDebounceMs = ResolveFileChangeDebounceMs(); + fileWatcher?.SetDebounceMs(fileChangeDebounceMs); + coalesceWatchRebuilds = monitor.CoalesceWatchRebuilds; + } + + private int ResolveFileChangeDebounceMs() => + AdaptiveFileChangeDebounce.ResolveEffectiveDebounce( + debounceMode, + manualFileChangeDebounceMs, + burstStatsStore.GetOrDefault(definition.Id)); + + private int GetSessionAdjustedFileChangeDebounceMs() + { + PruneRecentFileChangeBuildStarts(); + return AdaptiveFileChangeDebounce.ApplySessionPressure( + ResolveFileChangeDebounceMs(), + recentFileChangeBuildStarts.Count); + } + + private void SyncFileWatcherDebounceMs() + { + var effective = GetSessionAdjustedFileChangeDebounceMs(); + if (effective != fileChangeDebounceMs) + { + fileChangeDebounceMs = effective; + fileWatcher?.SetDebounceMs(effective); + } + } + + private DateTimeOffset GetFileChangeQuietUntilUtc() => + lastMeaningfulFileChangeUtc == DateTimeOffset.MinValue + ? DateTimeOffset.UtcNow + : AdaptiveFileChangeDebounce.ComputeQuietUntilUtc( + lastMeaningfulFileChangeUtc, + GetSessionAdjustedFileChangeDebounceMs()); + + private void NoteFileChangeBuildStarted() + { + recentFileChangeBuildStarts.Enqueue(DateTimeOffset.UtcNow); + PruneRecentFileChangeBuildStarts(); + SyncFileWatcherDebounceMs(); + } + + private void PruneRecentFileChangeBuildStarts() + { + var cutoff = DateTimeOffset.UtcNow.AddSeconds(-90); + while (recentFileChangeBuildStarts.Count > 0 + && recentFileChangeBuildStarts.Peek() < cutoff) + { + recentFileChangeBuildStarts.Dequeue(); + } + } + + private bool IsAgentEditSessionActive() + { + PruneRecentFileChangeBuildStarts(); + return recentFileChangeBuildStarts.Count >= 1; + } + + public BuildIntelligenceSnapshot GetIntelligenceSnapshot(GlobalMonitorSettings monitor) + { + PruneRecentFileChangeBuildStarts(); + var stats = burstStatsStore.GetOrDefault(definition.Id); + var liveDebounceMs = GetSessionAdjustedFileChangeDebounceMs(); + DateTimeOffset? rebuildQuietUntilUtc = pendingFileChangeRebuild + && lastMeaningfulFileChangeUtc != DateTimeOffset.MinValue + ? AdaptiveFileChangeDebounce.ComputeQuietUntilUtc( + lastMeaningfulFileChangeUtc, + liveDebounceMs) + : null; + + return BuildIntelligenceSnapshot.Create( + definition, + monitor, + stats, + manualFileChangeDebounceMs, + debounceMode, + ResolveFileChangeDebounceMs(), + liveDebounceMs, + recentFileChangeBuildStarts.Count, + coalesceWatchRebuilds, + lastMeaningfulFileChangeUtc == DateTimeOffset.MinValue ? null : lastMeaningfulFileChangeUtc, + pendingFileChangeRebuild, + rebuildQuietUntilUtc); + } + + private bool UsesCoalescedWatchRebuilds() => + definition.RunOptions.RunMode == ProjectRunMode.Watch && coalesceWatchRebuilds; + + private bool UsesDotNetWatchProcess() => + definition.RunOptions.RunMode == ProjectRunMode.Watch && !UsesCoalescedWatchRebuilds(); + + private bool ShouldStartFileWatcher() + { + if (definition.RunOptions.FileChanges == FileChangeMode.Off) + { + return false; + } + + if (UsesCoalescedWatchRebuilds()) + { + return true; + } + + return definition.RunOptions.FileChanges == FileChangeMode.TriggerRebuild + && definition.RunOptions.RunMode != ProjectRunMode.Watch; + } + + public void SetUserNotifier(Action? notifier) => + notifyUser = notifier; + + private static (int Errors, int Warnings) CountLiveIssues(BuildLogKind kind, string normalized) => + kind switch + { + BuildLogKind.Run => ( + DotNetRunOutputParser.ParseErrorCount(normalized), + DotNetRunOutputParser.ParseWarningCount(normalized)), + BuildLogKind.Test => CountTestIssues(normalized), + _ => ( + BuildLogParser.ParseErrorCount(normalized), + BuildLogParser.ParseWarningCount(normalized)) + }; + + private static (int Errors, int Warnings) CountTestIssues(string normalized) + { + var testIssues = DotNetTestOutputParser.ParseIssues(normalized); + return (testIssues.Count(i => i.IsError), testIssues.Count(i => !i.IsError)); + } + + public void PrepareBuild(string reason) => pendingBuildReason = reason; + + public void PrepareTest(string reason) => pendingTestReason = reason; + + public LiveBuildLogView? GetLiveBuildLogView(BuildLogKind kind) + { + var isDirectBuild = Volatile.Read(ref buildInProgress) != 0 + || state is ProjectLifecycleState.Building; + var isWatchRebuild = watchRebuildInProgress && runProcess?.IsRunning == true; + var isRunLive = kind == BuildLogKind.Run && runProcess?.IsRunning == true; + var isTestLive = kind == BuildLogKind.Test + && (Volatile.Read(ref testInProgress) != 0 || state is ProjectLifecycleState.Testing); + + if (kind == BuildLogKind.Run) + { + if (!isRunLive) + { + return null; + } + } + else if (kind == BuildLogKind.Test) + { + if (!isTestLive) + { + return null; + } + } + else if (kind != BuildLogKind.Build || (!isDirectBuild && !isWatchRebuild)) + { + return null; + } + + string text; + lock (liveOutputSync) + { + text = kind switch + { + BuildLogKind.Test => liveTestOutput.ToString(), + BuildLogKind.Build when isDirectBuild => liveBuildOutput.ToString(), + _ => runProcess?.Output ?? string.Empty + }; + } + + var normalized = BuildLogTextNormalizer.Normalize(text); + var revision = kind == BuildLogKind.Test + ? Volatile.Read(ref liveTestOutputRevision) + : Volatile.Read(ref liveOutputRevision); + var (liveErrors, liveWarnings) = CountLiveIssues(kind, normalized); + return new LiveBuildLogView( + normalized, + true, + state, + liveErrors, + liveWarnings, + revision); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + SetProjectCurrentAction("Starting — loading saved build state"); + await HydrateLastBuildFromStoreAsync(cancellationToken); + await BuildAsync(cancellationToken); + + if (definition.RunOptions.RunMode == ProjectRunMode.None) + { + return; + } + + if (lastBuildExitCode != 0) + { + RefreshHealth(); + return; + } + + // Build already completed above — skip watch/run's embedded rebuild. + StartRunProcess(skipEmbeddedBuild: true); + TryStartFileWatcher(); + } + + private void TryStartFileWatcher() + { + if (!ShouldStartFileWatcher()) + { + return; + } + + try + { + fileChangeDebounceMs = ResolveFileChangeDebounceMs(); + fileWatcher = new DebouncedFileWatcher( + definition.RootFolder, + fileChangeDebounceMs, + WatchExcludeSegments.Parse(definition.RunOptions.WatchExcludeSegments)); + fileWatcher.Changed += OnFileWatcherChanged; + } + catch (Exception ex) + { + notifyUser?.Invoke( + definition.Id, + $"File watcher disabled — {definition.DisplayName}", + $"Could not watch '{definition.RootFolder}': {ex.Message}", + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + } + } + + public async Task BuildAsync(CancellationToken cancellationToken) + { + if (Interlocked.CompareExchange(ref buildInProgress, 1, 0) != 0) + { + return; + } + + var triggeredByFileChange = Volatile.Read(ref buildTriggeredByFileChange) != 0; + if (triggeredByFileChange) + { + NoteFileChangeBuildStarted(); + } + + var buildReason = triggeredByFileChange + ? pendingBuildReason switch + { + "file change (queued)" => "file change (queued)", + _ => "file change" + } + : pendingBuildReason; + pendingBuildReason = "startup"; + var fileChangePaths = triggeredByFileChange ? lastFileChangePaths : null; + lastFileChangePaths = []; + RecordBuildTrigger( + BuildTriggerKindFormatter.FromBuildReason(buildReason, triggeredByFileChange), + buildReason, + detail: null, + fileChangePaths); + + fileWatcher?.Suspend(); + + try + { + if (runProcess is not null) + { + SetProjectCurrentAction("Building — stopping app"); + await StopRunProcessAsync(cancellationToken); + await Task.Delay(500, cancellationToken); + } + + lock (liveOutputSync) + { + liveBuildOutput.Clear(); + } + + watchRebuildInProgress = false; + Interlocked.Exchange(ref liveOutputRevision, 0); + buildErrorCount = 0; + buildWarningCount = 0; + lastErrorPreview = null; + + var buildBanner = WriteBuildStartBanner(buildReason); + SetState(ProjectLifecycleState.Building); + SetProjectCurrentAction($"Building — {buildReason}"); + + buildProgressTracker = new BuildProgressTracker(); + buildProgressTracker.Reset(); + progressSteps = buildProgressTracker.Steps; + NotifyProgressChanged(force: true); + + var releaseLocks = definition.RunOptions.ReleaseOutputLocksBeforeBuild; + if (releaseLocks) + { + SetProjectCurrentAction("Building — releasing output locks"); + await ReleaseOutputLocksAsync(cancellationToken); + } + + SetProjectCurrentAction("Building — dotnet build"); + var args = BuildProjectArgs(); + var result = await RunBuildAttemptAsync(args, cancellationToken, buildBanner); + + if (releaseLocks + && result.ExitCode != 0 + && BuildLogParser.IsOutputLockError(result.Output)) + { + await ReleaseOutputLocksAsync(cancellationToken); + await Task.Delay(1000, cancellationToken); + + lock (liveOutputSync) + { + liveBuildOutput.Clear(); + } + + Interlocked.Exchange(ref liveOutputRevision, 0); + var retryBanner = WriteBuildStartBanner($"{buildReason} (lock retry)"); + buildProgressTracker = new BuildProgressTracker(); + buildProgressTracker.Reset(); + progressSteps = buildProgressTracker.Steps; + NotifyProgressChanged(force: true); + + result = await RunBuildAttemptAsync(args, cancellationToken, retryBanner); + } + + if (result.ExitCode != 0 + && definition.RunOptions.AutoRepairCorruptedOutput + && CorruptedOutputTreeDetector.IsCorruptedTreeFailure(result.Output, definition.RootFolder)) + { + SetProjectCurrentAction("Building — repairing output folders"); + var repair = await RepairBuildOutputInternalAsync(cancellationToken, restartAfter: false); + if (repair.Repaired) + { + notifyUser?.Invoke( + definition.Id, + $"Repaired build output — {definition.DisplayName}", + $"Removed {string.Join(", ", repair.RemovedFolders)}. Retrying build…", + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + + lock (liveOutputSync) + { + liveBuildOutput.Clear(); + } + + Interlocked.Exchange(ref liveOutputRevision, 0); + var repairBanner = WriteBuildStartBanner($"{buildReason} (output repair retry)"); + buildProgressTracker = new BuildProgressTracker(); + buildProgressTracker.Reset(); + progressSteps = buildProgressTracker.Steps; + NotifyProgressChanged(force: true); + result = await RunBuildAttemptAsync(args, cancellationToken, repairBanner); + } + } + + lastBuildExitCode = result.ExitCode; + lastExitCode = result.ExitCode; + lastDuration = result.Duration; + + var finishBanner = BuildMonitorLogBanner.FormatFinished(buildNumber, result.ExitCode); + var logText = result.Output + Environment.NewLine + finishBanner; + + var buildLog = await logStore.SaveAsync( + definition.Id, + BuildLogKind.Build, + result.CommandLine, + result.ExitCode, + DateTimeOffset.UtcNow - result.Duration, + logText, + cancellationToken); + + lastBuildFinishedAtUtc = buildLog.FinishedAtUtc; + buildErrorCount = buildLog.ErrorCount; + buildWarningCount = BuildLogParser.ParseWarningCount(result.Output); + lastErrorPreview = buildLog.ErrorLines.FirstOrDefault(); + if (result.Duration.TotalMilliseconds > 0) + { + burstStatsStore.RecordBuildDuration(definition.Id, (int)result.Duration.TotalMilliseconds); + } + + if (result.ExitCode == 0) + { + SetState(ProjectLifecycleState.BuildOk); + if (definition.RunOptions.RunTests == TestRunTrigger.OnBuildSuccess) + { + PrepareTest("build success"); + await TestAsync(cancellationToken); + } + } + else + { + SetState(ProjectLifecycleState.BuildFailed); + } + + if (buildProgressTracker is not null) + { + if (buildProgressTracker.FinalizeFromResult(result.ExitCode, result.Output)) + { + progressSteps = buildProgressTracker.Steps; + NotifyProgressChanged(force: true); + } + } + + buildProgressTracker = null; + + var restartedAfterBuild = false; + if (definition.RunOptions.RestartAppAfterRebuild + && definition.RunOptions.RunMode != ProjectRunMode.None + && result.ExitCode == 0 + && runProcess?.IsRunning != true) + { + if (triggeredByFileChange) + { + await Task.Delay(1500, cancellationToken); + } + + StartRunProcess(skipEmbeddedBuild: true); + restartedAfterBuild = true; + } + + ApplyPendingHotReloadRestartAfterBuild(result.ExitCode, restartedAfterBuild); + } + finally + { + Interlocked.Exchange(ref buildInProgress, 0); + Interlocked.Exchange(ref buildTriggeredByFileChange, 0); + fileWatcher?.Resume(); + + if (triggeredByFileChange) + { + fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddMilliseconds( + GetSessionAdjustedFileChangeDebounceMs()); + } + else + { + var quietUntil = DateTimeOffset.UtcNow.AddMilliseconds(Math.Min(fileChangeDebounceMs / 2, 2000)); + if (quietUntil > fileChangeBuildCooldownUntil) + { + fileChangeBuildCooldownUntil = quietUntil; + } + } + + if (pendingFileChangeRebuild && lastFileChangePaths.Count > 0) + { + pendingFileChangeRebuild = false; + _ = ScheduleCoalescedFileChangeRebuildAsync(); + } + } + } + + private async Task ScheduleCoalescedFileChangeRebuildAsync() + { + var generation = Interlocked.Increment(ref fileChangeRebuildScheduleGeneration); + + while (generation == Volatile.Read(ref fileChangeRebuildScheduleGeneration)) + { + var waitUntil = GetFileChangeQuietUntilUtc(); + if (fileChangeBuildCooldownUntil > waitUntil) + { + waitUntil = fileChangeBuildCooldownUntil; + } + + var delay = waitUntil - DateTimeOffset.UtcNow; + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay); + continue; + } + + if (Volatile.Read(ref buildInProgress) != 0) + { + pendingFileChangeRebuild = true; + return; + } + + if (DateTimeOffset.UtcNow < GetFileChangeQuietUntilUtc()) + { + continue; + } + + break; + } + + if (generation != Volatile.Read(ref fileChangeRebuildScheduleGeneration)) + { + return; + } + + if (Volatile.Read(ref buildInProgress) != 0) + { + pendingFileChangeRebuild = true; + return; + } + + pendingFileChangeRebuild = false; + Interlocked.Exchange(ref buildTriggeredByFileChange, 1); + pendingBuildReason = "file change (queued)"; + + notifyUser?.Invoke( + definition.Id, + $"File change — {definition.DisplayName}", + "Source change detected. Rebuilding…", + UserNotificationKind.Info, + UserNotificationCategory.FileChangeDetected); + + await BuildAsync(CancellationToken.None); + } + + private async Task HydrateLastBuildFromStoreAsync(CancellationToken cancellationToken) + { + var metadata = await logStore.LoadMetadataAsync(definition.Id, BuildLogKind.Build, cancellationToken); + if (metadata is null) + { + return; + } + + lastBuildExitCode = metadata.ExitCode; + lastExitCode = metadata.ExitCode; + lastDuration = metadata.FinishedAtUtc - metadata.StartedAtUtc; + lastBuildFinishedAtUtc = metadata.FinishedAtUtc; + buildErrorCount = metadata.ErrorCount; + lastErrorPreview = metadata.ErrorLines.FirstOrDefault(); + var logText = await logStore.LoadLogTextAsync(metadata, maxBytes: 512_000, cancellationToken); + if (!string.IsNullOrWhiteSpace(logText)) + { + buildWarningCount = BuildLogParser.ParseWarningCount(logText); + if (buildErrorCount == 0) + { + buildErrorCount = BuildLogParser.ParseErrorCount(logText); + } + } + + RefreshHealth(); + HealthCoalesceRequested?.Invoke(true); + } + + private string WriteBuildStartBanner(string reason) + { + var banner = BuildMonitorLogBanner.Format(Interlocked.Increment(ref buildNumber), reason); + lock (liveOutputSync) + { + liveBuildOutput.AppendLine(banner); + liveBuildOutput.AppendLine(string.Empty); + } + + Interlocked.Increment(ref liveOutputRevision); + return banner; + } + + private async Task RunBuildAttemptAsync( + List args, + CancellationToken cancellationToken, + string? logBanner = null) => + await cliRunner.RunAsync( + definition.RootFolder, + args, + cancellationToken, + OnBuildOutputLine, + logBanner); + + private async Task ReleaseOutputLocksAsync(CancellationToken cancellationToken) + { + var releaseResult = await OutputLockReleaser.ReleaseAsync( + definition.RootFolder, + definition.ProjectFile, + cancellationToken); + + if (notifyUser is null) + { + return; + } + + if (releaseResult.Failures.Count > 0) + { + var lines = new List(); + if (releaseResult.ProcessesStopped > 0) + { + lines.Add($"Stopped {releaseResult.ProcessesStopped} process(es)."); + } + + lines.AddRange(releaseResult.Failures.Take(4)); + + var accessDeniedOnly = releaseResult.Failures.All(OutputLockReleaser.IsAccessDeniedFailure); + if (accessDeniedOnly) + { + lines.Add(string.Empty); + lines.Add("Build Monitor cannot stop some processes without permission."); + lines.Add("Close the running app yourself, or turn off \"Stop processes locking build output\" in Settings."); + } + + notifyUser( + definition.Id, + accessDeniedOnly + ? $"Couldn't release locks — {definition.DisplayName}" + : $"Lock release issues — {definition.DisplayName}", + string.Join(Environment.NewLine, lines), + accessDeniedOnly ? UserNotificationKind.Warning : UserNotificationKind.Error, + accessDeniedOnly ? UserNotificationCategory.Warning : UserNotificationCategory.Error); + return; + } + + if (releaseResult.ProcessesStopped > 0) + { + notifyUser( + definition.Id, + $"Released locks — {definition.DisplayName}", + string.Join(Environment.NewLine, releaseResult.StoppedDescriptions.Take(4)), + UserNotificationKind.Info, + UserNotificationCategory.Info); + } + } + + private void OnFileWatcherChanged(IReadOnlyList changedPaths, int burstDurationMs) + { + if (burstDurationMs > 0) + { + burstStatsStore.RecordBurst(definition.Id, burstDurationMs); + } + + var meaningful = WatchIgnoreRules.FilterMeaningfulPaths( + changedPaths, + WatchExcludeSegments.Parse(definition.RunOptions.WatchExcludeSegments)); + if (meaningful.Count == 0) + { + return; + } + + lastMeaningfulFileChangeUtc = DateTimeOffset.UtcNow; + HeartbeatProjectWorker("file-watcher", $"{meaningful.Count} file(s)"); + SetProjectCurrentAction($"File change — rebuild pending ({meaningful.Count} file(s))"); + + lastFileChangePaths = RelativizePaths(meaningful); + SyncFileWatcherDebounceMs(); + + if (DateTimeOffset.UtcNow < fileChangeBuildCooldownUntil) + { + pendingFileChangeRebuild = true; + return; + } + + if (Volatile.Read(ref testInProgress) != 0) + { + pendingFileChangeRebuild = true; + return; + } + + if (Volatile.Read(ref buildInProgress) != 0) + { + pendingFileChangeRebuild = true; + return; + } + + if (IsAgentEditSessionActive()) + { + pendingFileChangeRebuild = true; + _ = ScheduleCoalescedFileChangeRebuildAsync(); + return; + } + + Interlocked.Exchange(ref buildTriggeredByFileChange, 1); + pendingBuildReason = "file change"; + + notifyUser?.Invoke( + definition.Id, + $"File change — {definition.DisplayName}", + "Source change detected. Rebuilding…", + UserNotificationKind.Info, + UserNotificationCategory.FileChangeDetected); + + _ = BuildAsync(CancellationToken.None); + } + + private void OnRunProcessOutputLine(string line) + { + Interlocked.Increment(ref liveOutputRevision); + HeartbeatProjectWorker("run-output"); + + if (DotNetRunOutputParser.TryExtractListeningUrl(line, out var parsedUrl)) + { + var hadUrl = !string.IsNullOrWhiteSpace(pendingListenUrl); + pendingListenUrl = parsedUrl; + var wasReady = listenUrlReady; + RefreshListenUrlReady(); + if (!hadUrl || listenUrlReady != wasReady) + { + NotifyProgressChanged(force: true); + } + } + + if (DotNetRunOutputParser.IsHostTerminatedLine(line) + || DotNetRunOutputParser.IsFatalStartupLine(line)) + { + lastErrorPreview = line.Trim(); + runErrorCount = Math.Max(runErrorCount, 1); + SetState(ProjectLifecycleState.Crashed); + notifyUser?.Invoke( + definition.Id, + $"App failed to start — {definition.DisplayName}", + line.Trim(), + UserNotificationKind.Error, + UserNotificationCategory.Error); + SaveRunOutputIfChanged(force: true); + return; + } + + TryHandleHotReloadRestartRequest(line); + + if (UsesDotNetWatchProcess()) + { + HandleWatchProcessOutputLine(line); + } + + MarkHealthDirty(); + HealthCoalesceRequested?.Invoke(false); + } + + private void HandleWatchProcessOutputLine(string line) + { + if (DotNetWatchOutput.IsWatchBuildingLine(line)) + { + RecordBuildTrigger( + BuildTriggerKind.DotNetWatchCompile, + "dotnet watch compile started", + detail: line.Trim()); + watchRebuildInProgress = true; + return; + } + + if (DotNetWatchOutput.IsBuildFailedLine(line)) + { + watchRebuildInProgress = false; + lastBuildExitCode = 1; + lastErrorPreview = line.Trim(); + buildErrorCount = Math.Max(buildErrorCount, 1); + RefreshBuildIssueCountsFromWatchOutput(force: true); + if (runProcess?.IsRunning == true) + { + RefreshHealth(); + NotifyProgressChanged(force: true); + HealthCoalesceRequested?.Invoke(true); + } + else + { + SetState(ProjectLifecycleState.BuildFailed); + } + + return; + } + + if (DotNetWatchOutput.IsBuildSucceededLine(line)) + { + var wasWatchRebuild = watchRebuildInProgress; + watchRebuildInProgress = false; + lastBuildExitCode = 0; + RefreshBuildIssueCountsFromWatchOutput(force: true); + if (state is ProjectLifecycleState.BuildFailed) + { + SetState(ProjectLifecycleState.Watching); + } + + if (wasWatchRebuild) + { + notifyUser?.Invoke( + definition.Id, + $"Build succeeded — {definition.DisplayName}", + "Watch rebuild completed successfully.", + UserNotificationKind.Info, + UserNotificationCategory.BuildSuccess); + } + + RequestHealthCoalesce(immediate: true); + return; + } + + if (!DotNetWatchOutput.IsFileChangeLine(line)) + { + return; + } + + if (watchRebuildInProgress + || Volatile.Read(ref testInProgress) != 0 + || DateTimeOffset.UtcNow < fileChangeBuildCooldownUntil) + { + return; + } + + watchRebuildInProgress = true; + listenUrlReady = false; + listenUrlNotified = false; + RecordBuildTrigger( + BuildTriggerKind.DotNetWatchFileChange, + "dotnet watch detected a file change", + detail: line.Trim()); + + var now = DateTimeOffset.UtcNow; + var notifyCooldown = TimeSpan.FromMilliseconds(Math.Max(fileChangeDebounceMs, 2000)); + if (now - lastWatchFileChangeNotifyUtc < notifyCooldown) + { + return; + } + + lastWatchFileChangeNotifyUtc = now; + notifyUser?.Invoke( + definition.Id, + $"File change — {definition.DisplayName}", + "Source change detected. Rebuilding…", + UserNotificationKind.Info, + UserNotificationCategory.FileChangeDetected); + } + + private void StartRunLogSaveTimer() + { + StopRunLogSaveTimer(); + runLogSaveTimer = new Timer( + _ => SaveRunOutputIfChanged(), + null, + TimeSpan.FromSeconds(8), + TimeSpan.FromSeconds(8)); + } + + private void StopRunLogSaveTimer() + { + runLogSaveTimer?.Dispose(); + runLogSaveTimer = null; + } + + private void SaveRunOutputIfChanged(bool force = false) + { + var process = runProcess; + if (process is null) + { + return; + } + + var revision = Volatile.Read(ref liveOutputRevision); + if (!force && revision == runOutputSaveRevision) + { + return; + } + + var output = BuildLogTextNormalizer.Normalize(process.Output); + if (string.IsNullOrWhiteSpace(output)) + { + return; + } + + runOutputSaveRevision = revision; + var commandLine = process.CommandLine; + var exitCode = state is ProjectLifecycleState.Crashed ? 1 : 0; + + _ = Task.Run(async () => + { + try + { + await logStore.SaveAsync( + definition.Id, + BuildLogKind.Run, + commandLine, + exitCode, + DateTimeOffset.UtcNow, + output, + CancellationToken.None); + } + catch + { + // Best effort only — never block the hosted app on log I/O. + } + }); + } + + public void EnsureRunProcessStartedAfterBuild() + { + if (definition.RunOptions.RunMode == ProjectRunMode.None || lastBuildExitCode != 0) + { + return; + } + + if (runProcess?.IsRunning == true) + { + return; + } + + StartRunProcess(skipEmbeddedBuild: true); + } + + public Task RestartAppAsync(CancellationToken cancellationToken) => + RestartAppCoreAsync(rebuildFirst: false, cancellationToken); + + public Task RebuildAndRestartAsync(CancellationToken cancellationToken) => + RestartAppCoreAsync(rebuildFirst: true, cancellationToken, "rebuild & restart"); + + private async Task RestartAppCoreAsync( + bool rebuildFirst, + CancellationToken cancellationToken, + string? buildReason = null) + { + if (definition.RunOptions.RunMode == ProjectRunMode.None) + { + return; + } + + if (Volatile.Read(ref buildInProgress) != 0) + { + notifyUser?.Invoke( + definition.Id, + $"Restart skipped — {definition.DisplayName}", + "Wait for the current build to finish, then try again.", + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + return; + } + + isRestarting = true; + HealthCoalesceRequested?.Invoke(true); + + try + { + await StopRunProcessAsync(cancellationToken); + restartCount = 0; + runErrorCount = 0; + runWarningCount = 0; + + if (rebuildFirst) + { + PrepareBuild(buildReason ?? "rebuild & restart"); + await BuildAsync(cancellationToken); + } + else if (buildReason == "hot reload restart") + { + RecordBuildTrigger( + BuildTriggerKind.HotReloadRestart, + "Hot reload requested app restart (no rebuild)", + detail: null); + } + + EnsureRunProcessStartedAfterBuild(); + } + finally + { + isRestarting = false; + HealthCoalesceRequested?.Invoke(true); + } + } + + private void OnBuildOutputLine(string line) + { + lock (liveOutputSync) + { + liveBuildOutput.AppendLine(line); + } + + Interlocked.Increment(ref liveOutputRevision); + HeartbeatProjectWorker("build-output"); + + if (buildProgressTracker is not null && buildProgressTracker.OnOutputLine(line)) + { + progressSteps = buildProgressTracker.Steps; + RequestHealthCoalesce(immediate: false); + } + else + { + MarkHealthDirty(); + HealthCoalesceRequested?.Invoke(false); + } + + TryHandleHotReloadRestartRequest(line); + } + + private void TryHandleHotReloadRestartRequest(string line) + { + var request = HotReloadRestartDetector.Classify(line); + if (request == HotReloadRestartRequest.None) + { + return; + } + + if (!definition.RunOptions.AutoRestartOnHotReloadRequest + || definition.RunOptions.RunMode == ProjectRunMode.None) + { + return; + } + + if (ShouldDeferRestartToDotNetWatch(line, request)) + { + return; + } + + ScheduleHotReloadRestart(request); + } + + private bool ShouldDeferRestartToDotNetWatch(string line, HotReloadRestartRequest request) => + request == HotReloadRestartRequest.RestartApp + && UsesDotNetWatchProcess() + && definition.RunOptions.AutoRestartOnWatchChanges + && HotReloadRestartDetector.IsWatchAutoRestartMessage(line); + + private void ScheduleHotReloadRestart(HotReloadRestartRequest request) + { + if (isRestarting || Volatile.Read(ref testInProgress) != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + if (now - lastHotReloadRestartRequestUtc < TimeSpan.FromSeconds(5)) + { + UpgradePendingHotReloadRestartRequest(request); + return; + } + + if (Volatile.Read(ref buildInProgress) != 0) + { + UpgradePendingHotReloadRestartRequest(request); + return; + } + + if (runProcess?.IsRunning != true) + { + if (request == HotReloadRestartRequest.RebuildAndRestart) + { + lastHotReloadRestartRequestUtc = now; + _ = ExecuteHotReloadRestartAsync(HotReloadRestartRequest.RebuildAndRestart); + } + + return; + } + + lastHotReloadRestartRequestUtc = now; + _ = ExecuteHotReloadRestartAsync(request); + } + + private void UpgradePendingHotReloadRestartRequest(HotReloadRestartRequest request) + { + if (request == HotReloadRestartRequest.RebuildAndRestart) + { + Volatile.Write(ref pendingHotReloadRestartRequest, (int)HotReloadRestartRequest.RebuildAndRestart); + } + else if (Volatile.Read(ref pendingHotReloadRestartRequest) == 0) + { + Volatile.Write(ref pendingHotReloadRestartRequest, (int)request); + } + } + + private void ApplyPendingHotReloadRestartAfterBuild(int exitCode, bool restartedAfterBuild) + { + var pending = (HotReloadRestartRequest)Interlocked.Exchange(ref pendingHotReloadRestartRequest, 0); + if (pending == HotReloadRestartRequest.None || exitCode != 0) + { + return; + } + + if (restartedAfterBuild) + { + return; + } + + if (definition.RunOptions.RunMode == ProjectRunMode.None) + { + return; + } + + StartRunProcess(skipEmbeddedBuild: true); + } + + private async Task ExecuteHotReloadRestartAsync(HotReloadRestartRequest request) + { + if (isRestarting + || Volatile.Read(ref buildInProgress) != 0 + || Volatile.Read(ref testInProgress) != 0) + { + UpgradePendingHotReloadRestartRequest(request); + return; + } + + notifyUser?.Invoke( + definition.Id, + request == HotReloadRestartRequest.RebuildAndRestart + ? $"Rebuild required — {definition.DisplayName}" + : $"Restart required — {definition.DisplayName}", + "Output indicated hot reload could not apply the latest changes. Restarting automatically.", + UserNotificationKind.Info, + UserNotificationCategory.Info); + + try + { + await RestartAppCoreAsync( + rebuildFirst: request == HotReloadRestartRequest.RebuildAndRestart, + CancellationToken.None, + request == HotReloadRestartRequest.RebuildAndRestart + ? "hot reload rebuild" + : "hot reload restart"); + } + catch (Exception ex) + { + notifyUser?.Invoke( + definition.Id, + $"Auto-restart failed — {definition.DisplayName}", + ex.Message, + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + } + } + + private bool RefreshBuildIssueCountsFromWatchOutput(bool force) + { + if (!UsesDotNetWatchProcess() || runProcess is null) + { + return false; + } + + var now = DateTimeOffset.UtcNow; + if (!force && (now - lastLiveCountParseUtc).TotalMilliseconds < 150) + { + return false; + } + + lastLiveCountParseUtc = now; + var output = BuildLogTextNormalizer.Normalize(runProcess.Output); + if (string.IsNullOrWhiteSpace(output)) + { + return false; + } + + var parsedErrors = BuildLogParser.ParseErrorCount(output); + var parsedWarnings = BuildLogParser.ParseWarningCount(output); + if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount) + { + return false; + } + + buildErrorCount = parsedErrors; + buildWarningCount = parsedWarnings; + return true; + } + + private bool RefreshLiveIssueCounts(bool force) + { + if (state is not (ProjectLifecycleState.Building or ProjectLifecycleState.Testing)) + { + return false; + } + + var now = DateTimeOffset.UtcNow; + if (!force && (now - lastLiveCountParseUtc).TotalMilliseconds < 150) + { + return false; + } + + lastLiveCountParseUtc = now; + string output; + lock (liveOutputSync) + { + output = state == ProjectLifecycleState.Testing + ? liveTestOutput.ToString() + : liveBuildOutput.ToString(); + } + + var parsedErrors = BuildLogParser.ParseErrorCount(output); + var parsedWarnings = BuildLogParser.ParseWarningCount(output); + if (parsedErrors == buildErrorCount && parsedWarnings == buildWarningCount) + { + return false; + } + + buildErrorCount = parsedErrors; + buildWarningCount = parsedWarnings; + return true; + } + + private void NotifyProgressChanged(bool force = false) => + RequestHealthCoalesce(force); + + public async Task TestAsync(CancellationToken cancellationToken) + { + if (Interlocked.CompareExchange(ref testInProgress, 1, 0) != 0) + { + notifyUser?.Invoke( + definition.Id, + $"Tests skipped — {definition.DisplayName}", + "Tests are already running for this project.", + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + return; + } + + if (Volatile.Read(ref buildInProgress) != 0) + { + Interlocked.Exchange(ref testInProgress, 0); + notifyUser?.Invoke( + definition.Id, + $"Tests skipped — {definition.DisplayName}", + "Wait for the current build to finish, then try again.", + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + return; + } + + var testReason = pendingTestReason; + pendingTestReason = "tests"; + var wasRunProcessActive = runProcess?.IsRunning == true; + var releaseLocksSetting = definition.RunOptions.ReleaseOutputLocksBeforeBuild; + var stoppedAppForTests = false; + + fileWatcher?.Suspend(); + fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddMinutes(2); + + try + { + lock (liveOutputSync) + { + liveTestOutput.Clear(); + } + + Interlocked.Exchange(ref liveTestOutputRevision, 0); + buildErrorCount = 0; + buildWarningCount = 0; + lastErrorPreview = null; + + var resolution = TestProjectDiscovery.Resolve( + definition.RootFolder, + definition.ProjectFile, + definition.TestProjectFile); + + if (resolution.Targets.Count == 0) + { + WriteTestStartBanner(testReason, [], resolution.DiscoveryNote); + SetState(ProjectLifecycleState.TestFailed); + lastErrorPreview = resolution.DiscoveryNote; + buildErrorCount = 1; + return; + } + + WriteTestStartBanner(testReason, resolution); + SetState(ProjectLifecycleState.Testing); + NotifyProgressChanged(force: true); + + var startedAtUtc = DateTimeOffset.UtcNow; + var commandLines = new List(); + var exitCode = 0; + var wallDuration = TimeSpan.Zero; + + for (var i = 0; i < resolution.Targets.Count; i++) + { + var target = resolution.Targets[i]; + if (resolution.Targets.Count > 1) + { + AppendTestSectionHeader(i + 1, resolution.Targets.Count, target); + } + + var targetRun = await RunTestTargetWithRetryAsync( + target, + wasRunProcessActive, + releaseLocksSetting, + cancellationToken); + + stoppedAppForTests |= targetRun.StoppedApp; + commandLines.Add(targetRun.Result.CommandLine); + wallDuration += targetRun.Result.Duration; + if (targetRun.Result.ExitCode != 0) + { + exitCode = targetRun.Result.ExitCode; + } + } + + string logText; + lock (liveOutputSync) + { + logText = liveTestOutput.ToString(); + } + + var testsExecuted = DotNetTestOutputParser.LooksLikeTestsExecuted(logText); + var testSummary = DotNetTestOutputParser.TryParseSummary(logText); + var summaryLine = testSummary is not null + ? DotNetTestOutputParser.FormatSummaryLine(testSummary) + : DescribeMissingTestSummary(logText, testsExecuted); + var finishBanner = BuildMonitorLogBanner.FormatTestFinished( + testNumber, + testsExecuted ? exitCode : 1, + summaryLine, + wallDuration); + lock (liveOutputSync) + { + liveTestOutput.AppendLine(finishBanner); + } + + Interlocked.Increment(ref liveTestOutputRevision); + + lock (liveOutputSync) + { + logText = liveTestOutput.ToString(); + } + + var parsed = BuildLogParser.ParseErrors(logText); + var effectiveExitCode = testsExecuted ? exitCode : 1; + await logStore.SaveAsync( + definition.Id, + BuildLogKind.Test, + string.Join(" && ", commandLines), + effectiveExitCode, + startedAtUtc, + logText, + cancellationToken); + + if (effectiveExitCode == 0) + { + SetState(ProjectLifecycleState.TestOk); + } + else + { + buildErrorCount = Math.Max(parsed.ErrorCount, testsExecuted ? 0 : 1); + buildWarningCount = BuildLogParser.ParseWarningCount(logText); + lastErrorPreview = parsed.ErrorLines.FirstOrDefault() + ?? summaryLine + ?? "No tests were executed"; + SetState(ProjectLifecycleState.TestFailed); + } + } + finally + { + Interlocked.Exchange(ref testInProgress, 0); + fileChangeBuildCooldownUntil = DateTimeOffset.UtcNow.AddSeconds(10); + fileWatcher?.Resume(); + + if (stoppedAppForTests + && wasRunProcessActive + && definition.RunOptions.RunMode != ProjectRunMode.None) + { + _ = RestartRunProcessAfterTestsAsync(); + } + + HealthCoalesceRequested?.Invoke(true); + } + } + + private sealed record TestTargetRunResult(CliRunResult Result, bool StoppedApp); + + private async Task RestartRunProcessAfterTestsAsync() + { + await Task.Delay(2500); + + if (Volatile.Read(ref testInProgress) != 0 || Volatile.Read(ref buildInProgress) != 0) + { + return; + } + + StartRunProcess(skipEmbeddedBuild: true); + } + + private async Task RunTestTargetWithRetryAsync( + string target, + bool wasRunProcessActive, + bool releaseLocksSetting, + CancellationToken cancellationToken) + { + var stoppedApp = false; + CliRunResult result; + var usedNoBuild = false; + + if (TestRunPlanner.RequiresFullBuildFromStart(lastBuildExitCode)) + { + stoppedApp = await StopAppForTestBuildIfNeededAsync( + wasRunProcessActive, + "stopping run/watch to rebuild before tests", + cancellationToken); + await ReleaseLocksForTestBuildIfNeededAsync(releaseLocksSetting, stoppedApp, cancellationToken); + result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: false), cancellationToken); + } + else + { + AppendTestNote("running tests while app stays up (--no-build)"); + usedNoBuild = true; + result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: true), cancellationToken); + + if (!DotNetTestOutputParser.LooksLikeTestsExecuted(result.Output) + && DotNetTestOutputParser.LooksLikeNeedsFullBuildBeforeTest(result.Output)) + { + usedNoBuild = false; + stoppedApp = await StopAppForTestBuildIfNeededAsync( + wasRunProcessActive, + "test assemblies stale — stopping app briefly to rebuild", + cancellationToken); + await ReleaseLocksForTestBuildIfNeededAsync(releaseLocksSetting, stoppedApp, cancellationToken); + result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: false), cancellationToken); + } + } + + var shouldReleaseLocks = TestRunPlanner.ShouldReleaseLocksForTestBuild(releaseLocksSetting, stoppedApp); + var finalResult = await RetryTestOnLockErrorAsync( + result, + target, + usedNoBuild, + shouldReleaseLocks, + wasRunProcessActive, + cancellationToken); + + return new TestTargetRunResult(finalResult.Result, finalResult.StoppedApp || stoppedApp); + } + + private async Task StopAppForTestBuildIfNeededAsync( + bool wasRunProcessActive, + string note, + CancellationToken cancellationToken) + { + if (!wasRunProcessActive || runProcess?.IsRunning != true) + { + return false; + } + + AppendTestNote(note); + await StopRunProcessAsync(cancellationToken); + return true; + } + + private async Task ReleaseLocksForTestBuildIfNeededAsync( + bool releaseLocksSetting, + bool stoppedApp, + CancellationToken cancellationToken) + { + if (!TestRunPlanner.ShouldReleaseLocksForTestBuild(releaseLocksSetting, stoppedApp)) + { + return; + } + + await ReleaseOutputLocksAsync(cancellationToken); + } + + private async Task RetryTestOnLockErrorAsync( + CliRunResult result, + string target, + bool noBuild, + bool shouldReleaseLocks, + bool wasRunProcessActive, + CancellationToken cancellationToken) + { + if (result.ExitCode == 0 || !BuildLogParser.IsOutputLockError(result.Output)) + { + return new TestTargetRunResult(result, false); + } + + var stoppedApp = false; + if (shouldReleaseLocks || wasRunProcessActive) + { + stoppedApp = await StopAppForTestBuildIfNeededAsync( + wasRunProcessActive, + "output locked — stopping app before retrying tests", + cancellationToken); + AppendTestNote("output locked — releasing and retrying tests"); + await ReleaseOutputLocksAsync(cancellationToken); + await Task.Delay(1000, cancellationToken); + result = await RunTestAttemptAsync(BuildTestArgs(target, noBuild: false), cancellationToken); + } + + return new TestTargetRunResult(result, stoppedApp); + } + + private async Task RunTestAttemptAsync( + List args, + CancellationToken cancellationToken) => + await cliRunner.RunAsync( + definition.RootFolder, + args, + cancellationToken, + OnTestOutputLine); + + private void AppendTestNote(string note) + { + lock (liveOutputSync) + { + liveTestOutput.AppendLine($"[BuildMonitor] {note}"); + liveTestOutput.AppendLine(string.Empty); + } + + Interlocked.Increment(ref liveTestOutputRevision); + } + + private string WriteTestStartBanner(string reason, TestTargetResolution resolution) + { + var banner = BuildMonitorLogBanner.FormatTest(Interlocked.Increment(ref testNumber), reason); + lock (liveOutputSync) + { + liveTestOutput.AppendLine(banner); + liveTestOutput.AppendLine($"[BuildMonitor] {resolution.DiscoveryNote}"); + if (resolution.Targets.Count == 1) + { + var tryNoBuild = lastBuildExitCode == 0; + liveTestOutput.AppendLine( + $"dotnet {string.Join(' ', BuildTestArgs(resolution.Targets[0], tryNoBuild))}" + + (tryNoBuild ? " (app stays up; brief stop only if assemblies are stale)" : string.Empty)); + } + + liveTestOutput.AppendLine(string.Empty); + } + + Interlocked.Increment(ref liveTestOutputRevision); + return banner; + } + + private void WriteTestStartBanner(string reason, IReadOnlyList args, string note) + { + var banner = BuildMonitorLogBanner.FormatTest(Interlocked.Increment(ref testNumber), reason); + lock (liveOutputSync) + { + liveTestOutput.AppendLine(banner); + liveTestOutput.AppendLine($"[BuildMonitor] {note}"); + if (args.Count > 0) + { + liveTestOutput.AppendLine($"dotnet {string.Join(' ', args)}"); + } + + liveTestOutput.AppendLine(string.Empty); + } + + Interlocked.Increment(ref liveTestOutputRevision); + } + + private void AppendTestSectionHeader(int index, int total, string target) + { + lock (liveOutputSync) + { + liveTestOutput.AppendLine($"[BuildMonitor] --- Test target {index}/{total}: {target} ---"); + liveTestOutput.AppendLine($"dotnet {string.Join(' ', BuildTestArgs(target, lastBuildExitCode == 0))}"); + liveTestOutput.AppendLine(string.Empty); + } + + Interlocked.Increment(ref liveTestOutputRevision); + } + + private static string? DescribeMissingTestSummary(string logText, bool testsExecuted) + { + if (testsExecuted) + { + return null; + } + + if (BuildLogParser.IsOutputLockError(logText)) + { + return "build failed — app executable is locked; enable Stop processes locking build output in settings"; + } + + if (logText.Contains("No test is available", StringComparison.OrdinalIgnoreCase) + || logText.Contains("No tests found", StringComparison.OrdinalIgnoreCase)) + { + return "no tests discovered in target — set Test project / solution in settings"; + } + + if (DotNetTestOutputParser.LooksLikeRestoreOrBuildOnly(logText)) + { + return "no tests executed (build did not reach test host) — check build errors above"; + } + + return "no tests executed"; + } + + private void OnTestOutputLine(string line) + { + lock (liveOutputSync) + { + liveTestOutput.AppendLine(line); + } + + Interlocked.Increment(ref liveTestOutputRevision); + HeartbeatProjectWorker("test-output"); + RequestHealthCoalesce(immediate: false); + } + + private void StartRunProcess(bool skipEmbeddedBuild = false) + { + SetProjectCurrentAction(skipEmbeddedBuild + ? "Starting app (dotnet run --no-build)" + : "Starting app (dotnet run)"); + StopRunProcess(); + WarnIfRiskyBaseOutputPath(); + + runProcessGeneration++; + var generation = runProcessGeneration; + + runProcess = new SupervisedProcess(definition.Id); + runProcess.OutputLineReceived += OnRunProcessOutputLine; + + runProcessExitedHandler = (_, exitCode) => + { + if (generation != runProcessGeneration) + { + return; + } + + OnRunProcessExited(exitCode); + }; + runProcess.Exited += runProcessExitedHandler; + + var args = UsesDotNetWatchProcess() + ? BuildWatchArgs(skipEmbeddedBuild) + : BuildRunArgs(skipEmbeddedBuild); + + candidateListenUrls = LaunchProfileEnvironmentApplier.ResolveListenUrls( + definition.RootFolder, + definition.ProjectFile, + definition.LaunchProfile); + pendingListenUrl = candidateListenUrls.FirstOrDefault(); + listenUrlReady = false; + listenUrlNotified = false; + runOutputSaveRevision = 0; + StartListenUrlPolling(); + StartRunLogSaveTimer(); + + runProcess.Start( + definition.RootFolder, + args, + psi => + { + LaunchProfileEnvironmentApplier.ApplyTo( + psi, + definition.RootFolder, + definition.ProjectFile, + definition.LaunchProfile); + + if (UsesDotNetWatchProcess() + && !definition.RunOptions.AutoRestartOnWatchChanges) + { + psi.Environment["DOTNET_WATCH_RESTART_ON_RUDE_EDIT"] = "0"; + } + }); + + NotifyProgressChanged(force: true); + + SetState(definition.RunOptions.RunMode == ProjectRunMode.Watch + || UsesCoalescedWatchRebuilds() + ? ProjectLifecycleState.Watching + : ProjectLifecycleState.Running); + } + + private void OnRunProcessExited(int exitCode) + { + var exitedProcess = runProcess; + if (exitedProcess is null) + { + return; + } + + StopListenUrlPolling(); + StopRunLogSaveTimer(); + SaveRunOutputIfChanged(force: true); + listenUrlReady = false; + listenUrlNotified = false; + lastExitCode = exitCode; + var runOutput = exitedProcess.Output; + runErrorCount = DotNetRunOutputParser.ParseErrorCount(runOutput); + runWarningCount = DotNetRunOutputParser.ParseWarningCount(runOutput); + if (exitCode != 0 && runErrorCount == 0) + { + runErrorCount = 1; + } + + if (exitCode != 0 && definition.RunOptions.RestartOnCrash && restartCount < definition.RunOptions.MaxRestartRetries) + { + restartCount++; + SetState(ProjectLifecycleState.Crashed); + StartRunProcess(skipEmbeddedBuild: true); + return; + } + + if (exitCode != 0) + { + _ = logStore.SaveAsync( + definition.Id, + BuildLogKind.Run, + exitedProcess.CommandLine, + exitCode, + DateTimeOffset.UtcNow, + exitedProcess.Output, + CancellationToken.None); + SetState(ProjectLifecycleState.Crashed); + } + else + { + SetState(ProjectLifecycleState.Idle); + } + } + + private void StopRunProcess() + { + StopListenUrlPolling(); + StopRunLogSaveTimer(); + + if (runProcess is null) + { + return; + } + + SaveRunOutputIfChanged(force: true); + runProcessGeneration++; + DetachRunProcessHandlers(); + runProcess.Stop(); + runProcess = null; + } + + private async Task StopRunProcessAsync(CancellationToken cancellationToken) + { + if (runProcess is null) + { + return; + } + + runProcessGeneration++; + DetachRunProcessHandlers(); + await runProcess.StopGracefullyAsync(cancellationToken); + runProcess = null; + } + + private void DetachRunProcessHandlers() + { + if (runProcess is null) + { + return; + } + + runProcess.OutputLineReceived -= OnRunProcessOutputLine; + if (runProcessExitedHandler is not null) + { + runProcess.Exited -= runProcessExitedHandler; + runProcessExitedHandler = null; + } + } + + private List BuildProjectArgs() + { + var args = new List { "build", ResolveProjectFileArg() }; + AppendExtraArgs(args); + return args; + } + + private List BuildTestArgs(string testTargetPath, bool noBuild = false) + { + var args = new List + { + "test", + testTargetPath, + "--verbosity", + "normal", + "--logger", + "console;verbosity=detailed" + }; + + if (noBuild) + { + args.Add("--no-build"); + } + + AppendExtraArgs(args); + return args; + } + + private List BuildRunArgs(bool skipEmbeddedBuild = false) + { + var args = new List { "run", "--project", ResolveProjectFileArg() }; + if (skipEmbeddedBuild) + { + args.Add("--no-build"); + } + + if (!string.IsNullOrWhiteSpace(definition.LaunchProfile)) + { + args.AddRange(["--launch-profile", definition.LaunchProfile]); + } + + AppendExtraArgs(args); + return args; + } + + private List BuildWatchArgs(bool skipEmbeddedBuild = false) + { + var args = new List { "watch" }; + if (definition.RunOptions.AutoRestartOnWatchChanges) + { + // Tray host has no stdin for restart prompts — auto-restart when enabled per project. + args.Add("--non-interactive"); + } + + args.AddRange(["run", "--project", ResolveProjectFileArg()]); + if (skipEmbeddedBuild) + { + args.Add("--no-build"); + } + + if (!string.IsNullOrWhiteSpace(definition.LaunchProfile)) + { + args.AddRange(["--launch-profile", definition.LaunchProfile]); + } + + AppendExtraArgs(args); + return args; + } + + private void AppendExtraArgs(List args) + { + if (string.IsNullOrWhiteSpace(definition.ExtraDotNetArgs)) + { + return; + } + + args.AddRange(definition.ExtraDotNetArgs.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + private string ResolveProjectFileArg() => + Path.IsPathRooted(definition.ProjectFile) + ? definition.ProjectFile + : Path.Combine(definition.RootFolder, definition.ProjectFile); + + private void SetState(ProjectLifecycleState newState) + { + state = newState; + lastChangedUtc = DateTimeOffset.UtcNow; + var action = FormatLifecycleAction(newState); + SetProjectCurrentAction(action); + HeartbeatProjectWorker("state", newState.ToString()); + RefreshHealth(); + HealthCoalesceRequested?.Invoke(true); + } + + private static string FormatLifecycleAction(ProjectLifecycleState state) => + state switch + { + ProjectLifecycleState.Idle => "Idle", + ProjectLifecycleState.Building => "Building", + ProjectLifecycleState.BuildOk => "Build succeeded", + ProjectLifecycleState.BuildFailed => "Build failed", + ProjectLifecycleState.Running => "App running", + ProjectLifecycleState.Watching => "Watching for file changes", + ProjectLifecycleState.Crashed => "App crashed", + ProjectLifecycleState.Testing => "Running tests", + ProjectLifecycleState.TestOk => "Tests passed", + ProjectLifecycleState.TestFailed => "Tests failed", + _ => state.ToString() + }; + + private void RefreshHealth() + { + var (displayErrors, displayWarnings) = HealthIssueCountsFormatter.SelectPrimaryCounts( + state, + buildErrorCount, + buildWarningCount, + runErrorCount, + runWarningCount); + health = ProjectHealthEvaluator.Evaluate( + state, + lastBuildExitCode, + displayErrors, + displayWarnings, + inProgress: isRestarting + || state is ProjectLifecycleState.Building + || state is ProjectLifecycleState.Testing); + } + + private string? ResolveDisplayListenUrl() + { + if (definition.RunOptions.RunMode == ProjectRunMode.None) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(pendingListenUrl)) + { + return pendingListenUrl; + } + + if (candidateListenUrls.Count > 0) + { + return candidateListenUrls[0]; + } + + return LaunchProfileEnvironmentApplier.ResolvePrimaryListenUrl( + definition.RootFolder, + definition.ProjectFile, + definition.LaunchProfile); + } + + private void RefreshListenUrlReady() + { + if (runProcess?.IsRunning != true + || state is not (ProjectLifecycleState.Running or ProjectLifecycleState.Watching)) + { + listenUrlReady = false; + return; + } + + var urlsToProbe = candidateListenUrls.Count > 0 + ? candidateListenUrls + : string.IsNullOrWhiteSpace(pendingListenUrl) ? [] : new[] { pendingListenUrl }; + + foreach (var url in urlsToProbe) + { + if (LocalPortProbe.IsHttpEndpointOpen(url)) + { + MarkListenUrlReady(url); + return; + } + } + + listenUrlReady = false; + } + + private void StartListenUrlPolling() + { + StopListenUrlPolling(); + listenUrlPollTimer = new Timer( + _ => PollListenUrl(), + null, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1)); + } + + private void StopListenUrlPolling() + { + listenUrlPollTimer?.Dispose(); + listenUrlPollTimer = null; + } + + private void PollListenUrl() + { + if (listenUrlReady) + { + return; + } + + RefreshListenUrlReady(); + } + + private void MarkListenUrlReady(string url) + { + pendingListenUrl = url; + if (listenUrlReady) + { + return; + } + + listenUrlReady = true; + StopListenUrlPolling(); + NotifyProgressChanged(force: true); + + if (listenUrlNotified) + { + return; + } + + listenUrlNotified = true; + var openUrl = LocalPortProbe.NormalizeBrowserUrl(url); + notifyUser?.Invoke( + definition.Id, + $"App running — {definition.DisplayName}", + $"Open {openUrl}", + UserNotificationKind.Info, + UserNotificationCategory.Info); + } + + public async Task RepairBuildOutputAsync( + CancellationToken cancellationToken, + bool restartAfter) + { + fileWatcher?.Suspend(); + try + { + if (runProcess is not null) + { + await StopRunProcessAsync(cancellationToken); + await Task.Delay(500, cancellationToken); + } + + var result = BuildOutputTreeRepairer.Repair(definition.RootFolder); + if (result.Repaired && restartAfter && definition.RunOptions.RunMode != ProjectRunMode.None) + { + StartRunProcess(skipEmbeddedBuild: false); + } + + return result; + } + finally + { + fileWatcher?.Resume(); + } + } + + private Task RepairBuildOutputInternalAsync( + CancellationToken cancellationToken, + bool restartAfter) => + RepairBuildOutputAsync(cancellationToken, restartAfter); + + private bool baseOutputPathWarningShown; + + private void WarnIfRiskyBaseOutputPath() + { + if (baseOutputPathWarningShown + || definition.RunOptions.RunMode != ProjectRunMode.Watch + || !CorruptedOutputTreeDetector.HasRiskyBaseOutputPath(definition.ExtraDotNetArgs)) + { + return; + } + + baseOutputPathWarningShown = true; + notifyUser?.Invoke( + definition.Id, + $"Risky build args — {definition.DisplayName}", + "Extra dotnet args include BaseOutputPath while watch mode is enabled. " + + "This can corrupt artifacts/bin/obj output trees. Remove BaseOutputPath for local watch.", + UserNotificationKind.Warning, + UserNotificationCategory.Warning); + } + + public Task StopAsync() + { + fileWatcher?.Dispose(); + fileWatcher = null; + StopListenUrlPolling(); + StopRunProcess(); + buildProgressTracker = null; + progressSteps = []; + SetState(ProjectLifecycleState.Idle); + return Task.CompletedTask; + } + + private void RecordBuildTrigger( + BuildTriggerKind kind, + string summary, + string? detail, + IReadOnlyList? changedPaths = null) + { + triggerJournal.Record(new BuildTriggerRecord( + Guid.NewGuid().ToString("N"), + definition.Id, + definition.DisplayName, + DateTimeOffset.UtcNow, + kind, + summary, + detail, + changedPaths is { Count: > 0 } ? changedPaths : null, + InferredCause: BuildTriggerInference.Infer(kind, detail, changedPaths))); + } + + private IReadOnlyList RelativizePaths(IReadOnlyList fullPaths) + { + if (fullPaths.Count == 0) + { + return []; + } + + var root = Path.GetFullPath(definition.RootFolder); + var results = new List(fullPaths.Count); + foreach (var path in fullPaths) + { + try + { + var full = Path.GetFullPath(path); + results.Add(Path.GetRelativePath(root, full)); + } + catch + { + results.Add(path); + } + } + + return results; + } + + public void Dispose() + { + UnregisterProjectWorkers(); + StopListenUrlPolling(); + StopRunLogSaveTimer(); + fileWatcher?.Dispose(); + runProcess?.Dispose(); + } + + private string ProjectWorkerId(string suffix) => $"project.{definition.Id}.{suffix}"; + + private void RegisterProjectWorkers() + { + var registry = WorkerHealthRegistry.Shared; + void Register(string suffix, string label, TimeSpan staleAfter) + { + var id = ProjectWorkerId(suffix); + registry.Register(id, $"{definition.DisplayName} — {label}", staleAfter, "Project"); + registeredWorkerIds.Add(id); + } + + Register("build-output", "build output", TimeSpan.FromSeconds(5)); + Register("run-output", "run output", TimeSpan.FromSeconds(10)); + Register("test-output", "test output", TimeSpan.FromSeconds(10)); + Register("file-watcher", "file watcher", TimeSpan.FromMinutes(30)); + Register("state", "lifecycle", TimeSpan.FromMinutes(10)); + } + + private void UnregisterProjectWorkers() + { + var registry = WorkerHealthRegistry.Shared; + foreach (var id in registeredWorkerIds) + { + registry.Unregister(id); + } + + registeredWorkerIds.Clear(); + lastWorkerHeartbeatUtc.Clear(); + } + + private void SetProjectCurrentAction(string action) + { + WorkerHealthRegistry.Shared.SetCurrentAction(ProjectWorkerId("state"), action); + } + + private void HeartbeatProjectWorker(string suffix, string? note = null) + { + var id = ProjectWorkerId(suffix); + var now = DateTimeOffset.UtcNow; + if (lastWorkerHeartbeatUtc.TryGetValue(id, out var last) + && (now - last).TotalMilliseconds < 500) + { + return; + } + + lastWorkerHeartbeatUtc[id] = now; + WorkerHealthRegistry.Shared.Heartbeat( + id, + note, + Environment.CurrentManagedThreadId); + } +} diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs index 2611c01..06155f1 100644 --- a/src/TrayApp/App.xaml.cs +++ b/src/TrayApp/App.xaml.cs @@ -29,10 +29,10 @@ public partial class App : System.Windows.Application private DispatcherTimer? statusPanelLayoutSaveTimer; private bool pointerOverStatusPanel; private readonly Dictionary previousProjectHealth = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary previousProjectState = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary openLogViewers = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet autoOpenedLogForFailure = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet fileChangeBuildStarts = new(StringComparer.OrdinalIgnoreCase); + private readonly BuildLifecycleToastNotifier buildLifecycleToastNotifier = new(); private int settingsApplyVersion; private readonly SemaphoreSlim settingsApplyGate = new(1, 1); private DispatcherTimer? buildIconAnimationTimer; @@ -132,7 +132,7 @@ private async Task ApplySettingsAndStartAsync() } previousProjectHealth.Clear(); - previousProjectState.Clear(); + buildLifecycleToastNotifier.Reset(); autoOpenedLogForFailure.Clear(); fileChangeBuildStarts.Clear(); ToastNotificationService.ApplySettings(currentSettings.AppBehavior); @@ -257,7 +257,7 @@ private void ApplyPendingHealthUi() if (Volatile.Read(ref trayMenuOpen) == 0) { AutoOpenLogsOnFailureTransition(snapshots); - ShowBuildToasts(snapshots); + buildLifecycleToastNotifier.Process(snapshots, fileChangeBuildStarts); PlayBuildNotificationSounds(snapshots); } } @@ -1238,98 +1238,6 @@ private async Task RebuildAllActiveAsync() } } - private void ShowBuildToasts(IReadOnlyList snapshots) - { - foreach (var snapshot in snapshots.Where(s => s.IsActive)) - { - previousProjectState.TryGetValue(snapshot.ProjectId, out var previousState); - var currentState = snapshot.State; - - if (currentState == ProjectLifecycleState.Building && previousState != ProjectLifecycleState.Building) - { - if (!fileChangeBuildStarts.Remove(snapshot.ProjectId)) - { - ToastNotificationService.ShowIfEnabled( - $"Building — {snapshot.DisplayName}", - "Build started.", - ToastKind.Info, - UserNotificationCategory.BuildStart); - } - } - - if (previousState == ProjectLifecycleState.Building - && IsSuccessfulBuildEndState(currentState)) - { - var message = snapshot.LastDuration is { } duration - ? $"Completed in {FormatBuildDuration(duration)}." - : "Build completed successfully."; - ToastNotificationService.ShowIfEnabled( - $"Build succeeded — {snapshot.DisplayName}", - message, - ToastKind.Success, - UserNotificationCategory.BuildSuccess); - } - else if (previousState == ProjectLifecycleState.Testing && currentState == ProjectLifecycleState.TestOk) - { - ToastNotificationService.ShowIfEnabled( - $"Tests passed — {snapshot.DisplayName}", - "Tests completed successfully.", - ToastKind.Success, - UserNotificationCategory.BuildSuccess); - } - - if ((previousState == ProjectLifecycleState.Building - || previousState == ProjectLifecycleState.Watching) - && currentState == ProjectLifecycleState.BuildFailed) - { - var message = string.IsNullOrWhiteSpace(snapshot.LastErrorPreview) - ? "See build log for details." - : snapshot.LastErrorPreview; - ToastNotificationService.ShowIfEnabled( - $"Build failed — {snapshot.DisplayName}", - message, - ToastKind.Error, - UserNotificationCategory.BuildFailure); - } - else if (previousState == ProjectLifecycleState.Testing && currentState == ProjectLifecycleState.TestFailed) - { - var message = string.IsNullOrWhiteSpace(snapshot.LastErrorPreview) - ? "See test log for details." - : snapshot.LastErrorPreview; - ToastNotificationService.ShowIfEnabled( - $"Tests failed — {snapshot.DisplayName}", - message, - ToastKind.Error, - UserNotificationCategory.BuildFailure); - } - - previousProjectState[snapshot.ProjectId] = currentState; - } - - var activeIds = snapshots.Where(s => s.IsActive).Select(s => s.ProjectId).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var staleId in previousProjectState.Keys.Where(id => !activeIds.Contains(id)).ToList()) - { - previousProjectState.Remove(staleId); - } - } - - private static bool IsSuccessfulBuildEndState(ProjectLifecycleState state) => - state is ProjectLifecycleState.BuildOk - or ProjectLifecycleState.Watching - or ProjectLifecycleState.Running; - - private static string FormatBuildDuration(TimeSpan duration) - { - if (duration.TotalHours >= 1) - { - return duration.ToString(@"h\:mm\:ss"); - } - - return duration.TotalMinutes >= 1 - ? duration.ToString(@"m\:ss") - : $"{duration.TotalSeconds:F1}s"; - } - private void PlayBuildNotificationSounds(IReadOnlyList snapshots) { foreach (var snapshot in snapshots.Where(s => s.IsActive)) @@ -1472,54 +1380,9 @@ private void ApplyTrayIconFrame() currentTrayBuilding, buildIconAnimationFrame, currentTrayWebReady); - notifyIcon.Text = FormatTrayTooltip(currentTrayHeadline, currentTrayHealth, currentTrayBuilding); + notifyIcon.Text = TrayTooltipFormatter.Format(currentTrayHeadline, currentTrayHealth, currentTrayBuilding); } - private static string FormatTrayTooltip( - ProjectHealthSnapshot? headline, - MonitorHealth health, - bool isBuilding) - { - if (isBuilding) - { - var name = headline?.DisplayName ?? "project"; - return TruncateTrayText($"Building — {name}"); - } - - if (headline is null) - { - return DescribeHealthTooltip(health); - } - - if (headline.Health == MonitorHealth.Red) - { - var phase = string.IsNullOrWhiteSpace(headline.FailurePhase) - ? "Failed" - : headline.FailurePhase; - if (!string.IsNullOrWhiteSpace(headline.LastErrorPreview)) - { - return TruncateTrayText($"{headline.DisplayName} — {phase}: {headline.LastErrorPreview}"); - } - - return TruncateTrayText($"{headline.DisplayName} — {phase}"); - } - - if (headline.Health == MonitorHealth.Amber) - { - return TruncateTrayText($"{headline.DisplayName} — Warnings"); - } - - if (headline.ListenUrlReady && !string.IsNullOrWhiteSpace(headline.ListenUrl)) - { - return TruncateTrayText($"{headline.DisplayName} — Site up · {headline.ListenUrl}"); - } - - return TruncateTrayText($"{headline.DisplayName} — OK"); - } - - private static string TruncateTrayText(string text, int maxLength = 63) => - text.Length <= maxLength ? text : text[..(maxLength - 1)] + "…"; - private static void MigrateLegacyAppDataIfNeeded(string newAppDataDirectory) { if (Directory.Exists(newAppDataDirectory)) @@ -1581,22 +1444,4 @@ private bool ShouldAutoOpenBuildMonitorHealth() return currentSettings.Monitor.AutoOpenBuildMonitorHealthOnStartup; } - - private static string DescribeHealth(MonitorHealth health) => - health switch - { - MonitorHealth.Green => "OK", - MonitorHealth.Amber => "Warnings", - MonitorHealth.Red => "Errors", - _ => "Unknown" - }; - - private static string DescribeHealthTooltip(MonitorHealth health) => - health switch - { - MonitorHealth.Green => "Build monitor - Success", - MonitorHealth.Amber => "Build monitor - Warnings", - MonitorHealth.Red => "Build monitor - Failed", - _ => "Build Monitor" - }; } diff --git a/src/TrayApp/Services/BuildLifecycleToastNotifier.cs b/src/TrayApp/Services/BuildLifecycleToastNotifier.cs new file mode 100644 index 0000000..c994e3a --- /dev/null +++ b/src/TrayApp/Services/BuildLifecycleToastNotifier.cs @@ -0,0 +1,92 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; + +namespace BuildMonitor.TrayApp.Services; + +/// +/// Emits build/test lifecycle toasts from health snapshot transitions. +/// +public sealed class BuildLifecycleToastNotifier +{ + private readonly Dictionary previousProjectState = + new(StringComparer.OrdinalIgnoreCase); + + public void Reset() => previousProjectState.Clear(); + + public void Process( + IReadOnlyList snapshots, + ISet fileChangeBuildStarts) + { + foreach (var snapshot in snapshots.Where(s => s.IsActive)) + { + previousProjectState.TryGetValue(snapshot.ProjectId, out var previousState); + var currentState = snapshot.State; + + if (currentState == ProjectLifecycleState.Building && previousState != ProjectLifecycleState.Building) + { + if (!fileChangeBuildStarts.Remove(snapshot.ProjectId)) + { + ToastNotificationService.ShowIfEnabled( + $"Building — {snapshot.DisplayName}", + "Build started.", + ToastKind.Info, + UserNotificationCategory.BuildStart); + } + } + + if (previousState == ProjectLifecycleState.Building + && BuildLifecycleFormatting.IsSuccessfulBuildEndState(currentState)) + { + var message = snapshot.LastDuration is { } duration + ? $"Completed in {BuildLifecycleFormatting.FormatBuildDuration(duration)}." + : "Build completed successfully."; + ToastNotificationService.ShowIfEnabled( + $"Build succeeded — {snapshot.DisplayName}", + message, + ToastKind.Success, + UserNotificationCategory.BuildSuccess); + } + else if (previousState == ProjectLifecycleState.Testing && currentState == ProjectLifecycleState.TestOk) + { + ToastNotificationService.ShowIfEnabled( + $"Tests passed — {snapshot.DisplayName}", + "Tests completed successfully.", + ToastKind.Success, + UserNotificationCategory.BuildSuccess); + } + + if ((previousState == ProjectLifecycleState.Building + || previousState == ProjectLifecycleState.Watching) + && currentState == ProjectLifecycleState.BuildFailed) + { + var message = string.IsNullOrWhiteSpace(snapshot.LastErrorPreview) + ? "See build log for details." + : snapshot.LastErrorPreview; + ToastNotificationService.ShowIfEnabled( + $"Build failed — {snapshot.DisplayName}", + message, + ToastKind.Error, + UserNotificationCategory.BuildFailure); + } + else if (previousState == ProjectLifecycleState.Testing && currentState == ProjectLifecycleState.TestFailed) + { + var message = string.IsNullOrWhiteSpace(snapshot.LastErrorPreview) + ? "See test log for details." + : snapshot.LastErrorPreview; + ToastNotificationService.ShowIfEnabled( + $"Tests failed — {snapshot.DisplayName}", + message, + ToastKind.Error, + UserNotificationCategory.BuildFailure); + } + + previousProjectState[snapshot.ProjectId] = currentState; + } + + var activeIds = snapshots.Where(s => s.IsActive).Select(s => s.ProjectId).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var staleId in previousProjectState.Keys.Where(id => !activeIds.Contains(id)).ToList()) + { + previousProjectState.Remove(staleId); + } + } +}