246 lines
9.9 KiB
C#
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();
|
|
}
|
|
}
|