zcbot/windows-node/Zcbot.WindowsNode/Adapters/AdapterContractLoader.cs

186 lines
8.1 KiB
C#

using Json.Schema;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Zcbot.WindowsNode;
internal static class AdapterContractLoader
{
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"));
ValidateManifest(manifest);
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 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();
var (workspaceStateFilename, previewOutputIds) = ReadWorkspace(contractRoot);
var schema = JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone());
var entrypointPath = ResolveFile(root, manifest.Entrypoint);
ValidateEntrypoint(manifest, entrypointPath);
return new AdapterDescriptor(
root,
entrypointPath,
contractPath,
contractSha256,
manifest,
new NodeAdapterContract(
capability,
RequiredString(contractRoot, "display_name"),
features,
schema,
workspaceStateFilename,
previewOutputIds));
}
private static void ValidateManifest(AdapterManifest manifest)
{
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.");
}
}
private static (string? StateFilename, IReadOnlyList<string> PreviewOutputIds) ReadWorkspace(
JsonElement contractRoot)
{
if (!contractRoot.TryGetProperty("workspace", out var workspace)
|| workspace.ValueKind != JsonValueKind.Object)
{
return (null, []);
}
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.");
}
var previewOutputIds = workspace.GetProperty("preview_outputs")
.EnumerateArray()
.Select(item => item.GetString()
?? throw new InvalidDataException("Workspace preview output is invalid."))
.ToArray();
return (RequiredString(stateSpec, "filename"), previewOutputIds);
}
private static void ValidateEntrypoint(AdapterManifest manifest, string entrypointPath)
{
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.");
}
}
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);
}