357 lines
15 KiB
C#
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);
|