feat(drivers): consume IRediscoverable as an operator re-browse prompt (§8.2, #518)

Nine drivers raise IRediscoverable.OnRediscoveryNeeded when they observe that
a remote's tag set may have changed — a Galaxy redeploy, a TwinCAT
symbol-version bump, an MTConnect agent restart, a Sparkplug rebirth. Nothing
in src/ subscribed, so every raise went into the void. Drivers even wrap the
invoke in try/catch for "subscriber threw"; there has never been a subscriber.

DriverInstanceActor now attaches the event in PreStart and detaches in
PostStop, mirroring AttachAlarmSource/DetachAlarmSource. Attach is per-actor,
not per-connect: the raise has no connection affinity and can land while the
driver is between connects. The detach is load-bearing rather than tidy — the
IDriver instance outlives the actor when the host respawns a child around the
same driver object, so a missing -= accumulates one handler per respawn, each
holding a dead Self.

The signal rides the existing driver-health snapshot to /hosts as a
"re-browse" chip. It is ADVISORY: the served address space is deliberately not
rebuilt, because v3 authors raw tags explicitly through the /raw
browse-commit flow and a runtime graft would materialise nodes no operator
approved and no deployment artifact records.

Two things worth knowing:

- PublishHealthSnapshot dedups on a health fingerprint, and a rediscovery
  raise on an otherwise-healthy driver changes none of the four fields it
  hashed. Without adding the timestamp to that tuple the dedup swallows the
  exact publish carrying the signal. Reverting that one line turns 3 of the 5
  new tests red, which is how it was confirmed rather than assumed.
- The new test stub implements IDriver from scratch instead of deriving from
  the shared StubDriver, whose GetHealth() returns DateTime.UtcNow — its
  fingerprint differs on every call, so the dedup never engages and the dedup
  test would have passed vacuously either way.

The planned discovery-result drift detector was NOT built. Modbus, S7, MQTT,
AbLegacy and Sql all echo authored config from DiscoverAsync rather than
browsing the device, so a discovered-vs-authored diff is permanently empty for
them — it would have been another plausible-looking inert seam, which is the
class this audit exists to remove. IRediscoverable is the trustworthy signal
because the driver asserts it deliberately.

DriverHealthChanged, IDriverHealthPublisher.Publish and HostsDriverRow gain
defaulted optional parameters, so no existing call site changed. telemetry.proto
gains fields 8/9 and both Phase-5 mappers carry them.

Runtime.Tests 512 passed / 31 skipped.
This commit is contained in:
Joseph Doherty
2026-07-27 18:47:34 -04:00
parent 53ede679c3
commit 09a401b881
11 changed files with 397 additions and 12 deletions
@@ -191,6 +191,16 @@ else
{
<span class="mono small">@d.DriverInstanceId</span>
}
@if (d.RediscoveryNeededUtc is not null)
{
@* The driver reported its remote's tag set may have changed. Advisory
only — v3 authors raw tags via /raw browse-commit, so nothing is
grafted at runtime and an operator must re-browse to pick it up. *@
<span class="chip chip-warn ms-1"
title="@($"Reported {d.RediscoveryNeededUtc:u}: {d.RediscoveryReason ?? "tag set may have changed"}. The served address space is unchanged — re-browse this device under /raw and commit anything new.")">
re-browse
</span>
}
</td>
<td>@(d.DriverType ?? "—")</td>
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
@@ -35,9 +35,15 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
/// <param name="LastError">Latest error message; null when none.</param>
/// <param name="ErrorCount5Min">Faulted-transition count in the last 5 minutes.</param>
/// <param name="PublishedUtc">Timestamp the snapshot was published.</param>
/// <param name="RediscoveryNeededUtc">When the driver last reported that the remote's tag set may have
/// changed; null when never (or when the driver cannot report it). Advisory — the served address space
/// is unchanged and an operator must re-browse the device via <c>/raw</c> to pick anything up.</param>
/// <param name="RediscoveryReason">The driver-supplied reason for that report; null when
/// <paramref name="RediscoveryNeededUtc"/> is null.</param>
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc);
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null);
/// <summary>
/// One cluster's section on the <c>/hosts</c> page: its configured nodes plus its enriched
@@ -110,7 +116,9 @@ public static class HostsDriverView
s.LastSuccessfulReadUtc,
s.LastError,
s.ErrorCount5Min,
s.PublishedUtc);
s.PublishedUtc,
s.RediscoveryNeededUtc,
s.RediscoveryReason);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)