feat(templateengine+centralui): resolve follow-ups #1/#2 — inherited-member propagation & resync

Derived templates store IsInherited placeholder rows mirroring inherited
members, but a base member added/changed/removed AFTER a child was derived
never reached the child — leaving the editor's editable tabs incomplete (#1)
and stored rows drifted from the resolved set (#2).

Fix (one order-independent reconcile, two entry points):
- Auto-propagation: every attribute/alarm/script add/update/delete now
  reconciles the template's derived subtree (TemplateService.ReconcileDescendantsAsync),
  hooked into all member-mutating paths incl. native-alarm-source CRUD in the
  ManagementActor.
- Resync: ResyncInheritedMembersAsync repairs a template + its subtree on
  demand — materialize missing placeholders, re-sync drifted ones, remove
  orphans, across attributes/alarms/scripts/native sources. Exposed as
  management ResyncInheritedMembersCommand (Designer-gated, audited) → CLI
  `template resync-members` → a Resync button on the editor's staleness banner.

Reconcile drives off TemplateInheritanceResolver (same precedence + HiLo merge
as deploy), only ever touches IsInherited placeholders (never an authored
override), and matches the staleness comparison keys so the banner clears.
BuildDerivedTemplate now also materializes native-source placeholders at
compose time (previously omitted → any inherited native source was perpetually
stale).

Tests: +8 TemplateServiceTests (materialize / drift-update / orphan-remove /
override-untouched / base-cascade / multi-type / direct-propagate / end-to-end
add) + 1 ManagementService test fix (native-source add resolves TemplateService).
Affected suites green: TemplateEngine 446, ManagementService 230, CentralUI 866,
CLI 333, Transport 127, ConfigurationDatabase 307; full solution builds 0/0.

Docs: Component-TemplateEngine.md "Inherited-Member Propagation & Resync";
CLI README `template resync-members`; known-issues tracker #1/#2 resolved.
This commit is contained in:
Joseph Doherty
2026-06-24 15:51:26 -04:00
parent b3f6833b36
commit 2b5949320c
11 changed files with 873 additions and 19 deletions
@@ -1,6 +1,7 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -302,6 +303,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateAttribute>.Success(attribute);
}
@@ -396,6 +400,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateAttribute>.Success(existing);
}
@@ -431,6 +438,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(attribute.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -526,6 +536,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateAlarm>.Success(alarm);
}
@@ -608,6 +621,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateAlarm>.Success(existing);
}
@@ -642,6 +658,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(alarm.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -687,6 +706,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateScript>.Success(script);
}
@@ -770,6 +792,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateScript>.Success(existing);
}
@@ -804,6 +829,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(script.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -1090,9 +1118,450 @@ public class TemplateService
});
}
// Native alarm sources follow the same inherit/override/lock model as the
// other member types (TemplateNativeAlarmSource), and the inheritance
// resolver + staleness summary count them — so materialize their inherited
// placeholders here too, or a freshly-composed derived template reports
// stale for every inherited native source (follow-up #1/#2).
foreach (var src in baseTemplate.NativeAlarmSources)
{
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource(src.Name)
{
Description = src.Description,
ConnectionName = src.ConnectionName,
SourceReference = src.SourceReference,
ConditionFilter = src.ConditionFilter,
IsLocked = src.IsLocked,
IsInherited = true,
LockedInDerived = false,
});
}
return derived;
}
// ========================================================================
// Inherited-member propagation & resync (follow-up #1/#2)
// ========================================================================
//
// A derived template stores IsInherited placeholder rows mirroring the
// members it inherits. BuildDerivedTemplate materializes them at compose time,
// but a member ADDED / UPDATED / REMOVED on a base AFTER the derivation
// existed never reached the children — so derived row-sets drifted incomplete
// (the editor's editable tabs missed inherited members; #1) and stored rows
// diverged from the resolved set (#2). Two mechanisms keep them in sync:
// • ReconcileDescendantsAsync — called automatically after every base member
// mutation, propagates the change to the whole derived subtree.
// • ResyncInheritedMembersAsync — an explicit operator action (CLI + UI)
// that repairs an already-stale template and its subtree.
// Both delegate to one order-independent reconcile that only ever touches
// IsInherited placeholder rows (never an authored override), which the
// inheritance resolver ignores when picking winners — so reconciling one
// template never changes what another resolves.
/// <summary>
/// Reconciles the stored inherited member rows of a template and its entire
/// derived subtree against the resolved effective inherited set: materializes
/// missing placeholders, re-syncs drifted ones, and removes orphaned ones.
/// This is the operator-facing repair action behind the CLI
/// <c>template resync-members</c> command and the editor's "Resync" button.
/// </summary>
/// <param name="templateId">The template to resync (its subtree is included).</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result carrying the per-subtree change counts.</returns>
public async Task<Result<ResyncInheritedMembersResult>> ResyncInheritedMembersAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
var byId = BuildTemplateLookup(all);
if (!byId.TryGetValue(templateId, out var root))
return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");
// Reconcile the target itself AND its subtree: resyncing a base repairs
// every derivation; resyncing a leaf repairs just it.
var targets = new List<int> { templateId };
targets.AddRange(GetDescendantIdsBreadthFirst(templateId, all));
var total = new ReconcileCounts();
var changed = 0;
foreach (var id in targets)
{
var counts = await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken);
if (counts.Any) changed++;
total.Add(counts);
}
if (total.Any)
{
await _auditService.LogAsync(
user, "Resync", "Template", templateId.ToString(), root.Name,
new { templatesChanged = changed, total.Added, total.Updated, total.Removed },
cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
return Result<ResyncInheritedMembersResult>.Success(new ResyncInheritedMembersResult
{
TemplatesChanged = changed,
MembersAdded = total.Added,
MembersUpdated = total.Updated,
MembersRemoved = total.Removed
});
}
/// <summary>
/// Propagates a base member change to every derived descendant by reconciling
/// their inherited rows. Called automatically after a base member is added,
/// updated, or deleted. The base template itself is NOT reconciled — it is the
/// author of the change. A no-op (and a single cheap query) when the template
/// has no descendants, which is the common case.
/// </summary>
/// <param name="templateId">The template whose member set changed.</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ReconcileDescendantsAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;
var byId = BuildTemplateLookup(all);
var total = new ReconcileCounts();
foreach (var id in descendants)
total.Add(await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken));
if (total.Any)
{
var name = byId.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
await _auditService.LogAsync(
user, "Resync", "Template", templateId.ToString(), name,
new { propagatedFrom = templateId, total.Added, total.Updated, total.Removed },
cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
}
/// <summary>
/// Reconciles ONE template's stored IsInherited rows against its resolved
/// effective inherited set (the same resolver the editor preview + staleness
/// banner use). Adds missing placeholders, re-syncs drifted ones, removes
/// orphans. Authored override rows (<c>IsInherited == false</c>) are never
/// touched. Repository writes are queued; the caller owns SaveChanges.
/// </summary>
private async Task<ReconcileCounts> ReconcileInheritedRowsAsync(
int templateId,
IReadOnlyList<Template> all,
Dictionary<int, Template> byId,
CancellationToken cancellationToken)
{
var counts = new ReconcileCounts();
if (!byId.TryGetValue(templateId, out var child) || child.ParentTemplateId == null)
return counts; // root templates inherit nothing
var resolved = TemplateInheritanceResolver.Resolve(templateId, all);
await ReconcileAttributesAsync(child, resolved.Attributes, byId, counts, cancellationToken);
await ReconcileAlarmsAsync(child, resolved.Alarms, byId, counts, cancellationToken);
await ReconcileScriptsAsync(child, resolved.Scripts, byId, counts, cancellationToken);
await ReconcileNativeSourcesAsync(child, resolved.NativeAlarmSources, byId, counts, cancellationToken);
return counts;
}
private async Task ReconcileAttributesAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue; // own / override rows are authoritative
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Attributes.FirstOrDefault(a => a.Name == m.Name);
if (win == null) continue;
var existing = child.Attributes.FirstOrDefault(a => a.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateAttributeAsync(new TemplateAttribute(m.Name)
{
TemplateId = child.Id,
Value = m.EffectiveValue,
DataType = win.DataType,
ElementDataType = win.ElementDataType,
Description = win.Description,
DataSourceReference = win.DataSourceReference,
IsLocked = win.IsLocked,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.Value != m.EffectiveValue ||
existing.DataType != win.DataType ||
existing.ElementDataType != win.ElementDataType ||
existing.Description != win.Description ||
existing.DataSourceReference != win.DataSourceReference ||
existing.IsLocked != win.IsLocked))
{
existing.Value = m.EffectiveValue;
existing.DataType = win.DataType;
existing.ElementDataType = win.ElementDataType;
existing.Description = win.Description;
existing.DataSourceReference = win.DataSourceReference;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateAttributeAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Attributes.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
{
await _repository.DeleteTemplateAttributeAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileAlarmsAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Alarms.FirstOrDefault(a => a.Name == m.Name);
if (win == null) continue;
// EffectiveTriggerConfiguration is the per-setpoint MERGED HiLo config
// (mirrors the flattener), so the placeholder matches what a deploy and
// the staleness comparison produce.
var existing = child.Alarms.FirstOrDefault(a => a.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateAlarmAsync(new TemplateAlarm(m.Name)
{
TemplateId = child.Id,
Description = win.Description,
PriorityLevel = win.PriorityLevel,
IsLocked = win.IsLocked,
TriggerType = win.TriggerType,
TriggerConfiguration = m.EffectiveTriggerConfiguration,
OnTriggerScriptId = win.OnTriggerScriptId,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.PriorityLevel != win.PriorityLevel ||
existing.TriggerConfiguration != m.EffectiveTriggerConfiguration ||
existing.Description != win.Description ||
existing.OnTriggerScriptId != win.OnTriggerScriptId ||
existing.TriggerType != win.TriggerType ||
existing.IsLocked != win.IsLocked))
{
existing.PriorityLevel = win.PriorityLevel;
existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
existing.Description = win.Description;
existing.OnTriggerScriptId = win.OnTriggerScriptId;
existing.TriggerType = win.TriggerType;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateAlarmAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Alarms.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
{
await _repository.DeleteTemplateAlarmAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileScriptsAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Scripts.FirstOrDefault(s => s.Name == m.Name);
if (win == null) continue;
var existing = child.Scripts.FirstOrDefault(s => s.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateScriptAsync(new TemplateScript(m.Name, win.Code)
{
TemplateId = child.Id,
IsLocked = win.IsLocked,
TriggerType = win.TriggerType,
TriggerConfiguration = win.TriggerConfiguration,
ParameterDefinitions = win.ParameterDefinitions,
ReturnDefinition = win.ReturnDefinition,
MinTimeBetweenRuns = win.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.Code != win.Code ||
existing.TriggerType != win.TriggerType ||
existing.TriggerConfiguration != win.TriggerConfiguration ||
existing.ParameterDefinitions != win.ParameterDefinitions ||
existing.ReturnDefinition != win.ReturnDefinition ||
existing.MinTimeBetweenRuns != win.MinTimeBetweenRuns ||
existing.ExecutionTimeoutSeconds != win.ExecutionTimeoutSeconds ||
existing.IsLocked != win.IsLocked))
{
existing.Code = win.Code;
existing.TriggerType = win.TriggerType;
existing.TriggerConfiguration = win.TriggerConfiguration;
existing.ParameterDefinitions = win.ParameterDefinitions;
existing.ReturnDefinition = win.ReturnDefinition;
existing.MinTimeBetweenRuns = win.MinTimeBetweenRuns;
existing.ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateScriptAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Scripts.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
{
await _repository.DeleteTemplateScriptAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileNativeSourcesAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
if (win == null) continue;
var existing = child.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateNativeAlarmSourceAsync(new TemplateNativeAlarmSource(m.Name)
{
TemplateId = child.Id,
Description = win.Description,
ConnectionName = win.ConnectionName,
SourceReference = win.SourceReference,
ConditionFilter = win.ConditionFilter,
IsLocked = win.IsLocked,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.ConnectionName != win.ConnectionName ||
existing.SourceReference != win.SourceReference ||
existing.ConditionFilter != win.ConditionFilter ||
existing.Description != win.Description ||
existing.IsLocked != win.IsLocked))
{
existing.ConnectionName = win.ConnectionName;
existing.SourceReference = win.SourceReference;
existing.ConditionFilter = win.ConditionFilter;
existing.Description = win.Description;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateNativeAlarmSourceAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.NativeAlarmSources.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
{
await _repository.DeleteTemplateNativeAlarmSourceAsync(orphan.Id, ct);
counts.Removed++;
}
}
/// <summary>
/// Breadth-first list of every template that (transitively) derives from
/// <paramref name="rootId"/>, excluding the root itself. Cycle-guarded.
/// </summary>
private static List<int> GetDescendantIdsBreadthFirst(int rootId, IReadOnlyList<Template> all)
{
if (all is null) return new List<int>();
var childrenByParent = new Dictionary<int, List<int>>();
foreach (var t in all)
{
if (t.ParentTemplateId is { } pid)
{
if (!childrenByParent.TryGetValue(pid, out var list))
childrenByParent[pid] = list = new List<int>();
list.Add(t.Id);
}
}
var result = new List<int>();
var seen = new HashSet<int> { rootId };
var queue = new Queue<int>();
if (childrenByParent.TryGetValue(rootId, out var direct))
foreach (var c in direct) queue.Enqueue(c);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (!seen.Add(id)) continue;
result.Add(id);
if (childrenByParent.TryGetValue(id, out var kids))
foreach (var c in kids) queue.Enqueue(c);
}
return result;
}
private static Dictionary<int, Template> BuildTemplateLookup(IReadOnlyList<Template> all)
{
var byId = new Dictionary<int, Template>(all.Count);
foreach (var t in all) byId[t.Id] = t; // DB ids are unique; last-wins is harmless
return byId;
}
/// <summary>Running tally of inherited-row changes during a reconcile.</summary>
private sealed class ReconcileCounts
{
public int Added;
public int Updated;
public int Removed;
public bool Any => Added > 0 || Updated > 0 || Removed > 0;
public void Add(ReconcileCounts other)
{
Added += other.Added;
Updated += other.Updated;
Removed += other.Removed;
}
}
// ========================================================================
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
// ========================================================================