From e5d6b003c0a9c3ab48be215d31b94252751f89a7 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Fri, 13 Mar 2026 19:15:00 +0100 Subject: [PATCH 1/2] workbench --- .github/workflows/build-validation.yml | 2 +- .github/workflows/release-publish.yml | 215 ++++++++-- AGENTS.md | 12 +- DotPilot.Core/DotPilot.Core.csproj | 4 - .../CommunicationPrimitives.cs | 93 +++++ .../Features/Workbench/IWorkbenchCatalog.cs | 6 + .../Workbench/WorkbenchDocumentContracts.cs | 15 + .../Workbench/WorkbenchInspectorContracts.cs | 14 + .../Features/Workbench/WorkbenchIssues.cs | 15 + .../Features/Workbench/WorkbenchModes.cs | 27 ++ .../Workbench/WorkbenchRepositoryContracts.cs | 9 + .../Workbench/WorkbenchSessionContracts.cs | 7 + .../Workbench/WorkbenchSettingsContracts.cs | 14 + .../Features/Workbench/WorkbenchSnapshot.cs | 15 + .../ProviderToolchainProbe.cs | 5 + .../RuntimeFoundationCatalog.cs | 12 +- .../Features/Workbench/GitIgnoreRuleSet.cs | 141 +++++++ .../Features/Workbench/WorkbenchCatalog.cs | 31 ++ .../Features/Workbench/WorkbenchSeedData.cs | 237 +++++++++++ .../Workbench/WorkbenchWorkspaceResolver.cs | 71 ++++ .../WorkbenchWorkspaceSnapshotBuilder.cs | 348 ++++++++++++++++ DotPilot.Tests/GlobalUsings.cs | 1 + DotPilot.Tests/PresentationViewModelTests.cs | 50 ++- .../RuntimeFoundationCatalogTests.cs | 4 +- DotPilot.Tests/TemporaryWorkbenchDirectory.cs | 52 +++ DotPilot.Tests/WorkbenchCatalogTests.cs | 45 ++ DotPilot.UITests/AGENTS.md | 2 + .../BrowserAutomationBootstrap.cs | 70 ++-- .../BrowserAutomationBootstrapTests.cs | 8 +- DotPilot.UITests/BrowserTestHost.cs | 97 +---- DotPilot.UITests/BrowserTestHostTests.cs | 24 ++ DotPilot.UITests/DotPilot.UITests.csproj | 14 + DotPilot.UITests/Given_MainPage.cs | 388 +++++++++++++----- DotPilot.UITests/GlobalUsings.cs | 2 - DotPilot.UITests/TestBase.cs | 231 ++++++++--- DotPilot/AGENTS.md | 1 + DotPilot/App.xaml.cs | 191 +++++---- DotPilot/BrowserConsoleDiagnostics.cs | 36 ++ DotPilot/DotPilot.csproj | 4 +- DotPilot/GlobalUsings.cs | 1 + .../Presentation/Controls/AgentSidebar.xaml | 18 +- .../Presentation/Controls/SettingsShell.xaml | 152 +++++++ .../Controls/SettingsShell.xaml.cs | 9 + .../Controls/SettingsSidebar.xaml | 171 ++++++++ .../Controls/SettingsSidebar.xaml.cs | 9 + .../Controls/WorkbenchActivityPanel.xaml | 84 ++++ .../Controls/WorkbenchActivityPanel.xaml.cs | 9 + .../Controls/WorkbenchDocumentSurface.xaml | 129 ++++++ .../Controls/WorkbenchDocumentSurface.xaml.cs | 9 + .../Controls/WorkbenchInspectorPanel.xaml | 130 ++++++ .../Controls/WorkbenchInspectorPanel.xaml.cs | 9 + .../Controls/WorkbenchSidebar.xaml | 215 ++++++++++ .../Controls/WorkbenchSidebar.xaml.cs | 21 + DotPilot/Presentation/MainPage.xaml | 33 +- DotPilot/Presentation/MainPage.xaml.cs | 12 +- DotPilot/Presentation/MainViewModel.cs | 319 +++++++++++--- DotPilot/Presentation/ObservableObject.cs | 29 ++ .../Presentation/PresentationAutomationIds.cs | 24 ++ DotPilot/Presentation/SecondPage.xaml | 6 +- DotPilot/Presentation/SettingsPage.xaml | 41 ++ DotPilot/Presentation/SettingsPage.xaml.cs | 9 + DotPilot/Presentation/SettingsViewModel.cs | 69 ++++ DotPilot/Presentation/Shell.xaml.cs | 12 +- .../WorkbenchPresentationModels.cs | 18 + README.md | 71 +++- ...-split-github-actions-build-and-release.md | 6 +- docs/Architecture.md | 32 +- docs/Features/workbench-foundation.md | 59 +++ 68 files changed, 3699 insertions(+), 520 deletions(-) create mode 100644 DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs create mode 100644 DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchIssues.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchModes.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs create mode 100644 DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs create mode 100644 DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs create mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs create mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs create mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs create mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs create mode 100644 DotPilot.Tests/TemporaryWorkbenchDirectory.cs create mode 100644 DotPilot.Tests/WorkbenchCatalogTests.cs create mode 100644 DotPilot.UITests/BrowserTestHostTests.cs create mode 100644 DotPilot/BrowserConsoleDiagnostics.cs create mode 100644 DotPilot/Presentation/Controls/SettingsShell.xaml create mode 100644 DotPilot/Presentation/Controls/SettingsShell.xaml.cs create mode 100644 DotPilot/Presentation/Controls/SettingsSidebar.xaml create mode 100644 DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs create mode 100644 DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml create mode 100644 DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs create mode 100644 DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml create mode 100644 DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs create mode 100644 DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml create mode 100644 DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs create mode 100644 DotPilot/Presentation/Controls/WorkbenchSidebar.xaml create mode 100644 DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs create mode 100644 DotPilot/Presentation/ObservableObject.cs create mode 100644 DotPilot/Presentation/PresentationAutomationIds.cs create mode 100644 DotPilot/Presentation/SettingsPage.xaml create mode 100644 DotPilot/Presentation/SettingsPage.xaml.cs create mode 100644 DotPilot/Presentation/SettingsViewModel.cs create mode 100644 DotPilot/Presentation/WorkbenchPresentationModels.cs create mode 100644 docs/Features/workbench-foundation.md diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml index 2b8f47c..d86613a 100644 --- a/.github/workflows/build-validation.yml +++ b/.github/workflows/build-validation.yml @@ -36,7 +36,7 @@ jobs: - name: Build shell: pwsh - run: dotnet build DotPilot.slnx -warnaserror + run: dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false unit_tests: name: Unit Test Suite diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index de3f83f..1ce0a03 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -74,30 +74,12 @@ jobs: echo "release_tag=v${display_version}" } >> "$GITHUB_OUTPUT" - publish_desktop: - name: Publish Desktop (${{ matrix.name }}) - runs-on: ${{ matrix.runner }} + publish_macos: + name: Publish macOS Release Asset + runs-on: macos-latest + timeout-minutes: 60 needs: - prepare_release - strategy: - fail-fast: false - matrix: - include: - - name: macOS - runner: macos-latest - artifact_name: dotpilot-release-macos - archive_name: dotpilot-desktop-macos.zip - output_path: artifacts/publish/macos - - name: Windows - runner: windows-latest - artifact_name: dotpilot-release-windows - archive_name: dotpilot-desktop-windows.zip - output_path: artifacts/publish/windows - - name: Linux - runner: ubuntu-latest - artifact_name: dotpilot-release-linux - archive_name: dotpilot-desktop-linux.zip - output_path: artifacts/publish/linux steps: - uses: actions/checkout@v6 with: @@ -108,30 +90,189 @@ jobs: timeout-minutes: 60 uses: "./.github/steps/install_dependencies" - - name: Publish Desktop App + - name: Resolve macOS RID + id: macos_rid + shell: bash + run: | + architecture="$(uname -m)" + case "${architecture}" in + arm64) + rid="osx-arm64" + asset_suffix="macos-arm64" + ;; + x86_64) + rid="osx-x64" + asset_suffix="macos-x64" + ;; + *) + echo "Unsupported macOS runner architecture: ${architecture}" >&2 + exit 1 + ;; + esac + + echo "rid=${rid}" >> "$GITHUB_OUTPUT" + echo "asset_suffix=${asset_suffix}" >> "$GITHUB_OUTPUT" + + - name: Publish macOS Disk Image + shell: bash + working-directory: ./DotPilot + run: | + dotnet publish DotPilot.csproj \ + -c Release \ + -f net10.0-desktop \ + -r "${{ steps.macos_rid.outputs.rid }}" \ + -warnaserror \ + -m:1 \ + -p:BuildInParallel=false \ + -p:SelfContained=true \ + -p:PackageFormat=dmg \ + -p:CodesignKey=- \ + -p:DiskImageSigningKey=- \ + -p:ApplicationDisplayVersion=${{ needs.prepare_release.outputs.release_version }} \ + -p:ApplicationVersion=${{ needs.prepare_release.outputs.application_version }} + + - name: Stage macOS Release Asset + shell: bash + run: | + mkdir -p ./artifacts/releases + source_path="./DotPilot/bin/Release/net10.0-desktop/${{ steps.macos_rid.outputs.rid }}/publish/DotPilot.dmg" + target_path="./artifacts/releases/dotpilot-${{ needs.prepare_release.outputs.release_version }}-${{ steps.macos_rid.outputs.asset_suffix }}.dmg" + + if [[ ! -f "${source_path}" ]]; then + echo "Expected macOS release asset was not produced: ${source_path}" >&2 + exit 1 + fi + + cp "${source_path}" "${target_path}" + + - name: Upload macOS Release Artifact + uses: actions/upload-artifact@v4 + with: + name: dotpilot-release-macos + path: ./artifacts/releases/*.dmg + if-no-files-found: error + retention-days: 14 + + publish_windows: + name: Publish Windows Release Asset + runs-on: windows-latest + timeout-minutes: 60 + needs: + - prepare_release + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Install Dependencies + timeout-minutes: 60 + uses: "./.github/steps/install_dependencies" + + - name: Publish Windows Single-File Executable shell: pwsh + working-directory: .\DotPilot run: > - dotnet publish ./DotPilot/DotPilot.csproj -c Release -f net10.0-desktop - -o ./${{ matrix.output_path }} + dotnet publish DotPilot.csproj + -c Release + -f net10.0-desktop + -r win-x64 + -warnaserror + -m:1 + -p:BuildInParallel=false + -p:SelfContained=true + -p:PublishSingleFile=true + -p:IncludeNativeLibrariesForSelfExtract=true + -p:IncludeAllContentForSelfExtract=true -p:ApplicationDisplayVersion=${{ needs.prepare_release.outputs.release_version }} -p:ApplicationVersion=${{ needs.prepare_release.outputs.application_version }} - - name: Archive Desktop Publish Output + - name: Stage Windows Release Asset shell: pwsh run: | - New-Item -ItemType Directory -Force -Path ./artifacts/releases | Out-Null - $archivePath = "./artifacts/releases/${{ matrix.archive_name }}" - if (Test-Path $archivePath) { - Remove-Item $archivePath -Force + New-Item -ItemType Directory -Force -Path .\artifacts\releases | Out-Null + $sourcePath = ".\DotPilot\bin\Release\net10.0-desktop\win-x64\publish\DotPilot.exe" + $targetPath = ".\artifacts\releases\dotpilot-${{ needs.prepare_release.outputs.release_version }}-windows-x64.exe" + + if (-not (Test-Path $sourcePath)) { + throw "Expected Windows release asset was not produced: $sourcePath" } - Compress-Archive -Path "./${{ matrix.output_path }}/*" -DestinationPath $archivePath + Copy-Item $sourcePath $targetPath -Force + + - name: Upload Windows Release Artifact + uses: actions/upload-artifact@v4 + with: + name: dotpilot-release-windows + path: ./artifacts/releases/*.exe + if-no-files-found: error + retention-days: 14 + + publish_linux: + name: Publish Linux Snap Release Asset + runs-on: ubuntu-24.04 + timeout-minutes: 60 + needs: + - prepare_release + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Install Dependencies + timeout-minutes: 60 + uses: "./.github/steps/install_dependencies" + + - name: Install Linux Snap Packaging Prerequisites + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y snapd + sudo systemctl start snapd.socket + sudo snap wait system seed.loaded + sudo snap install core24 + sudo snap install multipass + sudo snap install lxd + sudo snap install snapcraft --classic + sudo lxd init --minimal + + - name: Publish Linux Snap Package + shell: bash + working-directory: ./DotPilot + run: | + dotnet publish DotPilot.csproj \ + -c Release \ + -f net10.0-desktop \ + -r linux-x64 \ + -warnaserror \ + -m:1 \ + -p:BuildInParallel=false \ + -p:SelfContained=true \ + -p:PackageFormat=snap \ + -p:UnoSnapcraftAdditionalParameters=--destructive-mode \ + -p:ApplicationDisplayVersion=${{ needs.prepare_release.outputs.release_version }} \ + -p:ApplicationVersion=${{ needs.prepare_release.outputs.application_version }} + + - name: Stage Linux Release Asset + shell: bash + run: | + mkdir -p ./artifacts/releases + source_path="$(find ./DotPilot/bin/Release/net10.0-desktop/linux-x64/publish -maxdepth 1 -type f -name '*.snap' | sort | head -n 1)" + target_path="./artifacts/releases/dotpilot-${{ needs.prepare_release.outputs.release_version }}-linux-x64.snap" + + if [[ -z "${source_path}" || ! -f "${source_path}" ]]; then + echo "Expected Linux snap release asset was not produced." >&2 + exit 1 + fi + + cp "${source_path}" "${target_path}" - - name: Upload Release Artifact + - name: Upload Linux Release Artifact uses: actions/upload-artifact@v4 with: - name: ${{ matrix.artifact_name }} - path: ./artifacts/releases/${{ matrix.archive_name }} + name: dotpilot-release-linux + path: ./artifacts/releases/*.snap if-no-files-found: error retention-days: 14 @@ -140,7 +281,9 @@ jobs: runs-on: ubuntu-latest needs: - prepare_release - - publish_desktop + - publish_macos + - publish_windows + - publish_linux steps: - uses: actions/checkout@v6 with: @@ -226,7 +369,7 @@ jobs: RELEASE_VERSION: ${{ needs.prepare_release.outputs.release_version }} REPOSITORY: ${{ github.repository }} run: | - mapfile -t release_assets < <(find ./artifacts/release-assets -type f -name '*.zip' | sort) + mapfile -t release_assets < <(find ./artifacts/release-assets -type f | sort) if [[ ${#release_assets[@]} -eq 0 ]]; then echo "No release assets were downloaded." >&2 exit 1 diff --git a/AGENTS.md b/AGENTS.md index d5869f1..8935711 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,10 +122,10 @@ Skill-management rules for this `.NET` solution: ### Commands -- `build`: `dotnet build DotPilot.slnx -warnaserror` +- `build`: `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - `test`: `dotnet test DotPilot.slnx` - `format`: `dotnet format DotPilot.slnx --verify-no-changes` -- `analyze`: `dotnet build DotPilot.slnx -warnaserror` +- `analyze`: `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - `coverage`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - `publish-desktop`: `dotnet publish DotPilot/DotPilot.csproj -c Release -f net10.0-desktop` @@ -134,6 +134,7 @@ For this app: - unit tests currently use `NUnit` through the default `VSTest` runner - UI tests live in `DotPilot.UITests` and are a mandatory part of normal verification; the harness must provision or resolve browser-driver prerequisites automatically instead of skipping when local setup is missing - a canceled, timed-out, or hanging `DotPilot.UITests` run is a harness failure to fix, not an acceptable substitute for a real pass or fail result in CI +- when debugging or validating the browser UI path, do not launch the app manually outside `DotPilot.UITests`; reproduce and diagnose only through the real UI-test harness so failures match the enforced verification path - `format` uses `dotnet format --verify-no-changes` as a local pre-push check; GitHub Actions validation should not spend CI time rechecking formatting drift that must already be fixed before push - coverage uses the `coverlet.collector` integration on `DotPilot.Tests` with the repo runsettings file to keep generated Uno artifacts out of the coverage path - desktop release publishing uses `dotnet publish DotPilot/DotPilot.csproj -c Release -f net10.0-desktop`; the validation workflow stays focused on build and automated tests, while the release workflow owns desktop publish outputs for macOS, Windows, and Linux @@ -141,6 +142,7 @@ For this app: - prefer the newest stable `.NET 10` and `C#` language features that are supported by the pinned SDK and do not weaken readability, determinism, or analyzability - the repo-root lowercase `.editorconfig` is the source of truth for formatting, naming, style, and analyzer severity - local and CI build commands must pass `-warnaserror`; warnings are not an acceptable "green" build state in this repository +- do not run parallel `dotnet` or `MSBuild` work that shares the same checkout, target outputs, or NuGet package cache; the multi-target Uno app must build serially in CI to avoid `Uno.Resizetizer` file-lock failures - quality gates should prefer analyzer-backed build failures over separate one-off CI tools; for overloaded methods and maintainability drift, enable build-time analyzers such as `CA1502` instead of adding a formatting-only gate - `Directory.Build.props` owns the shared analyzer and warning policy for future projects - `Directory.Packages.props` owns centrally managed package versions @@ -150,8 +152,10 @@ For this app: - keep the Uno app project presentation-only; domain, runtime host, orchestration, integrations, and persistence code must live in separate class-library projects so UI composition does not mix with feature implementation - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication +- meaningful GitHub review comments must be evaluated and fixed when they still apply even if the original PR was closed; closed review threads are not a reason to ignore valid engineering feedback - the release workflow must run automatically on pushes to `main`, build desktop apps, and publish the GitHub Release without requiring a manual dispatch - desktop app build or publish jobs must use native runners for their target OS: macOS artifacts on macOS runners, Windows artifacts on Windows runners, and Linux artifacts on Linux runners +- desktop release assets must be native installable or directly executable outputs for each OS, not archives of raw publish folders; package the real `.exe`, `.snap`, `.dmg`, `.pkg`, `Setup.exe`, or equivalent runnable installer/app artifact instead of zipping intermediate publish directories - desktop release versions must use the `ApplicationDisplayVersion` value in `DotPilot/DotPilot.csproj` as a manually maintained two-segment prefix, with CI appending the final segment from the build number (for example `0.0.`) - the release workflow must not take ownership of the first two version segments; those remain manually edited in source, while CI supplies only the last numeric segment and matching release tag/application version values - for CI and release automation in this solution, prefer existing `dotnet` and `MSBuild` capabilities plus small workflow-native steps over Python or adding a separate helper project for simple versioning and release-note tasks @@ -278,6 +282,8 @@ Local `AGENTS.md` files may tighten these values, but they must not loosen them - The task is not done until the full relevant test suite is green, not only the newly added tests. - UI tests are mandatory for this repository and must run in normal agent verification; missing local browser-driver setup is a harness bug to fix, not a reason to skip the suite. - UI coverage must validate complete end-to-end operator flows and also assert the presence and behavior of each interactive element introduced by a feature. +- For `Uno` UI-test changes, use the official `Uno` MCP documentation as the source of truth and align browser assertions with the documented WebAssembly automation mapping before changing the harness shape. +- When debugging local product behavior on this machine, prefer the real desktop `Uno` app head plus local `Uno` app tooling or MCP over ad hoc `browserwasm` runs; keep `browserwasm` for the dedicated `DotPilot.UITests` verification path. - GitHub Actions PR validation is mandatory for every PR and must enforce the real repo verification path so test failures are caught in CI, not only locally. - GitHub Actions PR validation must run full automated test verification, especially the real UI suite; build-only or smoke-only checks are not an acceptable substitute for pull-request gating. - GitHub Actions validation must also produce downloadable app artifacts for macOS, Windows, and Linux so every PR and mainline run has test results plus installable build outputs. @@ -332,6 +338,7 @@ Ask first: - Follow the canonical MCAF tutorial when bootstrapping or upgrading the agent workflow. - Commit cohesive code-change batches promptly while debugging, especially before switching focus or starting long verification runs, so the branch state stays inspectable and pushable. - After opening or updating a PR, create a fresh working branch before continuing with the next slice of work so follow-up changes do not pile onto the already-reviewed branch. +- Keep `DotPilot` feeling like a fast desktop control plane: startup, navigation, and visible UI reactions should be prompt, and agents should remove unnecessary waits instead of normalizing slow web-style loading behavior. - Keep the root `AGENTS.md` at the repository root. - Keep the repo-local agent skill directory limited to current `mcaf-*` skills. - Keep the solution file name cased as `DotPilot.slnx`. @@ -352,6 +359,7 @@ Ask first: - Installing stale, non-canonical, or non-`mcaf-*` skills into the repo-local agent skill directory. - Moving root governance out of the repository root. - Mixing multiple `.NET` test frameworks in the active solution without a documented migration plan. +- Adding fallback paths or alternate harnesses that only make failures disappear in tests while the primary product path remains broken. - Switching desktop Uno pages into stacked or mobile-style responsive layouts during resize work unless the user explicitly asks for a different composition; desktop pages must stay desktop-first and protect geometry through sizing constraints instead. - Adding extra UI-test orchestration complexity when the actual goal is simply to run the tests and get an honest pass or fail result. - Planning `MLXSharp` into the first product wave before it is ready for real use. diff --git a/DotPilot.Core/DotPilot.Core.csproj b/DotPilot.Core/DotPilot.Core.csproj index e08e532..200a482 100644 --- a/DotPilot.Core/DotPilot.Core.csproj +++ b/DotPilot.Core/DotPilot.Core.csproj @@ -6,8 +6,4 @@ $(NoWarn);CS1591 - - - - diff --git a/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs b/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs new file mode 100644 index 0000000..f883b2e --- /dev/null +++ b/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs @@ -0,0 +1,93 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace ManagedCode.Communication; + +public sealed class Problem +{ + private readonly Dictionary> _validationErrors = new(StringComparer.Ordinal); + + private Problem(string errorCode, string detail, int statusCode) + { + ArgumentException.ThrowIfNullOrWhiteSpace(errorCode); + ArgumentException.ThrowIfNullOrWhiteSpace(detail); + + ErrorCode = errorCode; + Detail = detail; + StatusCode = statusCode; + } + + public string ErrorCode { get; } + + public string Detail { get; } + + public int StatusCode { get; } + + public IReadOnlyDictionary> ValidationErrors => new ReadOnlyDictionary>(_validationErrors); + + public static Problem Create(TCode code, string detail, int statusCode) + where TCode : struct, Enum + { + return new Problem(code.ToString(), detail, statusCode); + } + + public void AddValidationError(string fieldName, string errorMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fieldName); + ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); + + if (_validationErrors.TryGetValue(fieldName, out var existingErrors)) + { + _validationErrors[fieldName] = [.. existingErrors, errorMessage]; + return; + } + + _validationErrors[fieldName] = [errorMessage]; + } + + public bool HasErrorCode(TCode code) + where TCode : struct, Enum + { + return string.Equals(ErrorCode, code.ToString(), StringComparison.Ordinal); + } + + public bool InvalidField(string fieldName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fieldName); + return _validationErrors.ContainsKey(fieldName); + } +} + +[SuppressMessage( + "Design", + "CA1000:Do not declare static members on generic types", + Justification = "The result contract intentionally exposes static success/failure factories to preserve the existing lightweight communication API.")] +public sealed class Result +{ + private Result(T? value, Problem? problem) + { + Value = value; + Problem = problem; + } + + public T? Value { get; } + + public Problem? Problem { get; } + + public bool IsSuccess => Problem is null; + + public bool IsFailed => !IsSuccess; + + public bool HasProblem => Problem is not null; + + public static Result Succeed(T value) + { + return new Result(value, problem: null); + } + + public static Result Fail(Problem problem) + { + ArgumentNullException.ThrowIfNull(problem); + return new Result(value: default, problem); + } +} diff --git a/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs b/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs new file mode 100644 index 0000000..3680349 --- /dev/null +++ b/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs @@ -0,0 +1,6 @@ +namespace DotPilot.Core.Features.Workbench; + +public interface IWorkbenchCatalog +{ + WorkbenchSnapshot GetSnapshot(); +} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs new file mode 100644 index 0000000..8bec47e --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs @@ -0,0 +1,15 @@ +namespace DotPilot.Core.Features.Workbench; + +public sealed record WorkbenchDiffLine( + WorkbenchDiffLineKind Kind, + string Content); + +public sealed record WorkbenchDocumentDescriptor( + string RelativePath, + string Title, + string LanguageLabel, + string RendererLabel, + string StatusSummary, + bool IsReadOnly, + string PreviewContent, + IReadOnlyList DiffLines); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs new file mode 100644 index 0000000..7c877a7 --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs @@ -0,0 +1,14 @@ +namespace DotPilot.Core.Features.Workbench; + +public sealed record WorkbenchArtifactDescriptor( + string Name, + string Kind, + string Status, + string RelativePath, + string Summary); + +public sealed record WorkbenchLogEntry( + string Timestamp, + string Level, + string Source, + string Message); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs b/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs new file mode 100644 index 0000000..bc0de38 --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs @@ -0,0 +1,15 @@ +namespace DotPilot.Core.Features.Workbench; + +public static class WorkbenchIssues +{ + private const string IssuePrefix = "#"; + + public const int DesktopWorkbenchEpic = 13; + public const int PrimaryShell = 28; + public const int RepositoryTree = 29; + public const int DocumentSurface = 30; + public const int ArtifactDock = 31; + public const int SettingsShell = 32; + + public static string FormatIssueLabel(int issueNumber) => string.Concat(IssuePrefix, issueNumber); +} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchModes.cs b/DotPilot.Core/Features/Workbench/WorkbenchModes.cs new file mode 100644 index 0000000..f6aeada --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchModes.cs @@ -0,0 +1,27 @@ +namespace DotPilot.Core.Features.Workbench; + +public enum WorkbenchDocumentViewMode +{ + Preview, + DiffReview, +} + +public enum WorkbenchDiffLineKind +{ + Context, + Added, + Removed, +} + +public enum WorkbenchInspectorSection +{ + Artifacts, + Logs, +} + +public enum WorkbenchSessionEntryKind +{ + Operator, + Agent, + System, +} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs new file mode 100644 index 0000000..1bfab34 --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Core.Features.Workbench; + +public sealed record WorkbenchRepositoryNode( + string RelativePath, + string DisplayLabel, + string Name, + int Depth, + bool IsDirectory, + bool CanOpen); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs new file mode 100644 index 0000000..801a80a --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs @@ -0,0 +1,7 @@ +namespace DotPilot.Core.Features.Workbench; + +public sealed record WorkbenchSessionEntry( + string Title, + string Timestamp, + string Summary, + WorkbenchSessionEntryKind Kind); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs new file mode 100644 index 0000000..eb7adff --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs @@ -0,0 +1,14 @@ +namespace DotPilot.Core.Features.Workbench; + +public sealed record WorkbenchSettingEntry( + string Name, + string Value, + string Summary, + bool IsSensitive, + bool IsActionable); + +public sealed record WorkbenchSettingsCategory( + string Key, + string Title, + string Summary, + IReadOnlyList Entries); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs b/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs new file mode 100644 index 0000000..9312cc6 --- /dev/null +++ b/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs @@ -0,0 +1,15 @@ +namespace DotPilot.Core.Features.Workbench; + +public sealed record WorkbenchSnapshot( + string WorkspaceName, + string WorkspaceRoot, + string SearchPlaceholder, + string SessionTitle, + string SessionStage, + string SessionSummary, + IReadOnlyList SessionEntries, + IReadOnlyList RepositoryNodes, + IReadOnlyList Documents, + IReadOnlyList Artifacts, + IReadOnlyList Logs, + IReadOnlyList SettingsCategories); diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs index 183dfc3..4f98536 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs @@ -37,6 +37,11 @@ status is ProviderConnectionStatus.Available private static string? ResolveExecutablePath(string commandName) { + if (OperatingSystem.IsBrowser()) + { + return null; + } + var searchPaths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs index 02cf9a0..9d5b6b3 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs @@ -9,6 +9,7 @@ public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog "Issue #12 is staged into isolated contracts, communication, host, and orchestration slices so the Uno workbench can stay presentation-only."; private const string DeterministicProbePrompt = "Summarize the runtime foundation readiness for a local-first session that may require approval."; + private const string DeterministicClientStatusSummary = "Always available for in-repo and CI validation."; private const string DomainModelName = "Domain contracts"; private const string DomainModelSummary = "Typed identifiers and durable agent, session, fleet, provider, and runtime contracts live outside the Uno app."; @@ -22,13 +23,6 @@ public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog private const string OrchestrationSummary = "Agent Framework integration is prepared as a separate slice that can plug into the embedded host without reshaping the UI layer."; - private readonly IAgentRuntimeClient _deterministicClient; - public RuntimeFoundationCatalog(IAgentRuntimeClient deterministicClient) - { - ArgumentNullException.ThrowIfNull(deterministicClient); - _deterministicClient = deterministicClient; - } - public RuntimeFoundationSnapshot GetSnapshot() { return new( @@ -71,7 +65,7 @@ private static IReadOnlyList CreateSlices() ]; } - private IReadOnlyList CreateProviders() + private static IReadOnlyList CreateProviders() { return [ @@ -81,7 +75,7 @@ private IReadOnlyList CreateProviders() DisplayName = ProviderToolchainNames.DeterministicClientDisplayName, CommandName = ProviderToolchainNames.DeterministicClientCommandName, Status = ProviderConnectionStatus.Available, - StatusSummary = _deterministicClient.GetType().Name, + StatusSummary = DeterministicClientStatusSummary, RequiresExternalToolchain = false, }, ProviderToolchainProbe.Probe( diff --git a/DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs b/DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs new file mode 100644 index 0000000..c0b766c --- /dev/null +++ b/DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs @@ -0,0 +1,141 @@ +using System.IO.Enumeration; + +namespace DotPilot.Runtime.Features.Workbench; + +internal sealed class GitIgnoreRuleSet +{ + private const char CommentPrefix = '#'; + private const char NegationPrefix = '!'; + private const char DirectorySuffix = '/'; + private const char PathSeparator = '/'; + private const string GitIgnoreFileName = ".gitignore"; + + private static readonly HashSet AlwaysIgnoredNames = new(StringComparer.OrdinalIgnoreCase) + { + ".codex", + ".git", + ".vs", + "bin", + "obj", + "TestResults", + }; + + private readonly IReadOnlyList _patterns; + + private GitIgnoreRuleSet(IReadOnlyList patterns) + { + _patterns = patterns; + } + + public static GitIgnoreRuleSet Load(string workspaceRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspaceRoot); + + var gitIgnorePath = Path.Combine(workspaceRoot, GitIgnoreFileName); + if (!File.Exists(gitIgnorePath)) + { + return new([]); + } + + var patterns = File.ReadLines(gitIgnorePath) + .Select(ParseLine) + .OfType() + .ToArray(); + + return new(patterns); + } + + public bool IsIgnored(string relativePath, bool isDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + var normalizedPath = Normalize(relativePath); + var segments = normalizedPath.Split(PathSeparator, StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(static segment => AlwaysIgnoredNames.Contains(segment))) + { + return true; + } + + foreach (var pattern in _patterns) + { + if (pattern.IsMatch(normalizedPath, segments, isDirectory)) + { + return true; + } + } + + return false; + } + + private static GitIgnorePattern? ParseLine(string rawLine) + { + var trimmed = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || + trimmed[0] is CommentPrefix or NegationPrefix) + { + return null; + } + + var directoryOnly = trimmed.EndsWith(DirectorySuffix); + var rooted = trimmed.StartsWith(PathSeparator); + var normalizedPattern = Normalize(trimmed.TrimStart(PathSeparator).TrimEnd(DirectorySuffix)); + if (string.IsNullOrWhiteSpace(normalizedPattern)) + { + return null; + } + + return new GitIgnorePattern( + normalizedPattern, + directoryOnly, + rooted, + normalizedPattern.Contains(PathSeparator)); + } + + private static string Normalize(string path) + { + return path.Replace(Path.DirectorySeparatorChar, PathSeparator) + .Replace(Path.AltDirectorySeparatorChar, PathSeparator) + .Trim(); + } + + private sealed record GitIgnorePattern( + string Pattern, + bool DirectoryOnly, + bool Rooted, + bool HasPathSeparator) + { + public bool IsMatch(string normalizedPath, IReadOnlyList segments, bool isDirectory) + { + if (DirectoryOnly && !isDirectory) + { + return false; + } + + if (Rooted) + { + return MatchesPath(normalizedPath); + } + + if (HasPathSeparator) + { + return MatchesPath(normalizedPath) || + normalizedPath.Contains(string.Concat(PathSeparator, Pattern), StringComparison.OrdinalIgnoreCase); + } + + return segments.Any(segment => FileSystemName.MatchesSimpleExpression(Pattern, segment, ignoreCase: true)); + } + + private bool MatchesPath(string normalizedPath) + { + if (FileSystemName.MatchesSimpleExpression(Pattern, normalizedPath, ignoreCase: true)) + { + return true; + } + + return normalizedPath.Equals(Pattern, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith(string.Concat(Pattern, PathSeparator), StringComparison.OrdinalIgnoreCase) || + normalizedPath.EndsWith(string.Concat(PathSeparator, Pattern), StringComparison.OrdinalIgnoreCase) || + normalizedPath.Contains(string.Concat(PathSeparator, Pattern, PathSeparator), StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs new file mode 100644 index 0000000..cdf9f87 --- /dev/null +++ b/DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs @@ -0,0 +1,31 @@ +using DotPilot.Core.Features.RuntimeFoundation; +using DotPilot.Core.Features.Workbench; + +namespace DotPilot.Runtime.Features.Workbench; + +public sealed class WorkbenchCatalog : IWorkbenchCatalog +{ + private readonly IRuntimeFoundationCatalog _runtimeFoundationCatalog; + private readonly string? _workspaceRootOverride; + + public WorkbenchCatalog(IRuntimeFoundationCatalog runtimeFoundationCatalog) + : this(runtimeFoundationCatalog, workspaceRootOverride: null) + { + } + + public WorkbenchCatalog(IRuntimeFoundationCatalog runtimeFoundationCatalog, string? workspaceRootOverride) + { + ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); + _runtimeFoundationCatalog = runtimeFoundationCatalog; + _workspaceRootOverride = workspaceRootOverride; + } + + public WorkbenchSnapshot GetSnapshot() + { + var runtimeFoundationSnapshot = _runtimeFoundationCatalog.GetSnapshot(); + var workspace = WorkbenchWorkspaceResolver.Resolve(_workspaceRootOverride); + return workspace.IsAvailable + ? new WorkbenchWorkspaceSnapshotBuilder(workspace, runtimeFoundationSnapshot).Build() + : WorkbenchSeedData.Create(runtimeFoundationSnapshot); + } +} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs new file mode 100644 index 0000000..41fbaa6 --- /dev/null +++ b/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs @@ -0,0 +1,237 @@ +using DotPilot.Core.Features.RuntimeFoundation; +using DotPilot.Core.Features.Workbench; + +namespace DotPilot.Runtime.Features.Workbench; + +internal static class WorkbenchSeedData +{ + private const string WorkspaceName = "Browser sandbox"; + private const string WorkspaceRoot = "Seeded browser-safe workspace"; + private const string SearchPlaceholder = "Search the workspace tree"; + private const string SessionTitle = "Issue #13 workbench slice"; + private const string SessionStage = "Review"; + private const string SessionSummary = + "Seeded workbench data keeps browser automation deterministic while the desktop host can use the real repository."; + private const string MonacoRendererLabel = "Monaco-aligned preview"; + private const string ReadOnlyStatusSummary = "Read-only workspace reference"; + private const string DiffReviewNote = "workbench review baseline"; + private const string ProviderCategoryKey = "providers"; + private const string PolicyCategoryKey = "policies"; + private const string StorageCategoryKey = "storage"; + private const string ProviderCategoryTitle = "Providers"; + private const string PolicyCategoryTitle = "Policies"; + private const string StorageCategoryTitle = "Storage"; + private const string ProviderCategorySummary = "Provider toolchains and runtime readiness"; + private const string PolicyCategorySummary = "Approval and review defaults"; + private const string StorageCategorySummary = "Workspace and artifact retention"; + private const string ReviewPath = "docs/Features/workbench-foundation.md"; + private const string PlanPath = "issue-13-workbench-foundation.plan.md"; + private const string MainPagePath = "DotPilot/Presentation/MainPage.xaml"; + private const string SettingsPath = "DotPilot/Presentation/SettingsPage.xaml"; + private const string ArchitecturePath = "docs/Architecture.md"; + private const string ArtifactsRelativePath = "artifacts/workbench-shell.png"; + private const string SessionOutputPath = "artifacts/session-output.log"; + private const string CurrentWorkspaceEntryName = "Current workspace"; + private const string ArtifactRetentionEntryName = "Artifact retention"; + private const string ApprovalModeEntryName = "Approval mode"; + private const string ReviewGateEntryName = "Diff review gate"; + private const string ReviewGateEntryValue = "Required"; + private const string ArtifactRetentionEntryValue = "14 days"; + private const string ApprovalModeEntryValue = "Operator confirmation"; + private const string TimestampOne = "09:10"; + private const string TimestampTwo = "09:12"; + private const string TimestampThree = "09:14"; + private const string TimestampFour = "09:15"; + private const string InfoLevel = "INFO"; + private const string ReviewLevel = "REVIEW"; + private const string AgentSource = "design-agent"; + private const string RuntimeSource = "runtime"; + private const string SettingsSource = "settings"; + private const string ReviewMessage = "Prepared the workbench shell review with repository navigation, diff mode, and settings coverage."; + private const string IndexMessage = "Loaded the browser-safe seeded workspace."; + private const string DiffMessage = "Queued a review diff for the primary workbench page."; + private const string SettingsMessage = "Published the unified settings shell categories."; + public static WorkbenchSnapshot Create(RuntimeFoundationSnapshot runtimeFoundationSnapshot) + { + ArgumentNullException.ThrowIfNull(runtimeFoundationSnapshot); + + var repositoryNodes = CreateRepositoryNodes(); + var documents = CreateDocuments(); + + return new( + WorkspaceName, + WorkspaceRoot, + SearchPlaceholder, + SessionTitle, + SessionStage, + SessionSummary, + CreateSessionEntries(), + repositoryNodes, + documents, + CreateArtifacts(), + CreateLogs(), + CreateSettingsCategories(runtimeFoundationSnapshot)); + } + + private static IReadOnlyList CreateRepositoryNodes() + { + return + [ + new("docs", "docs", "docs", 0, true, false), + new(ArchitecturePath, ArchitecturePath, "Architecture.md", 1, false, true), + new(ReviewPath, ReviewPath, "workbench-foundation.md", 1, false, true), + new("DotPilot", "DotPilot", "DotPilot", 0, true, false), + new("DotPilot/Presentation", "DotPilot/Presentation", "Presentation", 1, true, false), + new(MainPagePath, MainPagePath, "MainPage.xaml", 2, false, true), + new(SettingsPath, SettingsPath, "SettingsPage.xaml", 2, false, true), + new(PlanPath, PlanPath, "issue-13-workbench-foundation.plan.md", 0, false, true), + ]; + } + + private static IReadOnlyList CreateDocuments() + { + return + [ + CreateDocument( + MainPagePath, + "MainPage.xaml", + "XAML", + MonacoRendererLabel, + ReadOnlyStatusSummary, + isReadOnly: true, + """ + + + + + + """), + CreateDocument( + SettingsPath, + "SettingsPage.xaml", + "XAML", + MonacoRendererLabel, + ReadOnlyStatusSummary, + isReadOnly: true, + """ + + + + + """), + CreateDocument( + ReviewPath, + "workbench-foundation.md", + "Markdown", + MonacoRendererLabel, + ReadOnlyStatusSummary, + isReadOnly: true, + """ + # Workbench Foundation + + Epic #13 keeps the current desktop shell while replacing sample data with a repository tree, + a file surface, an artifact dock, and a unified settings shell. + """), + ]; + } + + private static WorkbenchDocumentDescriptor CreateDocument( + string relativePath, + string title, + string languageLabel, + string rendererLabel, + string statusSummary, + bool isReadOnly, + string previewContent) + { + return new( + relativePath, + title, + languageLabel, + rendererLabel, + statusSummary, + isReadOnly, + previewContent, + CreateDiffLines(title, relativePath)); + } + + private static IReadOnlyList CreateArtifacts() + { + return + [ + new("Workbench feature doc", "Documentation", "Ready", ReviewPath, "Tracks epic #13 scope, flow, and verification."), + new("Workbench implementation plan", "Plan", "Ready", PlanPath, "Records ordered implementation and validation work."), + new("Workbench shell proof", "Screenshot", "Queued", ArtifactsRelativePath, "Reserved for browser UI test screenshots."), + new("Session output", "Console", "Streaming", SessionOutputPath, "The runtime console stays attached to the current workbench."), + ]; + } + + private static IReadOnlyList CreateLogs() + { + return + [ + new(TimestampOne, InfoLevel, RuntimeSource, IndexMessage), + new(TimestampTwo, ReviewLevel, AgentSource, ReviewMessage), + new(TimestampThree, ReviewLevel, RuntimeSource, DiffMessage), + new(TimestampFour, InfoLevel, SettingsSource, SettingsMessage), + ]; + } + + private static IReadOnlyList CreateSessionEntries() + { + return + [ + new("Plan baseline", TimestampOne, "Locked the issue #13 workbench plan and preserved the green solution baseline.", WorkbenchSessionEntryKind.Operator), + new("Tree indexed", TimestampTwo, "Loaded a deterministic repository tree for browser-hosted validation.", WorkbenchSessionEntryKind.System), + new("Diff review", TimestampThree, "Prepared the MainPage review surface with a Monaco-aligned preview and diff mode.", WorkbenchSessionEntryKind.Agent), + new("Settings shell", TimestampFour, "Published providers, policies, and storage categories as a first-class route.", WorkbenchSessionEntryKind.System), + ]; + } + + private static IReadOnlyList CreateSettingsCategories(RuntimeFoundationSnapshot runtimeFoundationSnapshot) + { + return + [ + new( + ProviderCategoryKey, + ProviderCategoryTitle, + ProviderCategorySummary, + runtimeFoundationSnapshot.Providers + .Select(provider => new WorkbenchSettingEntry( + provider.DisplayName, + provider.Status.ToString(), + provider.StatusSummary, + IsSensitive: false, + IsActionable: provider.RequiresExternalToolchain)) + .ToArray()), + new( + PolicyCategoryKey, + PolicyCategoryTitle, + PolicyCategorySummary, + [ + new(ApprovalModeEntryName, ApprovalModeEntryValue, "All file and tool changes stay operator-approved.", IsSensitive: false, IsActionable: true), + new(ReviewGateEntryName, ReviewGateEntryValue, "Agent proposals must stay reviewable before acceptance.", IsSensitive: false, IsActionable: true), + ]), + new( + StorageCategoryKey, + StorageCategoryTitle, + StorageCategorySummary, + [ + new(CurrentWorkspaceEntryName, WorkspaceRoot, "Browser-hosted automation uses seeded workspace metadata.", IsSensitive: false, IsActionable: false), + new(ArtifactRetentionEntryName, ArtifactRetentionEntryValue, "Artifacts stay visible from the main workbench dock.", IsSensitive: false, IsActionable: true), + ]), + ]; + } + + private static IReadOnlyList CreateDiffLines(string title, string relativePath) + { + return + [ + new(WorkbenchDiffLineKind.Context, $"@@ {relativePath} @@"), + new(WorkbenchDiffLineKind.Removed, $"- prototype-only state for {title}"), + new(WorkbenchDiffLineKind.Added, $"+ runtime-backed workbench state for {DiffReviewNote}"), + ]; + } +} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs new file mode 100644 index 0000000..a1c0920 --- /dev/null +++ b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs @@ -0,0 +1,71 @@ +namespace DotPilot.Runtime.Features.Workbench; + +internal static class WorkbenchWorkspaceResolver +{ + private const string SolutionFileName = "DotPilot.slnx"; + private const string GitDirectoryName = ".git"; + + public static ResolvedWorkspace Resolve(string? workspaceRootOverride) + { + if (!string.IsNullOrWhiteSpace(workspaceRootOverride) && + Directory.Exists(workspaceRootOverride)) + { + return CreateResolvedWorkspace(workspaceRootOverride); + } + + if (OperatingSystem.IsBrowser()) + { + return ResolvedWorkspace.Unavailable; + } + + foreach (var candidate in GetCandidateDirectories()) + { + var resolvedRoot = FindWorkspaceRoot(candidate); + if (resolvedRoot is not null) + { + return CreateResolvedWorkspace(resolvedRoot); + } + } + + return ResolvedWorkspace.Unavailable; + } + + private static IEnumerable GetCandidateDirectories() + { + return new[] + { + Environment.CurrentDirectory, + AppContext.BaseDirectory, + } + .Where(static candidate => !string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate)) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static string? FindWorkspaceRoot(string startDirectory) + { + for (var current = new DirectoryInfo(startDirectory); current is not null; current = current.Parent) + { + if (File.Exists(Path.Combine(current.FullName, SolutionFileName)) || + Directory.Exists(Path.Combine(current.FullName, GitDirectoryName))) + { + return current.FullName; + } + } + + return null; + } + + private static ResolvedWorkspace CreateResolvedWorkspace(string workspaceRoot) + { + var workspaceName = new DirectoryInfo(workspaceRoot).Name; + return new ResolvedWorkspace(workspaceRoot, workspaceName, IsAvailable: true); + } +} + +internal sealed record ResolvedWorkspace( + string Root, + string Name, + bool IsAvailable) +{ + public static ResolvedWorkspace Unavailable { get; } = new(string.Empty, string.Empty, IsAvailable: false); +} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs new file mode 100644 index 0000000..3e378cb --- /dev/null +++ b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs @@ -0,0 +1,348 @@ +using System.Collections.Frozen; +using DotPilot.Core.Features.RuntimeFoundation; +using DotPilot.Core.Features.Workbench; + +namespace DotPilot.Runtime.Features.Workbench; + +internal sealed class WorkbenchWorkspaceSnapshotBuilder +{ + private const int MaxDocumentCount = 12; + private const int MaxNodeCount = 96; + private const int MaxPreviewLines = 18; + private const int MaxTraversalDepth = 4; + private const string SearchPlaceholder = "Search the workspace tree"; + private const string SessionStage = "Execute"; + private const string MonacoRendererLabel = "Monaco-aligned preview"; + private const string StructuredRendererLabel = "Structured preview"; + private const string ReadOnlyStatusSummary = "Read-only workspace reference"; + private const string DiffReviewNote = "issue #13 runtime-backed review"; + private const string ProvidersCategoryKey = "providers"; + private const string PoliciesCategoryKey = "policies"; + private const string StorageCategoryKey = "storage"; + private const string ProvidersCategoryTitle = "Providers"; + private const string PoliciesCategoryTitle = "Policies"; + private const string StorageCategoryTitle = "Storage"; + private const string ProvidersCategorySummary = "Provider readiness stays visible from the unified settings shell."; + private const string PoliciesCategorySummary = "Review and approval defaults for operator sessions."; + private const string StorageCategorySummary = "Workspace root and artifact handling."; + private const string ApprovalModeEntryName = "Approval mode"; + private const string ApprovalModeEntryValue = "Operator confirmation"; + private const string ReviewGateEntryName = "Diff review gate"; + private const string ReviewGateEntryValue = "Required"; + private const string WorkspaceRootEntryName = "Workspace root"; + private const string ArtifactRetentionEntryName = "Artifact retention"; + private const string ArtifactRetentionEntryValue = "14 days"; + private const string WorkbenchDocPath = "docs/Features/workbench-foundation.md"; + private const string ArchitecturePath = "docs/Architecture.md"; + private const string PlanPath = "issue-13-workbench-foundation.plan.md"; + private const string ConsolePath = "artifacts/session-output.log"; + private const string ScreenshotPath = "artifacts/workbench-shell.png"; + private const string TimestampOne = "09:10"; + private const string TimestampTwo = "09:13"; + private const string TimestampThree = "09:15"; + private const string TimestampFour = "09:17"; + private const string InfoLevel = "INFO"; + private const string ReviewLevel = "REVIEW"; + private const string RuntimeSource = "runtime"; + private const string AgentSource = "agent"; + private const string SettingsSource = "settings"; + private const string SettingsMessage = "Published unified settings categories for providers, policies, and storage."; + private const string SessionEntryPlanTitle = "Plan baseline"; + private const string SessionEntryIndexTitle = "Workspace indexed"; + private const string SessionEntryReviewTitle = "Review ready"; + private const string SessionEntrySettingsTitle = "Settings published"; + private const string SessionEntryPlanSummary = "Preserved the issue #13 workbench plan before implementation."; + private const string SessionEntrySettingsSummary = "Surfaced providers, policies, and storage as first-class settings categories."; + + private static readonly FrozenSet SupportedDocumentExtensions = new[] + { + ".cs", + ".csproj", + ".json", + ".md", + ".props", + ".slnx", + ".targets", + ".xaml", + ".xml", + ".yml", + ".yaml", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenSet MonacoPreviewExtensions = new[] + { + ".cs", + ".json", + ".md", + ".xaml", + ".xml", + ".yml", + ".yaml", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + private readonly ResolvedWorkspace _workspace; + private readonly RuntimeFoundationSnapshot _runtimeFoundationSnapshot; + private readonly GitIgnoreRuleSet _ignoreRules; + + public WorkbenchWorkspaceSnapshotBuilder( + ResolvedWorkspace workspace, + RuntimeFoundationSnapshot runtimeFoundationSnapshot) + { + ArgumentNullException.ThrowIfNull(workspace); + ArgumentNullException.ThrowIfNull(runtimeFoundationSnapshot); + + _workspace = workspace; + _runtimeFoundationSnapshot = runtimeFoundationSnapshot; + _ignoreRules = GitIgnoreRuleSet.Load(workspace.Root); + } + + public WorkbenchSnapshot Build() + { + var repositoryNodes = BuildRepositoryNodes(); + var documents = BuildDocuments(repositoryNodes); + if (repositoryNodes.Count == 0 || documents.Count == 0) + { + return WorkbenchSeedData.Create(_runtimeFoundationSnapshot); + } + + return new( + _workspace.Name, + _workspace.Root, + SearchPlaceholder, + $"{_workspace.Name} operator workbench", + SessionStage, + $"Indexed {repositoryNodes.Count} workspace nodes and prepared {documents.Count} reviewable documents.", + CreateSessionEntries(documents[0].Title), + repositoryNodes, + documents, + CreateArtifacts(documents), + CreateLogs(documents.Count, documents[0].Title), + CreateSettingsCategories()); + } + + private List BuildRepositoryNodes() + { + List nodes = []; + TraverseDirectory(_workspace.Root, relativePath: string.Empty, depth: 0, nodes); + return nodes; + } + + private void TraverseDirectory(string absoluteDirectory, string relativePath, int depth, List nodes) + { + if (depth > MaxTraversalDepth || nodes.Count >= MaxNodeCount) + { + return; + } + + foreach (var directoryPath in EnumerateEntries(absoluteDirectory, searchDirectories: true)) + { + if (nodes.Count >= MaxNodeCount) + { + return; + } + + var directoryName = Path.GetFileName(directoryPath); + var directoryRelativePath = CombineRelative(relativePath, directoryName); + if (_ignoreRules.IsIgnored(directoryRelativePath, isDirectory: true)) + { + continue; + } + + nodes.Add(new(directoryRelativePath, directoryRelativePath, directoryName, depth, IsDirectory: true, CanOpen: false)); + TraverseDirectory(directoryPath, directoryRelativePath, depth + 1, nodes); + } + + foreach (var filePath in EnumerateEntries(absoluteDirectory, searchDirectories: false)) + { + if (nodes.Count >= MaxNodeCount) + { + return; + } + + var fileName = Path.GetFileName(filePath); + var fileRelativePath = CombineRelative(relativePath, fileName); + if (_ignoreRules.IsIgnored(fileRelativePath, isDirectory: false) || + !SupportedDocumentExtensions.Contains(Path.GetExtension(filePath))) + { + continue; + } + + nodes.Add(new(fileRelativePath, fileRelativePath, fileName, depth, IsDirectory: false, CanOpen: true)); + } + } + + private List BuildDocuments(IReadOnlyList repositoryNodes) + { + List documents = []; + foreach (var node in repositoryNodes.Where(static node => node.CanOpen).Take(MaxDocumentCount)) + { + var absolutePath = Path.Combine(_workspace.Root, node.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + var previewContent = ReadPreview(absolutePath); + if (string.IsNullOrWhiteSpace(previewContent)) + { + continue; + } + + var extension = Path.GetExtension(absolutePath); + documents.Add(new( + node.RelativePath, + node.Name, + ResolveLanguageLabel(extension), + ResolveRendererLabel(extension), + ReadOnlyStatusSummary, + IsReadOnly: true, + previewContent, + CreateDiffLines(node.Name, node.RelativePath))); + } + + return documents; + } + + private IReadOnlyList CreateArtifacts(List documents) + { + var primaryDocument = documents[0]; + return + [ + new("Workbench feature doc", "Documentation", File.Exists(Path.Combine(_workspace.Root, WorkbenchDocPath)) ? "Ready" : "Pending", WorkbenchDocPath, "Tracks epic #13 scope and workbench flow."), + new("Architecture overview", "Documentation", File.Exists(Path.Combine(_workspace.Root, ArchitecturePath)) ? "Ready" : "Pending", ArchitecturePath, "Routes agents through the active solution boundaries."), + new("Issue #13 plan", "Plan", File.Exists(Path.Combine(_workspace.Root, PlanPath)) ? "Ready" : "Pending", PlanPath, "Captures ordered implementation and validation work."), + new(primaryDocument.Title, "Review target", "Open", primaryDocument.RelativePath, "The current file surface mirrors the selected workspace document."), + new("Session output", "Console", "Streaming", ConsolePath, "The runtime log console stays bound to the current workbench session."), + new("Workbench shell proof", "Screenshot", "Queued", ScreenshotPath, "Reserved for browser UI test screenshot attachments."), + ]; + } + + private IReadOnlyList CreateLogs(int documentCount, string primaryDocumentTitle) + { + return + [ + new(TimestampOne, InfoLevel, RuntimeSource, $"Indexed the workspace rooted at {_workspace.Root}."), + new(TimestampTwo, InfoLevel, RuntimeSource, $"Prepared Monaco-aligned previews for {documentCount} documents."), + new(TimestampThree, ReviewLevel, AgentSource, $"Queued an explicit diff review for {primaryDocumentTitle}."), + new(TimestampFour, InfoLevel, SettingsSource, SettingsMessage), + ]; + } + + private IReadOnlyList CreateSessionEntries(string primaryDocumentTitle) + { + return + [ + new(SessionEntryPlanTitle, TimestampOne, SessionEntryPlanSummary, WorkbenchSessionEntryKind.Operator), + new(SessionEntryIndexTitle, TimestampTwo, $"Indexed the live workspace rooted at {_workspace.Root}.", WorkbenchSessionEntryKind.System), + new(SessionEntryReviewTitle, TimestampThree, $"Prepared a diff review and preview surface for {primaryDocumentTitle}.", WorkbenchSessionEntryKind.Agent), + new(SessionEntrySettingsTitle, TimestampFour, SessionEntrySettingsSummary, WorkbenchSessionEntryKind.System), + ]; + } + + private IReadOnlyList CreateSettingsCategories() + { + return + [ + new( + ProvidersCategoryKey, + ProvidersCategoryTitle, + ProvidersCategorySummary, + _runtimeFoundationSnapshot.Providers + .Select(provider => new WorkbenchSettingEntry( + provider.DisplayName, + provider.Status.ToString(), + provider.StatusSummary, + IsSensitive: false, + IsActionable: provider.RequiresExternalToolchain)) + .ToArray()), + new( + PoliciesCategoryKey, + PoliciesCategoryTitle, + PoliciesCategorySummary, + [ + new(ApprovalModeEntryName, ApprovalModeEntryValue, "All file and tool changes stay operator-approved.", IsSensitive: false, IsActionable: true), + new(ReviewGateEntryName, ReviewGateEntryValue, "Agent proposals remain reviewable before acceptance.", IsSensitive: false, IsActionable: true), + ]), + new( + StorageCategoryKey, + StorageCategoryTitle, + StorageCategorySummary, + [ + new(WorkspaceRootEntryName, _workspace.Root, "The workbench binds to the live workspace when available.", IsSensitive: false, IsActionable: false), + new(ArtifactRetentionEntryName, ArtifactRetentionEntryValue, "Artifacts remain visible from the dock and console.", IsSensitive: false, IsActionable: true), + ]), + ]; + } + + private static string[] EnumerateEntries(string absoluteDirectory, bool searchDirectories) + { + try + { + var entries = searchDirectories + ? Directory.EnumerateDirectories(absoluteDirectory) + : Directory.EnumerateFiles(absoluteDirectory); + + return entries.OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase).ToArray(); + } + catch (IOException) + { + return []; + } + catch (UnauthorizedAccessException) + { + return []; + } + } + + private static string ReadPreview(string absolutePath) + { + try + { + return string.Join( + Environment.NewLine, + File.ReadLines(absolutePath) + .Take(MaxPreviewLines)); + } + catch (IOException) + { + return string.Empty; + } + catch (UnauthorizedAccessException) + { + return string.Empty; + } + } + + private static IReadOnlyList CreateDiffLines(string title, string relativePath) + { + return + [ + new(WorkbenchDiffLineKind.Context, $"@@ {relativePath} @@"), + new(WorkbenchDiffLineKind.Removed, $"- prototype-only state for {title}"), + new(WorkbenchDiffLineKind.Added, $"+ runtime-backed workbench state for {DiffReviewNote}"), + ]; + } + + private static string ResolveLanguageLabel(string extension) + { + return extension.ToLowerInvariant() switch + { + ".cs" => "C#", + ".csproj" => "MSBuild", + ".json" => "JSON", + ".md" => "Markdown", + ".props" or ".targets" or ".xml" => "XML", + ".slnx" => "Solution", + ".xaml" => "XAML", + ".yml" or ".yaml" => "YAML", + _ => "Text", + }; + } + + private static string ResolveRendererLabel(string extension) + { + return MonacoPreviewExtensions.Contains(extension) + ? MonacoRendererLabel + : StructuredRendererLabel; + } + + private static string CombineRelative(string relativePath, string name) + { + return string.IsNullOrEmpty(relativePath) ? name : string.Concat(relativePath, "/", name); + } +} diff --git a/DotPilot.Tests/GlobalUsings.cs b/DotPilot.Tests/GlobalUsings.cs index 1b2accc..bcca7d8 100644 --- a/DotPilot.Tests/GlobalUsings.cs +++ b/DotPilot.Tests/GlobalUsings.cs @@ -2,6 +2,7 @@ global using DotPilot.Core.Features.ControlPlaneDomain; global using DotPilot.Core.Features.RuntimeCommunication; global using DotPilot.Core.Features.RuntimeFoundation; +global using DotPilot.Core.Features.Workbench; global using DotPilot.Runtime.Features.RuntimeFoundation; global using FluentAssertions; global using NUnit.Framework; diff --git a/DotPilot.Tests/PresentationViewModelTests.cs b/DotPilot.Tests/PresentationViewModelTests.cs index f37900c..761ee0f 100644 --- a/DotPilot.Tests/PresentationViewModelTests.cs +++ b/DotPilot.Tests/PresentationViewModelTests.cs @@ -1,27 +1,51 @@ using DotPilot.Presentation; +using DotPilot.Runtime.Features.Workbench; namespace DotPilot.Tests; public class PresentationViewModelTests { [Test] - public void MainViewModelExposesChatScreenState() + public void MainViewModelExposesWorkbenchShellState() { - var viewModel = new MainViewModel(CreateRuntimeFoundationCatalog()); - - viewModel.Title.Should().Be("Design Automation Agent"); - viewModel.StatusSummary.Should().Be("3 members · GPT-4o"); - viewModel.RecentChats.Should().HaveCount(3); - viewModel.RecentChats.Should().ContainSingle(chat => chat.IsSelected); - viewModel.Messages.Should().HaveCount(3); - viewModel.Messages.Should().ContainSingle(message => message.IsCurrentUser); - viewModel.Members.Should().HaveCount(3); - viewModel.Agents.Should().ContainSingle(agent => agent.Name == "Design Agent"); + using var workspace = TemporaryWorkbenchDirectory.Create(); + var runtimeFoundationCatalog = CreateRuntimeFoundationCatalog(); + var viewModel = new MainViewModel( + new WorkbenchCatalog(runtimeFoundationCatalog, workspace.Root), + runtimeFoundationCatalog); + + viewModel.EpicLabel.Should().Be(WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.DesktopWorkbenchEpic)); + viewModel.WorkspaceRoot.Should().Be(workspace.Root); + viewModel.FilteredRepositoryNodes.Should().NotBeEmpty(); + viewModel.SelectedDocumentTitle.Should().NotBeEmpty(); + viewModel.IsPreviewMode.Should().BeTrue(); + viewModel.RepositorySearchText = "SettingsPage"; + viewModel.FilteredRepositoryNodes.Should().ContainSingle(node => node.RelativePath == "src/SettingsPage.xaml"); + viewModel.SelectedDocumentTitle.Should().Be("SettingsPage.xaml"); + viewModel.IsDiffReviewMode = true; + viewModel.IsPreviewMode.Should().BeFalse(); + viewModel.IsLogConsoleVisible = true; + viewModel.IsArtifactsVisible.Should().BeFalse(); viewModel.RuntimeFoundation.EpicLabel.Should().Be(RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.EmbeddedAgentRuntimeHostEpic)); - viewModel.RuntimeFoundation.Slices.Should().HaveCount(4); viewModel.RuntimeFoundation.Providers.Should().Contain(provider => !provider.RequiresExternalToolchain); } + [Test] + public void SettingsViewModelExposesUnifiedSettingsShellState() + { + using var workspace = TemporaryWorkbenchDirectory.Create(); + var runtimeFoundationCatalog = CreateRuntimeFoundationCatalog(); + var viewModel = new SettingsViewModel( + new WorkbenchCatalog(runtimeFoundationCatalog, workspace.Root), + runtimeFoundationCatalog); + + viewModel.SettingsIssueLabel.Should().Be(WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.SettingsShell)); + viewModel.Categories.Should().HaveCountGreaterOrEqualTo(3); + viewModel.SelectedCategoryTitle.Should().NotBeEmpty(); + viewModel.VisibleEntries.Should().NotBeEmpty(); + viewModel.ProviderSummary.Should().Contain("provider checks"); + } + [Test] public void SecondViewModelExposesAgentBuilderState() { @@ -45,6 +69,6 @@ public void SecondViewModelExposesAgentBuilderState() private static RuntimeFoundationCatalog CreateRuntimeFoundationCatalog() { - return new RuntimeFoundationCatalog(new DeterministicAgentRuntimeClient()); + return new RuntimeFoundationCatalog(); } } diff --git a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs index e25683e..2bf0d9c 100644 --- a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs +++ b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs @@ -7,6 +7,7 @@ public class RuntimeFoundationCatalogTests private const string CodexCommandName = "codex"; private const string ClaudeCommandName = "claude"; private const string GitHubCommandName = "gh"; + private const string DeterministicClientStatusSummary = "Always available for in-repo and CI validation."; [Test] public void CatalogGroupsEpicTwelveIntoFourSequencedSlices() @@ -33,6 +34,7 @@ public void CatalogAlwaysIncludesTheDeterministicClientForProviderIndependentCov snapshot.Providers.Should().ContainSingle(provider => provider.DisplayName == snapshot.DeterministicClientName && + provider.StatusSummary == DeterministicClientStatusSummary && provider.RequiresExternalToolchain == false && provider.Status == ProviderConnectionStatus.Available); } @@ -194,7 +196,7 @@ public void ExternalProvidersBecomeUnavailableWhenPathIsCleared() private static RuntimeFoundationCatalog CreateCatalog() { - return new RuntimeFoundationCatalog(new DeterministicAgentRuntimeClient()); + return new RuntimeFoundationCatalog(); } private static AgentTurnRequest CreateRequest( diff --git a/DotPilot.Tests/TemporaryWorkbenchDirectory.cs b/DotPilot.Tests/TemporaryWorkbenchDirectory.cs new file mode 100644 index 0000000..7a37770 --- /dev/null +++ b/DotPilot.Tests/TemporaryWorkbenchDirectory.cs @@ -0,0 +1,52 @@ +namespace DotPilot.Tests; + +internal sealed class TemporaryWorkbenchDirectory : IDisposable +{ + private const string GitIgnoreFileName = ".gitignore"; + private const string GitIgnoreContent = + """ + ignored/ + *.tmp + """; + + private TemporaryWorkbenchDirectory(string root) + { + Root = root; + } + + public string Root { get; } + + public static TemporaryWorkbenchDirectory Create(bool includeSupportedFiles = true) + { + var root = Path.Combine( + Path.GetTempPath(), + "dotpilot-workbench-tests", + Guid.NewGuid().ToString("N")); + + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, GitIgnoreFileName), GitIgnoreContent); + + if (includeSupportedFiles) + { + Directory.CreateDirectory(Path.Combine(root, "docs")); + Directory.CreateDirectory(Path.Combine(root, "src")); + Directory.CreateDirectory(Path.Combine(root, "ignored")); + + File.WriteAllText(Path.Combine(root, "docs", "Architecture.md"), "# Architecture"); + File.WriteAllText(Path.Combine(root, "src", "MainPage.xaml"), ""); + File.WriteAllText(Path.Combine(root, "src", "SettingsPage.xaml"), ""); + File.WriteAllText(Path.Combine(root, "ignored", "Secret.cs"), "internal sealed class Secret {}"); + File.WriteAllText(Path.Combine(root, "notes.tmp"), "ignored"); + } + + return new(root); + } + + public void Dispose() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } +} diff --git a/DotPilot.Tests/WorkbenchCatalogTests.cs b/DotPilot.Tests/WorkbenchCatalogTests.cs new file mode 100644 index 0000000..676a291 --- /dev/null +++ b/DotPilot.Tests/WorkbenchCatalogTests.cs @@ -0,0 +1,45 @@ +using DotPilot.Runtime.Features.Workbench; + +namespace DotPilot.Tests; + +public class WorkbenchCatalogTests +{ + [Test] + public void GetSnapshotUsesLiveWorkspaceAndRespectsIgnoreRules() + { + using var workspace = TemporaryWorkbenchDirectory.Create(); + + var snapshot = CreateWorkbenchCatalog(workspace.Root).GetSnapshot(); + + snapshot.WorkspaceRoot.Should().Be(workspace.Root); + snapshot.RepositoryNodes.Should().Contain(node => node.RelativePath == "src/MainPage.xaml"); + snapshot.RepositoryNodes.Should().Contain(node => node.RelativePath == "src/SettingsPage.xaml"); + snapshot.RepositoryNodes.Should().NotContain(node => node.RelativePath.Contains("ignored", StringComparison.OrdinalIgnoreCase)); + snapshot.RepositoryNodes.Should().NotContain(node => node.RelativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)); + snapshot.Documents.Should().Contain(document => document.RelativePath == "src/MainPage.xaml"); + snapshot.SettingsCategories.Should().Contain(category => category.Key == "providers"); + snapshot.Logs.Should().HaveCount(4); + } + + [Test] + public void GetSnapshotFallsBackToSeededDataWhenWorkspaceHasNoSupportedDocuments() + { + using var workspace = TemporaryWorkbenchDirectory.Create(includeSupportedFiles: false); + + var snapshot = CreateWorkbenchCatalog(workspace.Root).GetSnapshot(); + + snapshot.WorkspaceName.Should().Be("Browser sandbox"); + snapshot.Documents.Should().NotBeEmpty(); + snapshot.RepositoryNodes.Should().Contain(node => node.RelativePath == "DotPilot/Presentation/MainPage.xaml"); + } + + private static WorkbenchCatalog CreateWorkbenchCatalog(string workspaceRoot) + { + return new WorkbenchCatalog(CreateRuntimeFoundationCatalog(), workspaceRoot); + } + + private static RuntimeFoundationCatalog CreateRuntimeFoundationCatalog() + { + return new RuntimeFoundationCatalog(); + } +} diff --git a/DotPilot.UITests/AGENTS.md b/DotPilot.UITests/AGENTS.md index 3e536d7..68322c1 100644 --- a/DotPilot.UITests/AGENTS.md +++ b/DotPilot.UITests/AGENTS.md @@ -22,6 +22,8 @@ Stack: `.NET 10`, `NUnit`, `Uno.UITest`, browser-driven UI tests - Treat browser-driver setup and app-launch prerequisites as part of the harness, not as assumptions inside individual tests. - The harness must make `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` runnable without manual driver-path export and must fail loudly instead of silently skipping coverage. - Keep the harness direct and minimal; prefer the smallest deterministic setup needed to run the suite and return a real result. +- Use the official `Uno` MCP documentation as the source of truth for `Uno.UITest` browser behavior, and align selectors with the documented WebAssembly automation mapping before changing the harness. +- Do not manually launch the app or a standalone `browserwasm` host while working on this project; browser-path reproduction and debugging must go through `dotnet test` and the real `DotPilot.UITests` harness only. - UI tests must cover each feature's interactive elements, expected behaviors, and full operator flows instead of only a top-level smoke path. ## Local Commands diff --git a/DotPilot.UITests/BrowserAutomationBootstrap.cs b/DotPilot.UITests/BrowserAutomationBootstrap.cs index e0c983e..9d151a5 100644 --- a/DotPilot.UITests/BrowserAutomationBootstrap.cs +++ b/DotPilot.UITests/BrowserAutomationBootstrap.cs @@ -94,10 +94,23 @@ private static string ResolveBrowserDriverPath( return configuredDriverPath; } - var cachedDriverPath = ResolveAnyCachedChromeDriverDirectory(GetDriverCacheRootPath()); - return !string.IsNullOrWhiteSpace(cachedDriverPath) - ? cachedDriverPath - : EnsureChromeDriverDownloaded(browserBinaryPath); + var browserVersion = ResolveBrowserVersion(browserBinaryPath); + var browserBuild = BuildChromeVersionKey(browserVersion); + var driverPlatform = ResolveChromeDriverPlatform(); + var cacheRootPath = GetDriverCacheRootPath(); + + HarnessLog.Write($"Browser version '{browserVersion}' resolved for '{browserBinaryPath}'."); + + var cachedDriverPath = ResolveCachedChromeDriverDirectory(cacheRootPath, browserBuild, driverPlatform); + if (!string.IsNullOrWhiteSpace(cachedDriverPath)) + { + var cachedDriverExecutablePath = Path.Combine(cachedDriverPath, GetChromeDriverExecutableFileName()); + EnsureDriverExecutablePermissions(cachedDriverExecutablePath); + HarnessLog.Write($"Reusing cached ChromeDriver at '{cachedDriverExecutablePath}'."); + return cachedDriverPath; + } + + return EnsureChromeDriverDownloaded(browserBuild, driverPlatform, cacheRootPath); } private static string? NormalizeBrowserDriverPath(IReadOnlyDictionary environment) @@ -129,24 +142,11 @@ private static string ResolveBrowserDriverPath( return null; } - private static string EnsureChromeDriverDownloaded(string browserBinaryPath) + private static string EnsureChromeDriverDownloaded( + string browserBuild, + string driverPlatform, + string cacheRootPath) { - var browserVersion = ResolveBrowserVersion(browserBinaryPath); - var browserBuild = BuildChromeVersionKey(browserVersion); - var driverPlatform = ResolveChromeDriverPlatform(); - var cacheRootPath = GetDriverCacheRootPath(); - - HarnessLog.Write($"Browser version '{browserVersion}' resolved for '{browserBinaryPath}'."); - - var cachedDriverDirectory = ResolveCachedChromeDriverDirectory(cacheRootPath, browserBuild, driverPlatform); - if (!string.IsNullOrWhiteSpace(cachedDriverDirectory)) - { - var cachedDriverExecutablePath = Path.Combine(cachedDriverDirectory, GetChromeDriverExecutableFileName()); - EnsureDriverExecutablePermissions(cachedDriverExecutablePath); - HarnessLog.Write($"Reusing cached ChromeDriver at '{cachedDriverExecutablePath}'."); - return cachedDriverDirectory; - } - var driverVersion = ResolveChromeDriverVersion(browserBuild); var driverVersionRootPath = Path.Combine(cacheRootPath, driverVersion); var driverDirectory = Path.Combine(driverVersionRootPath, $"{ChromeDriverBundleNamePrefix}{driverPlatform}"); @@ -175,6 +175,13 @@ private static string EnsureChromeDriverDownloaded(string browserBinaryPath) return driverDirectory; } + internal static string? ResolveCachedChromeDriverDirectory(string cacheRootPath, string browserVersion) + { + var browserBuild = BuildChromeVersionKey(browserVersion); + var driverPlatform = ResolveChromeDriverPlatform(); + return ResolveCachedChromeDriverDirectory(cacheRootPath, browserBuild, driverPlatform); + } + internal static string? ResolveCachedChromeDriverDirectory(string cacheRootPath, string browserBuild, string driverPlatform) { var driverVersionMappingPath = GetDriverVersionMappingPath(cacheRootPath, browserBuild, driverPlatform); @@ -194,27 +201,6 @@ private static string EnsureChromeDriverDownloaded(string browserBinaryPath) return File.Exists(driverExecutablePath) ? driverDirectory : null; } - internal static string? ResolveAnyCachedChromeDriverDirectory(string cacheRootPath) - { - if (!Directory.Exists(cacheRootPath)) - { - return null; - } - - foreach (var executablePath in Directory - .EnumerateFiles(cacheRootPath, GetChromeDriverExecutableFileName(), SearchOption.AllDirectories) - .OrderByDescending(path => path, StringComparer.Ordinal)) - { - var driverDirectory = Path.GetDirectoryName(executablePath); - if (!string.IsNullOrWhiteSpace(driverDirectory)) - { - return driverDirectory; - } - } - - return null; - } - internal static void PersistDriverVersionMapping( string cacheRootPath, string browserBuild, diff --git a/DotPilot.UITests/BrowserAutomationBootstrapTests.cs b/DotPilot.UITests/BrowserAutomationBootstrapTests.cs index 3061dd0..afa42a4 100644 --- a/DotPilot.UITests/BrowserAutomationBootstrapTests.cs +++ b/DotPilot.UITests/BrowserAutomationBootstrapTests.cs @@ -91,7 +91,7 @@ public void WhenCachedDriverVersionMappingExistsThenResolverUsesCachedDriverDire } [Test] - public void WhenAnyCachedDriverDirectoryExistsThenResolverUsesItWithoutVersionMapping() + public void WhenCachedDriverExistsWithoutVersionMappingThenResolverIgnoresIt() { using var sandbox = new BrowserAutomationSandbox(); var cacheRootPath = sandbox.CreateDirectory("driver-cache"); @@ -99,9 +99,11 @@ public void WhenAnyCachedDriverDirectoryExistsThenResolverUsesItWithoutVersionMa Directory.CreateDirectory(driverDirectory); sandbox.CreateFile(Path.Combine(driverDirectory, GetChromeDriverExecutableFileName())); - var resolvedDirectory = BrowserAutomationBootstrap.ResolveAnyCachedChromeDriverDirectory(cacheRootPath); + var resolvedDirectory = BrowserAutomationBootstrap.ResolveCachedChromeDriverDirectory( + cacheRootPath, + "145.0.7632.117"); - Assert.That(resolvedDirectory, Is.EqualTo(driverDirectory)); + Assert.That(resolvedDirectory, Is.Null); } [Test] diff --git a/DotPilot.UITests/BrowserTestHost.cs b/DotPilot.UITests/BrowserTestHost.cs index fc5ddba..f704c24 100644 --- a/DotPilot.UITests/BrowserTestHost.cs +++ b/DotPilot.UITests/BrowserTestHost.cs @@ -5,22 +5,17 @@ namespace DotPilot.UITests; internal static class BrowserTestHost { private const string DotnetExecutableName = "dotnet"; - private const string BuildCommand = "build"; private const string RunCommand = "run"; private const string ConfigurationOption = "-c"; private const string ReleaseConfiguration = "Release"; private const string FrameworkOption = "-f"; private const string BrowserFramework = "net10.0-browserwasm"; private const string ProjectOption = "--project"; - private const string NoBuildOption = "--no-build"; private const string NoLaunchProfileOption = "--no-launch-profile"; private const string UiAutomationProperty = "-p:IsUiAutomationMappingEnabled=True"; private const string ProjectRelativePath = "DotPilot/DotPilot.csproj"; - private const string BrowserOutputRelativePath = "DotPilot/bin/Release/net10.0-browserwasm/DotPilot.dll"; private const string SolutionMarkerFileName = "DotPilot.slnx"; private const string HostReadyTimeoutMessage = "Timed out waiting for the WebAssembly host to become reachable."; - private const string BuildFailureMessage = "Failed to build the WebAssembly test host."; - private static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(2); private static readonly TimeSpan HostStartupTimeout = TimeSpan.FromSeconds(45); private static readonly TimeSpan HostShutdownTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan HostProbeInterval = TimeSpan.FromMilliseconds(250); @@ -58,56 +53,23 @@ public static void EnsureStarted(string hostUri) var repoRoot = FindRepositoryRoot(); var projectPath = Path.Combine(repoRoot, ProjectRelativePath); - - if (!IsBrowserHostBuilt(repoRoot)) - { - HarnessLog.Write("Building browser host."); - EnsureBuilt(repoRoot, projectPath); - } - else - { - HarnessLog.Write("Reusing existing browser host build output."); - } + var projectDirectory = Path.GetDirectoryName(projectPath) + ?? throw new InvalidOperationException($"Could not resolve project directory for '{projectPath}'."); HarnessLog.Write("Starting browser host process."); - StartHostProcess(repoRoot, projectPath); + StartHostProcess(projectDirectory, projectPath); WaitForHost(hostUri); } } - private static void EnsureBuilt(string repoRoot, string projectPath) + private static void StartHostProcess(string projectDirectory, string projectPath) { - var buildStartInfo = CreateStartInfo(repoRoot); - buildStartInfo.ArgumentList.Add(BuildCommand); - buildStartInfo.ArgumentList.Add(ConfigurationOption); - buildStartInfo.ArgumentList.Add(ReleaseConfiguration); - buildStartInfo.ArgumentList.Add(FrameworkOption); - buildStartInfo.ArgumentList.Add(BrowserFramework); - buildStartInfo.ArgumentList.Add(UiAutomationProperty); - buildStartInfo.ArgumentList.Add(projectPath); - - var result = RunAndCapture(buildStartInfo, BuildTimeout); - if (result.ExitCode != 0) + var processStartInfo = CreateStartInfo(projectDirectory); + foreach (var argument in CreateRunArguments(projectPath)) { - throw new InvalidOperationException($"{BuildFailureMessage} {result.Output}"); + processStartInfo.ArgumentList.Add(argument); } - HarnessLog.Write("Browser host build completed."); - } - - private static void StartHostProcess(string repoRoot, string projectPath) - { - var processStartInfo = CreateStartInfo(repoRoot); - processStartInfo.ArgumentList.Add(RunCommand); - processStartInfo.ArgumentList.Add(ConfigurationOption); - processStartInfo.ArgumentList.Add(ReleaseConfiguration); - processStartInfo.ArgumentList.Add(FrameworkOption); - processStartInfo.ArgumentList.Add(BrowserFramework); - processStartInfo.ArgumentList.Add(UiAutomationProperty); - processStartInfo.ArgumentList.Add(ProjectOption); - processStartInfo.ArgumentList.Add(projectPath); - processStartInfo.ArgumentList.Add(NoBuildOption); - processStartInfo.ArgumentList.Add(NoLaunchProfileOption); processStartInfo.Environment["ASPNETCORE_URLS"] = BrowserTestEnvironment.WebAssemblyUrlsValue; _hostProcess = Process.Start(processStartInfo) ?? throw new InvalidOperationException("Failed to start the WebAssembly test host."); @@ -119,9 +81,20 @@ private static void StartHostProcess(string repoRoot, string projectPath) HarnessLog.Write($"Browser host process started with PID {_hostProcess.Id}."); } - private static bool IsBrowserHostBuilt(string repoRoot) + internal static IReadOnlyList CreateRunArguments(string projectPath) { - return File.Exists(Path.Combine(repoRoot, BrowserOutputRelativePath)); + return + [ + RunCommand, + ConfigurationOption, + ReleaseConfiguration, + FrameworkOption, + BrowserFramework, + UiAutomationProperty, + ProjectOption, + projectPath, + NoLaunchProfileOption, + ]; } private static void CaptureOutput(string? line) @@ -168,12 +141,12 @@ private static bool IsReachable(string hostUri) } } - private static ProcessStartInfo CreateStartInfo(string repoRoot) + private static ProcessStartInfo CreateStartInfo(string workingDirectory) { return new ProcessStartInfo { FileName = DotnetExecutableName, - WorkingDirectory = repoRoot, + WorkingDirectory = workingDirectory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -181,32 +154,6 @@ private static ProcessStartInfo CreateStartInfo(string repoRoot) }; } - private static (int ExitCode, string Output) RunAndCapture(ProcessStartInfo startInfo, TimeSpan timeout) - { - using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet process."); - var standardOutputTask = process.StandardOutput.ReadToEndAsync(); - var standardErrorTask = process.StandardError.ReadToEndAsync(); - - if (!process.WaitForExit((int)timeout.TotalMilliseconds)) - { - try - { - process.Kill(entireProcessTree: true); - } - catch - { - // Best-effort cleanup only. - } - - throw new TimeoutException($"Timed out waiting for dotnet process to finish within {timeout}."); - } - - var combinedOutput = - $"{standardOutputTask.GetAwaiter().GetResult()}{Environment.NewLine}{standardErrorTask.GetAwaiter().GetResult()}"; - - return (process.ExitCode, combinedOutput.Trim()); - } - private static string FindRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/DotPilot.UITests/BrowserTestHostTests.cs b/DotPilot.UITests/BrowserTestHostTests.cs new file mode 100644 index 0000000..b894487 --- /dev/null +++ b/DotPilot.UITests/BrowserTestHostTests.cs @@ -0,0 +1,24 @@ +namespace DotPilot.UITests; + +[TestFixture] +public sealed class BrowserTestHostTests +{ + [Test] + public void RunArgumentsKeepUiAutomationEnabledWithoutDisablingBuildChecks() + { + const string projectPath = "/repo/DotPilot/DotPilot.csproj"; + + var arguments = BrowserTestHost.CreateRunArguments(projectPath); + + Assert.That(arguments, Does.Contain("run")); + Assert.That(arguments, Does.Contain("-c")); + Assert.That(arguments, Does.Contain("Release")); + Assert.That(arguments, Does.Contain("-f")); + Assert.That(arguments, Does.Contain("net10.0-browserwasm")); + Assert.That(arguments, Does.Contain("-p:IsUiAutomationMappingEnabled=True")); + Assert.That(arguments, Does.Contain("--project")); + Assert.That(arguments, Does.Contain(projectPath)); + Assert.That(arguments, Does.Contain("--no-launch-profile")); + Assert.That(arguments, Does.Not.Contain("--no-build")); + } +} diff --git a/DotPilot.UITests/DotPilot.UITests.csproj b/DotPilot.UITests/DotPilot.UITests.csproj index ec4b08c..b899a6a 100644 --- a/DotPilot.UITests/DotPilot.UITests.csproj +++ b/DotPilot.UITests/DotPilot.UITests.csproj @@ -5,6 +5,9 @@ true true $(NoWarn);CS1591 + Release + ..\DotPilot\DotPilot.csproj + net10.0-browserwasm @@ -24,4 +27,15 @@ + + + + diff --git a/DotPilot.UITests/Given_MainPage.cs b/DotPilot.UITests/Given_MainPage.cs index b785e50..7c44ea5 100644 --- a/DotPilot.UITests/Given_MainPage.cs +++ b/DotPilot.UITests/Given_MainPage.cs @@ -1,171 +1,281 @@ namespace DotPilot.UITests; +[NonParallelizable] public class GivenMainPage : TestBase { private static readonly TimeSpan InitialScreenProbeTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan ScreenTransitionTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan QueryRetryFrequency = TimeSpan.FromMilliseconds(250); - private const double DesktopSectionMinimumWidth = 900d; - private const string MainChatScreenAutomationId = "MainChatScreen"; - private const string ChatSidebarAutomationId = "ChatSidebar"; - private const string ChatMessagesListAutomationId = "ChatMessagesList"; - private const string ChatMembersListAutomationId = "ChatMembersList"; - private const string AgentsNavButtonAutomationId = "AgentsNavButton"; + private static readonly TimeSpan ShortProbeTimeout = TimeSpan.FromSeconds(3); + private const string WorkbenchScreenAutomationId = "WorkbenchScreen"; + private const string SettingsScreenAutomationId = "SettingsScreen"; private const string AgentBuilderScreenAutomationId = "AgentBuilderScreen"; - private const string AgentBasicInfoSectionAutomationId = "AgentBasicInfoSection"; - private const string AgentPromptSectionAutomationId = "AgentPromptSection"; - private const string AgentSkillsSectionAutomationId = "AgentSkillsSection"; - private const string PromptTemplateButtonAutomationId = "PromptTemplateButton"; - private const string BackToChatButtonAutomationId = "BackToChatButton"; - private const string SidebarChatButtonAutomationId = "SidebarChatButton"; + private const string WorkbenchSessionTitleAutomationId = "WorkbenchSessionTitle"; + private const string WorkbenchPreviewEditorAutomationId = "WorkbenchPreviewEditor"; + private const string RepositoryNodesListAutomationId = "RepositoryNodesList"; + private const string WorkbenchSearchInputAutomationId = "WorkbenchSearchInput"; + private const string SelectedDocumentTitleAutomationId = "SelectedDocumentTitle"; + private const string DocumentViewModeToggleAutomationId = "DocumentViewModeToggle"; + private const string WorkbenchDiffLinesListAutomationId = "WorkbenchDiffLinesList"; + private const string WorkbenchDiffLineItemAutomationId = "WorkbenchDiffLineItem"; + private const string InspectorModeToggleAutomationId = "InspectorModeToggle"; + private const string ArtifactDockListAutomationId = "ArtifactDockList"; + private const string ArtifactDockItemAutomationId = "ArtifactDockItem"; + private const string RuntimeLogListAutomationId = "RuntimeLogList"; + private const string RuntimeLogItemAutomationId = "RuntimeLogItem"; + private const string WorkbenchNavButtonAutomationId = "WorkbenchNavButton"; + private const string SidebarWorkbenchButtonAutomationId = "SidebarWorkbenchButton"; + private const string SidebarAgentsButtonAutomationId = "SidebarAgentsButton"; + private const string BackToWorkbenchButtonAutomationId = "BackToWorkbenchButton"; + private const string SidebarSettingsButtonAutomationId = "SidebarSettingsButton"; + private const string SettingsCategoryListAutomationId = "SettingsCategoryList"; + private const string SettingsEntriesListAutomationId = "SettingsEntriesList"; + private const string SelectedSettingsCategoryTitleAutomationId = "SelectedSettingsCategoryTitle"; + private const string StorageSettingsCategoryAutomationId = "SettingsCategory-storage"; + private const string SettingsPageRepositoryNodeAutomationId = "RepositoryNode-dotpilot-presentation-settingspage-xaml"; private const string RuntimeFoundationPanelAutomationId = "RuntimeFoundationPanel"; - private const string RuntimeFoundationSlicesListAutomationId = "RuntimeFoundationSlicesList"; - private const string RuntimeFoundationProvidersListAutomationId = "RuntimeFoundationProvidersList"; - private const string RuntimeFoundationDeterministicClientAutomationId = "RuntimeFoundationDeterministicClient"; - private const string RuntimeFoundationSliceItemAutomationId = "RuntimeFoundationSliceItem"; - private const string RuntimeFoundationProviderItemAutomationId = "RuntimeFoundationProviderItem"; - private const string AgentBuilderRuntimeBannerAutomationId = "AgentBuilderRuntimeBanner"; - private const string AgentBuilderRuntimeIssueLabelAutomationId = "AgentBuilderRuntimeIssueLabel"; - private const string AgentBuilderRuntimeSummaryAutomationId = "AgentBuilderRuntimeSummary"; - private const string AgentBuilderRuntimeClientAutomationId = "AgentBuilderRuntimeClient"; - private const string AgentBuilderWidthFailureMessage = - "The browser smoke host dropped below the desktop layout width threshold."; [Test] - public async Task WhenOpeningTheAppThenChatShellSectionsAreVisible() + public async Task WhenOpeningTheAppThenWorkbenchSectionsAreVisible() { await Task.CompletedTask; - EnsureOnMainChatScreen(); - App.WaitForElement(ByMarked(ChatSidebarAutomationId)); - App.WaitForElement(ByMarked(ChatMessagesListAutomationId)); - App.WaitForElement(ByMarked(ChatMembersListAutomationId)); - App.WaitForElement(ByMarked(RuntimeFoundationPanelAutomationId)); - App.WaitForElement(ByMarked(RuntimeFoundationSlicesListAutomationId)); - App.WaitForElement(ByMarked(RuntimeFoundationProvidersListAutomationId)); - TakeScreenshot("chat_shell_visible"); + EnsureOnWorkbenchScreen(); + EnsureArtifactDockVisible(); + WaitForElement(WorkbenchNavButtonAutomationId); + WaitForElement(WorkbenchSessionTitleAutomationId); + WaitForElement(WorkbenchPreviewEditorAutomationId); + WaitForElement(RepositoryNodesListAutomationId); + WaitForElement(ArtifactDockListAutomationId); + WaitForElement(ArtifactDockItemAutomationId); + WaitForElement(RuntimeFoundationPanelAutomationId); + TakeScreenshot("workbench_shell_visible"); } [Test] - public async Task WhenNavigatingToAgentBuilderThenKeySectionsAreVisible() + public async Task WhenFilteringTheRepositoryThenTheMatchingFileOpens() { await Task.CompletedTask; - EnsureOnMainChatScreen(); - App.Tap(ByMarked(AgentsNavButtonAutomationId)); - App.WaitForElement(ByMarked(AgentBuilderScreenAutomationId)); - App.WaitForElement(ByMarked(AgentBasicInfoSectionAutomationId)); - App.Find(AgentPromptSectionAutomationId, ScreenTransitionTimeout); - App.Find(AgentSkillsSectionAutomationId, ScreenTransitionTimeout); - App.Find(PromptTemplateButtonAutomationId, ScreenTransitionTimeout); - App.WaitForElement(ByMarked(AgentBuilderRuntimeBannerAutomationId)); - App.WaitForElement(ByMarked(AgentBuilderRuntimeIssueLabelAutomationId)); - App.WaitForElement(ByMarked(AgentBuilderRuntimeSummaryAutomationId)); - App.WaitForElement(ByMarked(AgentBuilderRuntimeClientAutomationId)); - TakeScreenshot("agent_builder_sections_visible"); + EnsureOnWorkbenchScreen(); + App.ClearText(WorkbenchSearchInputAutomationId); + App.EnterText(WorkbenchSearchInputAutomationId, "SettingsPage"); + WaitForElement(SettingsPageRepositoryNodeAutomationId); + App.Tap(SettingsPageRepositoryNodeAutomationId); + WaitForElement(SelectedDocumentTitleAutomationId); + + var title = GetSingleTextContent(SelectedDocumentTitleAutomationId); + Assert.That(title, Is.EqualTo("SettingsPage.xaml")); + + TakeScreenshot("repository_search_open_file"); + } + + [Test] + public async Task WhenSwitchingToDiffReviewThenDiffSurfaceIsVisible() + { + await Task.CompletedTask; + + EnsureOnWorkbenchScreen(); + EnsureDiffReviewVisible(); + WaitForElement(WorkbenchDiffLinesListAutomationId); + WaitForElement(WorkbenchDiffLineItemAutomationId); + + TakeScreenshot("diff_review_visible"); } [Test] - public async Task WhenReturningToChatFromAgentBuilderThenChatShellSectionsAreVisible() + public async Task WhenSwitchingInspectorModeThenRuntimeLogConsoleIsVisible() { await Task.CompletedTask; - EnsureOnMainChatScreen(); - App.Tap(ByMarked(AgentsNavButtonAutomationId)); - App.WaitForElement(ByMarked(SidebarChatButtonAutomationId)); - App.Tap(ByMarked(SidebarChatButtonAutomationId)); - App.WaitForElement(ByMarked(MainChatScreenAutomationId)); - App.WaitForElement(ByMarked(ChatMessagesListAutomationId)); - App.WaitForElement(ByMarked(RuntimeFoundationPanelAutomationId)); - TakeScreenshot("chat_shell_restored"); + EnsureOnWorkbenchScreen(); + EnsureRuntimeLogVisible(); + WaitForElement(RuntimeLogListAutomationId); + WaitForElement(RuntimeLogItemAutomationId); + + TakeScreenshot("runtime_log_console_visible"); } [Test] - public async Task WhenOpeningTheAppThenRuntimeFoundationPanelShowsSlicesAndProviders() + public async Task WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible() { await Task.CompletedTask; - EnsureOnMainChatScreen(); - App.WaitForElement(ByMarked(RuntimeFoundationDeterministicClientAutomationId)); + EnsureOnWorkbenchScreen(); + TapAutomationElement(SidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForElement(SettingsCategoryListAutomationId); + WaitForElement(SettingsEntriesListAutomationId); + App.Tap(StorageSettingsCategoryAutomationId); + + var categoryTitle = GetSingleTextContent(SelectedSettingsCategoryTitleAutomationId); + Assert.That(categoryTitle, Is.EqualTo("Storage")); - var sliceItems = GetResults(RuntimeFoundationSliceItemAutomationId); - var providerItems = GetResults(RuntimeFoundationProviderItemAutomationId); + TakeScreenshot("settings_shell_visible"); + } + + [Test] + public async Task WhenNavigatingFromSettingsToAgentsThenAgentBuilderIsVisible() + { + await Task.CompletedTask; + + EnsureOnWorkbenchScreen(); + TapAutomationElement(SidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + TapAutomationElement(SidebarAgentsButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForElement(BackToWorkbenchButtonAutomationId); + + TakeScreenshot("settings_to_agents_navigation"); + } + + [Test] + public async Task WhenNavigatingToSettingsAfterOpeningADocumentThenSettingsScreenIsVisible() + { + await Task.CompletedTask; - Assert.That(sliceItems, Has.Length.EqualTo(4)); - Assert.That(providerItems.Length, Is.GreaterThanOrEqualTo(4)); + EnsureOnWorkbenchScreen(); + App.Tap(SettingsPageRepositoryNodeAutomationId); + WaitForElement(SelectedDocumentTitleAutomationId); + TapAutomationElement(SidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); - TakeScreenshot("runtime_foundation_panel"); + TakeScreenshot("document_to_settings_navigation"); } [Test] - public async Task WhenNavigatingAcrossWorkbenchThenRuntimeFoundationRemainsVisible() + public async Task WhenNavigatingToAgentsAfterOpeningADocumentThenAgentBuilderIsVisible() { await Task.CompletedTask; - EnsureOnMainChatScreen(); - App.WaitForElement(ByMarked(RuntimeFoundationPanelAutomationId)); - App.Tap(ByMarked(AgentsNavButtonAutomationId)); - App.WaitForElement(ByMarked(AgentBuilderRuntimeBannerAutomationId)); - App.Tap(ByMarked(SidebarChatButtonAutomationId)); - App.WaitForElement(ByMarked(RuntimeFoundationPanelAutomationId)); + EnsureOnWorkbenchScreen(); + App.Tap(SettingsPageRepositoryNodeAutomationId); + WaitForElement(SelectedDocumentTitleAutomationId); + TapAutomationElement(SidebarAgentsButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); - TakeScreenshot("runtime_foundation_roundtrip"); + TakeScreenshot("document_to_agents_navigation"); } [Test] - public async Task WhenOpeningAgentBuilderThenDesktopSectionWidthIsPreserved() + public async Task WhenNavigatingToSettingsAfterChangingWorkbenchModesThenSettingsScreenIsVisible() { await Task.CompletedTask; - EnsureOnMainChatScreen(); - App.Tap(ByMarked(AgentsNavButtonAutomationId)); - App.WaitForElement(ByMarked(AgentBasicInfoSectionAutomationId)); + EnsureOnWorkbenchScreen(); + App.Tap(SettingsPageRepositoryNodeAutomationId); + EnsureDiffReviewVisible(); + EnsureRuntimeLogVisible(); + TapAutomationElement(SidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); - var basicInfoSection = GetSingleResult(AgentBasicInfoSectionAutomationId); - Assert.That( - basicInfoSection.Rect.Width, - Is.GreaterThanOrEqualTo(DesktopSectionMinimumWidth), - AgentBuilderWidthFailureMessage); + TakeScreenshot("workbench_modes_to_settings_navigation"); + } + + [Test] + public async Task WhenRunningAWorkbenchRoundTripThenTheMainShellCanBeRestored() + { + await Task.CompletedTask; + + EnsureOnWorkbenchScreen(); + App.Tap(SettingsPageRepositoryNodeAutomationId); + EnsureDiffReviewVisible(); + EnsureRuntimeLogVisible(); + TapAutomationElement(SidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + App.Tap(StorageSettingsCategoryAutomationId); + TapAutomationElement(SidebarAgentsButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + TapAutomationElement(BackToWorkbenchButtonAutomationId); + EnsureOnWorkbenchScreen(); + WaitForElement(RuntimeFoundationPanelAutomationId); - TakeScreenshot("agent_builder_desktop_width"); + TakeScreenshot("workbench_roundtrip_restored"); } - private void EnsureOnMainChatScreen() + private void EnsureOnWorkbenchScreen() { - var mainChatScreen = ByMarked(MainChatScreenAutomationId); - if (TryWaitForElement(mainChatScreen, InitialScreenProbeTimeout)) + if (TryWaitForWorkbenchSurface(InitialScreenProbeTimeout)) { return; } - var backToChatButton = ByMarked(BackToChatButtonAutomationId); - var sidebarChatButton = ByMarked(SidebarChatButtonAutomationId); - if (TryWaitForElement(backToChatButton, InitialScreenProbeTimeout)) + if (TryWaitForElement(SidebarWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) { - App.Tap(backToChatButton); + TapAutomationElement(SidebarWorkbenchButtonAutomationId); } - else if (TryWaitForElement(sidebarChatButton, InitialScreenProbeTimeout)) + else if (TryWaitForElement(BackToWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) { - App.Tap(sidebarChatButton); + TapAutomationElement(BackToWorkbenchButtonAutomationId); } - App.WaitForElement( - mainChatScreen, - timeoutMessage: "Timed out returning to the main chat screen.", - timeout: ScreenTransitionTimeout, - retryFrequency: QueryRetryFrequency); + WaitForElement(WorkbenchScreenAutomationId, "Timed out returning to the workbench screen.", ScreenTransitionTimeout); + WaitForElement(WorkbenchSearchInputAutomationId); + WaitForElement(SelectedDocumentTitleAutomationId); } - private bool TryWaitForElement(Query query, TimeSpan timeout) + private bool TryWaitForWorkbenchSurface(TimeSpan timeout) { - try + if (!TryWaitForElement(WorkbenchScreenAutomationId, timeout)) { - App.WaitForElement( - query, - timeoutMessage: "Element probe timed out.", - timeout: timeout, - retryFrequency: QueryRetryFrequency); + return false; + } + if (!TryWaitForElement(WorkbenchNavButtonAutomationId, timeout)) + { + return false; + } + + if (!TryWaitForElement(WorkbenchSearchInputAutomationId, timeout)) + { + return false; + } + + return TryWaitForElement(SelectedDocumentTitleAutomationId, timeout); + } + + private void EnsureArtifactDockVisible() + { + if (TryWaitForElement(ArtifactDockListAutomationId, ShortProbeTimeout)) + { + return; + } + + if (TryWaitForElement(RuntimeLogListAutomationId, ShortProbeTimeout)) + { + App.Tap(InspectorModeToggleAutomationId); + } + + WaitForElement(ArtifactDockListAutomationId); + } + + private void EnsureRuntimeLogVisible() + { + if (TryWaitForElement(RuntimeLogListAutomationId, ShortProbeTimeout)) + { + return; + } + + App.Tap(InspectorModeToggleAutomationId); + WaitForElement(RuntimeLogListAutomationId); + } + + private void EnsureDiffReviewVisible() + { + if (TryWaitForElement(WorkbenchDiffLinesListAutomationId, ShortProbeTimeout)) + { + return; + } + + App.Tap(DocumentViewModeToggleAutomationId); + WaitForElement(WorkbenchDiffLinesListAutomationId); + } + + private bool TryWaitForElement(string automationId, TimeSpan timeout) + { + try + { + WaitForElement(automationId, "Element probe timed out.", timeout); return true; } catch (TimeoutException) @@ -174,20 +284,82 @@ private bool TryWaitForElement(Query query, TimeSpan timeout) } } - private IAppResult GetSingleResult(string automationId) + private string GetSingleTextContent(string automationId) { - var results = App.Query(ByMarked(automationId)); + var results = App.Query(automationId); Assert.That(results, Has.Length.EqualTo(1), $"Expected a single result for automation id '{automationId}'."); - return results[0]; + return NormalizeTextContent(results[0].Text); } - private IAppResult[] GetResults(string automationId) + private static string NormalizeTextContent(string value) { - return App.Query(ByMarked(automationId)); + var segments = value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return string.Join(' ', segments); } - private static Query ByMarked(string automationId) + private IAppResult[] WaitForElement(string automationId, string? timeoutMessage = null, TimeSpan? timeout = null) { - return q => q.All().Marked(automationId); + try + { + return App.WaitForElement( + automationId, + timeoutMessage ?? $"Timed out waiting for automation id '{automationId}'.", + timeout ?? ScreenTransitionTimeout, + QueryRetryFrequency, + null); + } + catch (TimeoutException) + { + WriteTimeoutDiagnostics(automationId); + throw; + } + } + + private void WriteTimeoutDiagnostics(string automationId) + { + WriteBrowserSystemLogs($"timeout:{automationId}"); + WriteBrowserDomSnapshot($"timeout:{automationId}"); + WriteSelectorDiagnostics(automationId); + + try + { + TakeScreenshot($"timeout_{automationId}"); + } + catch (Exception exception) + { + HarnessLog.Write($"Timeout screenshot capture failed for '{automationId}': {exception.Message}"); + } + } + + private void WriteSelectorDiagnostics(string timedOutAutomationId) + { + var automationIds = new[] + { + timedOutAutomationId, + WorkbenchScreenAutomationId, + SettingsScreenAutomationId, + AgentBuilderScreenAutomationId, + WorkbenchNavButtonAutomationId, + SidebarWorkbenchButtonAutomationId, + SidebarAgentsButtonAutomationId, + SidebarSettingsButtonAutomationId, + WorkbenchSearchInputAutomationId, + SelectedDocumentTitleAutomationId, + RuntimeFoundationPanelAutomationId, + BackToWorkbenchButtonAutomationId, + }; + + foreach (var automationId in automationIds.Distinct(StringComparer.Ordinal)) + { + try + { + var matches = App.Query(automationId); + HarnessLog.Write($"Selector diagnostic '{automationId}' returned {matches.Length} matches."); + } + catch (Exception exception) + { + HarnessLog.Write($"Selector diagnostic '{automationId}' failed: {exception.Message}"); + } + } } } diff --git a/DotPilot.UITests/GlobalUsings.cs b/DotPilot.UITests/GlobalUsings.cs index a9d93f5..6603347 100644 --- a/DotPilot.UITests/GlobalUsings.cs +++ b/DotPilot.UITests/GlobalUsings.cs @@ -1,6 +1,4 @@ global using NUnit.Framework; global using Uno.UITest; -global using Uno.UITest.Helpers; global using Uno.UITest.Helpers.Queries; global using Uno.UITests.Helpers; -global using Query = System.Func; diff --git a/DotPilot.UITests/TestBase.cs b/DotPilot.UITests/TestBase.cs index 9f8a5c8..8b01126 100644 --- a/DotPilot.UITests/TestBase.cs +++ b/DotPilot.UITests/TestBase.cs @@ -14,10 +14,8 @@ public class TestBase private const string BrowserWindowSizeArgumentPrefix = "--window-size="; private const int BrowserWindowWidth = 1440; private const int BrowserWindowHeight = 960; - private static readonly object BrowserAppSyncRoot = new(); private static readonly TimeSpan AppCleanupTimeout = TimeSpan.FromSeconds(15); - private static IApp? _browserApp; private static readonly BrowserAutomationSettings? _browserAutomation = Constants.CurrentPlatform == Platform.Browser ? BrowserAutomationBootstrap.Resolve() @@ -68,7 +66,7 @@ public void SetUpTest() { HarnessLog.Write($"Starting setup for '{TestContext.CurrentContext.Test.Name}'."); App = Constants.CurrentPlatform == Platform.Browser - ? EnsureBrowserApp(_browserAutomation!) + ? StartBrowserApp(_browserAutomation!) : AppInitializer.AttachToApp(); HarnessLog.Write($"Setup completed for '{TestContext.CurrentContext.Test.Name}'."); } @@ -77,11 +75,35 @@ public void SetUpTest() public void TearDownTest() { HarnessLog.Write($"Starting teardown for '{TestContext.CurrentContext.Test.Name}'."); + List cleanupFailures = []; + if (_app is not null) { TakeScreenshot("teardown"); } + if (Constants.CurrentPlatform == Platform.Browser && _app is not null) + { + TryCleanup( + () => _app.Dispose(), + BrowserAppCleanupOperationName, + cleanupFailures); + } + + _app = null; + + if (cleanupFailures.Count == 1) + { + HarnessLog.Write("Teardown failed with a single cleanup exception."); + throw cleanupFailures[0]; + } + + if (cleanupFailures.Count > 1) + { + HarnessLog.Write("Teardown failed with multiple cleanup exceptions."); + throw new AggregateException(cleanupFailures); + } + HarnessLog.Write($"Teardown completed for '{TestContext.CurrentContext.Test.Name}'."); } @@ -91,37 +113,24 @@ public void TearDownFixture() HarnessLog.Write("Starting fixture cleanup."); List cleanupFailures = []; - if (_app is not null && !ReferenceEquals(_app, _browserApp)) + if (_app is not null) { TryCleanup( () => _app.Dispose(), - AttachedAppCleanupOperationName, + Constants.CurrentPlatform == Platform.Browser + ? BrowserAppCleanupOperationName + : AttachedAppCleanupOperationName, cleanupFailures); } _app = null; - try - { - if (_browserApp is not null) - { - TryCleanup( - () => _browserApp.Dispose(), - BrowserAppCleanupOperationName, - cleanupFailures); - } - } - finally + if (Constants.CurrentPlatform == Platform.Browser) { - _browserApp = null; - - if (Constants.CurrentPlatform == Platform.Browser) - { - TryCleanup( - BrowserTestHost.Stop, - BrowserHostCleanupOperationName, - cleanupFailures); - } + TryCleanup( + BrowserTestHost.Stop, + BrowserHostCleanupOperationName, + cleanupFailures); } if (cleanupFailures.Count == 1) @@ -172,6 +181,131 @@ public FileInfo TakeScreenshot(string stepName) return fileInfo; } + protected void WriteBrowserSystemLogs(string context, int maxEntries = 50) + { + if (Constants.CurrentPlatform != Platform.Browser || _app is null) + { + return; + } + + try + { + var logEntries = _app.GetSystemLogs() + .TakeLast(maxEntries) + .ToArray(); + + HarnessLog.Write($"Browser system log dump for '{context}' contains {logEntries.Length} entries."); + + foreach (var entry in logEntries) + { + HarnessLog.Write($"BrowserLog {entry.Timestamp:O} {entry.Level}: {entry.Message}"); + } + } + catch (Exception exception) + { + HarnessLog.Write($"Browser system log dump failed for '{context}': {exception.Message}"); + } + } + + protected void WriteBrowserDomSnapshot(string context) + { + if (Constants.CurrentPlatform != Platform.Browser || _app is null) + { + return; + } + + try + { + var driver = _app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app); + + if (driver is null) + { + HarnessLog.Write($"Browser DOM snapshot skipped for '{context}': Selenium driver field was not found."); + return; + } + + var executeScriptMethod = driver.GetType().GetMethod( + "ExecuteScript", + [typeof(string), typeof(object[])]); + + if (executeScriptMethod is null) + { + HarnessLog.Write($"Browser DOM snapshot skipped for '{context}': ExecuteScript was not found."); + return; + } + + static string Normalize(object? value) + { + var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + text = text.ReplaceLineEndings(" "); + return text.Length <= 800 ? text : text[..800]; + } + + object? ExecuteScript(string script) + { + return executeScriptMethod.Invoke(driver, [script, Array.Empty()]); + } + + var readyState = Normalize(ExecuteScript("return document.readyState;")); + var location = Normalize(ExecuteScript("return window.location.href;")); + var automationCount = Normalize(ExecuteScript("return document.querySelectorAll('[xamlautomationid]').length;")); + var automationIds = Normalize(ExecuteScript( + "return Array.from(document.querySelectorAll('[xamlautomationid]')).slice(0, 25).map(e => e.getAttribute('xamlautomationid')).join(' | ');")); + var ariaLabels = Normalize(ExecuteScript( + "return Array.from(document.querySelectorAll('[aria-label]')).slice(0, 25).map(e => e.getAttribute('aria-label')).join(' | ');")); + var bodyText = Normalize(ExecuteScript("return document.body.innerText;")); + var bodyHtml = Normalize(ExecuteScript("return document.body.innerHTML;")); + var settingsNavHitTest = Normalize(ExecuteScript( + """ + return (() => { + const target = document.querySelector('[xamlautomationid="SidebarSettingsButton"]'); + if (!target) { + return 'missing SidebarSettingsButton'; + } + + const rect = target.getBoundingClientRect(); + const x = rect.left + (rect.width / 2); + const y = rect.top + (rect.height / 2); + const top = document.elementFromPoint(x, y); + + return JSON.stringify({ + targetTag: target.tagName, + targetClass: target.className, + targetId: target.getAttribute('xamlautomationid') ?? '', + x, + y, + containsTop: top ? target.contains(top) : false, + topTag: top?.tagName ?? '', + topClass: top?.className ?? '', + topId: top?.getAttribute('xamlautomationid') ?? '', + topXamlType: top?.getAttribute('xamltype') ?? '', + topAria: top?.getAttribute('aria-label') ?? '' + }); + })(); + """)); + + HarnessLog.Write($"Browser DOM snapshot for '{context}': readyState='{readyState}', location='{location}', xamlautomationid-count='{automationCount}'."); + HarnessLog.Write($"Browser DOM snapshot automation ids for '{context}': {automationIds}"); + HarnessLog.Write($"Browser DOM snapshot aria-labels for '{context}': {ariaLabels}"); + HarnessLog.Write($"Browser DOM snapshot SidebarSettingsButton hit test for '{context}': {settingsNavHitTest}"); + HarnessLog.Write($"Browser DOM snapshot innerText for '{context}': {bodyText}"); + HarnessLog.Write($"Browser DOM snapshot innerHTML for '{context}': {bodyHtml}"); + } + catch (Exception exception) + { + HarnessLog.Write($"Browser DOM snapshot failed for '{context}': {exception.Message}"); + } + } + + protected void TapAutomationElement(string automationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + App.Tap(automationId); + } + private static bool ResolveBrowserHeadless() { #if DEBUG @@ -184,37 +318,28 @@ private static bool ResolveBrowserHeadless() #endif } - private static IApp EnsureBrowserApp(BrowserAutomationSettings browserAutomation) + private static IApp StartBrowserApp(BrowserAutomationSettings browserAutomation) { - lock (BrowserAppSyncRoot) + HarnessLog.Write("Starting browser app instance."); + var configurator = Uno.UITest.Selenium.ConfigureApp.WebAssembly + .Uri(new Uri(Constants.WebAssemblyDefaultUri)) + .UsingBrowser(Constants.WebAssemblyBrowser.ToString()) + .BrowserBinaryPath(browserAutomation.BrowserBinaryPath) + .ScreenShotsPath(AppContext.BaseDirectory) + .WindowSize(BrowserWindowWidth, BrowserWindowHeight) + .SeleniumArgument($"{BrowserWindowSizeArgumentPrefix}{BrowserWindowWidth},{BrowserWindowHeight}") + .Headless(_browserHeadless); + + configurator = configurator.DriverPath(browserAutomation.DriverPath); + + if (!_browserHeadless) { - if (_browserApp is not null) - { - HarnessLog.Write("Reusing browser app instance."); - return _browserApp; - } - - HarnessLog.Write("Starting browser app instance."); - var configurator = Uno.UITest.Selenium.ConfigureApp.WebAssembly - .Uri(new Uri(Constants.WebAssemblyDefaultUri)) - .UsingBrowser(Constants.WebAssemblyBrowser.ToString()) - .BrowserBinaryPath(browserAutomation.BrowserBinaryPath) - .ScreenShotsPath(AppContext.BaseDirectory) - .WindowSize(BrowserWindowWidth, BrowserWindowHeight) - .SeleniumArgument($"{BrowserWindowSizeArgumentPrefix}{BrowserWindowWidth},{BrowserWindowHeight}") - .Headless(_browserHeadless); - - configurator = configurator.DriverPath(browserAutomation.DriverPath); - - if (!_browserHeadless) - { - configurator = configurator.SeleniumArgument("--remote-debugging-port=9222"); - } - - _browserApp = configurator.StartApp(); - HarnessLog.Write("Browser app instance started."); - return _browserApp; + configurator = configurator.SeleniumArgument("--remote-debugging-port=9222"); } + + var browserApp = configurator.StartApp(); + HarnessLog.Write("Browser app instance started."); + return browserApp; } private static void TryCleanup(Action cleanupAction, string operationName, List cleanupFailures) diff --git a/DotPilot/AGENTS.md b/DotPilot/AGENTS.md index 2274cbe..40c152d 100644 --- a/DotPilot/AGENTS.md +++ b/DotPilot/AGENTS.md @@ -33,6 +33,7 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - Replace scaffold sample data with real runtime-backed state as product features arrive; do not throw away the shell structure unless a later documented decision explicitly requires it. - Reuse shared resources and small XAML components instead of duplicating large visual sections across pages. - Treat desktop window sizing and positioning as an app-startup responsibility in `App.xaml.cs`. +- For local UI debugging on this machine, run the real desktop head and prefer local `Uno` app tooling or MCP inspection over `browserwasm` reproduction unless the task is specifically about `DotPilot.UITests`. - Prefer `Microsoft Agent Framework` for orchestration, sessions, workflows, HITL, MCP-aware runtime features, and OpenTelemetry-based observability hooks. - Prefer official `.NET` AI evaluation libraries under `Microsoft.Extensions.AI.Evaluation*` for quality and safety evaluation features. - Do not plan or wire `MLXSharp` into the first product wave for this project. diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index cc16705..6efc90e 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -6,6 +6,12 @@ namespace DotPilot; public partial class App : Application { + private const string StartupLogPrefix = "[DotPilot.Startup]"; + private const string ConstructorMarker = "App constructor initialized."; + private const string OnLaunchedStartedMarker = "OnLaunched started."; + private const string BuilderCreatedMarker = "Uno host builder created."; + private const string NavigateStartedMarker = "Navigating to shell."; + private const string NavigateCompletedMarker = "Shell navigation completed."; #if !__WASM__ private const string CenterMethodName = "Center"; private const string WindowStartupLocationPropertyName = "WindowStartupLocation"; @@ -26,6 +32,7 @@ public partial class App : Application public App() { InitializeComponent(); + WriteStartupMarker(ConstructorMarker); } protected Window? MainWindow { get; private set; } @@ -34,91 +41,125 @@ public App() [SuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Uno.Extensions APIs are used in a way that is safe for trimming in this template context.")] protected override async void OnLaunched(LaunchActivatedEventArgs args) { - var builder = this.CreateBuilder(args) - // Add navigation support for toolkit controls such as TabBar and NavigationView - .UseToolkitNavigation() - .Configure(host => host + try + { + WriteStartupMarker(OnLaunchedStartedMarker); + var builder = this.CreateBuilder(args) + // Add navigation support for toolkit controls such as TabBar and NavigationView + .UseToolkitNavigation() + .Configure(host => host #if DEBUG - // Switch to Development environment when running in DEBUG - .UseEnvironment(Environments.Development) + // Switch to Development environment when running in DEBUG + .UseEnvironment(Environments.Development) #endif - .UseLogging(configure: (context, logBuilder) => - { - // Configure log levels for different categories of logging - logBuilder - .SetMinimumLevel( - context.HostingEnvironment.IsDevelopment() ? - LogLevel.Information : - LogLevel.Warning) - - // Default filters for core Uno Platform namespaces - .CoreLogLevel(LogLevel.Warning); - - // Uno Platform namespace filter groups - // Uncomment individual methods to see more detailed logging - //// Generic Xaml events - //logBuilder.XamlLogLevel(LogLevel.Debug); - //// Layout specific messages - //logBuilder.XamlLayoutLogLevel(LogLevel.Debug); - //// Storage messages - //logBuilder.StorageLogLevel(LogLevel.Debug); - //// Binding related messages - //logBuilder.XamlBindingLogLevel(LogLevel.Debug); - //// Binder memory references tracking - //logBuilder.BinderMemoryReferenceLogLevel(LogLevel.Debug); - //// DevServer and HotReload related - //logBuilder.HotReloadCoreLogLevel(LogLevel.Information); - //// Debug JS interop - //logBuilder.WebAssemblyLogLevel(LogLevel.Debug); - - }, enableUnoLogging: true) - .UseConfiguration(configure: configBuilder => - configBuilder - .EmbeddedSource() - .Section() - ) - // Enable localization (see appsettings.json for supported languages) - .UseLocalization() - .UseHttp((context, services) => - { + .UseLogging(configure: (context, logBuilder) => + { + // Configure log levels for different categories of logging + logBuilder + .SetMinimumLevel( + context.HostingEnvironment.IsDevelopment() ? + LogLevel.Information : + LogLevel.Warning) + + // Default filters for core Uno Platform namespaces + .CoreLogLevel(LogLevel.Warning); + + // Uno Platform namespace filter groups + // Uncomment individual methods to see more detailed logging + //// Generic Xaml events + //logBuilder.XamlLogLevel(LogLevel.Debug); + //// Layout specific messages + //logBuilder.XamlLayoutLogLevel(LogLevel.Debug); + //// Storage messages + //logBuilder.StorageLogLevel(LogLevel.Debug); + //// Binding related messages + //logBuilder.XamlBindingLogLevel(LogLevel.Debug); + //// Binder memory references tracking + //logBuilder.BinderMemoryReferenceLogLevel(LogLevel.Debug); + //// DevServer and HotReload related + //logBuilder.HotReloadCoreLogLevel(LogLevel.Information); + //// Debug JS interop + //logBuilder.WebAssemblyLogLevel(LogLevel.Debug); + + }, enableUnoLogging: true) + .UseConfiguration(configure: configBuilder => + configBuilder + .EmbeddedSource() + .Section() + ) + // Enable localization (see appsettings.json for supported languages) + .UseLocalization() + .UseHttp((context, services) => + { #if DEBUG - // DelegatingHandler will be automatically injected - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); + // DelegatingHandler will be automatically injected + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddTransient(services); #endif - }) - .ConfigureServices((context, services) => - { - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddSingleton< - DotPilot.Core.Features.RuntimeFoundation.IAgentRuntimeClient, - DotPilot.Runtime.Features.RuntimeFoundation.DeterministicAgentRuntimeClient>(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddSingleton< - DotPilot.Core.Features.RuntimeFoundation.IRuntimeFoundationCatalog, - DotPilot.Runtime.Features.RuntimeFoundation.RuntimeFoundationCatalog>(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - }) - .UseNavigation(RegisterRoutes) - ); - MainWindow = builder.Window; + }) + .ConfigureServices((context, services) => + { + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddSingleton< + DotPilot.Core.Features.Workbench.IWorkbenchCatalog, + DotPilot.Runtime.Features.Workbench.WorkbenchCatalog>(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddSingleton< + DotPilot.Core.Features.RuntimeFoundation.IAgentRuntimeClient, + DotPilot.Runtime.Features.RuntimeFoundation.DeterministicAgentRuntimeClient>(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddSingleton< + DotPilot.Core.Features.RuntimeFoundation.IRuntimeFoundationCatalog, + DotPilot.Runtime.Features.RuntimeFoundation.RuntimeFoundationCatalog>(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddTransient(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddTransient(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddTransient(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddTransient(services); + }) + .UseNavigation(RegisterRoutes) + ); + WriteStartupMarker(BuilderCreatedMarker); + MainWindow = builder.Window; #if DEBUG #if !__WASM__ - MainWindow.UseStudio(); + MainWindow.UseStudio(); #endif #endif - MainWindow.SetWindowIcon(); - Host = await builder.NavigateAsync(); + MainWindow.SetWindowIcon(); + WriteStartupMarker(NavigateStartedMarker); + Host = await builder.NavigateAsync(); + WriteStartupMarker(NavigateCompletedMarker); #if !__WASM__ - CenterDesktopWindow(MainWindow); + CenterDesktopWindow(MainWindow); #endif + } + catch (Exception exception) + { + WriteStartupError(exception); + throw; + } + } + + private static void WriteStartupMarker(string message) + { + var formattedMessage = $"{StartupLogPrefix} {message}"; + Console.WriteLine(formattedMessage); + BrowserConsoleDiagnostics.Info(formattedMessage); + } + + private static void WriteStartupError(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + var formattedMessage = $"{StartupLogPrefix} ERROR {exception}"; + Console.Error.WriteLine(formattedMessage); + BrowserConsoleDiagnostics.Error(formattedMessage); } #if !__WASM__ @@ -270,7 +311,8 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) views.Register( new ViewMap(ViewModel: typeof(ShellViewModel)), new ViewMap(), - new ViewMap() + new ViewMap(), + new ViewMap() ); routes.Register( @@ -279,6 +321,7 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) [ new ("Main", View: views.FindByViewModel(), IsDefault:true), new ("Second", View: views.FindByViewModel()), + new ("Settings", View: views.FindByViewModel()), ] ) ); diff --git a/DotPilot/BrowserConsoleDiagnostics.cs b/DotPilot/BrowserConsoleDiagnostics.cs new file mode 100644 index 0000000..2639dae --- /dev/null +++ b/DotPilot/BrowserConsoleDiagnostics.cs @@ -0,0 +1,36 @@ +namespace DotPilot; + +internal static partial class BrowserConsoleDiagnostics +{ + internal static void Info(string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); +#if __WASM__ +#pragma warning disable CA1416 + JSImportMethods.Info(message); +#pragma warning restore CA1416 +#endif + } + + internal static void Error(string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); +#if __WASM__ +#pragma warning disable CA1416 + JSImportMethods.Error(message); +#pragma warning restore CA1416 +#endif + } + +#if __WASM__ + [System.Runtime.Versioning.SupportedOSPlatform("browser")] + private static partial class JSImportMethods + { + [System.Runtime.InteropServices.JavaScript.JSImport("globalThis.console.info")] + internal static partial void Info(string message); + + [System.Runtime.InteropServices.JavaScript.JSImport("globalThis.console.error")] + internal static partial void Error(string message); + } +#endif +} diff --git a/DotPilot/DotPilot.csproj b/DotPilot/DotPilot.csproj index 1c98d18..a005f36 100644 --- a/DotPilot/DotPilot.csproj +++ b/DotPilot/DotPilot.csproj @@ -38,7 +38,7 @@ - + $(UnoFeatures);Dsp; @@ -49,7 +49,9 @@ true true + true false + true diff --git a/DotPilot/GlobalUsings.cs b/DotPilot/GlobalUsings.cs index aa77845..287e966 100644 --- a/DotPilot/GlobalUsings.cs +++ b/DotPilot/GlobalUsings.cs @@ -1,3 +1,4 @@ global using DotPilot.Core.Features.ApplicationShell; global using DotPilot.Core.Features.RuntimeFoundation; +global using DotPilot.Core.Features.Workbench; global using DotPilot.Presentation; diff --git a/DotPilot/Presentation/Controls/AgentSidebar.xaml b/DotPilot/Presentation/Controls/AgentSidebar.xaml index 2bde1c4..1cbde90 100644 --- a/DotPilot/Presentation/Controls/AgentSidebar.xaml +++ b/DotPilot/Presentation/Controls/AgentSidebar.xaml @@ -44,15 +44,15 @@ Margin="12,20,12,0" Spacing="4"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/SettingsShell.xaml.cs b/DotPilot/Presentation/Controls/SettingsShell.xaml.cs new file mode 100644 index 0000000..0f6ea00 --- /dev/null +++ b/DotPilot/Presentation/Controls/SettingsShell.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class SettingsShell : UserControl +{ + public SettingsShell() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/Controls/SettingsSidebar.xaml b/DotPilot/Presentation/Controls/SettingsSidebar.xaml new file mode 100644 index 0000000..2200580 --- /dev/null +++ b/DotPilot/Presentation/Controls/SettingsSidebar.xaml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs b/DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs new file mode 100644 index 0000000..39a9d8d --- /dev/null +++ b/DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class SettingsSidebar : UserControl +{ + public SettingsSidebar() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml b/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml new file mode 100644 index 0000000..5c545fe --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs new file mode 100644 index 0000000..30a929e --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class WorkbenchActivityPanel : UserControl +{ + public WorkbenchActivityPanel() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml b/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml new file mode 100644 index 0000000..9bb252e --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs new file mode 100644 index 0000000..df0f00f --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class WorkbenchDocumentSurface : UserControl +{ + public WorkbenchDocumentSurface() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml b/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml new file mode 100644 index 0000000..e6fbd23 --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs new file mode 100644 index 0000000..5ea55b6 --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class WorkbenchInspectorPanel : UserControl +{ + public WorkbenchInspectorPanel() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml b/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml new file mode 100644 index 0000000..2b8f4c7 --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs new file mode 100644 index 0000000..f42094b --- /dev/null +++ b/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs @@ -0,0 +1,21 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class WorkbenchSidebar : UserControl +{ + public WorkbenchSidebar() + { + InitializeComponent(); + } + + private void OnRepositoryNodeTapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + if (DataContext is not MainViewModel viewModel || + sender is not FrameworkElement element || + element.DataContext is not WorkbenchRepositoryNodeItem repositoryNode) + { + return; + } + + viewModel.SelectedRepositoryNode = repositoryNode; + } +} diff --git a/DotPilot/Presentation/MainPage.xaml b/DotPilot/Presentation/MainPage.xaml index 4388894..7c2e004 100644 --- a/DotPilot/Presentation/MainPage.xaml +++ b/DotPilot/Presentation/MainPage.xaml @@ -6,8 +6,8 @@ Background="{StaticResource AppShellBackgroundBrush}" FontFamily="{StaticResource AppBodyFontFamily}"> - + AutomationProperties.AutomationId="WorkbenchScreen"> + - + - + - - - - - + - + + + + + - + - + + + + diff --git a/DotPilot/Presentation/MainPage.xaml.cs b/DotPilot/Presentation/MainPage.xaml.cs index ffdc557..f24979e 100644 --- a/DotPilot/Presentation/MainPage.xaml.cs +++ b/DotPilot/Presentation/MainPage.xaml.cs @@ -4,6 +4,16 @@ public sealed partial class MainPage : Page { public MainPage() { - InitializeComponent(); + try + { + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainPage constructor started."); + InitializeComponent(); + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainPage constructor completed."); + } + catch (Exception exception) + { + BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] MainPage constructor failed: {exception}"); + throw; + } } } diff --git a/DotPilot/Presentation/MainViewModel.cs b/DotPilot/Presentation/MainViewModel.cs index 4248400..0ddb251 100644 --- a/DotPilot/Presentation/MainViewModel.cs +++ b/DotPilot/Presentation/MainViewModel.cs @@ -1,60 +1,279 @@ +using System.Collections.Frozen; + namespace DotPilot.Presentation; -public sealed class MainViewModel +public sealed class MainViewModel : ObservableObject { - public MainViewModel(IRuntimeFoundationCatalog runtimeFoundationCatalog) + private const double IndentSize = 16d; + private const string DefaultDocumentTitle = "Select a file"; + private const string DefaultDocumentPath = "Choose a repository item from the left sidebar."; + private const string DefaultDocumentStatus = "The file surface becomes active after you open a file."; + private const string DefaultInspectorArtifactsTitle = "Artifacts"; + private const string DefaultInspectorLogsTitle = "Runtime log console"; + private const string DefaultInspectorArtifactsSummary = "Generated files, plans, screenshots, and session outputs stay attached to the current workbench."; + private const string DefaultInspectorLogsSummary = "Runtime logs remain visible without leaving the main workbench."; + private const string DefaultLanguageLabel = "No document"; + private const string DefaultRendererLabel = "Select a repository item"; + private const string DefaultPreviewContent = "Open a file from the repository tree to inspect it here."; + + private readonly FrozenDictionary _documentsByPath; + private readonly IReadOnlyList _allRepositoryNodes; + private IReadOnlyList _filteredRepositoryNodes; + private string _repositorySearchText = string.Empty; + private WorkbenchRepositoryNodeItem? _selectedRepositoryNode; + private WorkbenchDocumentDescriptor? _selectedDocument; + private string _editablePreviewContent = DefaultPreviewContent; + private bool _isDiffReviewMode; + private bool _isLogConsoleVisible; + + public MainViewModel( + IWorkbenchCatalog workbenchCatalog, + IRuntimeFoundationCatalog runtimeFoundationCatalog) { - ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); - RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); - } + try + { + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainViewModel constructor started."); + ArgumentNullException.ThrowIfNull(workbenchCatalog); + ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); + + Snapshot = workbenchCatalog.GetSnapshot(); + BrowserConsoleDiagnostics.Info( + $"[DotPilot.Startup] MainViewModel workbench snapshot loaded. Nodes={Snapshot.RepositoryNodes.Count}, Documents={Snapshot.Documents.Count}."); + RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); + BrowserConsoleDiagnostics.Info( + $"[DotPilot.Startup] MainViewModel runtime foundation snapshot loaded. Providers={RuntimeFoundation.Providers.Count}."); + EpicLabel = WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.DesktopWorkbenchEpic); + _documentsByPath = Snapshot.Documents.ToFrozenDictionary(document => document.RelativePath, StringComparer.OrdinalIgnoreCase); + _allRepositoryNodes = Snapshot.RepositoryNodes + .Select(MapRepositoryNode) + .ToArray(); + _filteredRepositoryNodes = _allRepositoryNodes; - public string Title { get; } = "Design Automation Agent"; + _selectedDocument = Snapshot.Documents.Count > 0 ? Snapshot.Documents[0] : null; + _editablePreviewContent = _selectedDocument?.PreviewContent ?? DefaultPreviewContent; - public string StatusSummary { get; } = "3 members · GPT-4o"; + var initialNode = _selectedDocument is null + ? FindFirstOpenableNode(_allRepositoryNodes) + : FindNodeByRelativePath(_allRepositoryNodes, _selectedDocument.RelativePath); + + if (initialNode is not null) + { + SetSelectedRepositoryNode(initialNode); + } + + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainViewModel constructor completed."); + } + catch (Exception exception) + { + BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] MainViewModel constructor failed: {exception}"); + throw; + } + } + + public WorkbenchSnapshot Snapshot { get; } public RuntimeFoundationSnapshot RuntimeFoundation { get; } - public IReadOnlyList RecentChats { get; } = - [ - new("Design Automation", "Generate a landing page for our...", true), - new("Analytics Agent", "Q3 shows 23% growth in 2026 in s...", false), - new("Code Review Bot", "Found 3 potential issues in this cod...", false), - ]; - - public IReadOnlyList Messages { get; } = - [ - new( - "Design Agent", - "10:22 AM", - "Hello! I'm your Design Automation Agent. I can help you create wireframes, generate UI components, write pixel-perfect CSS, and automate your design workflow. What would you like to build today?", - "D", - DesignBrushPalette.DesignAvatarBrush, - false), - new( - "Jordan Lee", - "10:25 AM", - "@design_agent, generate a landing page for our new AI product. Use a dark theme with accent colors, clean typography, and include a hero section with an animated CTA button.", - "J", - DesignBrushPalette.UserAvatarBrush, - true), - new( - "Sarah Kim", - "10:27 AM", - "We also have to build a design system for this project.", - "S", - DesignBrushPalette.AnalyticsAvatarBrush, - false), - ]; - - public IReadOnlyList Members { get; } = - [ - new("Jordan Lee", "@jordan · you", "J", DesignBrushPalette.UserAvatarBrush, "Admin", DesignBrushPalette.AccentBrush), - new("Sarah Kim", "@sarahk", "S", DesignBrushPalette.AnalyticsAvatarBrush, "Member", DesignBrushPalette.BadgeSurfaceBrush), - new("Marcus Chen", "@marcus", "M", DesignBrushPalette.CodeAvatarBrush, "Member", DesignBrushPalette.BadgeSurfaceBrush), - ]; - - public IReadOnlyList Agents { get; } = - [ - new("Design Agent", "GPT-4o · v2.1", "D", DesignBrushPalette.DesignAvatarBrush), - ]; + public string EpicLabel { get; } + + public string PageTitle => Snapshot.SessionTitle; + + public string WorkspaceName => Snapshot.WorkspaceName; + + public string WorkspaceRoot => Snapshot.WorkspaceRoot; + + public string SearchPlaceholder => Snapshot.SearchPlaceholder; + + public string SessionStage => Snapshot.SessionStage; + + public string SessionSummary => Snapshot.SessionSummary; + + public IReadOnlyList SessionEntries => Snapshot.SessionEntries; + + public IReadOnlyList Artifacts => Snapshot.Artifacts; + + public IReadOnlyList Logs => Snapshot.Logs; + + public IReadOnlyList FilteredRepositoryNodes + { + get => _filteredRepositoryNodes; + private set + { + if (ReferenceEquals(_filteredRepositoryNodes, value)) + { + return; + } + + _filteredRepositoryNodes = value; + RaisePropertyChanged(); + RaisePropertyChanged(nameof(RepositoryResultSummary)); + } + } + + public string RepositoryResultSummary => $"{FilteredRepositoryNodes.Count} items"; + + public string RepositorySearchText + { + get => _repositorySearchText; + set + { + if (!SetProperty(ref _repositorySearchText, value)) + { + return; + } + + UpdateFilteredRepositoryNodes(); + } + } + + public WorkbenchRepositoryNodeItem? SelectedRepositoryNode + { + get => _selectedRepositoryNode; + set => SetSelectedRepositoryNode(value); + } + + public string SelectedDocumentTitle => _selectedDocument?.Title ?? DefaultDocumentTitle; + + public string SelectedDocumentPath => _selectedDocument?.RelativePath ?? DefaultDocumentPath; + + public string SelectedDocumentStatus => _selectedDocument?.StatusSummary ?? DefaultDocumentStatus; + + public string SelectedDocumentLanguage => _selectedDocument?.LanguageLabel ?? DefaultLanguageLabel; + + public string SelectedDocumentRenderer => _selectedDocument?.RendererLabel ?? DefaultRendererLabel; + + public bool SelectedDocumentIsReadOnly => _selectedDocument?.IsReadOnly ?? true; + + public IReadOnlyList SelectedDocumentDiffLines => _selectedDocument?.DiffLines ?? []; + + public string EditablePreviewContent + { + get => _editablePreviewContent; + set => SetProperty(ref _editablePreviewContent, value); + } + + public bool IsDiffReviewMode + { + get => _isDiffReviewMode; + set + { + if (!SetProperty(ref _isDiffReviewMode, value)) + { + return; + } + + RaisePropertyChanged(nameof(IsPreviewMode)); + } + } + + public bool IsPreviewMode => !IsDiffReviewMode; + + public bool IsLogConsoleVisible + { + get => _isLogConsoleVisible; + set + { + if (!SetProperty(ref _isLogConsoleVisible, value)) + { + return; + } + + RaisePropertyChanged(nameof(IsArtifactsVisible)); + RaisePropertyChanged(nameof(InspectorTitle)); + RaisePropertyChanged(nameof(InspectorSummary)); + } + } + + public bool IsArtifactsVisible => !IsLogConsoleVisible; + + public string InspectorTitle => IsLogConsoleVisible ? DefaultInspectorLogsTitle : DefaultInspectorArtifactsTitle; + + public string InspectorSummary => IsLogConsoleVisible ? DefaultInspectorLogsSummary : DefaultInspectorArtifactsSummary; + + private static WorkbenchRepositoryNodeItem MapRepositoryNode(WorkbenchRepositoryNode node) + { + var kindGlyph = node.IsDirectory ? "▾" : "•"; + var indentMargin = new Thickness(node.Depth * IndentSize, 0d, 0d, 0d); + var automationId = PresentationAutomationIds.RepositoryNode(node.RelativePath); + + return new( + node.RelativePath, + node.Name, + node.DisplayLabel, + node.IsDirectory, + node.CanOpen, + kindGlyph, + indentMargin, + automationId); + } + + private void UpdateFilteredRepositoryNodes() + { + var searchTerms = RepositorySearchText.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + FilteredRepositoryNodes = searchTerms.Length is 0 + ? _allRepositoryNodes + : _allRepositoryNodes + .Where(node => searchTerms.All(term => + node.DisplayLabel.Contains(term, StringComparison.OrdinalIgnoreCase) || + node.Name.Contains(term, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + + if (_selectedRepositoryNode is null || + !FilteredRepositoryNodes.Contains(_selectedRepositoryNode)) + { + SetSelectedRepositoryNode(FindFirstOpenableNode(FilteredRepositoryNodes)); + } + } + + private static WorkbenchRepositoryNodeItem? FindFirstOpenableNode(IReadOnlyList nodes) + { + for (var index = 0; index < nodes.Count; index++) + { + if (nodes[index].CanOpen) + { + return nodes[index]; + } + } + + return nodes.Count > 0 ? nodes[0] : null; + } + + private static WorkbenchRepositoryNodeItem? FindNodeByRelativePath( + IReadOnlyList nodes, + string relativePath) + { + for (var index = 0; index < nodes.Count; index++) + { + if (nodes[index].RelativePath.Equals(relativePath, StringComparison.OrdinalIgnoreCase)) + { + return nodes[index]; + } + } + + return null; + } + + private void SetSelectedRepositoryNode(WorkbenchRepositoryNodeItem? value) + { + if (!SetProperty(ref _selectedRepositoryNode, value, nameof(SelectedRepositoryNode))) + { + return; + } + + if (value?.CanOpen != true || + !_documentsByPath.TryGetValue(value.RelativePath, out var selectedDocument)) + { + return; + } + + _selectedDocument = selectedDocument; + EditablePreviewContent = selectedDocument.PreviewContent; + RaisePropertyChanged(nameof(SelectedDocumentTitle)); + RaisePropertyChanged(nameof(SelectedDocumentPath)); + RaisePropertyChanged(nameof(SelectedDocumentStatus)); + RaisePropertyChanged(nameof(SelectedDocumentLanguage)); + RaisePropertyChanged(nameof(SelectedDocumentRenderer)); + RaisePropertyChanged(nameof(SelectedDocumentIsReadOnly)); + RaisePropertyChanged(nameof(SelectedDocumentDiffLines)); + } } diff --git a/DotPilot/Presentation/ObservableObject.cs b/DotPilot/Presentation/ObservableObject.cs new file mode 100644 index 0000000..4fafc9b --- /dev/null +++ b/DotPilot/Presentation/ObservableObject.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace DotPilot.Presentation; + +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty( + ref T field, + T value, + [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + RaisePropertyChanged(propertyName); + return true; + } + + protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/DotPilot/Presentation/PresentationAutomationIds.cs b/DotPilot/Presentation/PresentationAutomationIds.cs new file mode 100644 index 0000000..05f891c --- /dev/null +++ b/DotPilot/Presentation/PresentationAutomationIds.cs @@ -0,0 +1,24 @@ +namespace DotPilot.Presentation; + +public static class PresentationAutomationIds +{ + private const char ReplacementCharacter = '-'; + private const string RepositoryNodePrefix = "RepositoryNode-"; + private const string SettingsCategoryPrefix = "SettingsCategory-"; + + public static string RepositoryNode(string relativePath) => CreateScopedId(RepositoryNodePrefix, relativePath); + + public static string SettingsCategory(string key) => CreateScopedId(SettingsCategoryPrefix, key); + + private static string CreateScopedId(string prefix, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + var sanitized = string.Concat(value.Select(static character => + char.IsLetterOrDigit(character) + ? char.ToLowerInvariant(character) + : ReplacementCharacter)); + + return string.Concat(prefix, sanitized.Trim(ReplacementCharacter)); + } +} diff --git a/DotPilot/Presentation/SecondPage.xaml b/DotPilot/Presentation/SecondPage.xaml index a2257e3..d0fc0ef 100644 --- a/DotPilot/Presentation/SecondPage.xaml +++ b/DotPilot/Presentation/SecondPage.xaml @@ -79,13 +79,13 @@ Style="{StaticResource SidebarButtonStyle}" HorizontalAlignment="Right" VerticalAlignment="Center" - AutomationProperties.AutomationId="BackToChatButton" - uen:Navigation.Request="-"> + AutomationProperties.AutomationId="BackToWorkbenchButton" + uen:Navigation.Request="Main"> - + diff --git a/DotPilot/Presentation/SettingsPage.xaml b/DotPilot/Presentation/SettingsPage.xaml new file mode 100644 index 0000000..9dd6b31 --- /dev/null +++ b/DotPilot/Presentation/SettingsPage.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/SettingsPage.xaml.cs b/DotPilot/Presentation/SettingsPage.xaml.cs new file mode 100644 index 0000000..7ccb219 --- /dev/null +++ b/DotPilot/Presentation/SettingsPage.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation; + +public sealed partial class SettingsPage : Page +{ + public SettingsPage() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/SettingsViewModel.cs b/DotPilot/Presentation/SettingsViewModel.cs new file mode 100644 index 0000000..5ae76ee --- /dev/null +++ b/DotPilot/Presentation/SettingsViewModel.cs @@ -0,0 +1,69 @@ +namespace DotPilot.Presentation; + +public sealed class SettingsViewModel : ObservableObject +{ + private const string PageTitleValue = "Unified settings shell"; + private const string PageSubtitleValue = + "Providers, policies, and storage stay visible from one operator-oriented surface."; + private const string DefaultCategoryTitle = "Select a settings category"; + private const string DefaultCategorySummary = "Choose a category to inspect its current entries."; + + private WorkbenchSettingsCategoryItem? _selectedCategory; + + public SettingsViewModel( + IWorkbenchCatalog workbenchCatalog, + IRuntimeFoundationCatalog runtimeFoundationCatalog) + { + ArgumentNullException.ThrowIfNull(workbenchCatalog); + ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); + + Snapshot = workbenchCatalog.GetSnapshot(); + RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); + Categories = Snapshot.SettingsCategories + .Select(category => new WorkbenchSettingsCategoryItem( + category.Key, + category.Title, + category.Summary, + PresentationAutomationIds.SettingsCategory(category.Key), + category.Entries)) + .ToArray(); + _selectedCategory = Categories.Count > 0 ? Categories[0] : null; + SettingsIssueLabel = WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.SettingsShell); + } + + public WorkbenchSnapshot Snapshot { get; } + + public RuntimeFoundationSnapshot RuntimeFoundation { get; } + + public string SettingsIssueLabel { get; } + + public string PageTitle => PageTitleValue; + + public string PageSubtitle => PageSubtitleValue; + + public IReadOnlyList Categories { get; } + + public WorkbenchSettingsCategoryItem? SelectedCategory + { + get => _selectedCategory; + set + { + if (!SetProperty(ref _selectedCategory, value)) + { + return; + } + + RaisePropertyChanged(nameof(SelectedCategoryTitle)); + RaisePropertyChanged(nameof(SelectedCategorySummary)); + RaisePropertyChanged(nameof(VisibleEntries)); + } + } + + public string SelectedCategoryTitle => SelectedCategory?.Title ?? DefaultCategoryTitle; + + public string SelectedCategorySummary => SelectedCategory?.Summary ?? DefaultCategorySummary; + + public IReadOnlyList VisibleEntries => SelectedCategory?.Entries ?? []; + + public string ProviderSummary => $"{RuntimeFoundation.Providers.Count} provider checks available"; +} diff --git a/DotPilot/Presentation/Shell.xaml.cs b/DotPilot/Presentation/Shell.xaml.cs index a53956f..609be02 100644 --- a/DotPilot/Presentation/Shell.xaml.cs +++ b/DotPilot/Presentation/Shell.xaml.cs @@ -4,7 +4,17 @@ public sealed partial class Shell : UserControl, IContentControlProvider { public Shell() { - InitializeComponent(); + try + { + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor started."); + InitializeComponent(); + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor completed."); + } + catch (Exception exception) + { + BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] Shell constructor failed: {exception}"); + throw; + } } public ContentControl ContentControl => Splash; } diff --git a/DotPilot/Presentation/WorkbenchPresentationModels.cs b/DotPilot/Presentation/WorkbenchPresentationModels.cs new file mode 100644 index 0000000..0813ed6 --- /dev/null +++ b/DotPilot/Presentation/WorkbenchPresentationModels.cs @@ -0,0 +1,18 @@ +namespace DotPilot.Presentation; + +public sealed record WorkbenchRepositoryNodeItem( + string RelativePath, + string Name, + string DisplayLabel, + bool IsDirectory, + bool CanOpen, + string KindGlyph, + Thickness IndentMargin, + string AutomationId); + +public sealed partial record WorkbenchSettingsCategoryItem( + string Key, + string Title, + string Summary, + string AutomationId, + IReadOnlyList Entries); diff --git a/README.md b/README.md index 8e183d3..8cc10d9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ `dotPilot` is a desktop-first, local-first control plane for AI agents built with `.NET 10` and `Uno Platform`. -The product is being shaped as a single operator workbench where you can: +## Product Summary + +`dotPilot` is designed as a single operator workbench for running, supervising, and reviewing agent workflows from one desktop UI. Coding workflows are first-class, but the product is not limited to coding agents. The same control plane is intended to support research, analysis, orchestration, reviewer, and operator-style flows. + +From the workbench, the operator should be able to: - manage agent profiles and fleets - connect external agent runtimes such as `Codex`, `Claude Code`, and `GitHub Copilot` @@ -10,28 +14,48 @@ The product is being shaped as a single operator workbench where you can: - browse repositories, inspect files, review diffs, and work with Git - orchestrate sessions, approvals, telemetry, replay, and evaluation from one UI -Coding workflows are first-class, but `dotPilot` is not a coding-only shell. The target product also supports research, analysis, orchestration, reviewer, and operator-style agent flows. +## Main Features + +### Available In The Current Repository + +- a desktop-first three-pane workbench shell +- repository tree search and open-file navigation +- read-only file inspection and diff-review surface +- artifact dock and runtime log console +- unified settings shell for providers, policies, and storage +- dedicated agent-builder screen +- deterministic runtime foundation panel for provider readiness and control-plane state +- `NUnit` unit tests plus `Uno.UITest` browser UI coverage + +### Main Product Capabilities On The Roadmap + +- multi-agent session composition and orchestration +- embedded local-first runtime hosting with `Orleans` +- SDK-first provider integrations for `Codex`, `Claude Code`, and `GitHub Copilot` +- local model runtime support through `LLamaSharp` and `ONNX Runtime` +- approvals, replay, audit trails, and artifact inspection +- OpenTelemetry-first observability and official `.NET` AI evaluation flows ## Current Status -The repository is currently in the **planned architecture and backlog** stage for the control-plane direction. +The repository is in the **active foundation and workbench implementation** stage. What already exists: -- a desktop-first `Uno Platform` shell with the future workbench information architecture -- a dedicated agent-builder screen -- `NUnit` unit tests and `Uno.UITest` browser UI coverage -- planning artifacts for the approved direction -- a detailed GitHub issue backlog for implementation +- the first runtime foundation slices in `DotPilot.Core` and `DotPilot.Runtime` +- the first operator workbench slice for repository browsing, document inspection, artifacts, logs, and settings +- a presentation-only `Uno Platform` app shell with separate non-UI class-library boundaries +- unit, coverage, and UI automation validation paths +- architecture docs, ADRs, feature specs, and GitHub backlog tracking What is planned next: -- embedded `Orleans` host inside the desktop app +- embedded `Orleans` hosting inside the desktop app - `Microsoft Agent Framework` orchestration and session workflows -- SDK-first provider adapters -- MCP and repo-intelligence tooling -- local runtime support -- OpenTelemetry-first observability and official `.NET` AI evaluation +- richer provider adapters and toolchain management +- MCP and repository-intelligence tooling +- local runtime execution flows +- telemetry, replay, and evaluation surfaces backed by real runtime events ## Product Direction @@ -57,8 +81,11 @@ Start here if you want the current source of truth: - [Architecture Overview](docs/Architecture.md) - [ADR-0001: Agent Control Plane Architecture](docs/ADR/ADR-0001-agent-control-plane-architecture.md) +- [ADR-0003: Vertical Slices And UI-Only Uno App](docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - [Feature Spec: Agent Control Plane Experience](docs/Features/agent-control-plane-experience.md) -- [Task Plan: Agent Control Plane Backlog](agent-control-plane-backlog.plan.md) +- [Feature Spec: Workbench Foundation](docs/Features/workbench-foundation.md) +- [Task Plan: Vertical Slice Runtime Foundation](vertical-slice-runtime-foundation.plan.md) +- [Task Plan: Workbench Foundation](issue-13-workbench-foundation.plan.md) - [Root Governance](AGENTS.md) GitHub tracking: @@ -69,14 +96,19 @@ GitHub tracking: ```text . -├── DotPilot/ # Uno desktop app and current shell -├── DotPilot.Tests/ # NUnit in-process tests +├── DotPilot/ # Uno desktop presentation host +├── DotPilot.Core/ # Vertical-slice contracts and typed identifiers +├── DotPilot.Runtime/ # Provider-independent runtime implementations +├── DotPilot.ReleaseTool/ # Release automation utilities +├── DotPilot.Tests/ # NUnit contract and composition tests ├── DotPilot.UITests/ # Uno.UITest browser coverage ├── docs/ │ ├── ADR/ # architectural decisions │ ├── Features/ # executable feature specs │ └── Architecture.md # repo architecture map ├── AGENTS.md # root governance for humans and agents +├── vertical-slice-runtime-foundation.plan.md +├── issue-13-workbench-foundation.plan.md └── DotPilot.slnx # solution entry point ``` @@ -91,13 +123,14 @@ GitHub tracking: ### Core Commands ```bash -dotnet build DotPilot.slnx +dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false dotnet test DotPilot.slnx dotnet format DotPilot.slnx --verify-no-changes -dotnet build DotPilot.slnx -warnaserror dotnet publish DotPilot/DotPilot.csproj -c Release -f net10.0-desktop ``` +`build` and `analyze` use the same serialized `-warnaserror` command because the multi-target Uno app must not build in parallel in a shared workspace or CI cache. + ### Run the App ```bash @@ -124,4 +157,4 @@ This repository treats the following as mandatory: - The current repository still contains prototype data in the shell; the new backlog tracks the transition to runtime-backed features. - If you are working on non-trivial changes, start with [AGENTS.md](AGENTS.md) and [docs/Architecture.md](docs/Architecture.md). -- The current machine-local baseline may still hit a `Uno.Resizetizer` file-lock during `dotnet build`; that risk is documented in [agent-control-plane-backlog.plan.md](agent-control-plane-backlog.plan.md). +- The current machine-local baseline may still hit a `Uno.Resizetizer` file-lock during `dotnet build`; that risk is documented in [ci-build-lock-fix.plan.md](ci-build-lock-fix.plan.md). diff --git a/docs/ADR/ADR-0002-split-github-actions-build-and-release.md b/docs/ADR/ADR-0002-split-github-actions-build-and-release.md index 9df19c5..5835411 100644 --- a/docs/ADR/ADR-0002-split-github-actions-build-and-release.md +++ b/docs/ADR/ADR-0002-split-github-actions-build-and-release.md @@ -32,7 +32,9 @@ We will split GitHub Actions into two explicit workflows: 2. `release-publish.yml` - runs automatically on pushes to `main` - resolves the release version from the two-segment `ApplicationDisplayVersion` prefix in `DotPilot/DotPilot.csproj` plus the GitHub Actions build number - - publishes desktop outputs for macOS, Windows, and Linux, and creates the GitHub Release + - publishes desktop release assets for macOS, Windows, and Linux as real packaged outputs instead of raw publish-folder archives + - uses `.dmg` for macOS, a self-contained single-file `.exe` for Windows, and `.snap` for Linux + - creates the GitHub Release - prepends repo-owned feature summaries and feature-doc links to GitHub-generated release notes ## Decision Diagram @@ -46,7 +48,7 @@ flowchart LR Quality["Format + build + analyze"] Tests["Unit + coverage + UI tests"] Version["Version resolved from DotPilot.csproj prefix + CI build number"] - Publish["Desktop publish matrix"] + Publish["Desktop packaged assets (.dmg, .exe, .snap)"] GitHubRelease["GitHub Release with feature notes"] Change --> Validation diff --git a/docs/Architecture.md b/docs/Architecture.md index 7c0e58c..5332841 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,6 +1,6 @@ # Architecture Overview -Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, and the new vertical-slice runtime foundation that starts epic `#12`. +Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, the workbench foundation for epic `#13`, and the vertical-slice runtime foundation that starts epic `#12`. This file is the required start-here architecture map for non-trivial tasks. @@ -8,6 +8,7 @@ This file is the required start-here architecture map for non-trivial tasks. - **System:** `DotPilot` is a `.NET 10` `Uno Platform` desktop-first application that is evolving from a static prototype into a local-first control plane for agent operations. - **Presentation boundary:** [../DotPilot/](../DotPilot/) is now the presentation host only. It owns XAML, routing, desktop startup, and UI composition, while non-UI feature logic moves into separate DLLs. +- **Workbench boundary:** epic [#13](https://github.com/managedcode/dotPilot/issues/13) is landing as a `Workbench` slice that will provide repository navigation, file inspection, artifact and log inspection, and a unified settings shell without moving that behavior into page code-behind. - **Runtime foundation boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns issue-aligned contracts, typed identifiers, and public slice interfaces; [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider-independent runtime implementations such as the deterministic test client, toolchain probing, and future embedded-host integration points. - **Domain slice boundary:** issue [#22](https://github.com/managedcode/dotPilot/issues/22) now lives in `DotPilot.Core/Features/ControlPlaneDomain`, which defines the shared agent, session, fleet, provider, runtime, approval, artifact, telemetry, and evaluation model that later slices reuse. - **Communication slice boundary:** issue [#23](https://github.com/managedcode/dotPilot/issues/23) lives in `DotPilot.Core/Features/RuntimeCommunication`, which defines the shared `ManagedCode.Communication` result/problem language for runtime public boundaries. @@ -57,6 +58,34 @@ flowchart LR Unit --> Runtime ``` +### Workbench foundation slice for epic #13 + +```mermaid +flowchart TD + Epic["#13 Desktop workbench"] + Shell["#28 Primary workbench shell"] + Tree["#29 Repository tree"] + File["#30 File surface + diff review"] + Dock["#31 Artifact dock + runtime console"] + Settings["#32 Settings shell"] + CoreSlice["DotPilot.Core/Features/Workbench"] + RuntimeSlice["DotPilot.Runtime/Features/Workbench"] + UiSlice["MainPage + SettingsPage + workbench controls"] + + Epic --> Shell + Epic --> Tree + Epic --> File + Epic --> Dock + Epic --> Settings + Shell --> CoreSlice + Tree --> CoreSlice + File --> CoreSlice + Dock --> CoreSlice + Settings --> CoreSlice + CoreSlice --> RuntimeSlice + RuntimeSlice --> UiSlice +``` + ### Runtime foundation slice for epic #12 ```mermaid @@ -118,6 +147,7 @@ flowchart LR - `Primary architecture decision` — [ADR-0001](./ADR/ADR-0001-agent-control-plane-architecture.md) - `Vertical-slice solution decision` — [ADR-0003](./ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - `Feature spec` — [Agent Control Plane Experience](./Features/agent-control-plane-experience.md) +- `Issue #13 feature doc` — [Workbench Foundation](./Features/workbench-foundation.md) - `Issue #22 feature doc` — [Control Plane Domain Model](./Features/control-plane-domain-model.md) - `Issue #23 feature doc` — [Runtime Communication Contracts](./Features/runtime-communication-contracts.md) diff --git a/docs/Features/workbench-foundation.md b/docs/Features/workbench-foundation.md new file mode 100644 index 0000000..b75a914 --- /dev/null +++ b/docs/Features/workbench-foundation.md @@ -0,0 +1,59 @@ +# Workbench Foundation + +## Summary + +Epic [#13](https://github.com/managedcode/dotPilot/issues/13) turns the current static Uno shell into the first real operator workbench. The slice keeps the existing desktop-first information architecture, but replaces prototype-only assumptions with a runtime-backed repository tree, file surface, artifact and log inspection, and a first-class settings shell. + +## Scope + +### In Scope + +- primary three-pane workbench shell for issue `#28` +- gitignore-aware repository tree with search and open-file navigation for issue `#29` +- file viewer and diff-review surface aligned with a Monaco-style editor contract for issue `#30` +- artifact dock and runtime log console for issue `#31` +- unified settings shell for providers, policies, and storage for issue `#32` + +### Out Of Scope + +- provider runtime execution +- Orleans host orchestration +- persistent session replay +- full IDE parity + +## Flow + +```mermaid +flowchart LR + Nav["Left navigation"] + Tree["Repository tree + search"] + File["File surface + diff review"] + Session["Central session surface"] + Inspector["Artifacts + logs"] + Settings["Settings shell"] + + Nav --> Tree + Tree --> File + File --> Inspector + Session --> Inspector + Nav --> Settings + Settings --> Nav +``` + +## Contract Notes + +- The Uno app stays presentation-only; workbench data, repository scanning, and settings descriptors come from app-external feature slices. +- Browser UI tests need deterministic data, so the workbench runtime path must provide browser-safe seeded content when direct filesystem access is unavailable. +- Repository navigation, file inspection, diff review, artifact inspection, and settings navigation are treated as one operator flow rather than isolated widgets. +- The file surface is designed around a Monaco-style editor contract even when the current renderer remains constrained by cross-platform Uno surfaces. + +## Verification + +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` +- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` +- `dotnet test DotPilot.slnx` + +## Dependencies + +- Parent epic: [#13](https://github.com/managedcode/dotPilot/issues/13) +- Child issues: [#28](https://github.com/managedcode/dotPilot/issues/28), [#29](https://github.com/managedcode/dotPilot/issues/29), [#30](https://github.com/managedcode/dotPilot/issues/30), [#31](https://github.com/managedcode/dotPilot/issues/31), [#32](https://github.com/managedcode/dotPilot/issues/32) From 0fcab7f9ec8d30180b226c36784385e384ecd8f2 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Fri, 13 Mar 2026 21:36:00 +0100 Subject: [PATCH 2/2] feat: add toolchain center slice --- AGENTS.md | 3 +- .../IToolchainCenterCatalog.cs | 6 + .../ToolchainCenterContracts.cs | 64 +++ .../ToolchainCenter/ToolchainCenterIssues.cs | 20 + .../ToolchainCenter/ToolchainCenterStates.cs | 71 +++ .../Workbench/WorkbenchSettingsContracts.cs | 8 + .../ProviderToolchainNames.cs | 2 +- .../ProviderToolchainProbe.cs | 2 +- .../RuntimeFoundationCatalog.cs | 15 +- .../ToolchainCenter/ToolchainCenterCatalog.cs | 118 +++++ .../ToolchainCenter/ToolchainCommandProbe.cs | 104 +++++ .../ToolchainConfigurationSignal.cs | 10 + .../ToolchainDeterministicIdentity.cs | 22 + .../ToolchainProviderProfile.cs | 13 + .../ToolchainProviderProfiles.cs | 73 +++ .../ToolchainProviderSnapshotFactory.cs | 374 +++++++++++++++ .../Features/Workbench/WorkbenchSeedData.cs | 16 +- .../WorkbenchWorkspaceSnapshotBuilder.cs | 16 +- .../ProviderToolchainProbeTests.cs | 63 +++ .../ToolchainCenterCatalogTests.cs | 117 +++++ .../ToolchainCommandProbeTests.cs | 113 +++++ .../ToolchainProviderSnapshotFactoryTests.cs | 132 ++++++ DotPilot.Tests/GlobalUsings.cs | 2 + DotPilot.Tests/PresentationViewModelTests.cs | 20 +- DotPilot.UITests/Given_MainPage.cs | 152 ++++-- DotPilot.UITests/TestBase.cs | 129 +++++- DotPilot/App.xaml.cs | 4 + .../Presentation/Controls/AgentSidebar.xaml | 8 +- .../Presentation/Controls/SettingsShell.xaml | 6 +- .../Controls/SettingsSidebar.xaml | 8 +- .../Controls/ToolchainCenterPanel.xaml | 431 ++++++++++++++++++ .../Controls/ToolchainCenterPanel.xaml.cs | 9 + .../Controls/WorkbenchSidebar.xaml | 86 ++-- .../Controls/WorkbenchSidebar.xaml.cs | 12 - DotPilot/Presentation/MainViewModel.cs | 4 +- .../Presentation/PresentationAutomationIds.cs | 9 + DotPilot/Presentation/SecondPage.xaml | 138 +++--- DotPilot/Presentation/SettingsViewModel.cs | 61 ++- .../WorkbenchPresentationModels.cs | 22 +- README.md | 6 + docs/Architecture.md | 58 ++- docs/Features/toolchain-center.md | 65 +++ issue-14-toolchain-center.plan.md | 144 ++++++ 43 files changed, 2524 insertions(+), 212 deletions(-) create mode 100644 DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs create mode 100644 DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs create mode 100644 DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs create mode 100644 DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs create mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs create mode 100644 DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs create mode 100644 DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs create mode 100644 DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs create mode 100644 DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs create mode 100644 DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml create mode 100644 DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml.cs create mode 100644 docs/Features/toolchain-center.md create mode 100644 issue-14-toolchain-center.plan.md diff --git a/AGENTS.md b/AGENTS.md index 8935711..e258b64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,7 @@ For this app: - `format` uses `dotnet format --verify-no-changes` as a local pre-push check; GitHub Actions validation should not spend CI time rechecking formatting drift that must already be fixed before push - coverage uses the `coverlet.collector` integration on `DotPilot.Tests` with the repo runsettings file to keep generated Uno artifacts out of the coverage path - desktop release publishing uses `dotnet publish DotPilot/DotPilot.csproj -c Release -f net10.0-desktop`; the validation workflow stays focused on build and automated tests, while the release workflow owns desktop publish outputs for macOS, Windows, and Linux -- `LangVersion` is pinned to `latest` at the root +- `LangVersion` is pinned to `14` at the root - prefer the newest stable `.NET 10` and `C#` language features that are supported by the pinned SDK and do not weaken readability, determinism, or analyzability - the repo-root lowercase `.editorconfig` is the source of truth for formatting, naming, style, and analyzer severity - local and CI build commands must pass `-warnaserror`; warnings are not an acceptable "green" build state in this repository @@ -153,6 +153,7 @@ For this app: - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication - meaningful GitHub review comments must be evaluated and fixed when they still apply even if the original PR was closed; closed review threads are not a reason to ignore valid engineering feedback +- PR bodies for issue-backed work must use GitHub closing references such as `Closes #14` so merged work closes the tracked issue automatically - the release workflow must run automatically on pushes to `main`, build desktop apps, and publish the GitHub Release without requiring a manual dispatch - desktop app build or publish jobs must use native runners for their target OS: macOS artifacts on macOS runners, Windows artifacts on Windows runners, and Linux artifacts on Linux runners - desktop release assets must be native installable or directly executable outputs for each OS, not archives of raw publish folders; package the real `.exe`, `.snap`, `.dmg`, `.pkg`, `Setup.exe`, or equivalent runnable installer/app artifact instead of zipping intermediate publish directories diff --git a/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs b/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs new file mode 100644 index 0000000..9b8e4a8 --- /dev/null +++ b/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs @@ -0,0 +1,6 @@ +namespace DotPilot.Core.Features.ToolchainCenter; + +public interface IToolchainCenterCatalog +{ + ToolchainCenterSnapshot GetSnapshot(); +} diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs new file mode 100644 index 0000000..7fb1fa2 --- /dev/null +++ b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs @@ -0,0 +1,64 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.ToolchainCenter; + +public sealed record ToolchainCenterWorkstreamDescriptor( + int IssueNumber, + string IssueLabel, + string Name, + string Summary); + +public sealed record ToolchainActionDescriptor( + string Title, + string Summary, + ToolchainActionKind Kind, + bool IsPrimary, + bool IsEnabled); + +public sealed record ToolchainDiagnosticDescriptor( + string Name, + ToolchainDiagnosticStatus Status, + string Summary); + +public sealed record ToolchainConfigurationEntry( + string Name, + string ValueDisplay, + string Summary, + ToolchainConfigurationKind Kind, + ToolchainConfigurationStatus Status, + bool IsSensitive); + +public sealed record ToolchainPollingDescriptor( + TimeSpan RefreshInterval, + DateTimeOffset LastRefreshAt, + DateTimeOffset NextRefreshAt, + ToolchainPollingStatus Status, + string Summary); + +public sealed record ToolchainProviderSnapshot( + int IssueNumber, + string IssueLabel, + ProviderDescriptor Provider, + string ExecutablePath, + string InstalledVersion, + ToolchainReadinessState ReadinessState, + string ReadinessSummary, + ToolchainVersionStatus VersionStatus, + string VersionSummary, + ToolchainAuthStatus AuthStatus, + string AuthSummary, + ToolchainHealthStatus HealthStatus, + string HealthSummary, + IReadOnlyList Actions, + IReadOnlyList Diagnostics, + IReadOnlyList Configuration, + ToolchainPollingDescriptor Polling); + +public sealed record ToolchainCenterSnapshot( + string EpicLabel, + string Summary, + IReadOnlyList Workstreams, + IReadOnlyList Providers, + ToolchainPollingDescriptor BackgroundPolling, + int ReadyProviderCount, + int AttentionRequiredProviderCount); diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs new file mode 100644 index 0000000..cf65cfa --- /dev/null +++ b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs @@ -0,0 +1,20 @@ +namespace DotPilot.Core.Features.ToolchainCenter; + +public static class ToolchainCenterIssues +{ + public const int ToolchainCenterEpic = 14; + public const int ToolchainCenterUi = 33; + public const int CodexReadiness = 34; + public const int ClaudeCodeReadiness = 35; + public const int GitHubCopilotReadiness = 36; + public const int ConnectionDiagnostics = 37; + public const int ProviderConfiguration = 38; + public const int BackgroundPolling = 39; + + private const string IssueLabelFormat = "ISSUE #{0}"; + private static readonly System.Text.CompositeFormat IssueLabelCompositeFormat = + System.Text.CompositeFormat.Parse(IssueLabelFormat); + + public static string FormatIssueLabel(int issueNumber) => + string.Format(System.Globalization.CultureInfo.InvariantCulture, IssueLabelCompositeFormat, issueNumber); +} diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs new file mode 100644 index 0000000..ff1b206 --- /dev/null +++ b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs @@ -0,0 +1,71 @@ +namespace DotPilot.Core.Features.ToolchainCenter; + +public enum ToolchainReadinessState +{ + Missing, + ActionRequired, + Limited, + Ready, +} + +public enum ToolchainVersionStatus +{ + Missing, + Unknown, + Detected, + UpdateAvailable, +} + +public enum ToolchainAuthStatus +{ + Missing, + Unknown, + Connected, +} + +public enum ToolchainHealthStatus +{ + Blocked, + Warning, + Healthy, +} + +public enum ToolchainDiagnosticStatus +{ + Blocked, + Failed, + Warning, + Ready, + Passed, +} + +public enum ToolchainConfigurationKind +{ + Secret, + EnvironmentVariable, + Setting, +} + +public enum ToolchainConfigurationStatus +{ + Missing, + Partial, + Configured, +} + +public enum ToolchainActionKind +{ + Install, + Connect, + Update, + TestConnection, + Troubleshoot, + OpenDocs, +} + +public enum ToolchainPollingStatus +{ + Idle, + Healthy, + Warning, +} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs index eb7adff..6395eb1 100644 --- a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs +++ b/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs @@ -1,5 +1,13 @@ namespace DotPilot.Core.Features.Workbench; +public static class WorkbenchSettingsCategoryKeys +{ + public const string Toolchains = "toolchains"; + public const string Providers = "providers"; + public const string Policies = "policies"; + public const string Storage = "storage"; +} + public sealed record WorkbenchSettingEntry( string Name, string Value, diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs index be3e213..dc9c3b7 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs @@ -8,6 +8,6 @@ internal static class ProviderToolchainNames public const string CodexCommandName = "codex"; public const string ClaudeCodeDisplayName = "Claude Code"; public const string ClaudeCodeCommandName = "claude"; - public const string GitHubCopilotDisplayName = "GitHub CLI / Copilot"; + public const string GitHubCopilotDisplayName = "GitHub Copilot"; public const string GitHubCopilotCommandName = "gh"; } diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs index 4f98536..7065d50 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs @@ -35,7 +35,7 @@ status is ProviderConnectionStatus.Available }; } - private static string? ResolveExecutablePath(string commandName) + internal static string? ResolveExecutablePath(string commandName) { if (OperatingSystem.IsBrowser()) { diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs index 9d5b6b3..7989231 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs @@ -1,5 +1,6 @@ using DotPilot.Core.Features.ControlPlaneDomain; using DotPilot.Core.Features.RuntimeFoundation; +using DotPilot.Runtime.Features.ToolchainCenter; namespace DotPilot.Runtime.Features.RuntimeFoundation; @@ -78,18 +79,8 @@ private static IReadOnlyList CreateProviders() StatusSummary = DeterministicClientStatusSummary, RequiresExternalToolchain = false, }, - ProviderToolchainProbe.Probe( - ProviderToolchainNames.CodexDisplayName, - ProviderToolchainNames.CodexCommandName, - true), - ProviderToolchainProbe.Probe( - ProviderToolchainNames.ClaudeCodeDisplayName, - ProviderToolchainNames.ClaudeCodeCommandName, - true), - ProviderToolchainProbe.Probe( - ProviderToolchainNames.GitHubCopilotDisplayName, - ProviderToolchainNames.GitHubCopilotCommandName, - true), + .. ToolchainProviderSnapshotFactory.Create(TimeProvider.System.GetUtcNow()) + .Select(snapshot => snapshot.Provider), ]; } } diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs new file mode 100644 index 0000000..33d7fc2 --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs @@ -0,0 +1,118 @@ +using DotPilot.Core.Features.ToolchainCenter; + +namespace DotPilot.Runtime.Features.ToolchainCenter; + +public sealed class ToolchainCenterCatalog : IToolchainCenterCatalog, IDisposable +{ + private const string EpicSummary = + "Issue #14 keeps provider installation, auth, diagnostics, configuration, and polling visible before the first live session."; + private const string UiWorkstreamName = "Toolchain Center UI"; + private const string UiWorkstreamSummary = + "The settings shell exposes a first-class desktop Toolchain Center with provider cards, detail panes, and operator actions."; + private const string DiagnosticsWorkstreamName = "Connection diagnostics"; + private const string DiagnosticsWorkstreamSummary = + "Launch, connection, resume, tool access, and auth diagnostics stay attributable before live work starts."; + private const string ConfigurationWorkstreamName = "Secrets and environment"; + private const string ConfigurationWorkstreamSummary = + "Provider secrets, local overrides, and non-secret environment configuration stay visible without leaking values."; + private const string PollingWorkstreamName = "Background polling"; + private const string PollingWorkstreamSummary = + "Version and auth readiness refresh in the background so the workbench can surface stale state early."; + private readonly TimeProvider _timeProvider; + private readonly CancellationTokenSource _disposeTokenSource = new(); + private readonly PeriodicTimer? _pollingTimer; + private readonly Task _pollingTask; + private ToolchainCenterSnapshot _snapshot; + + public ToolchainCenterCatalog() + : this(TimeProvider.System, startBackgroundPolling: true) + { + } + + public ToolchainCenterCatalog(TimeProvider timeProvider, bool startBackgroundPolling) + { + ArgumentNullException.ThrowIfNull(timeProvider); + + _timeProvider = timeProvider; + _snapshot = EvaluateSnapshot(); + if (startBackgroundPolling) + { + _pollingTimer = new PeriodicTimer(TimeSpan.FromMinutes(5), timeProvider); + _pollingTask = Task.Run(PollAsync); + } + else + { + _pollingTask = Task.CompletedTask; + } + } + + public ToolchainCenterSnapshot GetSnapshot() => _snapshot; + + public void Dispose() + { + _disposeTokenSource.Cancel(); + _pollingTimer?.Dispose(); + _disposeTokenSource.Dispose(); + } + + private async Task PollAsync() + { + if (_pollingTimer is null) + { + return; + } + + try + { + while (await _pollingTimer.WaitForNextTickAsync(_disposeTokenSource.Token)) + { + _snapshot = EvaluateSnapshot(); + } + } + catch (OperationCanceledException) + { + // Expected during app shutdown. + } + } + + private ToolchainCenterSnapshot EvaluateSnapshot() + { + var evaluatedAt = _timeProvider.GetUtcNow(); + var providers = ToolchainProviderSnapshotFactory.Create(evaluatedAt); + return new( + ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ToolchainCenterEpic), + EpicSummary, + CreateWorkstreams(), + providers, + ToolchainProviderSnapshotFactory.CreateBackgroundPolling(providers, evaluatedAt), + providers.Count(provider => provider.ReadinessState is ToolchainReadinessState.Ready), + providers.Count(provider => provider.ReadinessState is not ToolchainReadinessState.Ready)); + } + + private static IReadOnlyList CreateWorkstreams() + { + return + [ + new( + ToolchainCenterIssues.ToolchainCenterUi, + ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ToolchainCenterUi), + UiWorkstreamName, + UiWorkstreamSummary), + new( + ToolchainCenterIssues.ConnectionDiagnostics, + ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ConnectionDiagnostics), + DiagnosticsWorkstreamName, + DiagnosticsWorkstreamSummary), + new( + ToolchainCenterIssues.ProviderConfiguration, + ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ProviderConfiguration), + ConfigurationWorkstreamName, + ConfigurationWorkstreamSummary), + new( + ToolchainCenterIssues.BackgroundPolling, + ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.BackgroundPolling), + PollingWorkstreamName, + PollingWorkstreamSummary), + ]; + } +} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs new file mode 100644 index 0000000..9100f8a --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; + +namespace DotPilot.Runtime.Features.ToolchainCenter; + +internal static class ToolchainCommandProbe +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2); + private const string VersionSeparator = "version"; + + public static string? ResolveExecutablePath(string commandName) => + RuntimeFoundation.ProviderToolchainProbe.ResolveExecutablePath(commandName); + + public static string ReadVersion(string executablePath, IReadOnlyList arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + ArgumentNullException.ThrowIfNull(arguments); + + var execution = Execute(executablePath, arguments); + if (!execution.Succeeded) + { + return string.Empty; + } + + var output = string.IsNullOrWhiteSpace(execution.StandardOutput) + ? execution.StandardError + : execution.StandardOutput; + + var firstLine = output + .Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(firstLine)) + { + return string.Empty; + } + + var separatorIndex = firstLine.IndexOf(VersionSeparator, StringComparison.OrdinalIgnoreCase); + return separatorIndex >= 0 + ? firstLine[(separatorIndex + VersionSeparator.Length)..].Trim(' ', ':') + : firstLine.Trim(); + } + + public static bool CanExecute(string executablePath, IReadOnlyList arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + ArgumentNullException.ThrowIfNull(arguments); + + return Execute(executablePath, arguments).Succeeded; + } + + private static ToolchainCommandExecution Execute(string executablePath, IReadOnlyList arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = executablePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + if (process is null) + { + return ToolchainCommandExecution.Failed; + } + + if (!process.WaitForExit((int)CommandTimeout.TotalMilliseconds)) + { + TryTerminate(process); + return ToolchainCommandExecution.Failed; + } + + return new( + process.ExitCode == 0, + process.StandardOutput.ReadToEnd(), + process.StandardError.ReadToEnd()); + } + + private static void TryTerminate(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // Best-effort cleanup only. + } + } + + private readonly record struct ToolchainCommandExecution(bool Succeeded, string StandardOutput, string StandardError) + { + public static ToolchainCommandExecution Failed => new(false, string.Empty, string.Empty); + } +} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs new file mode 100644 index 0000000..87b81ac --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs @@ -0,0 +1,10 @@ +using DotPilot.Core.Features.ToolchainCenter; + +namespace DotPilot.Runtime.Features.ToolchainCenter; + +internal sealed record ToolchainConfigurationSignal( + string Name, + string Summary, + ToolchainConfigurationKind Kind, + bool IsSensitive, + bool IsRequiredForReadiness); diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs new file mode 100644 index 0000000..13e5ebb --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Text; +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Runtime.Features.ToolchainCenter; + +internal static class ToolchainDeterministicIdentity +{ + public static ProviderId CreateProviderId(string commandName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(commandName); + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandName)); + Span guidBytes = stackalloc byte[16]; + bytes.AsSpan(0, guidBytes.Length).CopyTo(guidBytes); + + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); + + return new ProviderId(new Guid(guidBytes)); + } +} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs new file mode 100644 index 0000000..61e01cb --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs @@ -0,0 +1,13 @@ +namespace DotPilot.Runtime.Features.ToolchainCenter; + +internal sealed record ToolchainProviderProfile( + int IssueNumber, + string DisplayName, + string CommandName, + IReadOnlyList VersionArguments, + IReadOnlyList ToolAccessArguments, + string ToolAccessDiagnosticName, + string ToolAccessReadySummary, + string ToolAccessBlockedSummary, + IReadOnlyList AuthenticationEnvironmentVariables, + IReadOnlyList ConfigurationSignals); diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs new file mode 100644 index 0000000..1c95bc3 --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs @@ -0,0 +1,73 @@ +using DotPilot.Core.Features.ToolchainCenter; +using DotPilot.Runtime.Features.RuntimeFoundation; + +namespace DotPilot.Runtime.Features.ToolchainCenter; + +internal static class ToolchainProviderProfiles +{ + private const string OpenAiApiKey = "OPENAI_API_KEY"; + private const string OpenAiBaseUrl = "OPENAI_BASE_URL"; + private const string AnthropicApiKey = "ANTHROPIC_API_KEY"; + private const string AnthropicBaseUrl = "ANTHROPIC_BASE_URL"; + private const string GitHubToken = "GITHUB_TOKEN"; + private const string GitHubHostToken = "GH_TOKEN"; + private const string GitHubModelsApiKey = "GITHUB_MODELS_API_KEY"; + private const string OpenAiApiKeySummary = "Required secret for Codex-ready non-interactive sessions."; + private const string OpenAiBaseUrlSummary = "Optional endpoint override for Codex-compatible deployments."; + private const string AnthropicApiKeySummary = "Required secret for Claude Code non-interactive sessions."; + private const string AnthropicBaseUrlSummary = "Optional endpoint override for Claude-compatible routing."; + private const string GitHubTokenSummary = "GitHub token for Copilot and GitHub CLI authenticated flows."; + private const string GitHubHostTokenSummary = "Alternative GitHub host token for CLI-authenticated Copilot flows."; + private const string GitHubModelsApiKeySummary = "Optional BYOK key for GitHub Models-backed Copilot routing."; + private static readonly string[] VersionArguments = ["--version"]; + + public static IReadOnlyList All { get; } = + [ + new( + ToolchainCenterIssues.CodexReadiness, + ProviderToolchainNames.CodexDisplayName, + ProviderToolchainNames.CodexCommandName, + VersionArguments, + ToolAccessArguments: [], + ToolAccessDiagnosticName: "Tool access", + ToolAccessReadySummary: "The Codex CLI command surface is reachable for session startup.", + ToolAccessBlockedSummary: "Install Codex CLI before tool access can be validated.", + AuthenticationEnvironmentVariables: [OpenAiApiKey], + ConfigurationSignals: + [ + new(OpenAiApiKey, OpenAiApiKeySummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), + new(OpenAiBaseUrl, OpenAiBaseUrlSummary, ToolchainConfigurationKind.EnvironmentVariable, IsSensitive: false, IsRequiredForReadiness: false), + ]), + new( + ToolchainCenterIssues.ClaudeCodeReadiness, + ProviderToolchainNames.ClaudeCodeDisplayName, + ProviderToolchainNames.ClaudeCodeCommandName, + VersionArguments, + ToolAccessArguments: [], + ToolAccessDiagnosticName: "MCP surface", + ToolAccessReadySummary: "Claude Code is installed and can expose its MCP-oriented CLI surface.", + ToolAccessBlockedSummary: "Install Claude Code before MCP-oriented checks can run.", + AuthenticationEnvironmentVariables: [AnthropicApiKey], + ConfigurationSignals: + [ + new(AnthropicApiKey, AnthropicApiKeySummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), + new(AnthropicBaseUrl, AnthropicBaseUrlSummary, ToolchainConfigurationKind.EnvironmentVariable, IsSensitive: false, IsRequiredForReadiness: false), + ]), + new( + ToolchainCenterIssues.GitHubCopilotReadiness, + ProviderToolchainNames.GitHubCopilotDisplayName, + ProviderToolchainNames.GitHubCopilotCommandName, + VersionArguments, + ToolAccessArguments: ["copilot", "--help"], + ToolAccessDiagnosticName: "Copilot command group", + ToolAccessReadySummary: "GitHub CLI exposes the Copilot command group for SDK-first adapter work.", + ToolAccessBlockedSummary: "GitHub CLI is present, but the Copilot command group is not available yet.", + AuthenticationEnvironmentVariables: [GitHubHostToken, GitHubToken], + ConfigurationSignals: + [ + new(GitHubHostToken, GitHubHostTokenSummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), + new(GitHubToken, GitHubTokenSummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), + new(GitHubModelsApiKey, GitHubModelsApiKeySummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: false), + ]), + ]; +} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs new file mode 100644 index 0000000..ad54d9b --- /dev/null +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs @@ -0,0 +1,374 @@ +using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.Features.ToolchainCenter; + +namespace DotPilot.Runtime.Features.ToolchainCenter; + +internal static class ToolchainProviderSnapshotFactory +{ + private static readonly TimeSpan BackgroundRefreshInterval = TimeSpan.FromMinutes(5); + private const string InstallActionTitleFormat = "Install {0}"; + private const string ConnectActionTitleFormat = "Connect {0}"; + private const string UpdateActionTitleFormat = "Update {0}"; + private const string TestActionTitleFormat = "Test {0}"; + private const string TroubleshootActionTitleFormat = "Troubleshoot {0}"; + private const string DocsActionTitleFormat = "Review {0} setup"; + private const string MissingExecutablePath = "Not detected"; + private const string MissingVersion = "Unavailable"; + private const string MissingVersionSummary = "Install the CLI before version checks can run."; + private const string UnknownVersionSummary = "The executable is present, but the version could not be confirmed automatically."; + private const string VersionSummaryFormat = "Detected version {0}."; + private const string AuthMissingSummary = "No non-interactive authentication signal was detected."; + private const string AuthConnectedSummary = "A non-interactive authentication signal is configured."; + private const string ReadinessMissingSummaryFormat = "{0} is not installed on PATH."; + private const string ReadinessAuthRequiredSummaryFormat = "{0} is installed, but authentication still needs operator attention."; + private const string ReadinessLimitedSummaryFormat = "{0} is installed, but one or more readiness prerequisites still need attention."; + private const string ReadinessReadySummaryFormat = "{0} is ready for pre-session operator checks."; + private const string HealthBlockedMissingSummaryFormat = "{0} launch is blocked until the CLI is installed."; + private const string HealthBlockedAuthSummaryFormat = "{0} launch is blocked until authentication is configured."; + private const string HealthWarningSummaryFormat = "{0} is installed, but diagnostics still show warnings."; + private const string HealthReadySummaryFormat = "{0} passed the available pre-session readiness checks."; + private const string LaunchDiagnosticName = "Launch"; + private const string VersionDiagnosticName = "Version"; + private const string AuthDiagnosticName = "Authentication"; + private const string ConnectionDiagnosticName = "Connection test"; + private const string ResumeDiagnosticName = "Resume test"; + private const string LaunchPassedSummary = "The executable is installed and launchable from PATH."; + private const string LaunchFailedSummary = "The executable is not available on PATH."; + private const string VersionFailedSummary = "The version could not be resolved automatically."; + private const string ConnectionReadySummary = "The provider is ready for a live connection test from the Toolchain Center."; + private const string ConnectionBlockedSummary = "Fix installation and authentication before running a live connection test."; + private const string ResumeReadySummary = "Resume diagnostics can run after the connection test succeeds."; + private const string ResumeBlockedSummary = "Resume diagnostics stay blocked until the connection test is ready."; + private const string BackgroundPollingSummaryFormat = "Background polling refreshes every {0} minutes to surface stale versions and broken auth state."; + private const string ProviderPollingHealthySummaryFormat = "Readiness was checked just now. The next background refresh runs in {0} minutes."; + private const string ProviderPollingWarningSummaryFormat = "Readiness needs attention. The next background refresh runs in {0} minutes."; + private static readonly System.Text.CompositeFormat InstallActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(InstallActionTitleFormat); + private static readonly System.Text.CompositeFormat ConnectActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(ConnectActionTitleFormat); + private static readonly System.Text.CompositeFormat UpdateActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(UpdateActionTitleFormat); + private static readonly System.Text.CompositeFormat TestActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(TestActionTitleFormat); + private static readonly System.Text.CompositeFormat TroubleshootActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(TroubleshootActionTitleFormat); + private static readonly System.Text.CompositeFormat DocsActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(DocsActionTitleFormat); + private static readonly System.Text.CompositeFormat VersionSummaryCompositeFormat = System.Text.CompositeFormat.Parse(VersionSummaryFormat); + private static readonly System.Text.CompositeFormat ReadinessMissingSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessMissingSummaryFormat); + private static readonly System.Text.CompositeFormat ReadinessAuthRequiredSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessAuthRequiredSummaryFormat); + private static readonly System.Text.CompositeFormat ReadinessLimitedSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessLimitedSummaryFormat); + private static readonly System.Text.CompositeFormat ReadinessReadySummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessReadySummaryFormat); + private static readonly System.Text.CompositeFormat HealthBlockedMissingSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthBlockedMissingSummaryFormat); + private static readonly System.Text.CompositeFormat HealthBlockedAuthSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthBlockedAuthSummaryFormat); + private static readonly System.Text.CompositeFormat HealthWarningSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthWarningSummaryFormat); + private static readonly System.Text.CompositeFormat HealthReadySummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthReadySummaryFormat); + private static readonly System.Text.CompositeFormat BackgroundPollingSummaryCompositeFormat = System.Text.CompositeFormat.Parse(BackgroundPollingSummaryFormat); + private static readonly System.Text.CompositeFormat ProviderPollingHealthySummaryCompositeFormat = System.Text.CompositeFormat.Parse(ProviderPollingHealthySummaryFormat); + private static readonly System.Text.CompositeFormat ProviderPollingWarningSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ProviderPollingWarningSummaryFormat); + + public static IReadOnlyList Create(DateTimeOffset evaluatedAt) + { + return ToolchainProviderProfiles.All + .Select(profile => Create(profile, evaluatedAt)) + .ToArray(); + } + + public static ToolchainPollingDescriptor CreateBackgroundPolling(IReadOnlyList providers, DateTimeOffset evaluatedAt) + { + ArgumentNullException.ThrowIfNull(providers); + + var status = providers.Any(provider => provider.ReadinessState is not ToolchainReadinessState.Ready) + ? ToolchainPollingStatus.Warning + : ToolchainPollingStatus.Healthy; + + return new( + BackgroundRefreshInterval, + evaluatedAt, + evaluatedAt.Add(BackgroundRefreshInterval), + status, + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + BackgroundPollingSummaryCompositeFormat, + BackgroundRefreshInterval.TotalMinutes)); + } + + private static ToolchainProviderSnapshot Create(ToolchainProviderProfile profile, DateTimeOffset evaluatedAt) + { + var executablePath = ToolchainCommandProbe.ResolveExecutablePath(profile.CommandName); + var isInstalled = !string.IsNullOrWhiteSpace(executablePath); + var installedVersion = isInstalled + ? ToolchainCommandProbe.ReadVersion(executablePath!, profile.VersionArguments) + : string.Empty; + var authConfigured = profile.AuthenticationEnvironmentVariables + .Select(Environment.GetEnvironmentVariable) + .Any(static value => !string.IsNullOrWhiteSpace(value)); + var toolAccessAvailable = isInstalled && ( + profile.ToolAccessArguments.Count == 0 || + ToolchainCommandProbe.CanExecute(executablePath!, profile.ToolAccessArguments)); + + var providerStatus = ResolveProviderStatus(isInstalled, authConfigured, toolAccessAvailable); + var readinessState = ResolveReadinessState(isInstalled, authConfigured, toolAccessAvailable, installedVersion); + var versionStatus = ResolveVersionStatus(isInstalled, installedVersion); + var authStatus = authConfigured ? ToolchainAuthStatus.Connected : ToolchainAuthStatus.Missing; + var healthStatus = ResolveHealthStatus(isInstalled, authConfigured, toolAccessAvailable, installedVersion); + var polling = CreateProviderPolling(evaluatedAt, readinessState); + + return new( + profile.IssueNumber, + ToolchainCenterIssues.FormatIssueLabel(profile.IssueNumber), + new ProviderDescriptor + { + Id = ToolchainDeterministicIdentity.CreateProviderId(profile.CommandName), + DisplayName = profile.DisplayName, + CommandName = profile.CommandName, + Status = providerStatus, + StatusSummary = ResolveReadinessSummary(profile.DisplayName, readinessState), + RequiresExternalToolchain = true, + }, + executablePath ?? MissingExecutablePath, + string.IsNullOrWhiteSpace(installedVersion) ? MissingVersion : installedVersion, + readinessState, + ResolveReadinessSummary(profile.DisplayName, readinessState), + versionStatus, + ResolveVersionSummary(versionStatus, installedVersion), + authStatus, + authConfigured ? AuthConnectedSummary : AuthMissingSummary, + healthStatus, + ResolveHealthSummary(profile.DisplayName, healthStatus, authConfigured), + CreateActions(profile, readinessState), + CreateDiagnostics(profile, isInstalled, authConfigured, installedVersion, toolAccessAvailable), + CreateConfiguration(profile), + polling); + } + + private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool authConfigured, bool toolAccessAvailable) + { + if (!isInstalled) + { + return ProviderConnectionStatus.Unavailable; + } + + if (!authConfigured) + { + return ProviderConnectionStatus.RequiresAuthentication; + } + + return toolAccessAvailable + ? ProviderConnectionStatus.Available + : ProviderConnectionStatus.Misconfigured; + } + + private static ToolchainReadinessState ResolveReadinessState( + bool isInstalled, + bool authConfigured, + bool toolAccessAvailable, + string installedVersion) + { + if (!isInstalled) + { + return ToolchainReadinessState.Missing; + } + + if (!authConfigured) + { + return ToolchainReadinessState.ActionRequired; + } + + if (!toolAccessAvailable || string.IsNullOrWhiteSpace(installedVersion)) + { + return ToolchainReadinessState.Limited; + } + + return ToolchainReadinessState.Ready; + } + + private static ToolchainVersionStatus ResolveVersionStatus(bool isInstalled, string installedVersion) + { + if (!isInstalled) + { + return ToolchainVersionStatus.Missing; + } + + return string.IsNullOrWhiteSpace(installedVersion) + ? ToolchainVersionStatus.Unknown + : ToolchainVersionStatus.Detected; + } + + private static ToolchainHealthStatus ResolveHealthStatus( + bool isInstalled, + bool authConfigured, + bool toolAccessAvailable, + string installedVersion) + { + if (!isInstalled || !authConfigured) + { + return ToolchainHealthStatus.Blocked; + } + + return toolAccessAvailable && !string.IsNullOrWhiteSpace(installedVersion) + ? ToolchainHealthStatus.Healthy + : ToolchainHealthStatus.Warning; + } + + private static string ResolveReadinessSummary(string displayName, ToolchainReadinessState readinessState) => + readinessState switch + { + ToolchainReadinessState.Missing => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessMissingSummaryCompositeFormat, displayName), + ToolchainReadinessState.ActionRequired => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessAuthRequiredSummaryCompositeFormat, displayName), + ToolchainReadinessState.Limited => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessLimitedSummaryCompositeFormat, displayName), + _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessReadySummaryCompositeFormat, displayName), + }; + + private static string ResolveVersionSummary(ToolchainVersionStatus versionStatus, string installedVersion) => + versionStatus switch + { + ToolchainVersionStatus.Missing => MissingVersionSummary, + ToolchainVersionStatus.Unknown => UnknownVersionSummary, + _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, VersionSummaryCompositeFormat, installedVersion), + }; + + private static string ResolveHealthSummary(string displayName, ToolchainHealthStatus healthStatus, bool authConfigured) => + healthStatus switch + { + ToolchainHealthStatus.Blocked when authConfigured => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedMissingSummaryCompositeFormat, displayName), + ToolchainHealthStatus.Blocked => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedAuthSummaryCompositeFormat, displayName), + ToolchainHealthStatus.Warning => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthWarningSummaryCompositeFormat, displayName), + _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthReadySummaryCompositeFormat, displayName), + }; + + private static ToolchainActionDescriptor[] CreateActions( + ToolchainProviderProfile profile, + ToolchainReadinessState readinessState) + { + var installEnabled = readinessState is ToolchainReadinessState.Missing; + var connectEnabled = readinessState is ToolchainReadinessState.ActionRequired or ToolchainReadinessState.Limited or ToolchainReadinessState.Ready; + var testEnabled = readinessState is ToolchainReadinessState.Limited or ToolchainReadinessState.Ready; + + return + [ + new( + FormatDisplayName(InstallActionTitleCompositeFormat, profile.DisplayName), + "Install the provider CLI before the first live session.", + ToolchainActionKind.Install, + IsPrimary: installEnabled, + IsEnabled: installEnabled), + new( + FormatDisplayName(ConnectActionTitleCompositeFormat, profile.DisplayName), + "Configure authentication so dotPilot can verify readiness before session start.", + ToolchainActionKind.Connect, + IsPrimary: readinessState is ToolchainReadinessState.ActionRequired, + IsEnabled: connectEnabled), + new( + FormatDisplayName(UpdateActionTitleCompositeFormat, profile.DisplayName), + "Recheck the installed version and apply provider updates outside the app when required.", + ToolchainActionKind.Update, + IsPrimary: false, + IsEnabled: connectEnabled), + new( + FormatDisplayName(TestActionTitleCompositeFormat, profile.DisplayName), + "Run the provider connection diagnostics before opening a live session.", + ToolchainActionKind.TestConnection, + IsPrimary: readinessState is ToolchainReadinessState.Ready, + IsEnabled: testEnabled), + new( + FormatDisplayName(TroubleshootActionTitleCompositeFormat, profile.DisplayName), + "Inspect prerequisites, broken auth, and blocked diagnostics without leaving the Toolchain Center.", + ToolchainActionKind.Troubleshoot, + IsPrimary: false, + IsEnabled: true), + new( + FormatDisplayName(DocsActionTitleCompositeFormat, profile.DisplayName), + "Review the provider setup guidance and operator runbook notes.", + ToolchainActionKind.OpenDocs, + IsPrimary: false, + IsEnabled: true), + ]; + } + + private static ToolchainDiagnosticDescriptor[] CreateDiagnostics( + ToolchainProviderProfile profile, + bool isInstalled, + bool authConfigured, + string installedVersion, + bool toolAccessAvailable) + { + var launchPassed = isInstalled; + var versionPassed = !string.IsNullOrWhiteSpace(installedVersion); + var connectionReady = launchPassed && authConfigured; + var resumeReady = connectionReady; + + return + [ + new(LaunchDiagnosticName, launchPassed ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Failed, launchPassed ? LaunchPassedSummary : LaunchFailedSummary), + new(VersionDiagnosticName, launchPassed ? (versionPassed ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Warning) : ToolchainDiagnosticStatus.Blocked, versionPassed ? ResolveVersionSummary(ToolchainVersionStatus.Detected, installedVersion) : VersionFailedSummary), + new(AuthDiagnosticName, launchPassed ? (authConfigured ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Warning) : ToolchainDiagnosticStatus.Blocked, authConfigured ? AuthConnectedSummary : AuthMissingSummary), + new(profile.ToolAccessDiagnosticName, launchPassed ? (toolAccessAvailable ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Warning) : ToolchainDiagnosticStatus.Blocked, toolAccessAvailable ? profile.ToolAccessReadySummary : profile.ToolAccessBlockedSummary), + new(ConnectionDiagnosticName, connectionReady ? ToolchainDiagnosticStatus.Ready : ToolchainDiagnosticStatus.Blocked, connectionReady ? ConnectionReadySummary : ConnectionBlockedSummary), + new(ResumeDiagnosticName, resumeReady ? ToolchainDiagnosticStatus.Ready : ToolchainDiagnosticStatus.Blocked, resumeReady ? ResumeReadySummary : ResumeBlockedSummary), + ]; + } + + private static ToolchainConfigurationEntry[] CreateConfiguration(ToolchainProviderProfile profile) + { + var resolvedPath = ToolchainCommandProbe.ResolveExecutablePath(profile.CommandName); + + return profile.ConfigurationSignals + .Select(signal => + { + var isConfigured = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(signal.Name)); + var valueDisplay = signal.IsSensitive + ? (isConfigured ? "Configured" : "Missing") + : (Environment.GetEnvironmentVariable(signal.Name) ?? "Not set"); + + return new ToolchainConfigurationEntry( + signal.Name, + valueDisplay, + signal.Summary, + signal.Kind, + ResolveConfigurationStatus(signal, isConfigured), + signal.IsSensitive); + }) + .Append( + new ToolchainConfigurationEntry( + $"{profile.CommandName} path", + resolvedPath ?? MissingExecutablePath, + "Resolved executable path for the provider CLI.", + ToolchainConfigurationKind.Setting, + resolvedPath is null + ? ToolchainConfigurationStatus.Missing + : ToolchainConfigurationStatus.Configured, + IsSensitive: false)) + .ToArray(); + } + + private static ToolchainConfigurationStatus ResolveConfigurationStatus(ToolchainConfigurationSignal signal, bool isConfigured) + { + if (isConfigured) + { + return ToolchainConfigurationStatus.Configured; + } + + return signal.IsRequiredForReadiness + ? ToolchainConfigurationStatus.Missing + : ToolchainConfigurationStatus.Partial; + } + + private static ToolchainPollingDescriptor CreateProviderPolling( + DateTimeOffset evaluatedAt, + ToolchainReadinessState readinessState) + { + var status = readinessState is ToolchainReadinessState.Ready + ? ToolchainPollingStatus.Healthy + : ToolchainPollingStatus.Warning; + + return new( + BackgroundRefreshInterval, + evaluatedAt, + evaluatedAt.Add(BackgroundRefreshInterval), + status, + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + readinessState is ToolchainReadinessState.Ready + ? ProviderPollingHealthySummaryCompositeFormat + : ProviderPollingWarningSummaryCompositeFormat, + BackgroundRefreshInterval.TotalMinutes)); + } + + private static string FormatDisplayName(System.Text.CompositeFormat compositeFormat, string displayName) => + string.Format(System.Globalization.CultureInfo.InvariantCulture, compositeFormat, displayName); +} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs index 41fbaa6..78c7246 100644 --- a/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs +++ b/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs @@ -15,9 +15,8 @@ internal static class WorkbenchSeedData private const string MonacoRendererLabel = "Monaco-aligned preview"; private const string ReadOnlyStatusSummary = "Read-only workspace reference"; private const string DiffReviewNote = "workbench review baseline"; - private const string ProviderCategoryKey = "providers"; - private const string PolicyCategoryKey = "policies"; - private const string StorageCategoryKey = "storage"; + private const string ToolchainCategoryTitle = "Toolchain Center"; + private const string ToolchainCategorySummary = "Install, connect, diagnose, and poll Codex, Claude Code, and GitHub Copilot."; private const string ProviderCategoryTitle = "Providers"; private const string PolicyCategoryTitle = "Policies"; private const string StorageCategoryTitle = "Storage"; @@ -195,7 +194,12 @@ private static IReadOnlyList CreateSettingsCategories return [ new( - ProviderCategoryKey, + WorkbenchSettingsCategoryKeys.Toolchains, + ToolchainCategoryTitle, + ToolchainCategorySummary, + []), + new( + WorkbenchSettingsCategoryKeys.Providers, ProviderCategoryTitle, ProviderCategorySummary, runtimeFoundationSnapshot.Providers @@ -207,7 +211,7 @@ private static IReadOnlyList CreateSettingsCategories IsActionable: provider.RequiresExternalToolchain)) .ToArray()), new( - PolicyCategoryKey, + WorkbenchSettingsCategoryKeys.Policies, PolicyCategoryTitle, PolicyCategorySummary, [ @@ -215,7 +219,7 @@ private static IReadOnlyList CreateSettingsCategories new(ReviewGateEntryName, ReviewGateEntryValue, "Agent proposals must stay reviewable before acceptance.", IsSensitive: false, IsActionable: true), ]), new( - StorageCategoryKey, + WorkbenchSettingsCategoryKeys.Storage, StorageCategoryTitle, StorageCategorySummary, [ diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs index 3e378cb..1beb6e2 100644 --- a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs +++ b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs @@ -16,9 +16,8 @@ internal sealed class WorkbenchWorkspaceSnapshotBuilder private const string StructuredRendererLabel = "Structured preview"; private const string ReadOnlyStatusSummary = "Read-only workspace reference"; private const string DiffReviewNote = "issue #13 runtime-backed review"; - private const string ProvidersCategoryKey = "providers"; - private const string PoliciesCategoryKey = "policies"; - private const string StorageCategoryKey = "storage"; + private const string ToolchainCategoryTitle = "Toolchain Center"; + private const string ToolchainCategorySummary = "Install, connect, diagnose, and poll Codex, Claude Code, and GitHub Copilot."; private const string ProvidersCategoryTitle = "Providers"; private const string PoliciesCategoryTitle = "Policies"; private const string StorageCategoryTitle = "Storage"; @@ -239,7 +238,12 @@ private IReadOnlyList CreateSettingsCategories() return [ new( - ProvidersCategoryKey, + WorkbenchSettingsCategoryKeys.Toolchains, + ToolchainCategoryTitle, + ToolchainCategorySummary, + []), + new( + WorkbenchSettingsCategoryKeys.Providers, ProvidersCategoryTitle, ProvidersCategorySummary, _runtimeFoundationSnapshot.Providers @@ -251,7 +255,7 @@ private IReadOnlyList CreateSettingsCategories() IsActionable: provider.RequiresExternalToolchain)) .ToArray()), new( - PoliciesCategoryKey, + WorkbenchSettingsCategoryKeys.Policies, PoliciesCategoryTitle, PoliciesCategorySummary, [ @@ -259,7 +263,7 @@ private IReadOnlyList CreateSettingsCategories() new(ReviewGateEntryName, ReviewGateEntryValue, "Agent proposals remain reviewable before acceptance.", IsSensitive: false, IsActionable: true), ]), new( - StorageCategoryKey, + WorkbenchSettingsCategoryKeys.Storage, StorageCategoryTitle, StorageCategorySummary, [ diff --git a/DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs new file mode 100644 index 0000000..d2bbf4d --- /dev/null +++ b/DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs @@ -0,0 +1,63 @@ +using System.Reflection; + +namespace DotPilot.Tests.Features.RuntimeFoundation; + +public class ProviderToolchainProbeTests +{ + private const string DotnetCommandName = "dotnet"; + + [Test] + public void ProbeReturnsAvailableWhenTheCommandExistsOnPath() + { + var descriptor = Probe("Dotnet CLI", DotnetCommandName, requiresExternalToolchain: true); + + descriptor.Status.Should().Be(ProviderConnectionStatus.Available); + descriptor.CommandName.Should().Be(DotnetCommandName); + descriptor.RequiresExternalToolchain.Should().BeTrue(); + descriptor.StatusSummary.Should().Be("Dotnet CLI is available on PATH."); + } + + [Test] + public void ProbeReturnsUnavailableWhenTheCommandDoesNotExistOnPath() + { + var missingCommandName = $"missing-{Guid.NewGuid():N}"; + + var descriptor = Probe("Missing CLI", missingCommandName, requiresExternalToolchain: true); + + descriptor.Status.Should().Be(ProviderConnectionStatus.Unavailable); + descriptor.CommandName.Should().Be(missingCommandName); + descriptor.StatusSummary.Should().Be("Missing CLI is not on PATH."); + } + + [Test] + public void ResolveExecutablePathFindsExistingExecutablesOnPath() + { + var executablePath = ResolveExecutablePath(DotnetCommandName); + + executablePath.Should().NotBeNullOrWhiteSpace(); + File.Exists(executablePath).Should().BeTrue(); + } + + private static ProviderDescriptor Probe(string displayName, string commandName, bool requiresExternalToolchain) + { + return (ProviderDescriptor)(InvokeProbeMethod("Probe", displayName, commandName, requiresExternalToolchain) + ?? throw new InvalidOperationException("ProviderToolchainProbe.Probe returned null.")); + } + + private static string? ResolveExecutablePath(string commandName) + { + return (string?)InvokeProbeMethod("ResolveExecutablePath", commandName); + } + + private static object? InvokeProbeMethod(string methodName, params object[] arguments) + { + var probeType = typeof(RuntimeFoundationCatalog).Assembly.GetType( + "DotPilot.Runtime.Features.RuntimeFoundation.ProviderToolchainProbe", + throwOnError: true)!; + var method = probeType.GetMethod( + methodName, + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + + return method.Invoke(null, arguments); + } +} diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs new file mode 100644 index 0000000..b31831e --- /dev/null +++ b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs @@ -0,0 +1,117 @@ +namespace DotPilot.Tests.Features.ToolchainCenter; + +public class ToolchainCenterCatalogTests +{ + [Test] + public void CatalogIncludesEpicIssueCoverageAndAllExternalProviders() + { + using var catalog = CreateCatalog(); + + var snapshot = catalog.GetSnapshot(); + var coveredIssues = snapshot.Workstreams + .Select(workstream => workstream.IssueNumber) + .Concat(snapshot.Providers.Select(provider => provider.IssueNumber)) + .Order() + .ToArray(); + + snapshot.EpicLabel.Should().Be(ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ToolchainCenterEpic)); + coveredIssues.Should().Equal( + ToolchainCenterIssues.ToolchainCenterUi, + ToolchainCenterIssues.CodexReadiness, + ToolchainCenterIssues.ClaudeCodeReadiness, + ToolchainCenterIssues.GitHubCopilotReadiness, + ToolchainCenterIssues.ConnectionDiagnostics, + ToolchainCenterIssues.ProviderConfiguration, + ToolchainCenterIssues.BackgroundPolling); + snapshot.Providers.Select(provider => provider.Provider.CommandName).Should().ContainInOrder("codex", "claude", "gh"); + } + + [Test] + public void CatalogSurfacesDiagnosticsConfigurationAndPollingForEachProvider() + { + using var catalog = CreateCatalog(); + + var snapshot = catalog.GetSnapshot(); + + snapshot.BackgroundPolling.RefreshInterval.Should().Be(TimeSpan.FromMinutes(5)); + snapshot.Providers.Should().OnlyContain(provider => + provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Launch") && + provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Connection test") && + provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Resume test") && + provider.Configuration.Any(entry => entry.Kind == ToolchainConfigurationKind.Secret) && + provider.Configuration.Any(entry => entry.Name == $"{provider.Provider.CommandName} path") && + provider.Polling.RefreshInterval == TimeSpan.FromMinutes(5)); + } + + [Test] + public void CatalogCanStartAndDisposeBackgroundPolling() + { + using var catalog = new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: true); + + var snapshot = catalog.GetSnapshot(); + + snapshot.BackgroundPolling.RefreshInterval.Should().Be(TimeSpan.FromMinutes(5)); + snapshot.Providers.Should().NotBeEmpty(); + } + + [Test] + [NonParallelizable] + public void CatalogMarksProvidersMissingWhenPathAndAuthenticationSignalsAreCleared() + { + using var path = new EnvironmentVariableScope("PATH", string.Empty); + using var openAi = new EnvironmentVariableScope("OPENAI_API_KEY", null); + using var anthropic = new EnvironmentVariableScope("ANTHROPIC_API_KEY", null); + using var githubToken = new EnvironmentVariableScope("GITHUB_TOKEN", null); + using var githubHostToken = new EnvironmentVariableScope("GH_TOKEN", null); + using var catalog = CreateCatalog(); + + var providers = catalog.GetSnapshot().Providers; + + providers.Should().OnlyContain(provider => + provider.ReadinessState == ToolchainReadinessState.Missing && + provider.Provider.Status == ProviderConnectionStatus.Unavailable && + provider.AuthStatus == ToolchainAuthStatus.Missing && + provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Launch" && diagnostic.Status == ToolchainDiagnosticStatus.Failed)); + } + + [TestCase("codex")] + [TestCase("claude")] + [TestCase("gh")] + public void AvailableProvidersExposeVersionAndConnectionReadinessWhenInstalled(string commandName) + { + using var catalog = CreateCatalog(); + var provider = catalog.GetSnapshot().Providers.Single(item => item.Provider.CommandName == commandName); + + Assume.That( + provider.Provider.Status, + Is.EqualTo(ProviderConnectionStatus.Available), + $"The '{commandName}' toolchain is not available in this environment."); + + provider.ExecutablePath.Should().NotBe("Not detected"); + provider.Diagnostics.Should().Contain(diagnostic => diagnostic.Name == "Launch" && diagnostic.Status == ToolchainDiagnosticStatus.Passed); + provider.VersionStatus.Should().NotBe(ToolchainVersionStatus.Missing); + } + + private static ToolchainCenterCatalog CreateCatalog() + { + return new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: false); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _variableName; + private readonly string? _originalValue; + + public EnvironmentVariableScope(string variableName, string? value) + { + _variableName = variableName; + _originalValue = Environment.GetEnvironmentVariable(variableName); + Environment.SetEnvironmentVariable(variableName, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_variableName, _originalValue); + } + } +} diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs new file mode 100644 index 0000000..105d6d8 --- /dev/null +++ b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs @@ -0,0 +1,113 @@ +using System.Reflection; + +namespace DotPilot.Tests.Features.ToolchainCenter; + +public class ToolchainCommandProbeTests +{ + [Test] + public void ReadVersionUsesStandardErrorWhenStandardOutputIsEmpty() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "echo Claude Code version: 2.3.4 1>&2" + : "printf 'Claude Code version: 2.3.4\\n' >&2"); + + var version = ReadVersion(executablePath, arguments); + + version.Should().Be("2.3.4"); + } + + [Test] + public void ReadVersionReturnsTheTrimmedFirstLineWhenNoVersionSeparatorExists() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "(echo v9.8.7) & (echo ignored)" + : "printf 'v9.8.7\\nignored\\n'"); + + var version = ReadVersion(executablePath, arguments); + + version.Should().Be("v9.8.7"); + } + + [Test] + public void ReadVersionReturnsEmptyWhenTheCommandFails() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "echo boom 1>&2 & exit /b 1" + : "printf 'boom\\n' >&2; exit 1"); + + var version = ReadVersion(executablePath, arguments); + + version.Should().BeEmpty(); + } + + [Test] + public void CanExecuteReturnsFalseWhenTheCommandFails() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "exit /b 1" + : "exit 1"); + + var canExecute = CanExecute(executablePath, arguments); + + canExecute.Should().BeFalse(); + } + + [Test] + public void CanExecuteReturnsTrueWhenTheCommandSucceeds() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "exit /b 0" + : "exit 0"); + + var canExecute = CanExecute(executablePath, arguments); + + canExecute.Should().BeTrue(); + } + + [Test] + public void ReadVersionReturnsEmptyWhenTheCommandTimesOut() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "ping 127.0.0.1 -n 4 >nul" + : "sleep 3"); + + var version = ReadVersion(executablePath, arguments); + + version.Should().BeEmpty(); + } + + private static string ReadVersion(string executablePath, IReadOnlyList arguments) + { + return (string)InvokeProbeMethod("ReadVersion", executablePath, arguments); + } + + private static bool CanExecute(string executablePath, IReadOnlyList arguments) + { + return (bool)InvokeProbeMethod("CanExecute", executablePath, arguments); + } + + private static object InvokeProbeMethod(string methodName, string executablePath, IReadOnlyList arguments) + { + var probeType = typeof(ToolchainCenterCatalog).Assembly.GetType( + "DotPilot.Runtime.Features.ToolchainCenter.ToolchainCommandProbe", + throwOnError: true)!; + var method = probeType.GetMethod( + methodName, + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + + return method.Invoke(null, [executablePath, arguments])!; + } + + private static (string ExecutablePath, string[] Arguments) CreateShellCommand(string command) + { + return OperatingSystem.IsWindows() + ? ("cmd.exe", ["/d", "/c", command]) + : ("/bin/sh", ["-c", command]); + } +} diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs new file mode 100644 index 0000000..7aad388 --- /dev/null +++ b/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs @@ -0,0 +1,132 @@ +using System.Reflection; + +namespace DotPilot.Tests.Features.ToolchainCenter; + +public class ToolchainProviderSnapshotFactoryTests +{ + [Test] + public void ResolveProviderStatusCoversUnavailableAuthenticationAndMisconfiguredBranches() + { + ResolveProviderStatus(isInstalled: false, authConfigured: false, toolAccessAvailable: false) + .Should().Be(ProviderConnectionStatus.Unavailable); + ResolveProviderStatus(isInstalled: true, authConfigured: false, toolAccessAvailable: false) + .Should().Be(ProviderConnectionStatus.RequiresAuthentication); + ResolveProviderStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: false) + .Should().Be(ProviderConnectionStatus.Misconfigured); + ResolveProviderStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: true) + .Should().Be(ProviderConnectionStatus.Available); + } + + [Test] + public void ResolveReadinessStateCoversMissingActionRequiredLimitedAndReady() + { + ResolveReadinessState(isInstalled: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) + .Should().Be(ToolchainReadinessState.Missing); + ResolveReadinessState(isInstalled: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") + .Should().Be(ToolchainReadinessState.ActionRequired); + ResolveReadinessState(isInstalled: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") + .Should().Be(ToolchainReadinessState.Limited); + ResolveReadinessState(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) + .Should().Be(ToolchainReadinessState.Limited); + ResolveReadinessState(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") + .Should().Be(ToolchainReadinessState.Ready); + } + + [Test] + public void ResolveHealthStatusCoversBlockedWarningAndHealthy() + { + ResolveHealthStatus(isInstalled: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) + .Should().Be(ToolchainHealthStatus.Blocked); + ResolveHealthStatus(isInstalled: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") + .Should().Be(ToolchainHealthStatus.Blocked); + ResolveHealthStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") + .Should().Be(ToolchainHealthStatus.Warning); + ResolveHealthStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) + .Should().Be(ToolchainHealthStatus.Warning); + ResolveHealthStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") + .Should().Be(ToolchainHealthStatus.Healthy); + } + + [Test] + public void ResolveConfigurationStatusDistinguishesRequiredAndOptionalSignals() + { + var requiredSignal = CreateSignal(name: "REQUIRED_TOKEN", isRequiredForReadiness: true); + var optionalSignal = CreateSignal(name: "OPTIONAL_ENDPOINT", isRequiredForReadiness: false); + + ResolveConfigurationStatus(requiredSignal, isConfigured: false) + .Should().Be(ToolchainConfigurationStatus.Missing); + ResolveConfigurationStatus(optionalSignal, isConfigured: false) + .Should().Be(ToolchainConfigurationStatus.Partial); + ResolveConfigurationStatus(optionalSignal, isConfigured: true) + .Should().Be(ToolchainConfigurationStatus.Configured); + } + + private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool authConfigured, bool toolAccessAvailable) + { + return (ProviderConnectionStatus)InvokeFactoryMethod( + "ResolveProviderStatus", + isInstalled, + authConfigured, + toolAccessAvailable)!; + } + + private static ToolchainReadinessState ResolveReadinessState( + bool isInstalled, + bool authConfigured, + bool toolAccessAvailable, + string installedVersion) + { + return (ToolchainReadinessState)InvokeFactoryMethod( + "ResolveReadinessState", + isInstalled, + authConfigured, + toolAccessAvailable, + installedVersion)!; + } + + private static ToolchainHealthStatus ResolveHealthStatus( + bool isInstalled, + bool authConfigured, + bool toolAccessAvailable, + string installedVersion) + { + return (ToolchainHealthStatus)InvokeFactoryMethod( + "ResolveHealthStatus", + isInstalled, + authConfigured, + toolAccessAvailable, + installedVersion)!; + } + + private static ToolchainConfigurationStatus ResolveConfigurationStatus(object signal, bool isConfigured) + { + return (ToolchainConfigurationStatus)InvokeFactoryMethod("ResolveConfigurationStatus", signal, isConfigured)!; + } + + private static object CreateSignal(string name, bool isRequiredForReadiness) + { + var signalType = typeof(ToolchainCenterCatalog).Assembly.GetType( + "DotPilot.Runtime.Features.ToolchainCenter.ToolchainConfigurationSignal", + throwOnError: true)!; + + return Activator.CreateInstance( + signalType, + name, + "summary", + ToolchainConfigurationKind.Secret, + true, + isRequiredForReadiness)!; + } + + private static object? InvokeFactoryMethod(string methodName, params object[] arguments) + { + var factoryType = typeof(ToolchainCenterCatalog).Assembly.GetType( + "DotPilot.Runtime.Features.ToolchainCenter.ToolchainProviderSnapshotFactory", + throwOnError: true)!; + var method = factoryType.GetMethod( + methodName, + BindingFlags.Static | BindingFlags.NonPublic)!; + + return method.Invoke(null, arguments); + } +} diff --git a/DotPilot.Tests/GlobalUsings.cs b/DotPilot.Tests/GlobalUsings.cs index bcca7d8..d3e404b 100644 --- a/DotPilot.Tests/GlobalUsings.cs +++ b/DotPilot.Tests/GlobalUsings.cs @@ -2,7 +2,9 @@ global using DotPilot.Core.Features.ControlPlaneDomain; global using DotPilot.Core.Features.RuntimeCommunication; global using DotPilot.Core.Features.RuntimeFoundation; +global using DotPilot.Core.Features.ToolchainCenter; global using DotPilot.Core.Features.Workbench; global using DotPilot.Runtime.Features.RuntimeFoundation; +global using DotPilot.Runtime.Features.ToolchainCenter; global using FluentAssertions; global using NUnit.Framework; diff --git a/DotPilot.Tests/PresentationViewModelTests.cs b/DotPilot.Tests/PresentationViewModelTests.cs index 761ee0f..e06e744 100644 --- a/DotPilot.Tests/PresentationViewModelTests.cs +++ b/DotPilot.Tests/PresentationViewModelTests.cs @@ -35,15 +35,20 @@ public void SettingsViewModelExposesUnifiedSettingsShellState() { using var workspace = TemporaryWorkbenchDirectory.Create(); var runtimeFoundationCatalog = CreateRuntimeFoundationCatalog(); + var toolchainCenterCatalog = CreateToolchainCenterCatalog(); var viewModel = new SettingsViewModel( new WorkbenchCatalog(runtimeFoundationCatalog, workspace.Root), - runtimeFoundationCatalog); + runtimeFoundationCatalog, + toolchainCenterCatalog); viewModel.SettingsIssueLabel.Should().Be(WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.SettingsShell)); - viewModel.Categories.Should().HaveCountGreaterOrEqualTo(3); - viewModel.SelectedCategoryTitle.Should().NotBeEmpty(); - viewModel.VisibleEntries.Should().NotBeEmpty(); - viewModel.ProviderSummary.Should().Contain("provider checks"); + viewModel.Categories.Should().HaveCountGreaterOrEqualTo(4); + viewModel.SelectedCategory?.Key.Should().Be(WorkbenchSettingsCategoryKeys.Toolchains); + viewModel.IsToolchainCenterVisible.Should().BeTrue(); + viewModel.ToolchainProviders.Should().HaveCount(3); + viewModel.SelectedToolchainProviderSnapshot.Should().NotBeNull(); + viewModel.ToolchainWorkstreams.Should().NotBeEmpty(); + viewModel.ProviderSummary.Should().Contain("ready"); } [Test] @@ -71,4 +76,9 @@ private static RuntimeFoundationCatalog CreateRuntimeFoundationCatalog() { return new RuntimeFoundationCatalog(); } + + private static ToolchainCenterCatalog CreateToolchainCenterCatalog() + { + return new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: false); + } } diff --git a/DotPilot.UITests/Given_MainPage.cs b/DotPilot.UITests/Given_MainPage.cs index 7c44ea5..3b26a5c 100644 --- a/DotPilot.UITests/Given_MainPage.cs +++ b/DotPilot.UITests/Given_MainPage.cs @@ -24,15 +24,28 @@ public class GivenMainPage : TestBase private const string RuntimeLogListAutomationId = "RuntimeLogList"; private const string RuntimeLogItemAutomationId = "RuntimeLogItem"; private const string WorkbenchNavButtonAutomationId = "WorkbenchNavButton"; - private const string SidebarWorkbenchButtonAutomationId = "SidebarWorkbenchButton"; - private const string SidebarAgentsButtonAutomationId = "SidebarAgentsButton"; + private const string AgentSidebarWorkbenchButtonAutomationId = "AgentSidebarWorkbenchButton"; + private const string SettingsSidebarWorkbenchButtonAutomationId = "SettingsSidebarWorkbenchButton"; + private const string WorkbenchSidebarAgentsButtonAutomationId = "WorkbenchSidebarAgentsButton"; + private const string SettingsSidebarAgentsButtonAutomationId = "SettingsSidebarAgentsButton"; private const string BackToWorkbenchButtonAutomationId = "BackToWorkbenchButton"; - private const string SidebarSettingsButtonAutomationId = "SidebarSettingsButton"; + private const string WorkbenchSidebarSettingsButtonAutomationId = "WorkbenchSidebarSettingsButton"; private const string SettingsCategoryListAutomationId = "SettingsCategoryList"; private const string SettingsEntriesListAutomationId = "SettingsEntriesList"; private const string SelectedSettingsCategoryTitleAutomationId = "SelectedSettingsCategoryTitle"; private const string StorageSettingsCategoryAutomationId = "SettingsCategory-storage"; - private const string SettingsPageRepositoryNodeAutomationId = "RepositoryNode-dotpilot-presentation-settingspage-xaml"; + private const string ToolchainSettingsCategoryAutomationId = "SettingsCategory-toolchains"; + private const string ToolchainCenterPanelAutomationId = "ToolchainCenterPanel"; + private const string ToolchainProviderListAutomationId = "ToolchainProviderList"; + private const string SelectedToolchainProviderTitleAutomationId = "SelectedToolchainProviderTitle"; + private const string ToolchainDiagnosticsListAutomationId = "ToolchainDiagnosticsList"; + private const string ToolchainConfigurationListAutomationId = "ToolchainConfigurationList"; + private const string ToolchainActionsListAutomationId = "ToolchainActionsList"; + private const string ToolchainBackgroundPollingAutomationId = "ToolchainBackgroundPolling"; + private const string ClaudeToolchainProviderAutomationId = "ToolchainProvider-claude"; + private const string SettingsPageSearchText = "SettingsPage"; + private const string SettingsPageDocumentTitle = "SettingsPage.xaml"; + private const string SettingsPageRepositoryNodeAutomationId = "RepositoryNodeTap-dotpilot-presentation-settingspage-xaml"; private const string RuntimeFoundationPanelAutomationId = "RuntimeFoundationPanel"; [Test] @@ -58,14 +71,7 @@ public async Task WhenFilteringTheRepositoryThenTheMatchingFileOpens() await Task.CompletedTask; EnsureOnWorkbenchScreen(); - App.ClearText(WorkbenchSearchInputAutomationId); - App.EnterText(WorkbenchSearchInputAutomationId, "SettingsPage"); - WaitForElement(SettingsPageRepositoryNodeAutomationId); - App.Tap(SettingsPageRepositoryNodeAutomationId); - WaitForElement(SelectedDocumentTitleAutomationId); - - var title = GetSingleTextContent(SelectedDocumentTitleAutomationId); - Assert.That(title, Is.EqualTo("SettingsPage.xaml")); + OpenSettingsPageDocumentFromRepositorySearch(); TakeScreenshot("repository_search_open_file"); } @@ -102,11 +108,13 @@ public async Task WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible() await Task.CompletedTask; EnsureOnWorkbenchScreen(); - TapAutomationElement(SidebarSettingsButtonAutomationId); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); WaitForElement(SettingsScreenAutomationId); WaitForElement(SettingsCategoryListAutomationId); + WaitForElement(ToolchainCenterPanelAutomationId); + WaitForElement(ToolchainProviderListAutomationId); + TapAutomationElement(StorageSettingsCategoryAutomationId); WaitForElement(SettingsEntriesListAutomationId); - App.Tap(StorageSettingsCategoryAutomationId); var categoryTitle = GetSingleTextContent(SelectedSettingsCategoryTitleAutomationId); Assert.That(categoryTitle, Is.EqualTo("Storage")); @@ -120,24 +128,81 @@ public async Task WhenNavigatingFromSettingsToAgentsThenAgentBuilderIsVisible() await Task.CompletedTask; EnsureOnWorkbenchScreen(); - TapAutomationElement(SidebarSettingsButtonAutomationId); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); WaitForElement(SettingsScreenAutomationId); - TapAutomationElement(SidebarAgentsButtonAutomationId); + WaitForElement(ToolchainCenterPanelAutomationId); + TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); WaitForElement(AgentBuilderScreenAutomationId); WaitForElement(BackToWorkbenchButtonAutomationId); TakeScreenshot("settings_to_agents_navigation"); } + [Test] + public async Task WhenReturningFromSettingsToWorkbenchThenWorkbenchScreenIsVisible() + { + await Task.CompletedTask; + + EnsureOnWorkbenchScreen(); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + TapAutomationElement(SettingsSidebarWorkbenchButtonAutomationId); + EnsureOnWorkbenchScreen(); + WaitForElement(RuntimeFoundationPanelAutomationId); + + TakeScreenshot("settings_to_workbench_navigation"); + } + + [Test] + public async Task WhenNavigatingToSettingsThenToolchainCenterProviderDetailsAreVisible() + { + await Task.CompletedTask; + + EnsureOnWorkbenchScreen(); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForElement(ToolchainSettingsCategoryAutomationId); + WaitForElement(ToolchainCenterPanelAutomationId); + WaitForElement(ToolchainProviderListAutomationId); + WaitForElement(SelectedToolchainProviderTitleAutomationId); + WaitForElement(ToolchainDiagnosticsListAutomationId); + WaitForElement(ToolchainConfigurationListAutomationId); + WaitForElement(ToolchainActionsListAutomationId); + WaitForElement(ToolchainBackgroundPollingAutomationId); + + TakeScreenshot("toolchain_center_visible"); + } + + [Test] + public async Task WhenSwitchingToolchainProvidersThenProviderSpecificDetailsAreVisible() + { + await Task.CompletedTask; + + EnsureOnWorkbenchScreen(); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForElement(ClaudeToolchainProviderAutomationId); + TapAutomationElement(ClaudeToolchainProviderAutomationId); + WaitForElement(SelectedToolchainProviderTitleAutomationId); + + var providerTitle = GetSingleTextContent(SelectedToolchainProviderTitleAutomationId); + Assert.That(providerTitle, Is.EqualTo("Claude Code")); + + WaitForElement(ToolchainDiagnosticsListAutomationId); + WaitForElement(ToolchainConfigurationListAutomationId); + WaitForElement(ToolchainActionsListAutomationId); + + TakeScreenshot("toolchain_center_claude_details"); + } + [Test] public async Task WhenNavigatingToSettingsAfterOpeningADocumentThenSettingsScreenIsVisible() { await Task.CompletedTask; EnsureOnWorkbenchScreen(); - App.Tap(SettingsPageRepositoryNodeAutomationId); - WaitForElement(SelectedDocumentTitleAutomationId); - TapAutomationElement(SidebarSettingsButtonAutomationId); + OpenSettingsPageDocumentFromRepositorySearch(); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); WaitForElement(SettingsScreenAutomationId); TakeScreenshot("document_to_settings_navigation"); @@ -149,9 +214,8 @@ public async Task WhenNavigatingToAgentsAfterOpeningADocumentThenAgentBuilderIsV await Task.CompletedTask; EnsureOnWorkbenchScreen(); - App.Tap(SettingsPageRepositoryNodeAutomationId); - WaitForElement(SelectedDocumentTitleAutomationId); - TapAutomationElement(SidebarAgentsButtonAutomationId); + OpenSettingsPageDocumentFromRepositorySearch(); + TapAutomationElement(WorkbenchSidebarAgentsButtonAutomationId); WaitForElement(AgentBuilderScreenAutomationId); TakeScreenshot("document_to_agents_navigation"); @@ -163,10 +227,10 @@ public async Task WhenNavigatingToSettingsAfterChangingWorkbenchModesThenSetting await Task.CompletedTask; EnsureOnWorkbenchScreen(); - App.Tap(SettingsPageRepositoryNodeAutomationId); + OpenSettingsPageDocumentFromRepositorySearch(); EnsureDiffReviewVisible(); EnsureRuntimeLogVisible(); - TapAutomationElement(SidebarSettingsButtonAutomationId); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); WaitForElement(SettingsScreenAutomationId); TakeScreenshot("workbench_modes_to_settings_navigation"); @@ -178,15 +242,15 @@ public async Task WhenRunningAWorkbenchRoundTripThenTheMainShellCanBeRestored() await Task.CompletedTask; EnsureOnWorkbenchScreen(); - App.Tap(SettingsPageRepositoryNodeAutomationId); + OpenSettingsPageDocumentFromRepositorySearch(); EnsureDiffReviewVisible(); EnsureRuntimeLogVisible(); - TapAutomationElement(SidebarSettingsButtonAutomationId); + TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); WaitForElement(SettingsScreenAutomationId); - App.Tap(StorageSettingsCategoryAutomationId); - TapAutomationElement(SidebarAgentsButtonAutomationId); + TapAutomationElement(StorageSettingsCategoryAutomationId); + TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); WaitForElement(AgentBuilderScreenAutomationId); - TapAutomationElement(BackToWorkbenchButtonAutomationId); + TapAutomationElement(AgentSidebarWorkbenchButtonAutomationId); EnsureOnWorkbenchScreen(); WaitForElement(RuntimeFoundationPanelAutomationId); @@ -200,14 +264,18 @@ private void EnsureOnWorkbenchScreen() return; } - if (TryWaitForElement(SidebarWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) + if (TryWaitForElement(AgentSidebarWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) { - TapAutomationElement(SidebarWorkbenchButtonAutomationId); + TapAutomationElement(AgentSidebarWorkbenchButtonAutomationId); } else if (TryWaitForElement(BackToWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) { TapAutomationElement(BackToWorkbenchButtonAutomationId); } + else if (TryWaitForElement(SettingsSidebarWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) + { + TapAutomationElement(SettingsSidebarWorkbenchButtonAutomationId); + } WaitForElement(WorkbenchScreenAutomationId, "Timed out returning to the workbench screen.", ScreenTransitionTimeout); WaitForElement(WorkbenchSearchInputAutomationId); @@ -260,6 +328,18 @@ private void EnsureRuntimeLogVisible() WaitForElement(RuntimeLogListAutomationId); } + private void OpenSettingsPageDocumentFromRepositorySearch() + { + App.ClearText(WorkbenchSearchInputAutomationId); + App.EnterText(WorkbenchSearchInputAutomationId, SettingsPageSearchText); + WaitForElement(SettingsPageRepositoryNodeAutomationId); + TapAutomationElement(SettingsPageRepositoryNodeAutomationId); + WaitForElement(SelectedDocumentTitleAutomationId); + + var title = GetSingleTextContent(SelectedDocumentTitleAutomationId); + Assert.That(title, Is.EqualTo(SettingsPageDocumentTitle)); + } + private void EnsureDiffReviewVisible() { if (TryWaitForElement(WorkbenchDiffLinesListAutomationId, ShortProbeTimeout)) @@ -318,7 +398,7 @@ private IAppResult[] WaitForElement(string automationId, string? timeoutMessage private void WriteTimeoutDiagnostics(string automationId) { WriteBrowserSystemLogs($"timeout:{automationId}"); - WriteBrowserDomSnapshot($"timeout:{automationId}"); + WriteBrowserDomSnapshot($"timeout:{automationId}", automationId); WriteSelectorDiagnostics(automationId); try @@ -340,9 +420,11 @@ private void WriteSelectorDiagnostics(string timedOutAutomationId) SettingsScreenAutomationId, AgentBuilderScreenAutomationId, WorkbenchNavButtonAutomationId, - SidebarWorkbenchButtonAutomationId, - SidebarAgentsButtonAutomationId, - SidebarSettingsButtonAutomationId, + AgentSidebarWorkbenchButtonAutomationId, + SettingsSidebarWorkbenchButtonAutomationId, + WorkbenchSidebarAgentsButtonAutomationId, + SettingsSidebarAgentsButtonAutomationId, + WorkbenchSidebarSettingsButtonAutomationId, WorkbenchSearchInputAutomationId, SelectedDocumentTitleAutomationId, RuntimeFoundationPanelAutomationId, diff --git a/DotPilot.UITests/TestBase.cs b/DotPilot.UITests/TestBase.cs index 8b01126..2832d34 100644 --- a/DotPilot.UITests/TestBase.cs +++ b/DotPilot.UITests/TestBase.cs @@ -207,7 +207,7 @@ protected void WriteBrowserSystemLogs(string context, int maxEntries = 50) } } - protected void WriteBrowserDomSnapshot(string context) + protected void WriteBrowserDomSnapshot(string context, string? automationId = null) { if (Constants.CurrentPlatform != Platform.Browser || _app is null) { @@ -258,12 +258,23 @@ static string Normalize(object? value) "return Array.from(document.querySelectorAll('[aria-label]')).slice(0, 25).map(e => e.getAttribute('aria-label')).join(' | ');")); var bodyText = Normalize(ExecuteScript("return document.body.innerText;")); var bodyHtml = Normalize(ExecuteScript("return document.body.innerHTML;")); - var settingsNavHitTest = Normalize(ExecuteScript( + var inspectedAutomationId = automationId ?? string.Empty; + var escapedAutomationId = inspectedAutomationId.Replace("'", "\\'", StringComparison.Ordinal); + var targetHitTest = Normalize(ExecuteScript(string.Concat( """ return (() => { - const target = document.querySelector('[xamlautomationid="SidebarSettingsButton"]'); + const automationId = ' + """, + escapedAutomationId, + """ + '; + if (!automationId) { + return 'no inspected automation id'; + } + + const target = document.querySelector(`[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`); if (!target) { - return 'missing SidebarSettingsButton'; + return `missing ${automationId}`; } const rect = target.getBoundingClientRect(); @@ -275,6 +286,7 @@ static string Normalize(object? value) targetTag: target.tagName, targetClass: target.className, targetId: target.getAttribute('xamlautomationid') ?? '', + targetAria: target.getAttribute('aria-label') ?? '', x, y, containsTop: top ? target.contains(top) : false, @@ -285,12 +297,12 @@ static string Normalize(object? value) topAria: top?.getAttribute('aria-label') ?? '' }); })(); - """)); + """))); HarnessLog.Write($"Browser DOM snapshot for '{context}': readyState='{readyState}', location='{location}', xamlautomationid-count='{automationCount}'."); HarnessLog.Write($"Browser DOM snapshot automation ids for '{context}': {automationIds}"); HarnessLog.Write($"Browser DOM snapshot aria-labels for '{context}': {ariaLabels}"); - HarnessLog.Write($"Browser DOM snapshot SidebarSettingsButton hit test for '{context}': {settingsNavHitTest}"); + HarnessLog.Write($"Browser DOM snapshot target hit test for '{context}' and automation id '{inspectedAutomationId}': {targetHitTest}"); HarnessLog.Write($"Browser DOM snapshot innerText for '{context}': {bodyText}"); HarnessLog.Write($"Browser DOM snapshot innerHTML for '{context}': {bodyHtml}"); } @@ -303,7 +315,110 @@ static string Normalize(object? value) protected void TapAutomationElement(string automationId) { ArgumentException.ThrowIfNullOrWhiteSpace(automationId); - App.Tap(automationId); + try + { + App.Tap(automationId); + } + catch (InvalidOperationException exception) + { + HarnessLog.Write($"Tap failed for '{automationId}': {exception.Message}"); + + try + { + var matches = App.Query(automationId); + HarnessLog.Write($"Tap selector '{automationId}' returned {matches.Length} matches."); + + for (var index = 0; index < matches.Length; index++) + { + var match = matches[index]; + HarnessLog.Write( + $"Tap selector '{automationId}' match[{index}] id='{match.Id}' text='{match.Text}' label='{match.Label}' rect='{match.Rect}'."); + } + } + catch (Exception diagnosticException) + { + HarnessLog.Write($"Tap selector diagnostics failed for '{automationId}': {diagnosticException.Message}"); + } + + WriteBrowserAutomationDiagnostics(automationId); + WriteBrowserDomSnapshot($"tap:{automationId}", automationId); + throw; + } + } + + private void WriteBrowserAutomationDiagnostics(string automationId) + { + if (Constants.CurrentPlatform != Platform.Browser || _app is null) + { + return; + } + + try + { + var driver = _app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app); + + if (driver is null) + { + HarnessLog.Write($"Browser automation diagnostics skipped for '{automationId}': Selenium driver field was not found."); + return; + } + + var executeScriptMethod = driver.GetType().GetMethod( + "ExecuteScript", + [typeof(string), typeof(object[])]); + + if (executeScriptMethod is null) + { + HarnessLog.Write($"Browser automation diagnostics skipped for '{automationId}': ExecuteScript was not found."); + return; + } + + var script = string.Concat( + """ + return (() => { + const automationId = + """, + "'", + automationId.Replace("'", "\\'"), + "'", + """ + ; + const byAutomation = Array.from(document.querySelectorAll(`[xamlautomationid="${automationId}"]`)) + .map((element, index) => ({ + index, + tag: element.tagName, + className: element.className, + ariaLabel: element.getAttribute('aria-label') ?? '', + xamlAutomationId: element.getAttribute('xamlautomationid') ?? '', + xamlType: element.getAttribute('xamltype') ?? '', + text: (element.innerText ?? '').trim(), + html: element.outerHTML.slice(0, 300) + })); + const byAria = Array.from(document.querySelectorAll(`[aria-label="${automationId}"]`)) + .map((element, index) => ({ + index, + tag: element.tagName, + className: element.className, + ariaLabel: element.getAttribute('aria-label') ?? '', + xamlAutomationId: element.getAttribute('xamlautomationid') ?? '', + xamlType: element.getAttribute('xamltype') ?? '', + text: (element.innerText ?? '').trim(), + html: element.outerHTML.slice(0, 300) + })); + return JSON.stringify({ byAutomation, byAria }); + })(); + """); + + var diagnostics = executeScriptMethod.Invoke(driver, [script, Array.Empty()]); + HarnessLog.Write($"Browser automation diagnostics for '{automationId}': {diagnostics}"); + } + catch (Exception exception) + { + HarnessLog.Write($"Browser automation diagnostics failed for '{automationId}': {exception.Message}"); + } } private static bool ResolveBrowserHeadless() diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index 6efc90e..23fe182 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -112,6 +112,10 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) .AddSingleton< DotPilot.Core.Features.RuntimeFoundation.IRuntimeFoundationCatalog, DotPilot.Runtime.Features.RuntimeFoundation.RuntimeFoundationCatalog>(services); + Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions + .AddSingleton< + DotPilot.Core.Features.ToolchainCenter.IToolchainCenterCatalog, + DotPilot.Runtime.Features.ToolchainCenter.ToolchainCenterCatalog>(services); Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions .AddTransient(services); Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions diff --git a/DotPilot/Presentation/Controls/AgentSidebar.xaml b/DotPilot/Presentation/Controls/AgentSidebar.xaml index 1cbde90..ed01d8a 100644 --- a/DotPilot/Presentation/Controls/AgentSidebar.xaml +++ b/DotPilot/Presentation/Controls/AgentSidebar.xaml @@ -44,8 +44,8 @@ Margin="12,20,12,0" Spacing="4"> - - - - - + + - + + + - - - - - + + + + + - - - - - + + + + + + diff --git a/DotPilot/Presentation/SettingsViewModel.cs b/DotPilot/Presentation/SettingsViewModel.cs index 5ae76ee..2afd493 100644 --- a/DotPilot/Presentation/SettingsViewModel.cs +++ b/DotPilot/Presentation/SettingsViewModel.cs @@ -1,24 +1,33 @@ +using DotPilot.Core.Features.ToolchainCenter; + namespace DotPilot.Presentation; public sealed class SettingsViewModel : ObservableObject { private const string PageTitleValue = "Unified settings shell"; private const string PageSubtitleValue = - "Providers, policies, and storage stay visible from one operator-oriented surface."; + "Toolchains, provider readiness, policies, and storage stay visible from one operator-oriented surface."; private const string DefaultCategoryTitle = "Select a settings category"; private const string DefaultCategorySummary = "Choose a category to inspect its current entries."; + private const string ToolchainProviderSummaryFormat = "{0} ready • {1} need attention"; + private static readonly System.Text.CompositeFormat ToolchainProviderSummaryCompositeFormat = + System.Text.CompositeFormat.Parse(ToolchainProviderSummaryFormat); private WorkbenchSettingsCategoryItem? _selectedCategory; + private ToolchainProviderItem? _selectedToolchainProvider; public SettingsViewModel( IWorkbenchCatalog workbenchCatalog, - IRuntimeFoundationCatalog runtimeFoundationCatalog) + IRuntimeFoundationCatalog runtimeFoundationCatalog, + IToolchainCenterCatalog toolchainCenterCatalog) { ArgumentNullException.ThrowIfNull(workbenchCatalog); ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); + ArgumentNullException.ThrowIfNull(toolchainCenterCatalog); Snapshot = workbenchCatalog.GetSnapshot(); RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); + ToolchainCenter = toolchainCenterCatalog.GetSnapshot(); Categories = Snapshot.SettingsCategories .Select(category => new WorkbenchSettingsCategoryItem( category.Key, @@ -27,7 +36,19 @@ public SettingsViewModel( PresentationAutomationIds.SettingsCategory(category.Key), category.Entries)) .ToArray(); - _selectedCategory = Categories.Count > 0 ? Categories[0] : null; + ToolchainProviders = ToolchainCenter.Providers + .Select(provider => new ToolchainProviderItem( + provider, + PresentationAutomationIds.ToolchainProvider(provider.Provider.CommandName))) + .ToArray(); + ToolchainWorkstreams = ToolchainCenter.Workstreams + .Select(workstream => new ToolchainWorkstreamItem( + workstream, + PresentationAutomationIds.ToolchainWorkstream(workstream.IssueNumber))) + .ToArray(); + _selectedCategory = Categories.FirstOrDefault(category => category.Key == WorkbenchSettingsCategoryKeys.Toolchains) ?? + (Categories.Count > 0 ? Categories[0] : null); + _selectedToolchainProvider = ToolchainProviders.Count > 0 ? ToolchainProviders[0] : null; SettingsIssueLabel = WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.SettingsShell); } @@ -35,6 +56,8 @@ public SettingsViewModel( public RuntimeFoundationSnapshot RuntimeFoundation { get; } + public ToolchainCenterSnapshot ToolchainCenter { get; } + public string SettingsIssueLabel { get; } public string PageTitle => PageTitleValue; @@ -43,6 +66,10 @@ public SettingsViewModel( public IReadOnlyList Categories { get; } + public IReadOnlyList ToolchainProviders { get; } + + public IReadOnlyList ToolchainWorkstreams { get; } + public WorkbenchSettingsCategoryItem? SelectedCategory { get => _selectedCategory; @@ -56,6 +83,22 @@ public WorkbenchSettingsCategoryItem? SelectedCategory RaisePropertyChanged(nameof(SelectedCategoryTitle)); RaisePropertyChanged(nameof(SelectedCategorySummary)); RaisePropertyChanged(nameof(VisibleEntries)); + RaisePropertyChanged(nameof(IsToolchainCenterVisible)); + RaisePropertyChanged(nameof(AreGenericSettingsVisible)); + } + } + + public ToolchainProviderItem? SelectedToolchainProvider + { + get => _selectedToolchainProvider; + set + { + if (!SetProperty(ref _selectedToolchainProvider, value)) + { + return; + } + + RaisePropertyChanged(nameof(SelectedToolchainProviderSnapshot)); } } @@ -63,7 +106,17 @@ public WorkbenchSettingsCategoryItem? SelectedCategory public string SelectedCategorySummary => SelectedCategory?.Summary ?? DefaultCategorySummary; + public bool IsToolchainCenterVisible => SelectedCategory?.Key == WorkbenchSettingsCategoryKeys.Toolchains; + + public bool AreGenericSettingsVisible => !IsToolchainCenterVisible; + public IReadOnlyList VisibleEntries => SelectedCategory?.Entries ?? []; - public string ProviderSummary => $"{RuntimeFoundation.Providers.Count} provider checks available"; + public ToolchainProviderSnapshot? SelectedToolchainProviderSnapshot => SelectedToolchainProvider?.Snapshot; + + public string ProviderSummary => string.Format( + System.Globalization.CultureInfo.InvariantCulture, + ToolchainProviderSummaryCompositeFormat, + ToolchainCenter.ReadyProviderCount, + ToolchainCenter.AttentionRequiredProviderCount); } diff --git a/DotPilot/Presentation/WorkbenchPresentationModels.cs b/DotPilot/Presentation/WorkbenchPresentationModels.cs index 0813ed6..a515e27 100644 --- a/DotPilot/Presentation/WorkbenchPresentationModels.cs +++ b/DotPilot/Presentation/WorkbenchPresentationModels.cs @@ -1,3 +1,5 @@ +using DotPilot.Core.Features.ToolchainCenter; + namespace DotPilot.Presentation; public sealed record WorkbenchRepositoryNodeItem( @@ -8,7 +10,8 @@ public sealed record WorkbenchRepositoryNodeItem( bool CanOpen, string KindGlyph, Thickness IndentMargin, - string AutomationId); + string AutomationId, + string TapAutomationId); public sealed partial record WorkbenchSettingsCategoryItem( string Key, @@ -16,3 +19,20 @@ public sealed partial record WorkbenchSettingsCategoryItem( string Summary, string AutomationId, IReadOnlyList Entries); + +public sealed record ToolchainProviderItem( + ToolchainProviderSnapshot Snapshot, + string AutomationId) +{ + public string DisplayName => Snapshot.Provider.DisplayName; + + public string IssueLabel => Snapshot.IssueLabel; + + public string ReadinessLabel => Snapshot.ReadinessState.ToString(); + + public string ReadinessSummary => Snapshot.ReadinessSummary; +} + +public sealed record ToolchainWorkstreamItem( + ToolchainCenterWorkstreamDescriptor Workstream, + string AutomationId); diff --git a/README.md b/README.md index 8cc10d9..1329d05 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ From the workbench, the operator should be able to: - read-only file inspection and diff-review surface - artifact dock and runtime log console - unified settings shell for providers, policies, and storage +- a Toolchain Center for `Codex`, `Claude Code`, and `GitHub Copilot` readiness +- provider diagnostics, environment and secret visibility, operator actions, and background polling summaries - dedicated agent-builder screen - deterministic runtime foundation panel for provider readiness and control-plane state - `NUnit` unit tests plus `Uno.UITest` browser UI coverage @@ -44,6 +46,7 @@ What already exists: - the first runtime foundation slices in `DotPilot.Core` and `DotPilot.Runtime` - the first operator workbench slice for repository browsing, document inspection, artifacts, logs, and settings +- the first Toolchain Center slice for pre-session provider readiness and operator diagnostics - a presentation-only `Uno Platform` app shell with separate non-UI class-library boundaries - unit, coverage, and UI automation validation paths - architecture docs, ADRs, feature specs, and GitHub backlog tracking @@ -84,8 +87,10 @@ Start here if you want the current source of truth: - [ADR-0003: Vertical Slices And UI-Only Uno App](docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - [Feature Spec: Agent Control Plane Experience](docs/Features/agent-control-plane-experience.md) - [Feature Spec: Workbench Foundation](docs/Features/workbench-foundation.md) +- [Feature Spec: Toolchain Center](docs/Features/toolchain-center.md) - [Task Plan: Vertical Slice Runtime Foundation](vertical-slice-runtime-foundation.plan.md) - [Task Plan: Workbench Foundation](issue-13-workbench-foundation.plan.md) +- [Task Plan: Toolchain Center](issue-14-toolchain-center.plan.md) - [Root Governance](AGENTS.md) GitHub tracking: @@ -109,6 +114,7 @@ GitHub tracking: ├── AGENTS.md # root governance for humans and agents ├── vertical-slice-runtime-foundation.plan.md ├── issue-13-workbench-foundation.plan.md +├── issue-14-toolchain-center.plan.md └── DotPilot.slnx # solution entry point ``` diff --git a/docs/Architecture.md b/docs/Architecture.md index 5332841..63a80de 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,6 +1,6 @@ # Architecture Overview -Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, the workbench foundation for epic `#13`, and the vertical-slice runtime foundation that starts epic `#12`. +Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, the workbench foundation for epic `#13`, the Toolchain Center for epic `#14`, and the vertical-slice runtime foundation that starts epic `#12`. This file is the required start-here architecture map for non-trivial tasks. @@ -9,11 +9,12 @@ This file is the required start-here architecture map for non-trivial tasks. - **System:** `DotPilot` is a `.NET 10` `Uno Platform` desktop-first application that is evolving from a static prototype into a local-first control plane for agent operations. - **Presentation boundary:** [../DotPilot/](../DotPilot/) is now the presentation host only. It owns XAML, routing, desktop startup, and UI composition, while non-UI feature logic moves into separate DLLs. - **Workbench boundary:** epic [#13](https://github.com/managedcode/dotPilot/issues/13) is landing as a `Workbench` slice that will provide repository navigation, file inspection, artifact and log inspection, and a unified settings shell without moving that behavior into page code-behind. +- **Toolchain Center boundary:** epic [#14](https://github.com/managedcode/dotPilot/issues/14) now lives as a `ToolchainCenter` slice. [../DotPilot.Core/Features/ToolchainCenter](../DotPilot.Core/Features/ToolchainCenter) defines the readiness, diagnostics, configuration, action, and polling contracts; [../DotPilot.Runtime/Features/ToolchainCenter](../DotPilot.Runtime/Features/ToolchainCenter) probes local provider CLIs for `Codex`, `Claude Code`, and `GitHub Copilot`; the Uno app surfaces the slice through the settings shell. - **Runtime foundation boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns issue-aligned contracts, typed identifiers, and public slice interfaces; [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider-independent runtime implementations such as the deterministic test client, toolchain probing, and future embedded-host integration points. - **Domain slice boundary:** issue [#22](https://github.com/managedcode/dotPilot/issues/22) now lives in `DotPilot.Core/Features/ControlPlaneDomain`, which defines the shared agent, session, fleet, provider, runtime, approval, artifact, telemetry, and evaluation model that later slices reuse. - **Communication slice boundary:** issue [#23](https://github.com/managedcode/dotPilot/issues/23) lives in `DotPilot.Core/Features/RuntimeCommunication`, which defines the shared `ManagedCode.Communication` result/problem language for runtime public boundaries. - **First implementation slice:** epic [#12](https://github.com/managedcode/dotPilot/issues/12) is represented locally through the `RuntimeFoundation` slice, which sequences issues `#22`, `#23`, `#24`, and `#25` behind a stable contract surface instead of mixing runtime work into the Uno app. -- **Automated verification:** [../DotPilot.Tests/](../DotPilot.Tests/) covers API-style and contract flows through the new DLL boundaries; [../DotPilot.UITests/](../DotPilot.UITests/) covers the visible workbench flow and the runtime-foundation UI surface. Provider-independent flows must pass in CI through the deterministic test client, while provider-specific checks can run only when the matching toolchain is available. +- **Automated verification:** [../DotPilot.Tests/](../DotPilot.Tests/) covers API-style and contract flows through the new DLL boundaries; [../DotPilot.UITests/](../DotPilot.UITests/) covers the visible workbench flow, Toolchain Center, and runtime-foundation UI surface. Provider-independent flows must pass in CI through deterministic or environment-agnostic checks, while provider-specific checks can run only when the matching toolchain is available. ## Scoping @@ -33,6 +34,7 @@ flowchart LR Adr1["ADR-0001 control-plane direction"] Adr3["ADR-0003 vertical slices + UI-only app"] Feature["agent-control-plane-experience.md"] + Toolchains["toolchain-center.md"] Plan["vertical-slice-runtime-foundation.plan.md"] Ui["DotPilot Uno UI host"] Core["DotPilot.Core contracts"] @@ -45,6 +47,7 @@ flowchart LR Root --> Adr1 Root --> Adr3 Root --> Feature + Root --> Toolchains Root --> Plan Root --> Ui Root --> Core @@ -86,6 +89,40 @@ flowchart TD RuntimeSlice --> UiSlice ``` +### Toolchain Center slice for epic #14 + +```mermaid +flowchart TD + Epic["#14 Provider toolchain center"] + UiIssue["#33 Toolchain Center UI"] + Codex["#34 Codex readiness"] + Claude["#35 Claude Code readiness"] + Copilot["#36 GitHub Copilot readiness"] + Diagnostics["#37 Connection diagnostics"] + Config["#38 Provider configuration"] + Polling["#39 Background polling"] + CoreSlice["DotPilot.Core/Features/ToolchainCenter"] + RuntimeSlice["DotPilot.Runtime/Features/ToolchainCenter"] + UiSlice["SettingsViewModel + ToolchainCenterPanel"] + + Epic --> UiIssue + Epic --> Codex + Epic --> Claude + Epic --> Copilot + Epic --> Diagnostics + Epic --> Config + Epic --> Polling + UiIssue --> CoreSlice + Codex --> CoreSlice + Claude --> CoreSlice + Copilot --> CoreSlice + Diagnostics --> CoreSlice + Config --> CoreSlice + Polling --> CoreSlice + CoreSlice --> RuntimeSlice + RuntimeSlice --> UiSlice +``` + ### Runtime foundation slice for epic #12 ```mermaid @@ -120,20 +157,25 @@ flowchart TD ```mermaid flowchart LR App["DotPilot/App.xaml.cs"] - Views["MainPage + SecondPage + RuntimeFoundationPanel"] - ViewModels["MainViewModel + SecondViewModel"] + Views["MainPage + SecondPage + SettingsShell + RuntimeFoundationPanel + ToolchainCenterPanel"] + ViewModels["MainViewModel + SecondViewModel + SettingsViewModel"] Catalog["RuntimeFoundationCatalog"] + Toolchains["ToolchainCenterCatalog"] TestClient["DeterministicAgentRuntimeClient"] Probe["ProviderToolchainProbe"] + ToolchainProbe["ToolchainCommandProbe + provider profiles"] Contracts["Typed IDs + contracts"] Future["Future Orleans + Agent Framework integrations"] App --> ViewModels Views --> ViewModels ViewModels --> Catalog + ViewModels --> Toolchains Catalog --> TestClient Catalog --> Probe Catalog --> Contracts + Toolchains --> ToolchainProbe + Toolchains --> Contracts Future --> Contracts Future --> Catalog ``` @@ -148,6 +190,7 @@ flowchart LR - `Vertical-slice solution decision` — [ADR-0003](./ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - `Feature spec` — [Agent Control Plane Experience](./Features/agent-control-plane-experience.md) - `Issue #13 feature doc` — [Workbench Foundation](./Features/workbench-foundation.md) +- `Issue #14 feature doc` — [Toolchain Center](./Features/toolchain-center.md) - `Issue #22 feature doc` — [Control Plane Domain Model](./Features/control-plane-domain-model.md) - `Issue #23 feature doc` — [Runtime Communication Contracts](./Features/runtime-communication-contracts.md) @@ -164,14 +207,20 @@ flowchart LR - `Application startup and composition` — [../DotPilot/App.xaml.cs](../DotPilot/App.xaml.cs) - `Chat workbench view model` — [../DotPilot/Presentation/MainViewModel.cs](../DotPilot/Presentation/MainViewModel.cs) +- `Settings view model` — [../DotPilot/Presentation/SettingsViewModel.cs](../DotPilot/Presentation/SettingsViewModel.cs) - `Agent builder view model` — [../DotPilot/Presentation/SecondViewModel.cs](../DotPilot/Presentation/SecondViewModel.cs) +- `Toolchain Center panel` — [../DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml](../DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml) - `Reusable runtime panel` — [../DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml](../DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml) +- `Toolchain Center contracts` — [../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs](../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs) +- `Toolchain Center issue catalog` — [../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs](../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs) - `Shell configuration contract` — [../DotPilot.Core/Features/ApplicationShell/AppConfig.cs](../DotPilot.Core/Features/ApplicationShell/AppConfig.cs) - `Runtime foundation contracts` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs) - `Runtime communication problems` — [../DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs](../DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs) - `Control-plane domain contracts` — [../DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs](../DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs) - `Provider and tool contracts` — [../DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs](../DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs) - `Runtime issue catalog` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs) +- `Toolchain Center catalog implementation` — [../DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs](../DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs) +- `Toolchain snapshot factory` — [../DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs](../DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs) - `Runtime catalog implementation` — [../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs](../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs) - `Deterministic test client` — [../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs](../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs) - `Provider toolchain probing` — [../DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs](../DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs) @@ -189,6 +238,7 @@ flowchart LR - The Uno app must remain a presentation-only host instead of becoming a dump for runtime logic. - Feature work should land as vertical slices with isolated contracts and implementations, not as shared horizontal folders. - Epic `#12` starts with contracts, sequencing, deterministic runtime coverage, and UI exposure before live Orleans or provider integration. +- Epic `#14` makes external-provider toolchain readiness explicit before session creation, so install, auth, diagnostics, and configuration state stays visible instead of being inferred later. - CI must stay meaningful without external provider CLIs by using the in-repo deterministic runtime client. - Real provider checks may run only when the corresponding toolchain is present and discoverable. diff --git a/docs/Features/toolchain-center.md b/docs/Features/toolchain-center.md new file mode 100644 index 0000000..6b74999 --- /dev/null +++ b/docs/Features/toolchain-center.md @@ -0,0 +1,65 @@ +# Toolchain Center + +## Summary + +Epic [#14](https://github.com/managedcode/dotPilot/issues/14) adds a first-class Toolchain Center for `Codex`, `Claude Code`, and `GitHub Copilot`. The slice gives the operator one desktop surface to inspect installation state, version visibility, authentication readiness, connection diagnostics, provider configuration, and background polling before a live session starts. + +## Scope + +### In Scope + +- Toolchain Center shell and detail surface for issue [#33](https://github.com/managedcode/dotPilot/issues/33) +- `Codex` readiness, version, auth, and operator actions for issue [#34](https://github.com/managedcode/dotPilot/issues/34) +- `Claude Code` readiness, version, auth, and operator actions for issue [#35](https://github.com/managedcode/dotPilot/issues/35) +- `GitHub Copilot` readiness, CLI or SDK prerequisite visibility, and operator actions for issue [#36](https://github.com/managedcode/dotPilot/issues/36) +- Connection-test and health-diagnostic modeling for issue [#37](https://github.com/managedcode/dotPilot/issues/37) +- Secrets and environment configuration modeling for issue [#38](https://github.com/managedcode/dotPilot/issues/38) +- Background polling summaries and stale-state surfacing for issue [#39](https://github.com/managedcode/dotPilot/issues/39) + +### Out Of Scope + +- live provider session execution +- remote version feeds or package-manager-driven auto-update workflows +- secure secret storage beyond current environment and local configuration visibility +- local model runtimes outside the external provider toolchain path + +## Flow + +```mermaid +flowchart LR + Settings["Settings shell"] + Center["Toolchain Center"] + Providers["Codex / Claude Code / GitHub Copilot"] + Diagnostics["Launch + auth + tool access + connection + resume diagnostics"] + Config["Secrets + environment + resolved CLI path"] + Polling["Background polling summary"] + Operator["Operator action list"] + + Settings --> Center + Center --> Providers + Providers --> Diagnostics + Providers --> Config + Providers --> Polling + Providers --> Operator +``` + +## Contract Notes + +- `DotPilot.Core/Features/ToolchainCenter` owns the provider-agnostic contracts for readiness state, version status, auth state, health state, diagnostics, configuration entries, actions, workstreams, and polling summaries. +- `DotPilot.Runtime/Features/ToolchainCenter` owns provider profile definitions and side-effect-bounded CLI probing. The slice reads local executable metadata and environment signals only; it does not start real provider sessions. +- The Toolchain Center is the default settings category so provider readiness is visible without extra drilling after the operator enters settings. +- Provider configuration must stay visible without leaking secrets. Secret entries show status only, while non-secret entries can show the current resolved value. +- Background polling is represented as operator-facing state, not a hidden implementation detail. The UI must tell the operator when readiness was checked and when the next refresh will run. +- Missing or incomplete provider readiness is a surfaced state, not a fallback path. The app keeps blocked, warning, and action-required states explicit. + +## Verification + +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` +- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` +- `dotnet test DotPilot.slnx` + +## Dependencies + +- Parent epic: [#14](https://github.com/managedcode/dotPilot/issues/14) +- Child issues: [#33](https://github.com/managedcode/dotPilot/issues/33), [#34](https://github.com/managedcode/dotPilot/issues/34), [#35](https://github.com/managedcode/dotPilot/issues/35), [#36](https://github.com/managedcode/dotPilot/issues/36), [#37](https://github.com/managedcode/dotPilot/issues/37), [#38](https://github.com/managedcode/dotPilot/issues/38), [#39](https://github.com/managedcode/dotPilot/issues/39) diff --git a/issue-14-toolchain-center.plan.md b/issue-14-toolchain-center.plan.md new file mode 100644 index 0000000..d9d58e4 --- /dev/null +++ b/issue-14-toolchain-center.plan.md @@ -0,0 +1,144 @@ +# Issue 14 Toolchain Center Plan + +## Goal + +Implement epic `#14` in one coherent vertical slice so `dotPilot` gains a first-class Toolchain Center for `Codex`, `Claude Code`, and `GitHub Copilot`, while keeping the `Uno` app presentation-focused and all non-UI logic in separate DLLs. + +## Scope + +### In scope + +- Issue `#33`: Toolchain Center UI +- Issue `#34`: Codex detection, version, auth, update, and operator actions +- Issue `#35`: Claude Code detection, version, auth, update, and operator actions +- Issue `#36`: GitHub Copilot readiness, CLI or server visibility, SDK prerequisite visibility, and operator actions +- Issue `#37`: provider connection-test and health-diagnostics model +- Issue `#38`: provider secrets and environment configuration management model and UI +- Issue `#39`: background polling model and surfaced stale-state warnings +- Core contracts in `DotPilot.Core` +- Runtime probing, diagnostics, polling, and configuration composition in `DotPilot.Runtime` +- Desktop-first `Uno` presentation and navigation in `DotPilot` +- Automated coverage in `DotPilot.Tests` and `DotPilot.UITests` +- Architecture and feature documentation updates required by the new slice + +### Out of scope + +- Epic `#15` provider adapter issues `#40`, `#41`, `#42` +- Real live session execution for external providers +- New external package dependencies unless explicitly approved +- Non-provider local runtime setup outside Toolchain Center scope + +## Constraints And Risks + +- Keep the `Uno` app cleanly UI-only; non-UI toolchain behavior must live in `DotPilot.Core` and `DotPilot.Runtime`. +- Do not add new NuGet dependencies without explicit user approval. +- Do not hide readiness problems behind fallback behavior; missing, stale, or broken provider state must remain visible and attributable. +- Provider-specific tests that require real `Codex`, `Claude Code`, or `GitHub Copilot` toolchains must be environment-gated, while provider-independent coverage must still stay green in CI. +- UI tests must continue to run through `dotnet test DotPilot.UITests/DotPilot.UITests.csproj`; no manual app launch path is allowed for UI verification. +- Local and CI validation must use `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false`. +- Existing workbench navigation issues may already be present in the UI baseline and must be tracked explicitly if reproduced in this clean worktree. + +## Testing Methodology + +- Unit and integration-style tests in `DotPilot.Tests` will verify: + - Toolchain Center contracts and snapshot shape + - provider readiness probes for success, missing toolchain, partial readiness, and stale-state warnings + - provider diagnostics and secrets or environment modeling + - background polling metadata and surfaced warning summaries +- UI tests in `DotPilot.UITests` will verify: + - navigation into the Toolchain Center + - provider summary visibility + - provider detail visibility for each supported provider + - secrets and environment sections + - diagnostics and polling-state visibility + - at least one end-to-end operator flow through settings to Toolchain Center and back to the broader shell +- Real-toolchain tests will run only when the corresponding executable and auth prerequisites are available. +- The task is not complete until the changed tests, related suites, broader solution verification, and coverage run are all green or any pre-existing blockers are documented with root cause and explicit non-regression evidence. + +## Ordered Plan + +- [x] Step 1. Capture the clean-worktree baseline. + - Run the mandatory build and relevant test suites before code changes. + - Update the failing-test tracker below with every reproduced baseline failure. +- [x] Step 2. Define the Toolchain Center slice contracts in `DotPilot.Core`. + - Add explicit provider readiness, version, auth, diagnostics, secrets, environment, action, and polling models. + - Keep contracts provider-agnostic where possible and provider-specific only where required by the epic. +- [x] Step 3. Implement runtime probing and composition in `DotPilot.Runtime`. + - Build provider-specific readiness snapshots for `Codex`, `Claude Code`, and `GitHub Copilot`. + - Add operator-action models, diagnostics summaries, secrets or environment metadata, and polling-state summaries. + - Keep probing side-effect free except for tightly bounded metadata or command checks. +- [x] Step 4. Integrate the Toolchain Center into the desktop settings surface in `DotPilot`. + - Add a first-class Toolchain Center entry and detail surface. + - Keep the layout desktop-first, fast to scan, and aligned with the current shell. + - Surface errors and warnings directly instead of masking them. +- [x] Step 5. Add or update automated tests in parallel with the production slice work. + - Start with failing regression or feature tests where new behavior is introduced. + - Cover provider-independent flows broadly and gated real-provider flows conditionally. +- [x] Step 6. Update durable docs. + - Update `docs/Architecture.md` with the new slice and diagrams. + - Add or update a feature doc in `docs/Features/` for Toolchain Center behavior and verification. + - Correct any stale root guidance discovered during the task, including `LangVersion` wording if still inconsistent with source. +- [x] Step 7. Run final validation and prepare the PR. + - Run format, build, focused tests, broader tests, UI tests, and coverage. + - Create a PR that uses GitHub closing references for the implemented issues. + +## Full-Test Baseline Step + +- [x] Run `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` +- [x] Run `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` +- [x] Run `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` + +## Already Failing Tests Tracker + +- [x] `GivenMainPage.WhenFilteringTheRepositoryThenTheMatchingFileOpens` + - Failure symptom: `Uno.UITest` target selection was unstable when the repository list used DOM-expanded item content instead of one stable tappable target. + - Root-cause notes: the sidebar repository flow mixed a `ListView` selection surface with a nested text-only automation target, which made follow-up navigation flows brittle after document-open actions. + - Resolution: the tests now open the document through one canonical search-and-open helper, assert the opened title explicitly, and the repository list remains unique under `Uno` automation mapping. +- [x] `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` run completion + - Failure symptom: the suite previously stalled around the first failing workbench navigation flow and left the browser harness in an unclear state. + - Root-cause notes: multiple broken navigation paths and stale diagnostics made the harness look hung even though the real issue was route resolution and ambiguous navigation controls. + - Resolution: page-specific sidebar automation ids, route fixes for `-/Main`, and improved DOM or hit-test diagnostics now leave the suite green and terminating normally. + +## Final Results + +- `dotnet format DotPilot.slnx --verify-no-changes` +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` +- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` +- `dotnet test DotPilot.slnx` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` + +Final green baselines after the slice landed: + +- `DotPilot.Tests`: `52` passed +- `DotPilot.UITests`: `22` passed +- Coverage collector overall: `91.58%` line / `61.33%` branch +- Key changed runtime files: + - `ToolchainCenterCatalog`: `95.00%` line / `100.00%` branch + - `ToolchainCommandProbe`: `89.23%` line / `87.50%` branch + - `ToolchainProviderSnapshotFactory`: `98.05%` line / `78.43%` branch + - `ProviderToolchainProbe`: `95.12%` line / `85.71%` branch + +## Final Validation Skills + +- `mcaf-dotnet` + - Reason: enforce repo-specific `.NET` commands, analyzer policy, language-version compatibility, and final validation order. +- `mcaf-testing` + - Reason: keep test layering explicit and prove user-visible flows instead of only internal wiring. +- `mcaf-architecture-overview` + - Reason: update the cross-project architecture map and diagrams after the new slice boundaries are introduced. + +## Final Validation Commands + +1. `dotnet format DotPilot.slnx --verify-no-changes` + - Reason: repo-required formatting and analyzer drift check. +2. `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` + - Reason: mandatory warning-free build for local and CI parity. +3. `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` + - Reason: unit and integration-style validation for the non-UI slice. +4. `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` + - Reason: mandatory end-to-end UI verification through the real harness. +5. `dotnet test DotPilot.slnx` + - Reason: broader solution regression pass across all test projects. +6. `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` + - Reason: prove coverage expectations for the changed production code.