feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run

Opt-in (Scripting:Runner=OutOfProcess; InProcess stays the default and its code path is unchanged). Implements
the P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md with the owner's §14 decisions:

- Options: Scripting:Runner (InProcess | Shadow | OutOfProcess), Scripting:Runner:Seams, Scripting:Worker:*
  (Shards 2, HardGrace max(1 s,T), HeapLimitBytes 512 MiB, QuarantineAfterKills 2, UnavailableGrace 5 s,
  IsolationProbationRuns 20, PeCacheDir validated absolute + outside the content root (fail closed),
  ExecutablePath). Shadow is not implemented yet: runs InProcess with a startup Warning.
- Core.Scripting: IScriptRunner seam + InProcessScriptRunner (today's guard, the parity baseline).
- Core.Scripting.Protocol: protobuf envelopes, 4-byte LE framing (zero/oversize/truncated/garbage refused),
  Variant carrying the exact CLR type; unsupported types are explicit failures.
- ScriptWorker app: compiles in the worker, P-thread scheduler, host-driven soft cancel, fail-fast
  watchdog, heartbeats, ctx.Logger forwarding, exits on pipe EOF / parent gone, forbidden-assembly
  self-check; Akka and Grpc.Core.Api excluded from its folder. Shipped in script-worker/ (Build + Publish).
- Core.Scripting.OutOfProcess: K shards + isolation worker, soft T / hard T+G kill, attribution
  (deadline kill = one key; crash = suspects -> isolation; isolation crash = strike), kill-strike
  quarantine, backoff 250 ms -> 30 s, fail closed, cleared allow-listed env, nonce + pid handshake on a
  current-user pipe, Windows Job Object.
- VirtualTag + Calculation seams use the runner when selected; a runner-side failure (worker restarted /
  runner unavailable) publishes BadResourceUnavailable (0x80040000) — ScadaBridge#112. Script faults keep
  BadInternalError. The scripted-alarm seam stays in process (alarms last).

Tests: new ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests (Category=ScriptWorker, real processes; green on macOS and
on windev/Windows), seam tests in Host/Runtime/Calculation suites; mutation-checked.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 23:31:51 -05:00
parent f5c30ed0ef
commit 69a6e0b4c8
49 changed files with 5107 additions and 25 deletions
@@ -0,0 +1,214 @@
// lmxopcua#780 — host <-> ScriptWorker IPC (design docs/plans/2026-11-09-out-of-process-script-runner-design.md §4).
// Framing: 4-byte little-endian length prefix + one serialized Envelope. Zero-length and oversize frames are protocol
// faults. One request frame and one reply frame per evaluation; the only other worker->host traffic is logs and
// heartbeats. Bump protocol_version on any incompatible change.
syntax = "proto3";
package otopcua.scriptworker.v1;
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1";
message Envelope {
uint32 protocol_version = 1;
uint64 seq = 2;
oneof body {
Hello hello = 10; // host -> worker, first frame
Ready ready = 11; // worker -> host, reply to Hello
LoadScripts load = 12; // host -> worker
LoadResult load_result = 13; // worker -> host
Unload unload = 14; // host -> worker
EvalBatch eval = 15; // host -> worker
EvalResults results = 16; // worker -> host, streamed as runs complete
LogBatch logs = 17; // worker -> host, fire-and-forget, bounded
Heartbeat heartbeat = 18; // worker -> host, every heartbeat_interval_ms
Shutdown shutdown = 19; // host -> worker
Fault fault = 20; // either way; the sender closes after it
DiagnosticsRequest diag_request = 21; // host -> worker (tests / ops)
DiagnosticsReply diag_reply = 22; // worker -> host
Cancel cancel = 23; // host -> worker, at the soft deadline
}
}
message Hello {
string nonce = 1; // must be echoed in Ready
uint32 max_frame_bytes = 2;
uint32 eval_threads = 3; // the worker's scheduler width; 1 for the isolation worker
uint32 queue_capacity = 4; // queued evaluations above this answer BUSY
uint32 hard_grace_ms = 5; // worker watchdog: fail fast past soft + grace (+ margin)
uint32 heartbeat_interval_ms = 6;
uint32 max_log_lines = 7; // outbound log buffer cap
}
message Ready {
string nonce = 1;
int32 pid = 2;
string runtime_version = 3;
}
enum ContextKind {
CONTEXT_KIND_UNSPECIFIED = 0;
VIRTUAL_TAG = 1;
CALCULATION = 2;
ALARM_PREDICATE = 3;
}
message ScriptSource {
string key = 1;
bytes source_sha256 = 2;
ContextKind kind = 3;
string source = 4;
repeated string input_paths = 5; // slot order for EvalRequest.inputs
}
message LoadScripts {
uint64 load_id = 1;
repeated ScriptSource scripts = 2;
}
message LoadOutcome {
string key = 1;
bool ok = 2;
ScriptError error = 3; // COMPILE or SANDBOX when !ok
}
message LoadResult {
uint64 load_id = 1;
repeated LoadOutcome outcomes = 2;
}
message Unload {
repeated string keys = 1;
}
message EvalBatch {
repeated EvalRequest items = 1;
}
message EvalRequest {
uint64 eval_id = 1;
string key = 2;
repeated Input inputs = 3;
int64 now_utc_ticks = 4; // ctx.Now (host clock)
uint32 soft_timeout_ms = 5;
uint32 hard_grace_ms = 6; // this run's G; 0 = Hello.hard_grace_ms
}
message Input {
uint32 slot = 1; // index into ScriptSource.input_paths; ignored when path is set
Variant value = 2;
uint32 status_code = 3;
int64 source_ts_ticks = 4; // 0 = null
int64 server_ts_ticks = 5;
string path = 6; // inline path for an input with no registered slot
bool unsupported = 7; // the host could not encode the value's CLR type (see unsupported_type)
string unsupported_type = 8;
}
message EvalResult {
uint64 eval_id = 1;
oneof outcome {
Variant value = 2;
bool predicate = 3;
ScriptError error = 4;
}
uint64 allocated_bytes = 5; // GC.GetAllocatedBytesForCurrentThread around the run
uint32 run_micros = 6;
}
message EvalResults {
repeated EvalResult items = 1;
}
message ScriptError {
enum Kind {
THREW = 0;
COMPILE = 1;
SANDBOX = 2;
SOFT_TIMEOUT = 3;
UNSUPPORTED_TYPE = 4;
BUSY = 5;
NOT_LOADED = 6;
}
Kind kind = 1;
string message = 2;
}
message Variant {
enum TypeTag {
NULL = 0;
BOOLEAN = 1;
SBYTE = 2;
BYTE = 3;
INT16 = 4;
UINT16 = 5;
INT32 = 6;
UINT32 = 7;
INT64 = 8;
UINT64 = 9;
FLOAT = 10;
DOUBLE = 11;
STRING = 12;
DATETIME_UTC = 13;
ARRAY = 14;
}
TypeTag type = 1; // the exact CLR type: (short)ctx.GetTag(x).Value must keep working
oneof v {
bool b = 2;
sint64 i = 3;
uint64 u = 4;
double d = 5;
float f = 6;
string s = 7;
int64 dt_ticks = 8;
ArrayValue array = 9;
}
}
message ArrayValue {
Variant.TypeTag element_type = 1;
repeated Variant items = 2;
}
message LogBatch {
repeated LogLine lines = 1;
uint32 dropped = 2;
}
message LogLine {
string key = 1;
uint32 level = 2; // Serilog LogEventLevel
string rendered = 3;
string exception = 4;
int64 ts_ticks = 5;
}
message Heartbeat {
uint32 inflight = 1;
uint32 queued = 2;
int64 heap_bytes = 3;
uint32 gen2_count = 4;
}
message Shutdown {
}
// Sent by the host when it has answered the caller with a soft timeout: cancels the run's ctx.CancellationToken, so a
// polling script ends itself. The worker never cancels on its own clock, which keeps the in-process ordering — the
// caller always has its timeout before the script can observe cancellation.
message Cancel {
uint64 eval_id = 1;
}
message Fault {
string message = 1;
}
message DiagnosticsRequest {
}
message DiagnosticsReply {
int32 pid = 1;
repeated string loaded_assemblies = 2;
repeated string environment_names = 3;
string working_directory = 4;
}
@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
/// <summary>
/// The environment variables the host sets on a ScriptWorker (design §6). The nonce travels in the environment,
/// never on the command line (mxaccessgw <c>WorkerBootstrap.md</c>): a command line is world-readable on most
/// systems.
/// </summary>
public static class ScriptWorkerEnvironment
{
/// <summary>The pipe name the worker connects to.</summary>
public const string PipeName = "OTOPCUA_SCRIPTWORKER_PIPE";
/// <summary>The one-time launch nonce the worker must echo in <c>Ready</c>.</summary>
public const string Nonce = "OTOPCUA_SCRIPTWORKER_NONCE";
/// <summary>The host's process id; the worker exits when it disappears.</summary>
public const string ParentPid = "OTOPCUA_SCRIPTWORKER_PARENT_PID";
/// <summary>The worker's shard label, for its own diagnostics.</summary>
public const string Shard = "OTOPCUA_SCRIPTWORKER_SHARD";
/// <summary>
/// Exit code of a worker whose pipe reached end of stream (the host went away). Distinct from a crash so the
/// host's exit reason metric can tell them apart.
/// </summary>
public const int ExitPipeClosed = 3;
/// <summary>Exit code of a worker whose handshake or framing failed.</summary>
public const int ExitProtocolFault = 4;
/// <summary>Exit code of a worker whose parent process disappeared.</summary>
public const int ExitParentGone = 5;
/// <summary>Exit code of a worker that found a forbidden assembly loaded in itself.</summary>
public const int ExitForbiddenAssembly = 6;
/// <summary>
/// Assembly-name prefixes that must never be loaded in a worker (design §6): the host's cluster, database,
/// OPC UA and secrets stacks. The worker checks itself against this list and exits if it finds one.
/// </summary>
public static readonly IReadOnlyList<string> ForbiddenAssemblyPrefixes =
[
"Akka",
"Microsoft.Data.SqlClient",
"System.Data.SqlClient",
"Microsoft.EntityFrameworkCore",
"Opc.Ua",
"ZB.MOM.WW.Secrets",
"ZB.MOM.WW.OtOpcUa.Runtime",
"ZB.MOM.WW.OtOpcUa.Host",
"ZB.MOM.WW.OtOpcUa.Driver.",
"Grpc.",
];
}
@@ -0,0 +1,116 @@
using System.Buffers;
using System.Buffers.Binary;
using Google.Protobuf;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
/// <summary>
/// Length-prefixed protobuf framing for the ScriptWorker pipe (lmxopcua#780, design §4.2): a 4-byte little-endian
/// length, then one serialized <see cref="Envelope"/>. Copies mxaccessgw's <c>WorkerFrameProtocol</c> discipline:
/// zero-length, oversize, truncated and unparseable frames are all a <see cref="ScriptWorkerProtocolException"/>,
/// after which the stream is unusable and the peer is dropped.
/// </summary>
public static class ScriptWorkerFrameCodec
{
/// <summary>The protocol version both ends put on every envelope.</summary>
public const uint ProtocolVersion = 1;
/// <summary>Default frame cap: 4 MiB.</summary>
public const int DefaultMaxFrameBytes = 4 * 1024 * 1024;
/// <summary>Writes one frame. Not thread-safe: callers serialise writes.</summary>
/// <param name="stream">The pipe.</param>
/// <param name="envelope">The envelope.</param>
/// <param name="maxFrameBytes">The negotiated cap; a larger envelope is refused before anything is written.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>A task that completes when the frame is written (not flushed).</returns>
public static async ValueTask WriteAsync(Stream stream, Envelope envelope, int maxFrameBytes, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(envelope);
var size = envelope.CalculateSize();
if (size == 0) throw new ScriptWorkerProtocolException("refusing to write an empty frame");
if (size > maxFrameBytes)
throw new ScriptWorkerProtocolException($"frame of {size} bytes exceeds the {maxFrameBytes}-byte cap");
var buffer = ArrayPool<byte>.Shared.Rent(size + 4);
try
{
BinaryPrimitives.WriteInt32LittleEndian(buffer, size);
envelope.WriteTo(new Span<byte>(buffer, 4, size));
await stream.WriteAsync(buffer.AsMemory(0, size + 4), ct).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>Reads one frame.</summary>
/// <param name="stream">The pipe.</param>
/// <param name="maxFrameBytes">The negotiated cap.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The envelope, or null on a clean end of stream at a frame boundary.</returns>
/// <exception cref="ScriptWorkerProtocolException">A zero-length, oversize, truncated or unparseable frame.</exception>
public static async ValueTask<Envelope?> ReadAsync(Stream stream, int maxFrameBytes, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(stream);
var header = new byte[4];
var got = await ReadFullyAsync(stream, header, ct).ConfigureAwait(false);
if (got == 0) return null;
if (got < 4) throw new ScriptWorkerProtocolException("truncated frame header");
var size = BinaryPrimitives.ReadInt32LittleEndian(header);
if (size == 0) throw new ScriptWorkerProtocolException("zero-length frame");
if (size < 0 || size > maxFrameBytes)
throw new ScriptWorkerProtocolException($"frame length {size} is outside 1..{maxFrameBytes}");
var body = ArrayPool<byte>.Shared.Rent(size);
try
{
var read = await ReadFullyAsync(stream, body.AsMemory(0, size), ct).ConfigureAwait(false);
if (read < size) throw new ScriptWorkerProtocolException($"truncated frame: {read} of {size} bytes");
Envelope envelope;
try
{
envelope = Envelope.Parser.ParseFrom(body, 0, size);
}
catch (InvalidProtocolBufferException ex)
{
throw new ScriptWorkerProtocolException($"unparseable frame: {ex.Message}", ex);
}
if (envelope.ProtocolVersion != ProtocolVersion)
throw new ScriptWorkerProtocolException(
$"protocol version {envelope.ProtocolVersion} is not the supported {ProtocolVersion}");
if (envelope.BodyCase == Envelope.BodyOneofCase.None)
throw new ScriptWorkerProtocolException("frame carries no body");
return envelope;
}
finally
{
ArrayPool<byte>.Shared.Return(body);
}
}
private static async ValueTask<int> ReadFullyAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
{
var total = 0;
while (total < buffer.Length)
{
var n = await stream.ReadAsync(buffer[total..], ct).ConfigureAwait(false);
if (n == 0) break;
total += n;
}
return total;
}
}
/// <summary>A framing or schema violation on the ScriptWorker pipe; the connection is dropped.</summary>
public sealed class ScriptWorkerProtocolException : Exception
{
/// <summary>Initializes the exception.</summary>
/// <param name="message">What was wrong.</param>
/// <param name="inner">The underlying parser error, if any.</param>
public ScriptWorkerProtocolException(string message, Exception? inner = null) : base(message, inner) { }
}
@@ -0,0 +1,159 @@
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
/// <summary>
/// CLR value ↔ <see cref="Variant"/> (design §4.4). The exact boxed CLR type survives the round trip — a script
/// doing <c>(short)ctx.GetTag(x).Value</c> throws <see cref="InvalidCastException"/> if a <c>short</c> arrives as an
/// <c>int</c> — so the type tag names the CLR type, not just an OPC UA one. A type with no tag is never converted:
/// <see cref="TryEncode"/> refuses it, and the caller reports <c>UNSUPPORTED_TYPE</c> (design §14 #11).
/// </summary>
public static class VariantCodec
{
/// <summary>Encodes <paramref name="value"/>.</summary>
/// <param name="value">The boxed CLR value (null allowed).</param>
/// <param name="variant">The encoded variant when supported.</param>
/// <param name="unsupportedType">The CLR type name when not supported.</param>
/// <returns><c>true</c> when the type has a tag.</returns>
public static bool TryEncode(object? value, out Variant variant, out string? unsupportedType)
{
unsupportedType = null;
variant = new Variant();
switch (value)
{
case null: variant.Type = Variant.Types.TypeTag.Null; return true;
case bool b: variant.Type = Variant.Types.TypeTag.Boolean; variant.B = b; return true;
case sbyte v: variant.Type = Variant.Types.TypeTag.Sbyte; variant.I = v; return true;
case byte v: variant.Type = Variant.Types.TypeTag.Byte; variant.U = v; return true;
case short v: variant.Type = Variant.Types.TypeTag.Int16; variant.I = v; return true;
case ushort v: variant.Type = Variant.Types.TypeTag.Uint16; variant.U = v; return true;
case int v: variant.Type = Variant.Types.TypeTag.Int32; variant.I = v; return true;
case uint v: variant.Type = Variant.Types.TypeTag.Uint32; variant.U = v; return true;
case long v: variant.Type = Variant.Types.TypeTag.Int64; variant.I = v; return true;
case ulong v: variant.Type = Variant.Types.TypeTag.Uint64; variant.U = v; return true;
case float v: variant.Type = Variant.Types.TypeTag.Float; variant.F = v; return true;
case double v: variant.Type = Variant.Types.TypeTag.Double; variant.D = v; return true;
case string v: variant.Type = Variant.Types.TypeTag.String; variant.S = v; return true;
case DateTime v:
variant.Type = Variant.Types.TypeTag.DatetimeUtc;
variant.DtTicks = ToUtc(v).Ticks;
return true;
case Array array when array.Rank == 1 && TryElementTag(array.GetType().GetElementType()!, out var elementTag):
{
var av = new ArrayValue { ElementType = elementTag };
foreach (var item in array)
{
if (!TryEncode(item, out var element, out unsupportedType)) return false;
av.Items.Add(element);
}
variant.Type = Variant.Types.TypeTag.Array;
variant.Array = av;
return true;
}
default:
unsupportedType = value.GetType().FullName ?? value.GetType().Name;
return false;
}
}
/// <summary>Decodes <paramref name="variant"/> back to the boxed CLR value it was encoded from.</summary>
/// <param name="variant">The variant.</param>
/// <returns>The boxed value.</returns>
/// <exception cref="ScriptWorkerProtocolException">An unknown tag, or a payload that does not match the tag.</exception>
public static object? Decode(Variant? variant)
{
if (variant is null) return null;
return variant.Type switch
{
Variant.Types.TypeTag.Null => null,
Variant.Types.TypeTag.Boolean => Expect(variant, Variant.VOneofCase.B).B,
Variant.Types.TypeTag.Sbyte => checked((sbyte)Expect(variant, Variant.VOneofCase.I).I),
Variant.Types.TypeTag.Byte => checked((byte)Expect(variant, Variant.VOneofCase.U).U),
Variant.Types.TypeTag.Int16 => checked((short)Expect(variant, Variant.VOneofCase.I).I),
Variant.Types.TypeTag.Uint16 => checked((ushort)Expect(variant, Variant.VOneofCase.U).U),
Variant.Types.TypeTag.Int32 => checked((int)Expect(variant, Variant.VOneofCase.I).I),
Variant.Types.TypeTag.Uint32 => checked((uint)Expect(variant, Variant.VOneofCase.U).U),
Variant.Types.TypeTag.Int64 => Expect(variant, Variant.VOneofCase.I).I,
Variant.Types.TypeTag.Uint64 => Expect(variant, Variant.VOneofCase.U).U,
Variant.Types.TypeTag.Float => Expect(variant, Variant.VOneofCase.F).F,
Variant.Types.TypeTag.Double => Expect(variant, Variant.VOneofCase.D).D,
Variant.Types.TypeTag.String => Expect(variant, Variant.VOneofCase.S).S,
Variant.Types.TypeTag.DatetimeUtc => new DateTime(Expect(variant, Variant.VOneofCase.DtTicks).DtTicks, DateTimeKind.Utc),
Variant.Types.TypeTag.Array => DecodeArray(Expect(variant, Variant.VOneofCase.Array).Array),
_ => throw new ScriptWorkerProtocolException($"unknown variant type tag {(int)variant.Type}"),
};
}
/// <summary>A <see cref="DateTime"/> as UTC: a <c>Local</c> value is converted; an <c>Unspecified</c> one is taken as UTC.</summary>
/// <param name="value">The value.</param>
/// <returns>The UTC value.</returns>
public static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
private static Variant Expect(Variant variant, Variant.VOneofCase expected)
{
// proto3 drops a default scalar (0, false, "") from the oneof, so an absent payload is the default value.
if (variant.VCase != expected && variant.VCase != Variant.VOneofCase.None)
throw new ScriptWorkerProtocolException($"variant tagged {variant.Type} carries a {variant.VCase} payload");
return variant;
}
private static Array DecodeArray(ArrayValue? array)
{
if (array is null) throw new ScriptWorkerProtocolException("array variant without an array payload");
var elementType = ClrType(array.ElementType);
var result = Array.CreateInstance(elementType, array.Items.Count);
for (var i = 0; i < array.Items.Count; i++)
{
var item = array.Items[i];
if (item.Type != array.ElementType && item.Type != Variant.Types.TypeTag.Null)
throw new ScriptWorkerProtocolException($"array of {array.ElementType} holds a {item.Type} element");
result.SetValue(Decode(item), i);
}
return result;
}
private static bool TryElementTag(Type elementType, out Variant.Types.TypeTag tag)
{
tag = elementType switch
{
_ when elementType == typeof(bool) => Variant.Types.TypeTag.Boolean,
_ when elementType == typeof(sbyte) => Variant.Types.TypeTag.Sbyte,
_ when elementType == typeof(byte) => Variant.Types.TypeTag.Byte,
_ when elementType == typeof(short) => Variant.Types.TypeTag.Int16,
_ when elementType == typeof(ushort) => Variant.Types.TypeTag.Uint16,
_ when elementType == typeof(int) => Variant.Types.TypeTag.Int32,
_ when elementType == typeof(uint) => Variant.Types.TypeTag.Uint32,
_ when elementType == typeof(long) => Variant.Types.TypeTag.Int64,
_ when elementType == typeof(ulong) => Variant.Types.TypeTag.Uint64,
_ when elementType == typeof(float) => Variant.Types.TypeTag.Float,
_ when elementType == typeof(double) => Variant.Types.TypeTag.Double,
_ when elementType == typeof(string) => Variant.Types.TypeTag.String,
_ when elementType == typeof(DateTime) => Variant.Types.TypeTag.DatetimeUtc,
_ => Variant.Types.TypeTag.Null,
};
return tag != Variant.Types.TypeTag.Null;
}
private static Type ClrType(Variant.Types.TypeTag tag) => tag switch
{
Variant.Types.TypeTag.Boolean => typeof(bool),
Variant.Types.TypeTag.Sbyte => typeof(sbyte),
Variant.Types.TypeTag.Byte => typeof(byte),
Variant.Types.TypeTag.Int16 => typeof(short),
Variant.Types.TypeTag.Uint16 => typeof(ushort),
Variant.Types.TypeTag.Int32 => typeof(int),
Variant.Types.TypeTag.Uint32 => typeof(uint),
Variant.Types.TypeTag.Int64 => typeof(long),
Variant.Types.TypeTag.Uint64 => typeof(ulong),
Variant.Types.TypeTag.Float => typeof(float),
Variant.Types.TypeTag.Double => typeof(double),
Variant.Types.TypeTag.String => typeof(string),
Variant.Types.TypeTag.DatetimeUtc => typeof(DateTime),
_ => throw new ScriptWorkerProtocolException($"array element type {tag} is not supported"),
};
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- protoc-generated members carry no XML docs. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<!-- lmxopcua#780: the host <-> ScriptWorker frame protocol (design §4). Shared by the host-side runner and the
worker, so it references nothing but Google.Protobuf: the worker's assembly set stays small (design §6). -->
<ItemGroup>
<PackageReference Include="Google.Protobuf"/>
<PackageReference Include="Grpc.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\script_worker.proto" GrpcServices="None"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests"/>
</ItemGroup>
</Project>