merge worktree-agent-ad3207d913b3b74e6 (B3-WP4) into v3/batch3-uns-rework

This commit is contained in:
Joseph Doherty
2026-07-16 06:59:22 -04:00
23 changed files with 1142 additions and 426 deletions
@@ -1226,29 +1226,45 @@ public sealed class UnsTreeService(
}
/// <summary>
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
/// base exists). The script source is fetched from the supplied (in-scope) context.
/// v3 (M1): when the bound script uses <c>{{equip}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</c> must
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
/// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
/// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
/// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
/// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
/// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
{
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count == 0) return null;
// The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver
// tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here.
var fullNames = Array.Empty<string>();
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
// The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name.
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = refs.Select(r => r.TagId).ToList();
var tagNameById = await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{
return new UnsMutationResult(false,
$"Equipment '{equipmentId}' has no single tag base, so the "
+ $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this "
+ $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
}
return null;
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
if (missing.Count == 0) return null;
return new UnsMutationResult(false,
$"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
+ $"but equipment '{equipmentId}' has no reference with that effective name. "
+ "Add a matching reference on the Tags tab, or correct the script.");
}
/// <inheritdoc />