zcbot/windows-node/Zcbot.WindowsNode/NodeAdapters.cs

324 lines
13 KiB
C#

using Json.Schema;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Zcbot.WindowsNode;
internal interface INodeAdapter
{
string Capability { get; }
string DisplayName { get; }
string AdapterVersion { get; }
IReadOnlyList<string> Features { get; }
bool HasActiveJobs { get; }
string RunningDetail { get; }
bool SupportsLocalAcceptance { get; }
string RuntimePath { get; }
string ContractPath { get; }
string ContractSha256 { get; }
string? WorkspaceStateFilename { get; }
IReadOnlyList<string> PreviewOutputIds { get; }
AdapterRuntimeStatus DetectRuntime();
Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
string workRoot,
IProgress<string> progress,
CancellationToken cancellationToken);
bool ValidateRequest(JsonElement request);
Task RunAsync(RecoverableJob job);
void Cancel(Guid jobId);
}
internal sealed record AdapterManifest(
string Capability,
string AdapterVersion,
string Runtime,
string? RuntimeId,
string Entrypoint,
string Contract,
int WorkerTimeoutMinutes,
string RunningDetail);
internal sealed record AdapterAcceptanceResult(
bool Passed,
string ReportPath,
string Detail);
internal sealed record NodeAdapterContract(
string Capability,
string DisplayName,
IReadOnlyList<string> Features,
JsonSchema RequestSchema,
string? WorkspaceStateFilename,
IReadOnlyList<string> PreviewOutputIds);
internal sealed record AdapterDescriptor(
string DirectoryPath,
string EntrypointPath,
string ContractPath,
string ContractSha256,
AdapterManifest Manifest,
NodeAdapterContract Contract)
{
internal static AdapterDescriptor Load(string directory)
{
var root = Path.GetFullPath(directory);
var manifestPath = ResolveFile(root, "adapter.json");
using var manifestDocument = JsonDocument.Parse(File.ReadAllBytes(manifestPath));
var manifestRoot = manifestDocument.RootElement;
RequireOnlyProperties(
manifestRoot, "capability", "adapter_version", "runtime", "runtime_id",
"entrypoint", "contract", "worker_timeout_minutes", "running_detail");
var manifest = new AdapterManifest(
RequiredString(manifestRoot, "capability"),
RequiredVersion(manifestRoot, "adapter_version"),
RequiredString(manifestRoot, "runtime"),
OptionalString(manifestRoot, "runtime_id"),
RequiredString(manifestRoot, "entrypoint"),
RequiredString(manifestRoot, "contract"),
OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440),
RequiredString(manifestRoot, "running_detail"));
if (!Regex.IsMatch(manifest.Capability, "^[a-z][a-z0-9_.-]+@v[1-9][0-9]*$"))
{
throw new InvalidDataException("Adapter capability is invalid.");
}
if (manifest.Runtime is not ("python" or "executable"))
{
throw new InvalidDataException("Adapter runtime must be python or executable.");
}
if (manifest.Runtime == "python"
&& (manifest.RuntimeId is null
|| !Regex.IsMatch(manifest.RuntimeId, "^[a-z][a-z0-9_-]{0,31}$")))
{
throw new InvalidDataException("Python adapter runtime_id is invalid.");
}
if (manifest.Runtime == "executable" && manifest.RuntimeId is not null)
{
throw new InvalidDataException("Executable adapter must not declare runtime_id.");
}
var contractPath = ResolveFile(root, manifest.Contract);
var contractBytes = File.ReadAllBytes(contractPath);
var contractSha256 = Convert.ToHexString(SHA256.HashData(contractBytes)).ToLowerInvariant();
using var contractDocument = JsonDocument.Parse(contractBytes);
var contractRoot = contractDocument.RootElement;
var capability = RequiredString(contractRoot, "capability");
if (!capability.Equals(manifest.Capability, StringComparison.Ordinal))
{
throw new InvalidDataException("Adapter manifest capability does not match its contract.");
}
var displayName = RequiredString(contractRoot, "display_name");
var currentVersion = ParseVersion(manifest.AdapterVersion);
var features = contractRoot.GetProperty("features").EnumerateObject()
.Where(item => ParseVersion(item.Value.GetString() ?? "0.0.0") <= currentVersion)
.Select(item => item.Name)
.ToArray();
string? workspaceStateFilename = null;
IReadOnlyList<string> previewOutputIds = [];
if (contractRoot.TryGetProperty("workspace", out var workspace)
&& workspace.ValueKind == JsonValueKind.Object)
{
var stateOutput = RequiredString(workspace, "state_output");
var outputs = contractRoot.GetProperty("outputs");
if (!outputs.TryGetProperty(stateOutput, out var stateSpec))
{
throw new InvalidDataException("Workspace state output is missing from contract.");
}
workspaceStateFilename = RequiredString(stateSpec, "filename");
previewOutputIds = workspace.GetProperty("preview_outputs")
.EnumerateArray()
.Select(item => item.GetString()
?? throw new InvalidDataException("Workspace preview output is invalid."))
.ToArray();
}
// JsonSchema may retain the source element for deferred evaluation. Give it an
// independent backing document before contractDocument is disposed.
var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone());
var entrypointPath = ResolveFile(root, manifest.Entrypoint);
if ((manifest.Runtime == "python"
&& !Path.GetExtension(entrypointPath).Equals(".py", StringComparison.OrdinalIgnoreCase))
|| (manifest.Runtime == "executable"
&& !Path.GetExtension(entrypointPath).Equals(".exe", StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidDataException("Adapter entrypoint extension does not match its runtime.");
}
return new AdapterDescriptor(
root,
entrypointPath,
contractPath,
contractSha256,
manifest,
new NodeAdapterContract(
capability,
displayName,
features,
schema,
workspaceStateFilename,
previewOutputIds));
}
private static string ResolveFile(string root, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathFullyQualified(relativePath))
{
throw new InvalidDataException("Adapter file path must be relative.");
}
var resolved = Path.GetFullPath(Path.Combine(root, relativePath));
if (!resolved.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| !File.Exists(resolved))
{
throw new InvalidDataException("Adapter file is missing or outside its directory.");
}
return resolved;
}
private static void RequireOnlyProperties(JsonElement value, params string[] names)
{
if (value.ValueKind != JsonValueKind.Object)
{
throw new InvalidDataException("Adapter manifest must be an object.");
}
var allowed = names.ToHashSet(StringComparer.Ordinal);
if (value.EnumerateObject().Any(item => !allowed.Contains(item.Name)))
{
throw new InvalidDataException("Adapter manifest contains unknown properties.");
}
}
private static string RequiredString(JsonElement value, string name) =>
value.TryGetProperty(name, out var property)
&& property.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(property.GetString())
? property.GetString()!
: throw new InvalidDataException($"Adapter property {name} is missing.");
private static string RequiredVersion(JsonElement value, string name)
{
var version = RequiredString(value, name);
if (!Regex.IsMatch(version, "^[0-9]+\\.[0-9]+\\.[0-9]+$")
|| !Version.TryParse(version, out _))
{
throw new InvalidDataException($"Adapter property {name} is not a semantic version.");
}
return version;
}
private static int OptionalInteger(
JsonElement value, string name, int defaultValue, int minimum, int maximum)
{
if (!value.TryGetProperty(name, out var property)) return defaultValue;
return property.TryGetInt32(out var result)
&& result >= minimum
&& result <= maximum
? result
: throw new InvalidDataException($"Adapter property {name} is invalid.");
}
private static string? OptionalString(JsonElement value, string name) =>
!value.TryGetProperty(name, out var property) || property.ValueKind == JsonValueKind.Null
? null
: property.ValueKind == JsonValueKind.String
? property.GetString()
: throw new InvalidDataException($"Adapter property {name} must be a string.");
private static Version ParseVersion(string value) =>
Version.TryParse(value, out var version) ? version : new Version(0, 0, 0);
}
internal sealed class ProcessNodeAdapter : INodeAdapter
{
private static readonly EvaluationOptions SchemaOptions = new()
{
OutputFormat = OutputFormat.Flag,
RequireFormatValidation = true,
};
private readonly AdapterProcessRunner runner;
private AdapterRuntimeStatus? cachedRuntime;
private DateTimeOffset runtimeCheckedAt;
internal ProcessNodeAdapter(AdapterDescriptor descriptor, JobInboxStore inbox)
{
Descriptor = descriptor;
runner = new AdapterProcessRunner(descriptor, inbox);
}
internal AdapterDescriptor Descriptor { get; }
public string Capability => Descriptor.Contract.Capability;
public string DisplayName => Descriptor.Contract.DisplayName;
public string AdapterVersion => Descriptor.Manifest.AdapterVersion;
public IReadOnlyList<string> Features => Descriptor.Contract.Features;
public bool HasActiveJobs => runner.HasActiveJobs;
public string RunningDetail => Descriptor.Manifest.RunningDetail;
public bool SupportsLocalAcceptance =>
Capability.Equals(
"ansys.mechanical.static_structural@v1", StringComparison.Ordinal)
&& File.Exists(Path.Combine(Descriptor.DirectoryPath, "acceptance.py"));
public string RuntimePath => runner.RuntimePath;
public string ContractPath => Descriptor.ContractPath;
public string ContractSha256 => Descriptor.ContractSha256;
public string? WorkspaceStateFilename => Descriptor.Contract.WorkspaceStateFilename;
public IReadOnlyList<string> PreviewOutputIds => Descriptor.Contract.PreviewOutputIds;
public AdapterRuntimeStatus DetectRuntime()
{
if (cachedRuntime is null
|| DateTimeOffset.UtcNow - runtimeCheckedAt > TimeSpan.FromSeconds(30))
{
cachedRuntime = runner.Probe();
runtimeCheckedAt = DateTimeOffset.UtcNow;
}
return cachedRuntime;
}
public bool ValidateRequest(JsonElement request) =>
Descriptor.Contract.RequestSchema.Evaluate(request, SchemaOptions).IsValid;
public Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
string workRoot,
IProgress<string> progress,
CancellationToken cancellationToken) => SupportsLocalAcceptance
? runner.RunAcceptanceAsync(workRoot, progress, cancellationToken)
: throw new InvalidOperationException(
"This adapter does not provide a local acceptance suite.");
public Task RunAsync(RecoverableJob job) => runner.RunAsync(job);
public void Cancel(Guid jobId) => runner.Cancel(jobId);
}
internal sealed class NodeAdapterRegistry
{
private readonly IReadOnlyDictionary<string, INodeAdapter> adapters;
internal NodeAdapterRegistry(IEnumerable<INodeAdapter> values)
{
adapters = values.ToDictionary(item => item.Capability, StringComparer.Ordinal);
}
internal static string AdapterRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "adapters"));
internal static NodeAdapterRegistry CreateDefault(JobInboxStore inbox) =>
new(DiscoverDescriptors().Select(item => new ProcessNodeAdapter(item, inbox)));
internal static IReadOnlyList<NodeAdapterContract> InstalledContracts =>
DiscoverDescriptors().Select(item => item.Contract).ToArray();
internal static IReadOnlyList<string> InstalledCapabilities =>
InstalledContracts.Select(item => item.Capability).ToArray();
internal IReadOnlyCollection<INodeAdapter> All => adapters.Values.ToArray();
internal INodeAdapter? Find(string capability) =>
adapters.TryGetValue(capability, out var adapter) ? adapter : null;
private static IReadOnlyList<AdapterDescriptor> DiscoverDescriptors()
{
if (!Directory.Exists(AdapterRoot)) return [];
return Directory.EnumerateDirectories(AdapterRoot)
.Where(path => File.Exists(Path.Combine(path, "adapter.json")))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(AdapterDescriptor.Load)
.ToArray();
}
}