SDT/src/DevTool.Host.Bridge/BridgeStdioServer.cs
stan44 2c5493f249
Some checks failed
reliability-matrix / ubuntu-latest / .NET tests (push) Failing after 2m46s
reliability-matrix / macos-latest / .NET tests (push) Has been cancelled
reliability-matrix / windows-latest / .NET tests (push) Has been cancelled
second first push?
2026-03-01 21:40:14 -06:00

357 lines
15 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Sdt.Config;
using Sdt.Core;
namespace Sdt.Bridge;
public sealed class BridgeStdioServer
{
private readonly JsonSerializerOptions _json;
private readonly string? _startupProjectRoot;
private readonly RunEventLogReader _eventReader = new();
private readonly ConfigDoctorService _doctor = new(new ToolProbeService(), new RequirementResolver());
private readonly SetupWizardConfigService _setupConfigService = new(new RequirementResolver());
private readonly ConfigDoctorAutoFixService _doctorFixes = new();
public BridgeStdioServer(string? startupProjectRoot = null)
{
_startupProjectRoot = startupProjectRoot;
_json = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
_json.Converters.Add(new JsonStringEnumConverter());
}
public async Task<int> RunAsync(CancellationToken cancellationToken = default)
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await Console.In.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
BridgeResponseEnvelope response;
try
{
var request = JsonSerializer.Deserialize<BridgeRequestEnvelope>(line, _json);
if (request is null || string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Method))
{
response = Error(null, "bad_request", "Invalid bridge request envelope.");
}
else
{
response = await HandleAsync(request, cancellationToken).ConfigureAwait(false);
}
}
catch (JsonException jex)
{
response = Error(null, "bad_json", $"Invalid JSON: {jex.Message}");
}
catch (Exception ex)
{
response = Error(null, "internal_error", ex.Message);
}
Console.Out.WriteLine(JsonSerializer.Serialize(response, _json));
await Console.Out.FlushAsync(cancellationToken).ConfigureAwait(false);
}
return 0;
}
private async Task<BridgeResponseEnvelope> HandleAsync(BridgeRequestEnvelope request, CancellationToken cancellationToken)
{
try
{
return request.Method switch
{
"workspace.get" => Ok(request.Id, HandleWorkspaceGet(request.Params)),
"workspace.add" => Ok(request.Id, HandleWorkspaceAdd(request.Params)),
"favorites.list" => Ok(request.Id, HandleFavoritesList(request.Params)),
"favorites.toggle" => Ok(request.Id, HandleFavoritesToggle(request.Params)),
"history.list" => Ok(request.Id, HandleHistoryList(request.Params)),
"events.listFiles" => Ok(request.Id, HandleEventsListFiles(request.Params)),
"events.readFile" => Ok(request.Id, HandleEventsReadFile(request.Params)),
"envProfiles.list" => Ok(request.Id, HandleEnvProfilesList(request.Params)),
"envProfiles.resolve" => Ok(request.Id, HandleEnvProfilesResolve(request.Params)),
"doctor.run" => Ok(request.Id, await HandleDoctorRunAsync(request.Params, cancellationToken).ConfigureAwait(false)),
"setup.plan" => Ok(request.Id, HandleSetupPlan(request.Params)),
_ => Error(request.Id, "method_not_found", $"Unsupported bridge method: {request.Method}")
};
}
catch (BridgeValidationException vex)
{
return Error(request.Id, "validation_failed", vex.Message);
}
catch (Exception ex)
{
return Error(request.Id, "method_failed", ex.Message);
}
}
private object HandleWorkspaceGet(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? Path.GetFullPath(startDir);
var inventory = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace);
return new
{
workspaceRoot,
currentProjectRoot = currentRoot,
configuredProjects = workspace.Projects.Select(project => new
{
name = project.Name,
description = project.Description,
path = project.Path,
tags = project.Tags,
toolFamilies = project.ToolFamilies,
disabled = project.Disabled,
detectedBy = project.DetectedBy,
lastValidatedUtc = project.LastValidatedUtc,
resolvedRoot = WorkspaceLoader.ResolveProjectRoot(workspaceRoot, project)
}),
favorites = workspace.Favorites.Select(f => new
{
projectPath = f.ProjectPath,
workflowId = f.WorkflowId,
label = f.Label,
resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f)
}),
knownProjects = inventory.KnownProjects,
candidates = inventory.Candidates,
scanStats = inventory.Snapshot.ScanStats
};
}
private object HandleWorkspaceAdd(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var candidatePath = GetRequiredString(@params, "candidatePath");
var initConfig = GetBool(@params, "initializeConfig") ?? false;
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
var absoluteCandidate = Path.GetFullPath(candidatePath);
if (!Directory.Exists(absoluteCandidate))
throw new BridgeValidationException($"Candidate path does not exist: {absoluteCandidate}");
if (initConfig && !File.Exists(Path.Combine(absoluteCandidate, "devtool.json")))
{
var scan = ConfigBootstrapper.Scan(absoluteCandidate);
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
}
var existing = workspace.Projects.Any(p =>
string.Equals(
WorkspaceLoader.ResolveProjectRoot(workspaceRoot, p),
absoluteCandidate,
StringComparison.OrdinalIgnoreCase));
if (!existing)
{
var relPath = Path.GetRelativePath(workspaceRoot, absoluteCandidate);
workspace.Projects.Add(new WorkspaceProject
{
Name = Path.GetFileName(absoluteCandidate),
Description = initConfig ? "Added via GUI bridge (initialized)." : "Added via GUI bridge.",
Path = relPath,
Tags = [],
ToolFamilies = [],
Disabled = false,
DetectedBy = "inventory",
LastValidatedUtc = DateTimeOffset.UtcNow,
});
WorkspaceLoader.Save(workspaceRoot, workspace);
}
return HandleWorkspaceGet(@params);
}
private object HandleFavoritesList(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
return workspace.Favorites.Select(f => new
{
projectPath = f.ProjectPath,
workflowId = f.WorkflowId,
label = f.Label,
resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f)
}).ToList();
}
private object HandleFavoritesToggle(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var projectPath = GetRequiredString(@params, "favoriteProjectPath");
var workflowId = GetRequiredString(@params, "workflowId");
var label = GetString(@params, "label");
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
var absoluteProject = Path.GetFullPath(projectPath);
var relativeProject = Path.IsPathRooted(projectPath) ? Path.GetRelativePath(workspaceRoot, absoluteProject) : projectPath;
var existing = workspace.Favorites.FirstOrDefault(f =>
string.Equals(WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f), absoluteProject, StringComparison.OrdinalIgnoreCase) &&
string.Equals(f.WorkflowId, workflowId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
workspace.Favorites.Remove(existing);
}
else
{
workspace.Favorites.Add(new WorkspaceFavorite
{
ProjectPath = relativeProject,
WorkflowId = workflowId,
Label = string.IsNullOrWhiteSpace(label) ? null : label
});
}
WorkspaceLoader.Save(workspaceRoot, workspace);
return HandleFavoritesList(@params);
}
private object HandleHistoryList(JsonElement @params)
{
var projectRoot = ResolveProjectRootForProjectScopedMethod(@params);
var limit = GetInt(@params, "limit") ?? 50;
return _eventReader.ListRunHistory(projectRoot, Math.Clamp(limit, 1, 500));
}
private object HandleEventsListFiles(JsonElement @params)
{
var projectRoot = ResolveProjectRootForProjectScopedMethod(@params);
return _eventReader.ListEventFiles(projectRoot);
}
private object HandleEventsReadFile(JsonElement @params)
{
var projectRoot = ResolveProjectRootForProjectScopedMethod(@params);
var filePath = GetRequiredString(@params, "filePath");
var absolute = Path.GetFullPath(filePath);
var eventsRoot = Path.GetFullPath(Path.Combine(projectRoot, ".sdt", "events"));
if (!absolute.StartsWith(eventsRoot, StringComparison.OrdinalIgnoreCase))
throw new BridgeValidationException("Event file path must be under .sdt/events.");
return _eventReader.ReadEvents(absolute);
}
private object HandleEnvProfilesList(JsonElement @params)
{
var loaded = LoadProject(@params);
var envProfiles = loaded.Config.EnvProfiles;
return new
{
active = envProfiles?.Active,
profiles = envProfiles?.Profiles ?? []
};
}
private object HandleEnvProfilesResolve(JsonElement @params)
{
var loaded = LoadProject(@params);
var profileId = GetString(@params, "envProfile");
var effective = EnvProfileService.ResolveEffectiveEnv(loaded.Config, profileId);
return new
{
selected = string.IsNullOrWhiteSpace(profileId) ? loaded.Config.EnvProfiles?.Active : profileId,
values = effective
};
}
private async Task<object> HandleDoctorRunAsync(JsonElement @params, CancellationToken cancellationToken)
{
var loaded = LoadProject(@params);
var report = await _doctor.RunAsync(loaded.Config, loaded.ProjectRoot, cancellationToken).ConfigureAwait(false);
return report;
}
private object HandleSetupPlan(JsonElement @params)
{
var loaded = LoadProject(@params);
var report = _doctor.RunAsync(loaded.Config, loaded.ProjectRoot).GetAwaiter().GetResult();
var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config);
var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot);
return new
{
projectRoot = loaded.ProjectRoot,
doctor = new
{
failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail),
warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn),
checks = report.Checks
},
plan = new[]
{
new { id = "doctor", label = "Run config doctor", mode = "read-only", count = report.Checks.Count },
new { id = "autofix-dirs", label = "Create missing working directories", mode = "preview", count = missingDirs.Count },
new { id = "legacy-migration", label = "Migrate legacy targets -> workflows", mode = "preview", count = loaded.Config.Targets.Count > 0 ? 1 : 0 },
new { id = "recommended-config", label = "Apply recommended config enhancements", mode = "preview", count = update.Changes.Count },
},
recommendedChanges = update.Changes
};
}
private LoadedProjectConfig LoadProject(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No devtool.json found for project.");
}
private string ResolveProjectRootForProjectScopedMethod(JsonElement @params)
{
var explicitRoot = GetString(@params, "projectRoot");
if (!string.IsNullOrWhiteSpace(explicitRoot))
return Path.GetFullPath(explicitRoot);
return LoadProject(@params).ProjectRoot;
}
private static string? GetString(JsonElement @params, string name)
{
if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop))
return null;
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
}
private static string GetRequiredString(JsonElement @params, string name)
{
var value = GetString(@params, name);
if (string.IsNullOrWhiteSpace(value))
throw new BridgeValidationException($"Missing required parameter '{name}'.");
return value;
}
private static bool? GetBool(JsonElement @params, string name)
{
if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop))
return null;
return prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False ? prop.GetBoolean() : null;
}
private static int? GetInt(JsonElement @params, string name)
{
if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop))
return null;
return prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var i) ? i : null;
}
private static BridgeResponseEnvelope Ok(string? id, object result) =>
new(id, true, result, null);
private static BridgeResponseEnvelope Error(string? id, string code, string message, object? details = null) =>
new(id, false, null, new BridgeErrorEnvelope(code, message, details));
}
public sealed class BridgeValidationException(string message) : Exception(message);