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

246 lines
9.9 KiB
C#

using Json.Schema;
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; }
AdapterRuntimeStatus DetectRuntime();
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,
string RunningDetail);
internal sealed record NodeAdapterContract(
string Capability,
string DisplayName,
IReadOnlyList<string> Features,
JsonSchema RequestSchema);
internal sealed record AdapterDescriptor(
string DirectoryPath,
string EntrypointPath,
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", "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"),
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);
using var contractDocument = JsonDocument.Parse(File.ReadAllBytes(contractPath));
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();
// 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,
manifest,
new NodeAdapterContract(capability, displayName, features, schema));
}
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 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 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 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();
}
}