mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
feat: add Copilot version and account diagnostics
- report Copilot CLI version freshness as latest, outdated, or unknown - expose active Copilot account metadata from the SDK auth status - add best-effort GitHub organization context when gh is available - document the frontend follow-up in HANDOVER.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -62,9 +62,29 @@ public sealed class SidecarConnectionDiagnosticsDto
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public string? CopilotCliPath { get; init; }
|
||||
public SidecarCopilotCliVersionDiagnosticsDto? CopilotCliVersion { get; init; }
|
||||
public SidecarCopilotAccountDiagnosticsDto? Account { get; init; }
|
||||
public string CheckedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
public string Status { get; init; } = "unknown";
|
||||
public string? InstalledVersion { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
public bool Authenticated { get; init; }
|
||||
public string? Login { get; init; }
|
||||
public string? Host { get; init; }
|
||||
public string? AuthType { get; init; }
|
||||
public string? StatusMessage { get; init; }
|
||||
public IReadOnlyList<string>? Organizations { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarCapabilitiesDto
|
||||
{
|
||||
public string Runtime { get; init; } = "dotnet-maf";
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Kopaya.AgentHost.Contracts;
|
||||
|
||||
namespace Kopaya.AgentHost.Services;
|
||||
|
||||
internal static partial class CopilotConnectionMetadataResolver
|
||||
{
|
||||
private static readonly string[] LatestVersionIndicators =
|
||||
[
|
||||
"running the latest version",
|
||||
"already up to date",
|
||||
"is up to date",
|
||||
];
|
||||
|
||||
private static readonly string[] OutdatedVersionIndicators =
|
||||
[
|
||||
"newer version",
|
||||
"new version available",
|
||||
"update available",
|
||||
"download link",
|
||||
];
|
||||
|
||||
private static readonly TimeSpan CopilotVersionTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan GitHubContextTimeout = TimeSpan.FromSeconds(4);
|
||||
|
||||
internal static async Task<SidecarCopilotCliVersionDiagnosticsDto> GetCliVersionDiagnosticsAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
CommandResult result = await RunProcessAsync(
|
||||
executablePath: cliContext.CliPath,
|
||||
arguments: ["version"],
|
||||
environment: cliContext.Environment,
|
||||
timeout: CopilotVersionTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseCliVersionOutput(result.StandardOutput, result.StandardError, result.ExitCode);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "unknown",
|
||||
Detail = "Timed out while checking the installed GitHub Copilot CLI version.",
|
||||
};
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "unknown",
|
||||
Detail = $"Failed to check the installed GitHub Copilot CLI version: {exception.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static SidecarCopilotCliVersionDiagnosticsDto ParseCliVersionOutput(
|
||||
string? standardOutput,
|
||||
string? standardError = null,
|
||||
int exitCode = 0)
|
||||
{
|
||||
string output = NormalizeOutput(standardOutput, standardError);
|
||||
string? installedVersion = ExtractInstalledVersion(output);
|
||||
string status = ClassifyCliVersionStatus(output, exitCode);
|
||||
string? latestVersion = status switch
|
||||
{
|
||||
"latest" => installedVersion,
|
||||
"outdated" => ExtractLatestVersion(output, installedVersion),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
string? detail = string.IsNullOrWhiteSpace(output)
|
||||
? null
|
||||
: output;
|
||||
|
||||
if (detail is null && status == "unknown")
|
||||
{
|
||||
detail = exitCode == 0
|
||||
? "GitHub Copilot CLI version could not be determined."
|
||||
: $"GitHub Copilot CLI version check exited with code {exitCode}.";
|
||||
}
|
||||
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
InstalledVersion = installedVersion,
|
||||
LatestVersion = latestVersion,
|
||||
Detail = detail,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyCliVersionStatus(string output, int exitCode = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (LatestVersionIndicators.Any(indicator =>
|
||||
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "latest";
|
||||
}
|
||||
|
||||
if (OutdatedVersionIndicators.Any(indicator =>
|
||||
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "outdated";
|
||||
}
|
||||
|
||||
return exitCode == 0 && ExtractInstalledVersion(output) is not null
|
||||
? "unknown"
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
internal static string? ExtractInstalledVersion(string output)
|
||||
{
|
||||
Match match = SemanticVersionPattern().Match(output);
|
||||
return match.Success ? match.Groups["version"].Value : null;
|
||||
}
|
||||
|
||||
internal static string? ExtractLatestVersion(string output, string? installedVersion)
|
||||
{
|
||||
return SemanticVersionPattern()
|
||||
.Matches(output)
|
||||
.Select(match => match.Groups["version"].Value)
|
||||
.FirstOrDefault(version =>
|
||||
!string.Equals(version, installedVersion, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static async Task<GetAuthStatusResponse?> TryGetAuthStatusAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await client.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[kopaya sidecar] Failed to inspect Copilot auth status: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<SidecarCopilotAccountDiagnosticsDto?> CreateAccountDiagnosticsAsync(
|
||||
GetAuthStatusResponse? authStatus,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (authStatus is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? normalizedHost = NormalizeHost(authStatus.Host);
|
||||
IReadOnlyList<string>? organizations = null;
|
||||
|
||||
if (authStatus.IsAuthenticated
|
||||
&& !string.IsNullOrWhiteSpace(authStatus.Login)
|
||||
&& !string.IsNullOrWhiteSpace(normalizedHost))
|
||||
{
|
||||
organizations = await TryListOrganizationsAsync(
|
||||
authStatus.Login,
|
||||
normalizedHost,
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
Authenticated = authStatus.IsAuthenticated,
|
||||
Login = authStatus.Login,
|
||||
Host = normalizedHost,
|
||||
AuthType = authStatus.AuthType,
|
||||
StatusMessage = authStatus.StatusMessage,
|
||||
Organizations = organizations,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? NormalizeHost(string? host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string trimmed = host.Trim();
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.TrimEnd('/');
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> ParseOrganizationsOutput(string? output)
|
||||
{
|
||||
return (output ?? string.Empty)
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>?> TryListOrganizationsAsync(
|
||||
string login,
|
||||
string host,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CommandResult? loginResult = await TryRunGhCommandAsync(
|
||||
["api", "--hostname", host, "user", "--jq", ".login"],
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? resolvedLogin = loginResult is { ExitCode: 0 }
|
||||
? loginResult.StandardOutput.Trim()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedLogin)
|
||||
|| !string.Equals(resolvedLogin, login, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CommandResult? organizationsResult = await TryRunGhCommandAsync(
|
||||
["api", "--hostname", host, "user/orgs", "--jq", ".[].login"],
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return organizationsResult is { ExitCode: 0 }
|
||||
? ParseOrganizationsOutput(organizationsResult.StandardOutput)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<CommandResult?> TryRunGhCommandAsync(
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await RunProcessAsync(
|
||||
executablePath: OperatingSystem.IsWindows() ? "gh.exe" : "gh",
|
||||
arguments,
|
||||
environment,
|
||||
GitHubContextTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunProcessAsync(
|
||||
string executablePath,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
using Process process = new()
|
||||
{
|
||||
StartInfo = CreateProcessStartInfo(executablePath, arguments, environment),
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to start command '{executablePath}'.");
|
||||
}
|
||||
|
||||
Task<string> standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token);
|
||||
Task<string> standardErrorTask = process.StandardError.ReadToEndAsync(timeoutSource.Token);
|
||||
|
||||
await process.WaitForExitAsync(timeoutSource.Token).ConfigureAwait(false);
|
||||
|
||||
return new CommandResult(
|
||||
process.ExitCode,
|
||||
await standardOutputTask.ConfigureAwait(false),
|
||||
await standardErrorTask.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateProcessStartInfo(
|
||||
string executablePath,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows() && RequiresWindowsShell(executablePath))
|
||||
{
|
||||
startInfo.FileName = string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ComSpec"))
|
||||
? "cmd.exe"
|
||||
: Environment.GetEnvironmentVariable("ComSpec");
|
||||
|
||||
startInfo.ArgumentList.Add("/d");
|
||||
startInfo.ArgumentList.Add("/s");
|
||||
startInfo.ArgumentList.Add("/c");
|
||||
startInfo.ArgumentList.Add(BuildWindowsShellCommand(executablePath, arguments));
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = executablePath;
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
}
|
||||
|
||||
startInfo.Environment.Clear();
|
||||
foreach (KeyValuePair<string, string> entry in environment)
|
||||
{
|
||||
startInfo.Environment[entry.Key] = entry.Value;
|
||||
}
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static bool RequiresWindowsShell(string executablePath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(executablePath);
|
||||
return extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".bat", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildWindowsShellCommand(string executablePath, IReadOnlyList<string> arguments)
|
||||
{
|
||||
IEnumerable<string> tokens = [executablePath, .. arguments];
|
||||
return string.Join(" ", tokens.Select(QuoteWindowsShellToken));
|
||||
}
|
||||
|
||||
private static string QuoteWindowsShellToken(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
bool needsQuotes = value.Any(character =>
|
||||
char.IsWhiteSpace(character)
|
||||
|| character is '&' or '(' or ')' or '[' or ']' or '{' or '}' or '^' or '=' or ';' or '!' or '+' or ',' or '`' or '~');
|
||||
|
||||
if (!needsQuotes)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
|
||||
}
|
||||
|
||||
private static string NormalizeOutput(string? standardOutput, string? standardError)
|
||||
{
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
new[] { standardOutput, standardError }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!.Trim()));
|
||||
}
|
||||
|
||||
private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError);
|
||||
|
||||
[GeneratedRegex(@"\b(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)]
|
||||
private static partial Regex SemanticVersionPattern();
|
||||
}
|
||||
@@ -179,6 +179,8 @@ public sealed class SidecarProtocolHost
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
CopilotCliContext cliContext;
|
||||
SidecarConnectionDiagnosticsDto connection;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -197,14 +199,31 @@ public sealed class SidecarProtocolHost
|
||||
};
|
||||
}
|
||||
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
models = await ListAvailableModelsAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count);
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
GetAuthStatusResponse? authStatus =
|
||||
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
|
||||
authStatus,
|
||||
cliContext.Environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account);
|
||||
Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
}
|
||||
|
||||
@@ -234,14 +253,9 @@ public sealed class SidecarProtocolHost
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return models
|
||||
.Select(model => new SidecarModelCapabilityDto
|
||||
@@ -278,7 +292,9 @@ public sealed class SidecarProtocolHost
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount)
|
||||
int modelCount,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
@@ -293,13 +309,17 @@ public sealed class SidecarProtocolHost
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception)
|
||||
Exception exception,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
@@ -312,6 +332,8 @@ public sealed class SidecarProtocolHost
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user