v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space

- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences),
  drop the retired Namespaces snapshot; RevisionHash follows the new blob.
- DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device->
  TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged
  DriverConfig+DeviceConfig config injected into DriverInstanceSpec via
  DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set
  (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster
  scoping attributes equipment by UNS line only.
- AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind,
  no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror).
- DriverHostActor: fan-out/write map tuple member FullName -> RawPath.
- Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger
  (single/multi-device endpoint+RawTags merge).
- DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment),
  historized effective-tagname <=255, UNS effective-leaf uniqueness across
  UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables.
- AdminOperationsActor: tag-config inspection resolves driver via Device (no
  EquipmentId/DriverInstanceId on Tag).
- Tests: RawPathResolver/merger unit tests (Commons.Tests, green) +
  DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
This commit is contained in:
Joseph Doherty
2026-07-15 20:45:24 -04:00
parent 8e8dd2e824
commit e81ac352ed
12 changed files with 749 additions and 307 deletions
@@ -297,41 +297,24 @@ public static class AddressSpaceComposer
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>UNS-aware overload that doesn't supply tags.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="devices">The per-device rows used to resolve each equipment's <c>DeviceHost</c>. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Device>? devices = null) =>
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>(), devices: devices);
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
/// <summary>
/// Composes the address space build plan from the configuration entities.
/// Composes the address space build plan from the v3 configuration entities.
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
/// </summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="tags">The tags.</param>
/// <param name="namespaces">The namespaces.</param>
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <param name="devices">The per-device rows (<c>DeviceId</c> + schemaless <c>DeviceConfig</c> JSON) used to resolve
/// each equipment's <c>DeviceHost</c> from its bound <c>DeviceId</c>. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
@@ -339,28 +322,12 @@ public static class AddressSpaceComposer
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Tag> tags,
IReadOnlyList<Namespace> namespaces,
IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null,
IReadOnlyList<Device>? devices = null)
IReadOnlyList<Script>? scripts = null)
{
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>();
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
// via the shared DeviceConfigIntent.TryExtractHost (single source of truth + normalization for both
// this composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
// byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
// Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
// foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
// side's last-wins). DeviceId is DB-unique so a dupe is defensive-only.
var deviceHostById = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var d in devices ?? Array.Empty<Device>())
{
if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
deviceHostById[d.DeviceId] = DeviceConfigIntent.TryExtractHost(d.DeviceConfig);
}
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
@@ -373,18 +340,10 @@ public static class AddressSpaceComposer
var nodes = equipment
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
// DriverInstanceId / DeviceId are copied straight from the row; DeviceHost resolves from the
// bound device's config (null when there's no device or no parseable HostAddress).
.Select(e => new EquipmentNode(
e.EquipmentId,
e.Name,
e.UnsLineId,
DriverInstanceId: e.DriverInstanceId,
DeviceId: e.DeviceId,
DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)))
// DisplayName = the UNS level-5 Name segment (friendly browse name). NodeId stays the logical
// EquipmentId. v3: equipment no longer binds a driver/device (it references raw tags via
// UnsTagReference), so DriverInstanceId/DeviceId/DeviceHost are always null.
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
.ToList();
var plans = driverInstances
@@ -397,55 +356,10 @@ public static class AddressSpaceComposer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList();
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
// Equipment tags = a Tag bound to an Equipment (non-null EquipmentId) whose driver's namespace
// is Equipment-kind. FullName is the driver-side wire reference pulled from TagConfig — it
// becomes the variable's NodeId + read/write routing key. Galaxy points are ordinary equipment
// tags now (GalaxyMxGateway is a standard Equipment-kind driver), so no driver-type exception.
var equipmentTags = tags
.Where(t => t.EquipmentId is not null)
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
&& ns.Kind == NamespaceKind.Equipment)
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
.ThenBy(t => t.Name, StringComparer.Ordinal)
.Select(t =>
{
// Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity
// authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the
// artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the
// walker all consume — so the live-compose and artifact-decode plans stay byte-equal.
var intent = TagConfigIntent.Parse(t.TagConfig);
return new EquipmentTagPlan(
TagId: t.TagId,
EquipmentId: t.EquipmentId!,
DriverInstanceId: t.DriverInstanceId,
FolderPath: t.FolderPath ?? string.Empty,
Name: t.Name,
DataType: t.DataType,
FullName: intent.FullName,
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength);
})
.ToList();
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
// scripts (equipment-relative tag paths). Derived from equipmentTags so one script reused
// across N identical machines resolves to N machine-specific dependency graphs.
var baseByEquip = equipmentTags
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
StringComparer.Ordinal);
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
var equipmentTags = Array.Empty<EquipmentTagPlan>();
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
// expression source. The {{equip}} token is substituted with the owning equipment's tag