Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
T
Joseph Doherty 8d9155682d fix(mqtt): browse-commit writes a bindable address + a Request-rebirth affordance
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.

Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.

The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.

Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.

Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 23:43:07 -04:00

110 lines
6.7 KiB
C#

using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Outcome of <see cref="IBrowserSessionService.OpenAsync"/>. On success
/// <paramref name="Ok"/> is <see langword="true"/> and <paramref name="Token"/> is the
/// registry handle; on failure <paramref name="Message"/> carries a human-readable
/// diagnostic for the UI's error chip.
/// </summary>
/// <param name="Ok">True iff the browse session was opened and registered.</param>
/// <param name="Message">Failure diagnostic, or <see langword="null"/> on success.</param>
/// <param name="Token">Registry handle on success; <see cref="Guid.Empty"/> on failure.</param>
public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token);
/// <summary>
/// Scoped Razor-page facade over the in-process browse-session machinery. Owns
/// driver-type dispatch on open and per-call timeout enforcement on expand/attributes.
/// </summary>
public interface IBrowserSessionService
{
/// <summary>Opens a session against the named driver type using the given JSON config.
/// Never throws — all errors are surfaced via <see cref="BrowseOpenResult"/>.</summary>
/// <param name="driverType">The driver type to open a browse session against.</param>
/// <param name="configJson">The driver configuration as JSON.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The result of opening the session, including the registry token on success.</returns>
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
/// <summary>Returns the root nodes of an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The top-level browse nodes.</returns>
Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
/// <summary>Returns the direct children of <paramref name="nodeId"/> in an open session.
/// Throws <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="nodeId">The node whose direct children are returned.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The direct children of the node.</returns>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Returns the attributes of <paramref name="nodeId"/> in an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="nodeId">The node whose attributes are returned.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>
/// Asks the remote peer(s) addressed by <paramref name="scope"/> to re-announce themselves, on
/// a session whose driver offers that action (today: MQTT in Sparkplug B mode, where it is a
/// Sparkplug rebirth-request NCMD).
/// </summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="scope">The driver-specific target — for Sparkplug, a browse node id from the
/// session's own tree, or a bare <c>{group}/{edgeNode}</c>.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <remarks>
/// <b>The only browse operation that writes to the device network</b>, and therefore the only
/// one carrying an authorization check: the caller must satisfy the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance. Everything else on
/// this facade is read-only.
/// </remarks>
/// <exception cref="BrowseSessionNotFoundException">The token is unknown (or was reaped).</exception>
/// <exception cref="UnauthorizedAccessException">The caller is not a DriverOperator.</exception>
/// <exception cref="NotSupportedException">This session's driver has no re-announce action.</exception>
Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
/// <summary>
/// True when the open session behind <paramref name="token"/> can actually re-announce — i.e.
/// <see cref="RequestRebirthAsync"/> is worth offering. Cheap (no I/O); never throws; false for
/// an unknown/reaped token.
/// </summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <returns>True when the picker should draw a Request-rebirth affordance.</returns>
/// <remarks>
/// A capability probe, <b>not</b> an authorization check — the DriverOperator gate lives on
/// <see cref="RequestRebirthAsync"/> and fails closed there. The picker gates on both: this
/// decides whether the action exists at all (a plain-MQTT window has none), the policy decides
/// whether this operator may fire it.
/// </remarks>
bool CanRequestRebirth(Guid token);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
/// <param name="token">Registry handle for the browse session to close.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task CloseAsync(Guid token);
/// <summary>True when a browse affordance exists for this driver type: a bespoke browser
/// is registered, or the universal browser can serve it. Cheap (no connect); never throws.
/// Drives the picker's Browse-button visibility.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="configJson">The authoring DriverConfig JSON (may be mid-edit).</param>
/// <returns>True when the picker should offer a Browse affordance for this driver.</returns>
bool CanBrowse(string driverType, string configJson);
}
/// <summary>
/// Raised by the service layer when a caller references a token that is not
/// (or no longer) in the registry — typically because the reaper evicted it
/// between calls.
/// </summary>
public sealed class BrowseSessionNotFoundException(Guid token)
: InvalidOperationException($"Browse session {token} not found (may have been reaped).");