diff --git a/AGENTS.md b/AGENTS.md index e258b64..e0ac8aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,9 @@ For this app: - `DotPilot/DotPilot.csproj` keeps `GenerateDocumentationFile=true` with `CS1591` suppressed so `IDE0005` stays enforceable in CI across all target frameworks without inventing command-line-only build flags - architecture work must keep a vertical-slice shape: each feature owns its contracts, orchestration, and tests behind clear boundaries instead of growing a shared horizontal service layer - 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 is the backlog, not the product: use issues and PRs only to drive task scope and traceability, and never copy GitHub issue text, labels, workflow language, or tracker metadata into production code, runtime snapshots, or user-facing UI +- Desktop responsiveness is a product requirement: avoid synchronous probe, filesystem, network, or process work on UI-facing construction and navigation paths so the app stays fast and immediately reactive +- Do not invent a repo-specific product framing such as "workbench" unless the active issue or feature spec explicitly uses it; implement the app features described in the backlog instead of turning internal implementation language into the product narrative - 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 @@ -302,6 +305,7 @@ Local `AGENTS.md` files may tighten these values, but they must not loosen them - Hardcoded values are forbidden. - String literals are forbidden in implementation code. Declare them once as named constants, enums, configuration entries, or dedicated value objects, then reuse those symbols. - Avoid magic literals. Extract shared values into constants, enums, configuration, or dedicated types. +- Backlog metadata does not belong in product code: issue numbers, PR numbers, review language, and planning terminology must never appear in production runtime models, diagnostics, or user-facing text unless the feature explicitly exposes source-control metadata. - Design boundaries so real behaviour can be tested through public interfaces. - For `.NET`, the repo-root `.editorconfig` is the source of truth for formatting, naming, style, and analyzer severity. - Use nested `.editorconfig` files when they serve a clear subtree-specific purpose. Do not let IDE defaults, pipeline flags, and repo config disagree. diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs index 7989231..1055a91 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs @@ -1,38 +1,44 @@ using DotPilot.Core.Features.ControlPlaneDomain; using DotPilot.Core.Features.RuntimeFoundation; -using DotPilot.Runtime.Features.ToolchainCenter; - namespace DotPilot.Runtime.Features.RuntimeFoundation; public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog { private const string EpicSummary = - "Issue #12 is staged into isolated contracts, communication, host, and orchestration slices so the Uno workbench can stay presentation-only."; + "Runtime contracts, host sequencing, and orchestration seams stay isolated so the Uno app can remain presentation-only."; + private const string EpicLabelValue = "LOCAL RUNTIME READINESS"; 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 DomainModelLabel = "DOMAIN"; 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."; + private const string CommunicationLabel = "CONTRACTS"; private const string CommunicationName = "Communication contracts"; private const string CommunicationSummary = "Public result and problem boundaries are isolated so later provider and orchestration slices share one contract language."; + private const string HostLabel = "HOST"; private const string HostName = "Embedded host"; private const string HostSummary = "The Orleans host integration point is sequenced behind dedicated runtime contracts instead of being baked into page code."; + private const string OrchestrationLabel = "ORCHESTRATION"; private const string OrchestrationName = "Orchestration runtime"; 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 IReadOnlyList _providers; + + public RuntimeFoundationCatalog() => _providers = CreateProviders(); public RuntimeFoundationSnapshot GetSnapshot() { return new( - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.EmbeddedAgentRuntimeHostEpic), + EpicLabelValue, EpicSummary, ProviderToolchainNames.DeterministicClientDisplayName, DeterministicProbePrompt, CreateSlices(), - CreateProviders()); + _providers); } private static IReadOnlyList CreateSlices() @@ -41,25 +47,25 @@ private static IReadOnlyList CreateSlices() [ new( RuntimeFoundationIssues.DomainModel, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.DomainModel), + DomainModelLabel, DomainModelName, DomainModelSummary, RuntimeSliceState.ReadyForImplementation), new( RuntimeFoundationIssues.CommunicationContracts, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.CommunicationContracts), + CommunicationLabel, CommunicationName, CommunicationSummary, RuntimeSliceState.Sequenced), new( RuntimeFoundationIssues.EmbeddedOrleansHost, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.EmbeddedOrleansHost), + HostLabel, HostName, HostSummary, RuntimeSliceState.Sequenced), new( RuntimeFoundationIssues.AgentFrameworkRuntime, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.AgentFrameworkRuntime), + OrchestrationLabel, OrchestrationName, OrchestrationSummary, RuntimeSliceState.Sequenced), @@ -79,8 +85,6 @@ private static IReadOnlyList CreateProviders() StatusSummary = DeterministicClientStatusSummary, RequiresExternalToolchain = false, }, - .. 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 index 33d7fc2..bf2d6fd 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs @@ -4,25 +4,31 @@ namespace DotPilot.Runtime.Features.ToolchainCenter; public sealed class ToolchainCenterCatalog : IToolchainCenterCatalog, IDisposable { + private const string EpicLabelValue = "PRESESSION READINESS"; private const string EpicSummary = - "Issue #14 keeps provider installation, auth, diagnostics, configuration, and polling visible before the first live session."; + "Provider installation, launch checks, authentication, configuration, and refresh state stay visible before the first live session."; + private const string UiWorkstreamLabel = "SURFACE"; 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 DiagnosticsWorkstreamLabel = "DIAGNOSTICS"; 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 ConfigurationWorkstreamLabel = "CONFIGURATION"; 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 PollingWorkstreamLabel = "POLLING"; 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."; + "Version and auth readiness refresh in the background so the app 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; + private int _disposeState; public ToolchainCenterCatalog() : this(TimeProvider.System, startBackgroundPolling: true) @@ -46,13 +52,29 @@ public ToolchainCenterCatalog(TimeProvider timeProvider, bool startBackgroundPol } } - public ToolchainCenterSnapshot GetSnapshot() => _snapshot; + public ToolchainCenterSnapshot GetSnapshot() => Volatile.Read(ref _snapshot); public void Dispose() { + if (Interlocked.Exchange(ref _disposeState, 1) != 0) + { + return; + } + _disposeTokenSource.Cancel(); _pollingTimer?.Dispose(); + + try + { + _pollingTask.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + _disposeTokenSource.Dispose(); + GC.SuppressFinalize(this); } private async Task PollAsync() @@ -66,13 +88,17 @@ private async Task PollAsync() { while (await _pollingTimer.WaitForNextTickAsync(_disposeTokenSource.Token)) { - _snapshot = EvaluateSnapshot(); + Volatile.Write(ref _snapshot, EvaluateSnapshot()); } } catch (OperationCanceledException) { // Expected during app shutdown. } + catch (ObjectDisposedException) when (_disposeTokenSource.IsCancellationRequested) + { + // Expected when the timer is disposed during shutdown. + } } private ToolchainCenterSnapshot EvaluateSnapshot() @@ -80,7 +106,7 @@ private ToolchainCenterSnapshot EvaluateSnapshot() var evaluatedAt = _timeProvider.GetUtcNow(); var providers = ToolchainProviderSnapshotFactory.Create(evaluatedAt); return new( - ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ToolchainCenterEpic), + EpicLabelValue, EpicSummary, CreateWorkstreams(), providers, @@ -95,22 +121,22 @@ private static IReadOnlyList CreateWorkstre [ new( ToolchainCenterIssues.ToolchainCenterUi, - ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ToolchainCenterUi), + UiWorkstreamLabel, UiWorkstreamName, UiWorkstreamSummary), new( ToolchainCenterIssues.ConnectionDiagnostics, - ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ConnectionDiagnostics), + DiagnosticsWorkstreamLabel, DiagnosticsWorkstreamName, DiagnosticsWorkstreamSummary), new( ToolchainCenterIssues.ProviderConfiguration, - ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ProviderConfiguration), + ConfigurationWorkstreamLabel, ConfigurationWorkstreamName, ConfigurationWorkstreamSummary), new( ToolchainCenterIssues.BackgroundPolling, - ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.BackgroundPolling), + PollingWorkstreamLabel, PollingWorkstreamName, PollingWorkstreamSummary), ]; diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs index 9100f8a..a9dd590 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs @@ -6,11 +6,15 @@ internal static class ToolchainCommandProbe { private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2); private const string VersionSeparator = "version"; + private const string EmptyOutput = ""; public static string? ResolveExecutablePath(string commandName) => RuntimeFoundation.ProviderToolchainProbe.ResolveExecutablePath(commandName); public static string ReadVersion(string executablePath, IReadOnlyList arguments) + => ProbeVersion(executablePath, arguments).Version; + + public static ToolchainVersionProbeResult ProbeVersion(string executablePath, IReadOnlyList arguments) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); ArgumentNullException.ThrowIfNull(arguments); @@ -18,7 +22,7 @@ public static string ReadVersion(string executablePath, IReadOnlyList ar var execution = Execute(executablePath, arguments); if (!execution.Succeeded) { - return string.Empty; + return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched }; } var output = string.IsNullOrWhiteSpace(execution.StandardOutput) @@ -31,13 +35,15 @@ public static string ReadVersion(string executablePath, IReadOnlyList ar if (string.IsNullOrWhiteSpace(firstLine)) { - return string.Empty; + return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched }; } var separatorIndex = firstLine.IndexOf(VersionSeparator, StringComparison.OrdinalIgnoreCase); - return separatorIndex >= 0 + var version = separatorIndex >= 0 ? firstLine[(separatorIndex + VersionSeparator.Length)..].Trim(' ', ':') : firstLine.Trim(); + + return new(execution.Launched, version); } public static bool CanExecute(string executablePath, IReadOnlyList arguments) @@ -64,22 +70,57 @@ private static ToolchainCommandExecution Execute(string executablePath, IReadOnl startInfo.ArgumentList.Add(argument); } - using var process = Process.Start(startInfo); + Process? process; + try + { + process = Process.Start(startInfo); + } + catch + { + return ToolchainCommandExecution.LaunchFailed; + } + if (process is null) { - return ToolchainCommandExecution.Failed; + return ToolchainCommandExecution.LaunchFailed; } - if (!process.WaitForExit((int)CommandTimeout.TotalMilliseconds)) + using (process) { - TryTerminate(process); - return ToolchainCommandExecution.Failed; + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)CommandTimeout.TotalMilliseconds)) + { + TryTerminate(process); + ObserveRedirectedStreamFaults(standardOutputTask, standardErrorTask); + return new(true, false, EmptyOutput, EmptyOutput); + } + + return new( + true, + process.ExitCode == 0, + AwaitStreamRead(standardOutputTask), + AwaitStreamRead(standardErrorTask)); } + } - return new( - process.ExitCode == 0, - process.StandardOutput.ReadToEnd(), - process.StandardError.ReadToEnd()); + private static string AwaitStreamRead(Task readTask) + { + try + { + return readTask.GetAwaiter().GetResult(); + } + catch + { + return EmptyOutput; + } + } + + private static void ObserveRedirectedStreamFaults(Task standardOutputTask, Task standardErrorTask) + { + _ = standardOutputTask.Exception; + _ = standardErrorTask.Exception; } private static void TryTerminate(Process process) @@ -97,8 +138,13 @@ private static void TryTerminate(Process process) } } - private readonly record struct ToolchainCommandExecution(bool Succeeded, string StandardOutput, string StandardError) + public readonly record struct ToolchainVersionProbeResult(bool Launched, string Version) + { + public static ToolchainVersionProbeResult Missing => new(false, EmptyOutput); + } + + private readonly record struct ToolchainCommandExecution(bool Launched, bool Succeeded, string StandardOutput, string StandardError) { - public static ToolchainCommandExecution Failed => new(false, string.Empty, string.Empty); + public static ToolchainCommandExecution LaunchFailed => new(false, false, EmptyOutput, EmptyOutput); } } diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs index 61e01cb..4b0171e 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs @@ -2,6 +2,7 @@ namespace DotPilot.Runtime.Features.ToolchainCenter; internal sealed record ToolchainProviderProfile( int IssueNumber, + string SectionLabel, string DisplayName, string CommandName, IReadOnlyList VersionArguments, diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs index 1c95bc3..e22bf44 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs @@ -19,12 +19,16 @@ internal static class ToolchainProviderProfiles 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 const string CodexSectionLabel = "CODEX"; + private const string ClaudeSectionLabel = "CLAUDE"; + private const string GitHubSectionLabel = "GITHUB"; private static readonly string[] VersionArguments = ["--version"]; public static IReadOnlyList All { get; } = [ new( ToolchainCenterIssues.CodexReadiness, + CodexSectionLabel, ProviderToolchainNames.CodexDisplayName, ProviderToolchainNames.CodexCommandName, VersionArguments, @@ -40,6 +44,7 @@ internal static class ToolchainProviderProfiles ]), new( ToolchainCenterIssues.ClaudeCodeReadiness, + ClaudeSectionLabel, ProviderToolchainNames.ClaudeCodeDisplayName, ProviderToolchainNames.ClaudeCodeCommandName, VersionArguments, @@ -55,6 +60,7 @@ internal static class ToolchainProviderProfiles ]), new( ToolchainCenterIssues.GitHubCopilotReadiness, + GitHubSectionLabel, ProviderToolchainNames.GitHubCopilotDisplayName, ProviderToolchainNames.GitHubCopilotCommandName, VersionArguments, diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs index ad54d9b..6dd988a 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs +++ b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs @@ -20,10 +20,12 @@ internal static class ToolchainProviderSnapshotFactory 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 ReadinessLaunchFailedSummaryFormat = "{0} is on PATH, but dotPilot could not launch the CLI automatically."; 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 HealthBlockedLaunchSummaryFormat = "{0} launch is blocked until dotPilot can start the CLI successfully."; 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."; @@ -34,6 +36,7 @@ internal static class ToolchainProviderSnapshotFactory 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 LaunchUnavailableSummary = "The executable was detected, but dotPilot could not launch it automatically."; 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."; @@ -50,10 +53,12 @@ internal static class ToolchainProviderSnapshotFactory 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 ReadinessLaunchFailedSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessLaunchFailedSummaryFormat); 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 HealthBlockedLaunchSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthBlockedLaunchSummaryFormat); 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); @@ -91,54 +96,56 @@ private static ToolchainProviderSnapshot Create(ToolchainProviderProfile profile { var executablePath = ToolchainCommandProbe.ResolveExecutablePath(profile.CommandName); var isInstalled = !string.IsNullOrWhiteSpace(executablePath); - var installedVersion = isInstalled - ? ToolchainCommandProbe.ReadVersion(executablePath!, profile.VersionArguments) - : string.Empty; + var versionProbe = isInstalled + ? ToolchainCommandProbe.ProbeVersion(executablePath!, profile.VersionArguments) + : ToolchainCommandProbe.ToolchainVersionProbeResult.Missing; + var launchAvailable = isInstalled && versionProbe.Launched; + var installedVersion = launchAvailable ? versionProbe.Version : string.Empty; var authConfigured = profile.AuthenticationEnvironmentVariables .Select(Environment.GetEnvironmentVariable) .Any(static value => !string.IsNullOrWhiteSpace(value)); - var toolAccessAvailable = isInstalled && ( + var toolAccessAvailable = launchAvailable && ( profile.ToolAccessArguments.Count == 0 || ToolchainCommandProbe.CanExecute(executablePath!, profile.ToolAccessArguments)); - var providerStatus = ResolveProviderStatus(isInstalled, authConfigured, toolAccessAvailable); - var readinessState = ResolveReadinessState(isInstalled, authConfigured, toolAccessAvailable, installedVersion); + var providerStatus = ResolveProviderStatus(isInstalled, launchAvailable, authConfigured, toolAccessAvailable); + var readinessState = ResolveReadinessState(isInstalled, launchAvailable, authConfigured, toolAccessAvailable, installedVersion); var versionStatus = ResolveVersionStatus(isInstalled, installedVersion); var authStatus = authConfigured ? ToolchainAuthStatus.Connected : ToolchainAuthStatus.Missing; - var healthStatus = ResolveHealthStatus(isInstalled, authConfigured, toolAccessAvailable, installedVersion); + var healthStatus = ResolveHealthStatus(isInstalled, launchAvailable, authConfigured, toolAccessAvailable, installedVersion); var polling = CreateProviderPolling(evaluatedAt, readinessState); return new( profile.IssueNumber, - ToolchainCenterIssues.FormatIssueLabel(profile.IssueNumber), + profile.SectionLabel, new ProviderDescriptor { Id = ToolchainDeterministicIdentity.CreateProviderId(profile.CommandName), DisplayName = profile.DisplayName, CommandName = profile.CommandName, Status = providerStatus, - StatusSummary = ResolveReadinessSummary(profile.DisplayName, readinessState), + StatusSummary = ResolveReadinessSummary(profile.DisplayName, isInstalled, launchAvailable, readinessState), RequiresExternalToolchain = true, }, executablePath ?? MissingExecutablePath, string.IsNullOrWhiteSpace(installedVersion) ? MissingVersion : installedVersion, readinessState, - ResolveReadinessSummary(profile.DisplayName, readinessState), + ResolveReadinessSummary(profile.DisplayName, isInstalled, launchAvailable, readinessState), versionStatus, ResolveVersionSummary(versionStatus, installedVersion), authStatus, authConfigured ? AuthConnectedSummary : AuthMissingSummary, healthStatus, - ResolveHealthSummary(profile.DisplayName, healthStatus, authConfigured), + ResolveHealthSummary(profile.DisplayName, healthStatus, isInstalled, launchAvailable, authConfigured), CreateActions(profile, readinessState), - CreateDiagnostics(profile, isInstalled, authConfigured, installedVersion, toolAccessAvailable), + CreateDiagnostics(profile, isInstalled, launchAvailable, authConfigured, installedVersion, toolAccessAvailable), CreateConfiguration(profile), polling); } - private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool authConfigured, bool toolAccessAvailable) + private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool launchAvailable, bool authConfigured, bool toolAccessAvailable) { - if (!isInstalled) + if (!isInstalled || !launchAvailable) { return ProviderConnectionStatus.Unavailable; } @@ -155,11 +162,12 @@ private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, private static ToolchainReadinessState ResolveReadinessState( bool isInstalled, + bool launchAvailable, bool authConfigured, bool toolAccessAvailable, string installedVersion) { - if (!isInstalled) + if (!isInstalled || !launchAvailable) { return ToolchainReadinessState.Missing; } @@ -191,11 +199,12 @@ private static ToolchainVersionStatus ResolveVersionStatus(bool isInstalled, str private static ToolchainHealthStatus ResolveHealthStatus( bool isInstalled, + bool launchAvailable, bool authConfigured, bool toolAccessAvailable, string installedVersion) { - if (!isInstalled || !authConfigured) + if (!isInstalled || !launchAvailable || !authConfigured) { return ToolchainHealthStatus.Blocked; } @@ -205,9 +214,14 @@ private static ToolchainHealthStatus ResolveHealthStatus( : ToolchainHealthStatus.Warning; } - private static string ResolveReadinessSummary(string displayName, ToolchainReadinessState readinessState) => + private static string ResolveReadinessSummary( + string displayName, + bool isInstalled, + bool launchAvailable, + ToolchainReadinessState readinessState) => readinessState switch { + ToolchainReadinessState.Missing when isInstalled && !launchAvailable => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessLaunchFailedSummaryCompositeFormat, displayName), 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), @@ -222,11 +236,17 @@ private static string ResolveVersionSummary(ToolchainVersionStatus versionStatus _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, VersionSummaryCompositeFormat, installedVersion), }; - private static string ResolveHealthSummary(string displayName, ToolchainHealthStatus healthStatus, bool authConfigured) => + private static string ResolveHealthSummary( + string displayName, + ToolchainHealthStatus healthStatus, + bool isInstalled, + bool launchAvailable, + 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.Blocked when !isInstalled => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedMissingSummaryCompositeFormat, displayName), + ToolchainHealthStatus.Blocked when !launchAvailable => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedLaunchSummaryCompositeFormat, displayName), + ToolchainHealthStatus.Blocked when !authConfigured => 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), }; @@ -283,18 +303,19 @@ private static ToolchainActionDescriptor[] CreateActions( private static ToolchainDiagnosticDescriptor[] CreateDiagnostics( ToolchainProviderProfile profile, bool isInstalled, + bool launchAvailable, bool authConfigured, string installedVersion, bool toolAccessAvailable) { - var launchPassed = isInstalled; + var launchPassed = launchAvailable; var versionPassed = !string.IsNullOrWhiteSpace(installedVersion); var connectionReady = launchPassed && authConfigured; var resumeReady = connectionReady; return [ - new(LaunchDiagnosticName, launchPassed ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Failed, launchPassed ? LaunchPassedSummary : LaunchFailedSummary), + new(LaunchDiagnosticName, launchPassed ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Failed, launchPassed ? LaunchPassedSummary : (isInstalled ? LaunchUnavailableSummary : 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), diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs index b31831e..9b38c99 100644 --- a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs +++ b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs @@ -2,6 +2,8 @@ namespace DotPilot.Tests.Features.ToolchainCenter; public class ToolchainCenterCatalogTests { + private const string ToolchainEpicLabel = "PRESESSION READINESS"; + [Test] public void CatalogIncludesEpicIssueCoverageAndAllExternalProviders() { @@ -14,7 +16,9 @@ public void CatalogIncludesEpicIssueCoverageAndAllExternalProviders() .Order() .ToArray(); - snapshot.EpicLabel.Should().Be(ToolchainCenterIssues.FormatIssueLabel(ToolchainCenterIssues.ToolchainCenterEpic)); + snapshot.EpicLabel.Should().Be(ToolchainEpicLabel); + snapshot.Summary.Should().NotContain("Issue #"); + snapshot.Workstreams.Select(workstream => workstream.IssueLabel).Should().Equal("SURFACE", "DIAGNOSTICS", "CONFIGURATION", "POLLING"); coveredIssues.Should().Equal( ToolchainCenterIssues.ToolchainCenterUi, ToolchainCenterIssues.CodexReadiness, @@ -54,6 +58,16 @@ public void CatalogCanStartAndDisposeBackgroundPolling() snapshot.Providers.Should().NotBeEmpty(); } + [Test] + public void CatalogDisposeIsIdempotentAfterBackgroundPollingStarts() + { + var catalog = new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: true); + + catalog.Dispose(); + + catalog.Invoking(item => item.Dispose()).Should().NotThrow(); + } + [Test] [NonParallelizable] public void CatalogMarksProvidersMissingWhenPathAndAuthenticationSignalsAreCleared() diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs index 105d6d8..86c0112 100644 --- a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs +++ b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs @@ -4,6 +4,8 @@ namespace DotPilot.Tests.Features.ToolchainCenter; public class ToolchainCommandProbeTests { + private const string NonExecutableContents = "not an executable"; + [Test] public void ReadVersionUsesStandardErrorWhenStandardOutputIsEmpty() { @@ -82,6 +84,37 @@ public void ReadVersionReturnsEmptyWhenTheCommandTimesOut() version.Should().BeEmpty(); } + [Test] + public void CanExecuteReturnsFalseWhenTheResolvedPathCannotBeLaunched() + { + var nonExecutablePath = Path.GetTempFileName(); + + try + { + File.WriteAllText(nonExecutablePath, NonExecutableContents); + + CanExecute(nonExecutablePath, []).Should().BeFalse(); + ReadVersion(nonExecutablePath, []).Should().BeEmpty(); + } + finally + { + File.Delete(nonExecutablePath); + } + } + + [Test] + public void CanExecuteReturnsTrueWhenTheCommandProducesLargeRedirectedOutput() + { + var (executablePath, arguments) = CreateShellCommand( + OperatingSystem.IsWindows() + ? "for /L %i in (1,1,3000) do @echo output-line-%i" + : "i=1; while [ $i -le 3000 ]; do printf 'output-line-%s\\n' \"$i\"; i=$((i+1)); done"); + + var canExecute = CanExecute(executablePath, arguments); + + canExecute.Should().BeTrue(); + } + private static string ReadVersion(string executablePath, IReadOnlyList arguments) { return (string)InvokeProbeMethod("ReadVersion", executablePath, arguments); diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs index 7aad388..62b1a61 100644 --- a/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs +++ b/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs @@ -7,46 +7,72 @@ public class ToolchainProviderSnapshotFactoryTests [Test] public void ResolveProviderStatusCoversUnavailableAuthenticationAndMisconfiguredBranches() { - ResolveProviderStatus(isInstalled: false, authConfigured: false, toolAccessAvailable: false) + ResolveProviderStatus(isInstalled: false, launchAvailable: false, authConfigured: false, toolAccessAvailable: false) .Should().Be(ProviderConnectionStatus.Unavailable); - ResolveProviderStatus(isInstalled: true, authConfigured: false, toolAccessAvailable: false) + ResolveProviderStatus(isInstalled: true, launchAvailable: false, authConfigured: false, toolAccessAvailable: false) + .Should().Be(ProviderConnectionStatus.Unavailable); + ResolveProviderStatus(isInstalled: true, launchAvailable: true, authConfigured: false, toolAccessAvailable: false) .Should().Be(ProviderConnectionStatus.RequiresAuthentication); - ResolveProviderStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: false) + ResolveProviderStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: false) .Should().Be(ProviderConnectionStatus.Misconfigured); - ResolveProviderStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: true) + ResolveProviderStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true) .Should().Be(ProviderConnectionStatus.Available); } [Test] public void ResolveReadinessStateCoversMissingActionRequiredLimitedAndReady() { - ResolveReadinessState(isInstalled: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) + ResolveReadinessState(isInstalled: false, launchAvailable: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) + .Should().Be(ToolchainReadinessState.Missing); + ResolveReadinessState(isInstalled: true, launchAvailable: false, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") .Should().Be(ToolchainReadinessState.Missing); - ResolveReadinessState(isInstalled: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") + ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") .Should().Be(ToolchainReadinessState.ActionRequired); - ResolveReadinessState(isInstalled: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") + ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") .Should().Be(ToolchainReadinessState.Limited); - ResolveReadinessState(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) + ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) .Should().Be(ToolchainReadinessState.Limited); - ResolveReadinessState(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") + ResolveReadinessState(isInstalled: true, launchAvailable: 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) + ResolveHealthStatus(isInstalled: false, launchAvailable: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) + .Should().Be(ToolchainHealthStatus.Blocked); + ResolveHealthStatus(isInstalled: true, launchAvailable: false, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") .Should().Be(ToolchainHealthStatus.Blocked); - ResolveHealthStatus(isInstalled: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") + ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") .Should().Be(ToolchainHealthStatus.Blocked); - ResolveHealthStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") + ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") .Should().Be(ToolchainHealthStatus.Warning); - ResolveHealthStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) + ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) .Should().Be(ToolchainHealthStatus.Warning); - ResolveHealthStatus(isInstalled: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") + ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") .Should().Be(ToolchainHealthStatus.Healthy); } + [Test] + public void ResolveReadinessSummaryDistinguishesMissingInstallFromBrokenLaunch() + { + ResolveReadinessSummary("Codex CLI", isInstalled: false, launchAvailable: false, ToolchainReadinessState.Missing) + .Should().Contain("not installed"); + ResolveReadinessSummary("Codex CLI", isInstalled: true, launchAvailable: false, ToolchainReadinessState.Missing) + .Should().Contain("could not launch"); + } + + [Test] + public void ResolveHealthSummaryPrefersInstallAndLaunchGuidanceBeforeAuth() + { + ResolveHealthSummary("Codex CLI", ToolchainHealthStatus.Blocked, isInstalled: false, launchAvailable: false, authConfigured: false) + .Should().Contain("installed"); + ResolveHealthSummary("Codex CLI", ToolchainHealthStatus.Blocked, isInstalled: true, launchAvailable: false, authConfigured: false) + .Should().Contain("start the CLI"); + ResolveHealthSummary("Codex CLI", ToolchainHealthStatus.Blocked, isInstalled: true, launchAvailable: true, authConfigured: false) + .Should().Contain("authentication"); + } + [Test] public void ResolveConfigurationStatusDistinguishesRequiredAndOptionalSignals() { @@ -61,17 +87,19 @@ public void ResolveConfigurationStatusDistinguishesRequiredAndOptionalSignals() .Should().Be(ToolchainConfigurationStatus.Configured); } - private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool authConfigured, bool toolAccessAvailable) + private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool launchAvailable, bool authConfigured, bool toolAccessAvailable) { return (ProviderConnectionStatus)InvokeFactoryMethod( "ResolveProviderStatus", isInstalled, + launchAvailable, authConfigured, toolAccessAvailable)!; } private static ToolchainReadinessState ResolveReadinessState( bool isInstalled, + bool launchAvailable, bool authConfigured, bool toolAccessAvailable, string installedVersion) @@ -79,6 +107,7 @@ private static ToolchainReadinessState ResolveReadinessState( return (ToolchainReadinessState)InvokeFactoryMethod( "ResolveReadinessState", isInstalled, + launchAvailable, authConfigured, toolAccessAvailable, installedVersion)!; @@ -86,6 +115,7 @@ private static ToolchainReadinessState ResolveReadinessState( private static ToolchainHealthStatus ResolveHealthStatus( bool isInstalled, + bool launchAvailable, bool authConfigured, bool toolAccessAvailable, string installedVersion) @@ -93,11 +123,42 @@ private static ToolchainHealthStatus ResolveHealthStatus( return (ToolchainHealthStatus)InvokeFactoryMethod( "ResolveHealthStatus", isInstalled, + launchAvailable, authConfigured, toolAccessAvailable, installedVersion)!; } + private static string ResolveReadinessSummary( + string displayName, + bool isInstalled, + bool launchAvailable, + ToolchainReadinessState readinessState) + { + return (string)InvokeFactoryMethod( + "ResolveReadinessSummary", + displayName, + isInstalled, + launchAvailable, + readinessState)!; + } + + private static string ResolveHealthSummary( + string displayName, + ToolchainHealthStatus healthStatus, + bool isInstalled, + bool launchAvailable, + bool authConfigured) + { + return (string)InvokeFactoryMethod( + "ResolveHealthSummary", + displayName, + healthStatus, + isInstalled, + launchAvailable, + authConfigured)!; + } + private static ToolchainConfigurationStatus ResolveConfigurationStatus(object signal, bool isConfigured) { return (ToolchainConfigurationStatus)InvokeFactoryMethod("ResolveConfigurationStatus", signal, isConfigured)!; diff --git a/DotPilot.Tests/PresentationViewModelTests.cs b/DotPilot.Tests/PresentationViewModelTests.cs index e06e744..672469c 100644 --- a/DotPilot.Tests/PresentationViewModelTests.cs +++ b/DotPilot.Tests/PresentationViewModelTests.cs @@ -26,7 +26,7 @@ public void MainViewModelExposesWorkbenchShellState() viewModel.IsPreviewMode.Should().BeFalse(); viewModel.IsLogConsoleVisible = true; viewModel.IsArtifactsVisible.Should().BeFalse(); - viewModel.RuntimeFoundation.EpicLabel.Should().Be(RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.EmbeddedAgentRuntimeHostEpic)); + viewModel.RuntimeFoundation.EpicLabel.Should().Be("LOCAL RUNTIME READINESS"); viewModel.RuntimeFoundation.Providers.Should().Contain(provider => !provider.RequiresExternalToolchain); } @@ -69,7 +69,7 @@ public void SecondViewModelExposesAgentBuilderState() viewModel.Skills.Should().Contain(skill => skill.IsEnabled); viewModel.Skills.Should().Contain(skill => !skill.IsEnabled); viewModel.RuntimeFoundation.DeterministicClientName.Should().Be("In-Repo Test Client"); - viewModel.RuntimeFoundation.Providers.Should().HaveCountGreaterOrEqualTo(4); + viewModel.RuntimeFoundation.Providers.Should().ContainSingle(); } private static RuntimeFoundationCatalog CreateRuntimeFoundationCatalog() diff --git a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs index 2bf0d9c..b10d810 100644 --- a/DotPilot.Tests/RuntimeFoundationCatalogTests.cs +++ b/DotPilot.Tests/RuntimeFoundationCatalogTests.cs @@ -4,10 +4,8 @@ public class RuntimeFoundationCatalogTests { private const string ApprovalPrompt = "Please continue, but stop for approval before changing files."; private const string BlankPrompt = " "; - 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."; + private const string RuntimeEpicLabel = "LOCAL RUNTIME READINESS"; [Test] public void CatalogGroupsEpicTwelveIntoFourSequencedSlices() @@ -16,8 +14,9 @@ public void CatalogGroupsEpicTwelveIntoFourSequencedSlices() var snapshot = catalog.GetSnapshot(); - snapshot.EpicLabel.Should().Be(RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.EmbeddedAgentRuntimeHostEpic)); + snapshot.EpicLabel.Should().Be(RuntimeEpicLabel); snapshot.Slices.Should().HaveCount(4); + snapshot.Slices.Select(slice => slice.IssueLabel).Should().ContainInOrder("DOMAIN", "CONTRACTS", "HOST", "ORCHESTRATION"); snapshot.Slices.Select(slice => slice.IssueNumber).Should().ContainInOrder( RuntimeFoundationIssues.DomainModel, RuntimeFoundationIssues.CommunicationContracts, @@ -148,23 +147,6 @@ public async Task DeterministicClientReturnsProviderUnavailableProblemWhenProvid problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.ServiceUnavailable); } - [TestCase(CodexCommandName)] - [TestCase(ClaudeCommandName)] - [TestCase(GitHubCommandName)] - public void ExternalToolchainVerificationRunsOnlyWhenTheCommandIsAvailable(string commandName) - { - var catalog = CreateCatalog(); - var provider = catalog.GetSnapshot().Providers.Single(item => item.CommandName == commandName); - - Assume.That( - provider.Status, - Is.EqualTo(ProviderConnectionStatus.Available), - $"The '{commandName}' toolchain is not available in this environment."); - - provider.RequiresExternalToolchain.Should().BeTrue(); - provider.StatusSummary.Should().Contain("available"); - } - [Test] public void TypedIdentifiersProduceStableNonEmptyRepresentations() { @@ -183,15 +165,14 @@ public void TypedIdentifiersProduceStableNonEmptyRepresentations() } [Test] - [NonParallelizable] - public void ExternalProvidersBecomeUnavailableWhenPathIsCleared() + public void CatalogCachesProviderListAcrossSnapshotReads() { - using var scope = new EnvironmentVariableScope("PATH", string.Empty); var catalog = CreateCatalog(); - var externalProviders = catalog.GetSnapshot().Providers.Where(provider => provider.RequiresExternalToolchain); + var firstSnapshot = catalog.GetSnapshot(); + var secondSnapshot = catalog.GetSnapshot(); - externalProviders.Should().OnlyContain(provider => provider.Status == ProviderConnectionStatus.Unavailable); + ReferenceEquals(firstSnapshot.Providers, secondSnapshot.Providers).Should().BeTrue(); } private static RuntimeFoundationCatalog CreateCatalog() @@ -206,22 +187,4 @@ private static AgentTurnRequest CreateRequest( { return new AgentTurnRequest(SessionId.New(), AgentProfileId.New(), prompt, mode, providerStatus); } - - 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/docs/Architecture.md b/docs/Architecture.md index 63a80de..2ba8201 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -162,7 +162,6 @@ flowchart LR Catalog["RuntimeFoundationCatalog"] Toolchains["ToolchainCenterCatalog"] TestClient["DeterministicAgentRuntimeClient"] - Probe["ProviderToolchainProbe"] ToolchainProbe["ToolchainCommandProbe + provider profiles"] Contracts["Typed IDs + contracts"] Future["Future Orleans + Agent Framework integrations"] @@ -172,7 +171,6 @@ flowchart LR ViewModels --> Catalog ViewModels --> Toolchains Catalog --> TestClient - Catalog --> Probe Catalog --> Contracts Toolchains --> ToolchainProbe Toolchains --> Contracts diff --git a/pr-76-review-followup.plan.md b/pr-76-review-followup.plan.md new file mode 100644 index 0000000..2f2e4f0 --- /dev/null +++ b/pr-76-review-followup.plan.md @@ -0,0 +1,87 @@ +# PR 76 Review Follow-up Plan + +## Goal + +Address the meaningful review comments on `PR #76`, remove backlog-specific text that leaked into production `ToolchainCenter` runtime metadata, and update the PR body so merge closes every relevant open issue included in the stacked change set. + +## Scope + +- In scope: + - `DotPilot.Runtime` fixes for `ToolchainCenterCatalog`, `ToolchainCommandProbe`, `ToolchainProviderSnapshotFactory`, and `RuntimeFoundationCatalog` + - regression and behavior tests in `DotPilot.Tests` + - PR `#76` body update with GitHub closing references for the open issue stack included in the branch history +- Out of scope: + - new product features outside existing `PR #76` + - dependency changes + - release workflow changes + +## Constraints And Risks + +- Build and test must run with `-warnaserror`. +- Do not run parallel `dotnet` or `MSBuild` work in the same checkout. +- `DotPilot.UITests` remains mandatory final verification. +- Review fixes must not keep GitHub backlog text inside production runtime snapshots or user-facing summaries. +- PR body should only close issues actually delivered by this stacked branch. + +## Testing Methodology + +- Runtime snapshot and probe behavior will be tested through `DotPilot.Tests` using real subprocess execution paths rather than mocks. +- Catalog lifecycle fixes will be covered with deterministic tests that validate disposal, snapshot stability, and provider caching behavior. +- Final validation must prove both the focused runtime slice and the broader repo verification path. + +## Ordered Plan + +- [x] Step 1. Establish the real baseline for this PR branch. + - Verification: + - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` + - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter Toolchain` + - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter RuntimeFoundationCatalog` +- [x] Step 2. Remove backlog-specific text from `ToolchainCenterCatalog` and make snapshot polling/disposal thread-safe. + - Verification: + - targeted `ToolchainCenterCatalogTests` +- [x] Step 3. Fix `ToolchainCommandProbe` launch-failure and redirected-stream handling. + - Verification: + - targeted `ToolchainCommandProbeTests` +- [x] Step 4. Fix provider-summary/status logic in `ToolchainProviderSnapshotFactory`. + - Verification: + - targeted `ToolchainProviderSnapshotFactoryTests` +- [x] Step 5. Fix `RuntimeFoundationCatalog` provider caching so UI-thread snapshot reads do not re-probe subprocesses. + - Verification: + - targeted `RuntimeFoundationCatalogTests` +- [x] Step 6. Update PR `#76` body with GitHub closing references for all relevant open issues merged through this stack. + - Verification: + - `gh pr view 76 --repo managedcode/dotPilot --json body` +- [x] Step 7. Run final verification and record outcomes. + - Verification: + - `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.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` + +## Baseline Results + +- [x] `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` +- [x] `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter Toolchain` +- [x] `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter RuntimeFoundationCatalog` + +## Known Failing Tests + +- None. The focused baseline and final repo validation passed. + +## Results + +- `dotnet format DotPilot.slnx --verify-no-changes` passed. +- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` passed. +- `dotnet test DotPilot.slnx` passed with `57` unit tests and `22` UI tests green. +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` passed with overall collector result `91.09%` line / `63.66%` branch. +- `PR #76` body now uses `Closes #13`, `Closes #14`, and `Closes #28-#39`, so those issues will auto-close on merge. + +## Final Validation Skills + +- `mcaf-dotnet` + - Run build and test verification with the repo-defined commands. +- `mcaf-testing` + - Confirm new regressions cover the review-comment failure modes. +- `gh-address-comments` + - Verify the review comments are resolved and the PR body closes the correct issues on merge.