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 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 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 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 adapters; internal NodeAdapterRegistry(IEnumerable 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 InstalledContracts => DiscoverDescriptors().Select(item => item.Contract).ToArray(); internal static IReadOnlyList InstalledCapabilities => InstalledContracts.Select(item => item.Capability).ToArray(); internal IReadOnlyCollection All => adapters.Values.ToArray(); internal INodeAdapter? Find(string capability) => adapters.TryGetValue(capability, out var adapter) ? adapter : null; private static IReadOnlyList 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(); } }