docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)

Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:25 -04:00
parent 3a217b9484
commit 0ffd4bdbea
217 changed files with 1224 additions and 695 deletions
@@ -227,7 +227,7 @@ public class AuditLogIngestActor : ReceiveActor
projected.Add(AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc)); projected.Add(AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc));
} }
// ONE set-based statement for the whole packet (WP2.2) instead of one // ONE set-based statement for the whole packet instead of one
// IF NOT EXISTS … INSERT round trip per event. A telemetry packet of 200 // IF NOT EXISTS … INSERT round trip per event. A telemetry packet of 200
// rows used to cost 200 sequential round trips on the central singleton's // rows used to cost 200 sequential round trips on the central singleton's
// dispatcher; it now costs two. Idempotency is unchanged — duplicates // dispatcher; it now costs two. Idempotency is unchanged — duplicates
@@ -356,7 +356,7 @@ public class AuditLogIngestActor : ReceiveActor
var strategy = dbContext.Database.CreateExecutionStrategy(); var strategy = dbContext.Database.CreateExecutionStrategy();
// Fast path (WP2.2): the WHOLE packet in ONE transaction — one // Fast path: the WHOLE packet in ONE transaction — one
// set-based audit insert plus one single-statement upsert per entry. // set-based audit insert plus one single-statement upsert per entry.
// The predecessor opened a transaction PER entry and issued three // The predecessor opened a transaction PER entry and issued three
// statements inside it (IF NOT EXISTS insert, then the two-statement // statements inside it (IF NOT EXISTS insert, then the two-statement
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// than <c>DateTime.UtcNow - RetentionDays</c>.</item> /// than <c>DateTime.UtcNow - RetentionDays</c>.</item>
/// <item>For each eligible boundary, calls /// <item>For each eligible boundary, calls
/// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>, a /// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>, a
/// metadata-only staging-table switch (WP2.2 removed the index /// metadata-only staging-table switch (no index
/// drop/rebuild that used to bracket it).</item> /// drop/rebuild brackets it).</item>
/// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system /// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system
/// EventStream so the central health collector + ops surfaces /// EventStream so the central health collector + ops surfaces
/// can subscribe without coupling to this actor.</item> /// can subscribe without coupling to this actor.</item>
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The purge actor is a daily-cadence singleton, not a hot-loop. The /// The purge actor is a daily-cadence singleton, not a hot-loop. The
/// partition switch itself is metadata-only (since WP2.2's /// partition switch itself is metadata-only (since the
/// <c>AlignAuditLogEventIdUniqueness</c> it no longer drops and rebuilds a /// <c>AlignAuditLogEventIdUniqueness</c> migration it no longer drops and rebuilds a
/// non-aligned unique index around it), but it still takes schema-modification /// non-aligned unique index around it), but it still takes schema-modification
/// locks and competes with ingest for the same table. Lower this only when an /// locks and competes with ingest for the same table. Lower this only when an
/// operator can prove they need sub-daily purge granularity. /// operator can prove they need sub-daily purge granularity.
@@ -28,6 +28,9 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
private readonly ILogger<GrpcPullTraceEventsClient> _logger; private readonly ILogger<GrpcPullTraceEventsClient> _logger;
/// <summary>Creates the client over the given site enumerator and unary-call invoker.</summary> /// <summary>Creates the client over the given site enumerator and unary-call invoker.</summary>
/// <param name="sites">Enumerates the sites and their gRPC endpoints.</param>
/// <param name="invoker">Issues the unary <c>PullTraceEvents</c> call.</param>
/// <param name="logger">Logger for pull diagnostics.</param>
public GrpcPullTraceEventsClient( public GrpcPullTraceEventsClient(
ISiteEnumerator sites, ISiteEnumerator sites,
IPullTraceEventsInvoker invoker, IPullTraceEventsInvoker invoker,
@@ -152,6 +155,11 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
public interface IPullTraceEventsInvoker public interface IPullTraceEventsInvoker
{ {
/// <summary>Issues the RPC against <paramref name="endpoint"/> with the site's preshared key.</summary> /// <summary>Issues the RPC against <paramref name="endpoint"/> with the site's preshared key.</summary>
/// <param name="siteId">The site the call targets.</param>
/// <param name="endpoint">The site node gRPC endpoint to call.</param>
/// <param name="request">The pull request.</param>
/// <param name="ct">Token to cancel the call.</param>
/// <returns>The site's trace-event response.</returns>
Task<PullTraceEventsResponse> InvokeAsync( Task<PullTraceEventsResponse> InvokeAsync(
string siteId, string endpoint, PullTraceEventsRequest request, CancellationToken ct); string siteId, string endpoint, PullTraceEventsRequest request, CancellationToken ct);
} }
@@ -169,6 +177,8 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
private readonly ISitePskProvider? _pskProvider; private readonly ISitePskProvider? _pskProvider;
/// <summary>Creates the invoker with the given options and optional per-site PSK provider.</summary> /// <summary>Creates the invoker with the given options and optional per-site PSK provider.</summary>
/// <param name="options">Communication options supplying keepalive settings.</param>
/// <param name="pskProvider">Optional provider of per-site preshared keys.</param>
public GrpcPullTraceEventsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider) public GrpcPullTraceEventsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider)
{ {
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
@@ -212,7 +222,7 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
}, },
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
/// <inheritdoc /> /// <summary>Disposes every cached gRPC channel.</summary>
public void Dispose() public void Dispose()
{ {
foreach (var channel in _channels.Values) foreach (var channel in _channels.Values)
@@ -521,6 +521,8 @@ public class SiteAuditReconciliationActor : ReceiveActor
/// lost rows get distinct synthetic ids. /// lost rows get distinct synthetic ids.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="lostEventId">The event id of the lost row being abandoned.</param>
/// <returns>A deterministic synthetic GUID for the abandonment row.</returns>
internal static Guid DeriveAbandonmentEventId(Guid lostEventId) internal static Guid DeriveAbandonmentEventId(Guid lostEventId)
{ {
Span<byte> input = stackalloc byte[32]; Span<byte> input = stackalloc byte[32];
@@ -150,7 +150,7 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor, IDisposable
} }
} }
/// <inheritdoc /> /// <summary>Disposes the options-change registration, if any.</summary>
public void Dispose() => _optionsReload?.Dispose(); public void Dispose() => _optionsReload?.Dispose();
/// <summary> /// <summary>
@@ -174,7 +174,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery(); pragmaCmd.ExecuteNonQuery();
} }
// synchronous=NORMAL (arch-review remediation WP1.2): SQLite's FULL default // synchronous=NORMAL: SQLite's FULL default
// fsyncs on every transaction commit, which is the dominant cost of the // fsyncs on every transaction commit, which is the dominant cost of the
// per-event write path this same change coalesces via FlushIntervalMs — FULL // per-event write path this same change coalesces via FlushIntervalMs — FULL
// would still fsync once per FLUSH even after that fix. NORMAL fsyncs at // would still fsync once per FLUSH even after that fix. NORMAL fsyncs at
@@ -346,8 +346,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// burst of concurrent enqueues. Once the immediately-available // burst of concurrent enqueues. Once the immediately-available
// burst is drained, keep the transaction open for up to // burst is drained, keep the transaction open for up to
// FlushIntervalMs waiting for MORE events to trickle in — this is // FlushIntervalMs waiting for MORE events to trickle in — this is
// the soft-flush coalescing arch-review remediation WP1.2 adds: // the soft-flush coalescing
// without it, a low-rate trickle of writes (one script call every // this loop provides: without it, a low-rate trickle of writes (one script call every
// few ms, well under BatchSize) fsyncs once PER EVENT, because the // few ms, well under BatchSize) fsyncs once PER EVENT, because the
// writer loop reaches an empty channel and flushes immediately. // writer loop reaches an empty channel and flushes immediately.
// FlushIntervalMs bounds the worst-case added latency any single // FlushIntervalMs bounds the worst-case added latency any single
@@ -392,8 +392,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary> /// <summary>
/// Total number of <see cref="FlushBatch"/> transactions committed since /// Total number of <see cref="FlushBatch"/> transactions committed since
/// construction. Test-only observability seam (arch-review remediation /// construction. Test-only observability seam for asserting that FlushIntervalMs coalesces a trickle of
/// WP1.2) for asserting that FlushIntervalMs coalesces a trickle of
/// near-simultaneous writes into a single transaction rather than one /// near-simultaneous writes into a single transaction rather than one
/// commit per event; production code never reads this. /// commit per event; production code never reads this.
/// </summary> /// </summary>
@@ -499,8 +498,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{ {
eventCmd.ExecuteNonQuery(); eventCmd.ExecuteNonQuery();
fwdCmd.ExecuteNonQuery(); fwdCmd.ExecuteNonQuery();
// Do NOT complete pending.Completion here (arch-review 2026-08-19 // Do NOT complete pending.Completion here. A row's INSERTs succeeding is not durable until the
// P4-2). A row's INSERTs succeeding is not durable until the
// transaction actually commits below — completing the caller's Task // transaction actually commits below — completing the caller's Task
// this early means a later commit failure (SQLITE_FULL, IOERR, or a // this early means a later commit failure (SQLITE_FULL, IOERR, or a
// later row in this same batch throwing) reports false success: the // later row in this same batch throwing) reports false success: the
@@ -866,7 +864,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>Flow Debugger PullTraceEvents: hard cap on ids per read (design §5.4).</summary> /// <summary>Flow Debugger PullTraceEvents: hard cap on ids per read (design §5.4).</summary>
public const int MaxExecutionIdsPerRead = 64; public const int MaxExecutionIdsPerRead = 64;
/// <inheritdoc /> /// <summary>
/// Reads audit events belonging to any of the given execution ids, newer than <paramref name="sinceUtc"/>, in keyset-paged order.
/// </summary>
/// <param name="executionIds">Execution ids to match (capped at <see cref="MaxExecutionIdsPerRead"/>).</param>
/// <param name="sinceUtc">Lower bound on the event's occurrence time.</param>
/// <param name="limit">Maximum number of events to return; must be positive.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="afterEventId">Optional keyset cursor; only events after this id are returned.</param>
/// <returns>The matching audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync( public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null) IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
{ {
@@ -925,6 +931,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// UNION deduplicates rows matching both IDs. Keeping payloads outside the union bounds sorting /// UNION deduplicates rows matching both IDs. Keeping payloads outside the union bounds sorting
/// to IDs/timestamps and avoids the optimizer preferring a whole-store timestamp scan. /// to IDs/timestamps and avoids the optimizer preferring a whole-store timestamp scan.
/// </summary> /// </summary>
/// <param name="inList">The pre-built SQL parameter list for the <c>IN (...)</c> clauses.</param>
/// <returns>The trace-read SQL text.</returns>
internal static string BuildTraceReadSql(string inList) => $""" internal static string BuildTraceReadSql(string inList) => $"""
SELECT e.EventId, e.OccurredAtUtc, e.Actor, e.Action, e.Outcome, SELECT e.EventId, e.OccurredAtUtc, e.Actor, e.Action, e.Outcome,
e.Category, e.Target, e.SourceNode, e.CorrelationId, e.DetailsJson e.Category, e.Target, e.SourceNode, e.CorrelationId, e.DetailsJson
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <see cref="SqliteAuditWriterOptions.DatabasePath"/> is deliberately NOT /// <see cref="SqliteAuditWriterOptions.DatabasePath"/> is deliberately NOT
/// validated here (arch-review remediation WP1.2). <c>AddAuditLog</c> runs on /// validated here. <c>AddAuditLog</c> runs on
/// BOTH Central and Site composition roots and binds this options type with /// BOTH Central and Site composition roots and binds this options type with
/// <c>ValidateOnStart</c> either way, but only Site nodes ever resolve /// <c>ValidateOnStart</c> either way, but only Site nodes ever resolve
/// <see cref="SqliteAuditWriter"/> — requiring a non-empty path in this /// <see cref="SqliteAuditWriter"/> — requiring a non-empty path in this
@@ -17,6 +17,8 @@ internal static class SqliteErrorCodes
/// "primary/extended" (e.g. "10/522") for a <see cref="SqliteException"/> /// "primary/extended" (e.g. "10/522") for a <see cref="SqliteException"/>
/// anywhere in the exception chain; "n/a" for non-SQLite failures. /// anywhere in the exception chain; "n/a" for non-SQLite failures.
/// </summary> /// </summary>
/// <param name="ex">The exception to inspect, including its inner exceptions.</param>
/// <returns>The "primary/extended" SQLite error code string, or "n/a" for non-SQLite failures.</returns>
public static string Describe(Exception ex) public static string Describe(Exception ex)
{ {
for (Exception? e = ex; e is not null; e = e.InnerException) for (Exception? e = ex; e is not null; e = e.InnerException)
@@ -220,8 +220,8 @@ public static class AuditCommands
// Ctrl-C is the one way to abort an in-flight export other than the // Ctrl-C is the one way to abort an in-flight export other than the
// headers/inactivity deadlines below — mirrors the CancellationTokenSource // headers/inactivity deadlines below — mirrors the CancellationTokenSource
// owned by DebugCommands' streaming debug-view command (arch-review // owned by DebugCommands' streaming debug-view command (this was the
// 2026-08-19 P6-8: this was the one CLI HTTP path with NO deadline at all). // one CLI HTTP path with NO deadline at all).
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
ConsoleCancelEventHandler onCancel = (_, e) => ConsoleCancelEventHandler onCancel = (_, e) =>
{ {
@@ -115,7 +115,7 @@ public static class AuditExportHelpers
/// and <see cref="SocketsHttpHandler.ConnectTimeout"/> bounds only the TCP/TLS /// and <see cref="SocketsHttpHandler.ConnectTimeout"/> bounds only the TCP/TLS
/// connect phase, not a connected-but-silent server. Reuses /// connect phase, not a connected-but-silent server. Reuses
/// <see cref="ManagementHttpClient.DefaultConnectTimeout"/> as the standard CLI /// <see cref="ManagementHttpClient.DefaultConnectTimeout"/> as the standard CLI
/// per-call default (arch-review 2026-08-19 P6-8). /// per-call default.
/// </summary> /// </summary>
public static readonly TimeSpan DefaultHeadersTimeout = ManagementHttpClient.DefaultConnectTimeout; public static readonly TimeSpan DefaultHeadersTimeout = ManagementHttpClient.DefaultConnectTimeout;
@@ -126,7 +126,7 @@ public static class AuditExportHelpers
/// hung/crashed server holding the connection open) is bounded rather than /// hung/crashed server holding the connection open) is bounded rather than
/// hanging the CLI forever — the residual gap the timeout-honesty fix /// hanging the CLI forever — the residual gap the timeout-honesty fix
/// (<c>ManagementHttpClient.DefaultConnectTimeout</c> doc comment) left in this /// (<c>ManagementHttpClient.DefaultConnectTimeout</c> doc comment) left in this
/// one path (arch-review 2026-08-19 P6-8). /// one path.
/// </summary> /// </summary>
public static readonly TimeSpan DefaultInactivityTimeout = ManagementHttpClient.DefaultConnectTimeout; public static readonly TimeSpan DefaultInactivityTimeout = ManagementHttpClient.DefaultConnectTimeout;
@@ -204,7 +204,7 @@ public static class AuditExportHelpers
// Honour the documented "authorization failure → exit 2" // Honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the // contract on the REST audit surface as well. HTTP 403 is the
// primary signal; the server may also surface UNAUTHORIZED / // primary signal; the server may also surface UNAUTHORIZED /
// FORBIDDEN via the JSON error envelope on a non-403 status. // FORBIDDEN via the JSON error envelope on a status other than 403.
var errorCode = TryExtractErrorCode(message); var errorCode = TryExtractErrorCode(message);
var isAuthFailure = (int)response.StatusCode == 403 var isAuthFailure = (int)response.StatusCode == 403
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase) || string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
@@ -404,7 +404,7 @@ public static class BundleCommands
// RunBundleCommandAsync that re-implemented URL/credential resolution and // RunBundleCommandAsync that re-implemented URL/credential resolution and
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync // skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error // enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
// code on a non-403 status would exit 1 instead of the documented exit 2. // code on a status other than 403 would exit 1 instead of the documented exit 2.
// The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with // The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with
// the longer BundleCommandTimeout and a per-command success handler, so the // the longer BundleCommandTimeout and a per-command success handler, so the
// exit-code contract is unified across every command group. // exit-code contract is unified across every command group.
@@ -262,7 +262,7 @@ internal static class CommandHelpers
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList(); var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
// A "note" property that appears ONLY on single-property rows (a trailing // A "note" property that appears ONLY on single-property rows (a trailing
// run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception // run-level note, or a step's folded log/exception
// line) is not a real column: every other row's cell there would render blank, // line) is not a real column: every other row's cell there would render blank,
// and column padding would swallow the raw line's own leading indentation. Such // and column padding would swallow the raw line's own leading indentation. Such
// rows print as a raw unpadded line instead, at their position in the sequence, // rows print as a raw unpadded line instead, at their position in the sequence,
@@ -96,6 +96,10 @@ public static class DeployCommands
/// whose summaries all carry <c>failureCount</c> — a per-site failure is a successful HTTP /// whose summaries all carry <c>failureCount</c> — a per-site failure is a successful HTTP
/// call, so without this the process exited 0 and scripts could not detect it. /// call, so without this the process exited 0 and scripts could not detect it.
/// </summary> /// </summary>
/// <param name="json">The JSON response body from the management API.</param>
/// <param name="format">The output format (e.g. json or table).</param>
/// <param name="summaryName">Display name for the summary, used in the invalid-response error.</param>
/// <returns>0 when nothing failed; 1 when any failure was reported or the summary was invalid.</returns>
internal static int HandleSiteDeploymentResponse(string json, string format, string summaryName = "site deployment") internal static int HandleSiteDeploymentResponse(string json, string format, string summaryName = "site deployment")
{ {
CommandHelpers.HandleResponse(new ManagementResponse(200, json, null, null), format); CommandHelpers.HandleResponse(new ManagementResponse(200, json, null, null), format);
@@ -215,6 +215,8 @@ public static class FlowDebugCommands
/// means "no parameters". Throws <see cref="JsonException"/> on malformed input or a /// means "no parameters". Throws <see cref="JsonException"/> on malformed input or a
/// non-object root (the inbound API body is always a flat JSON object). /// non-object root (the inbound API body is always a flat JSON object).
/// </summary> /// </summary>
/// <param name="json">The raw <c>--params</c> JSON text, or null/blank for none.</param>
/// <returns>The parsed root object, or null when no parameters were supplied.</returns>
internal static JsonElement? ParseParameters(string? json) internal static JsonElement? ParseParameters(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -45,7 +45,7 @@ internal static class FlowDebugTableProjection
/// <param name="showLogs"> /// <param name="showLogs">
/// When true, prints each step's <c>ScriptLog</c> lines and (on a failed step) the first /// When true, prints each step's <c>ScriptLog</c> lines and (on a failed step) the first
/// three lines of its exception detail as additional indented <c>note</c> rows immediately /// three lines of its exception detail as additional indented <c>note</c> rows immediately
/// following that step's row (step-logs design 2026-09-09, CLI Task 14). The <c>logs</c> /// following that step's row. The <c>logs</c>
/// column (a count of attached log lines) is always present, independent of this flag. /// column (a count of attached log lines) is always present, independent of this flag.
/// </param> /// </param>
/// <returns>Compact JSON suitable for table rendering.</returns> /// <returns>Compact JSON suitable for table rendering.</returns>
@@ -103,7 +103,7 @@ internal static class FlowDebugTableProjection
if (truncated) if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)"); notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
// Final review M8: the page shows this; the CLI silently dropped it. // The page shows this; the CLI silently dropped it.
if (logsTruncated) if (logsTruncated)
notes.Add("inline log lines capped — replay pulls the rest"); notes.Add("inline log lines capped — replay pulls the rest");
@@ -97,7 +97,7 @@ public class ManagementHttpClient : IDisposable
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password) internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
{ {
_httpClient = httpClient; _httpClient = httpClient;
// Test seam (WP2.6e): exposes the constructed HttpClient's effective Timeout // Test seam: exposes the constructed HttpClient's effective Timeout
// without requiring reflection. // without requiring reflection.
EffectiveTimeout = httpClient.Timeout; EffectiveTimeout = httpClient.Timeout;
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); _httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
@@ -36,8 +36,7 @@ public static class OutputFormatter
/// <summary> /// <summary>
/// One row for the <see cref="TableRow"/> overload of <c>WriteTable</c>: either padded /// One row for the <see cref="TableRow"/> overload of <c>WriteTable</c>: either padded
/// column cells like the plain <see cref="string[]"/> overload, or an already-formatted /// column cells like the plain <see cref="string[]"/> overload, or an already-formatted
/// line printed verbatim with no column padding/alignment at all (Task 14 review, /// line printed verbatim with no column padding/alignment at all — used for a note/log line folded onto a step, which must render as a raw
/// 2026-09-09) — used for a note/log line folded onto a step, which must render as a raw
/// indented line under that step's row rather than as a cell in a table column (every /// indented line under that step's row rather than as a cell in a table column (every
/// other row's cell there would otherwise render blank, and the line's own leading /// other row's cell there would otherwise render blank, and the line's own leading
/// whitespace would be swallowed by column padding). /// whitespace would be swallowed by column padding).
@@ -50,14 +49,20 @@ public static class OutputFormatter
RawLine = rawLine; RawLine = rawLine;
} }
/// <summary>The column cells of a normal row, or null for a raw line.</summary>
internal string[]? Cells { get; } internal string[]? Cells { get; }
/// <summary>The verbatim line of a raw row, or null for a column row.</summary>
internal string? RawLine { get; } internal string? RawLine { get; }
/// <summary>A normal row, rendered as padded columns aligned with the header set.</summary> /// <summary>A normal row, rendered as padded columns aligned with the header set.</summary>
/// <param name="cells">The column cell values.</param>
/// <returns>A column row.</returns>
internal static TableRow Columns(string[] cells) => new(cells, null); internal static TableRow Columns(string[] cells) => new(cells, null);
/// <summary>An already-formatted line, printed exactly as given with no column alignment.</summary> /// <summary>An already-formatted line, printed exactly as given with no column alignment.</summary>
/// <param name="line">The preformatted line.</param>
/// <returns>A raw row.</returns>
internal static TableRow Raw(string line) => new(null, line); internal static TableRow Raw(string line) => new(null, line);
} }
@@ -2,5 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker;
internal static class WorkerProgram internal static class WorkerProgram
{ {
/// <summary>Runs the sandbox worker protocol loop until the host closes it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public static Task Main() => ScriptAnalysis.SandboxWorkerProtocol.RunWorkerAsync(); public static Task Main() => ScriptAnalysis.SandboxWorkerProtocol.RunWorkerAsync();
} }
@@ -228,6 +228,11 @@ public static class AuthEndpoints
} }
/// <summary>Records real browser input only after authenticating and validating antiforgery.</summary> /// <summary>Records real browser input only after authenticating and validating antiforgery.</summary>
/// <param name="context">The current HTTP context.</param>
/// <param name="antiforgery">Antiforgery service used to validate the request.</param>
/// <param name="tracker">Tracker that records the session activity.</param>
/// <param name="clock">Time source for the activity timestamp.</param>
/// <returns>An HTTP result indicating whether the activity was recorded.</returns>
public static async Task<IResult> HandleActivity(HttpContext context, IAntiforgery antiforgery, public static async Task<IResult> HandleActivity(HttpContext context, IAntiforgery antiforgery,
SessionActivityTracker tracker, TimeProvider clock) SessionActivityTracker tracker, TimeProvider clock)
{ {
@@ -13,11 +13,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
public static class ClaimsPrincipalExtensions public static class ClaimsPrincipalExtensions
{ {
/// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary> /// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary>
/// <param name="provider">The authentication state provider.</param>
/// <returns>The authoritative authentication state.</returns>
public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider) public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider)
=> provider is CookieAuthenticationStateProvider cookie => provider is CookieAuthenticationStateProvider cookie
? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync(); ? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync();
/// <summary>Checks the current grant before invoking a directly audited application service.</summary> /// <summary>Checks the current grant before invoking a directly audited application service.</summary>
/// <param name="provider">The authentication state provider.</param>
/// <param name="role">The role the current session must hold.</param>
/// <returns>The username of the authorized principal.</returns>
public static async Task<string> RequireRoleAsync(this AuthenticationStateProvider provider, string role) public static async Task<string> RequireRoleAsync(this AuthenticationStateProvider provider, string role)
{ {
var principal = (await provider.GetAuthoritativeStateAsync()).User; var principal = (await provider.GetAuthoritativeStateAsync()).User;
@@ -23,6 +23,9 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
private readonly SemaphoreSlim _gate = new(1, 1); private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary> /// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
/// <param name="httpContextAccessor">Supplies the user captured at construction.</param>
/// <param name="scopes">Factory for fresh scopes used during revalidation.</param>
/// <param name="autoLogin">Options indicating whether login is disabled (auto-login).</param>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null) public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null)
{ {
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity())); _state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
@@ -36,6 +39,7 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false); public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary> /// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
/// <returns>The authoritative authentication state.</returns>
public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true); public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true);
private async Task<AuthenticationState> RefreshAsync(bool failClosed) private async Task<AuthenticationState> RefreshAsync(bool failClosed)
@@ -23,7 +23,7 @@ public static class BrowserTime
/// <param name="browserUtcOffsetMinutes"> /// <param name="browserUtcOffsetMinutes">
/// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number /// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number
/// of minutes that, <b>added</b> to local time, yields UTC. It is positive /// of minutes that, <b>added</b> to local time, yields UTC. It is positive
/// for time zones behind UTC (e.g. +300 for UTC-5) and negative for zones /// for time zones behind UTC (e.g. +300 for five hours behind UTC) and negative for zones
/// ahead (e.g. -120 for UTC+2). /// ahead (e.g. -120 for UTC+2).
/// </param> /// </param>
/// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns> /// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns>
@@ -79,7 +79,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
[Inject] private ISiteRepository SiteRepository { get; set; } = default!; [Inject] private ISiteRepository SiteRepository { get; set; } = default!;
/// <summary> /// <summary>
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON /// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
/// escape hatch validates against the same resolved shape the form renders (#37). /// escape hatch validates against the same resolved shape the form renders.
/// </summary> /// </summary>
[Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!; [Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!;
[Inject] private SiteScopeService SiteScope { get; set; } = default!; [Inject] private SiteScopeService SiteScope { get; set; } = default!;
@@ -154,7 +154,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool _scriptParamsValid; private bool _scriptParamsValid;
/// <summary> /// <summary>
/// Method entry (#37): the chosen method's resolved root schema (null when it declares no /// Method entry: the chosen method's resolved root schema (null when it declares no
/// parameters or the definitions do not parse) and its top-level fields — the card renders /// parameters or the definitions do not parse) and its top-level fields — the card renders
/// only when there is at least one. The values live in <see cref="_methodParams"/>, the same /// only when there is at least one. The values live in <see cref="_methodParams"/>, the same
/// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request /// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request
@@ -166,7 +166,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool _methodParamsValid; private bool _methodParamsValid;
/// <summary> /// <summary>
/// Raw-JSON escape hatch for the whole method payload (#37): paste the exact body an /// Raw-JSON escape hatch for the whole method payload: paste the exact body an
/// external caller would send. The text is parsed and schema-validated on every edit; /// external caller would send. The text is parsed and schema-validated on every edit;
/// only a valid document reaches <see cref="_methodParams"/>, which stays the single source /// only a valid document reaches <see cref="_methodParams"/>, which stays the single source
/// of truth in both modes. /// of truth in both modes.
@@ -214,6 +214,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// on a step are NOT here — they are reported separately as /// on a step are NOT here — they are reported separately as
/// <see cref="FlowGraph.UnattachedLogLines"/>. /// <see cref="FlowGraph.UnattachedLogLines"/>.
/// </summary> /// </summary>
/// <param name="graph">The flow graph to count log lines in, or null.</param>
/// <returns>The error, warning, info and total log-line counts.</returns>
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph) internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
{ {
if (graph is null) if (graph is null)
@@ -706,7 +708,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
// row the run did not already have, the page is no longer short of lines and the flag // row the run did not already have, the page is no longer short of lines and the flag
// clears. Until then it stays: the site's rows reach central's audit store by telemetry // clears. Until then it stays: the site's rows reach central's audit store by telemetry
// 5-30 s later, so a refresh that adds nothing proves nothing. // 5-30 s later, so a refresh that adds nothing proves nothing.
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's // Read the OUTCOME's own flag — FlowDebugService copies the raw result's
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record // onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
// member with no consumer. // member with no consumer.
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated) var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
@@ -721,6 +723,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0; => events?.Count(FlowStepProjector.IsLogLine) ?? 0;
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary> /// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
/// <param name="known">Events already held by the page, or null.</param>
/// <param name="fresh">Newly read Replay events, or null.</param>
/// <returns>The merged event list.</returns>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh) internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{ {
var byId = new Dictionary<Guid, AuditEvent>(); var byId = new Dictionary<Guid, AuditEvent>();
@@ -852,7 +857,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id); return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
} }
/// <inheritdoc/> /// <summary>Cancels any pending outcome refresh when the component is disposed.</summary>
public void Dispose() => CancelPendingRefresh(); public void Dispose() => CancelPendingRefresh();
private async Task LoadReplayAsync() private async Task LoadReplayAsync()
@@ -972,7 +977,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
} }
/// <summary> /// <summary>
/// Form ⇄ Raw JSON (#37). Entering raw mode seeds the editor from the current value tree so /// Form ⇄ Raw JSON. Entering raw mode seeds the editor from the current value tree so
/// the operator edits what the form would have sent; leaving it re-mounts the form over the /// the operator edits what the form would have sent; leaving it re-mounts the form over the
/// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is /// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is
/// on, so it mounts fresh and re-reports validity from the tree). /// on, so it mounts fresh and re-reports validity from the tree).
@@ -1054,6 +1059,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method /// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method
/// declares no parameters. Run is gated on validity, so nothing here re-checks the tree. /// declares no parameters. Run is gated on validity, so nothing here re-checks the tree.
/// </summary> /// </summary>
/// <returns>The serialized parameters, or null when the method declares none.</returns>
internal JsonElement? BuildParameters() internal JsonElement? BuildParameters()
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams); => _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
@@ -1062,6 +1068,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as /// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is. /// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
/// </summary> /// </summary>
/// <returns>The serialized parameters, or null when the form produced nothing.</returns>
internal JsonElement? BuildScriptParameters() internal JsonElement? BuildScriptParameters()
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams); => _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
@@ -1092,7 +1099,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary> /// <summary>
/// Restores a persisted value tree only when it still validates against the method's current /// Restores a persisted value tree only when it still validates against the method's current
/// schema — a stale tree (the schema changed, or the pre-#37 flat string-per-field entry) is /// schema — a stale tree (the schema changed, or the older flat string-per-field entry) is
/// dropped rather than painted into the form as errors. /// dropped rather than painted into the form as errors.
/// </summary> /// </summary>
private async Task RestoreInputsAsync(int methodId) private async Task RestoreInputsAsync(int methodId)
@@ -123,6 +123,10 @@ public partial class InstanceConfigure
/// counts: it pins a reportable template alarm off for this instance, which is a /// counts: it pins a reportable template alarm off for this instance, which is a
/// real override and not the same as inheriting. /// real override and not the same as inheriting.
/// </summary> /// </summary>
/// <param name="overrideJson">The override JSON entered in the modal, if any.</param>
/// <param name="priority">The priority override, if one was chosen.</param>
/// <param name="mesReportable">The MES-reportable override, if one was chosen.</param>
/// <returns><c>true</c> when the modal carries any override worth persisting; otherwise <c>false</c>.</returns>
internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable) internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
=> !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue; => !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
@@ -112,7 +112,7 @@ public partial class TransportImport : ComponentBase, IDisposable
/// <summary> /// <summary>
/// Guards <see cref="_session"/> together with <see cref="_disposed"/> (register row /// Guards <see cref="_session"/> together with <see cref="_disposed"/> (register row
/// 103, the P6-6 lesser instance — mirrors <c>DebugView</c>'s <c>_sessionLock</c>). /// 103 — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
/// <see cref="TryLoadAsync"/> awaits <see cref="IBundleImporter.LoadAsync"/>, and the /// <see cref="TryLoadAsync"/> awaits <see cref="IBundleImporter.LoadAsync"/>, and the
/// circuit can be torn down inside that window; publishing the freshly-opened session /// circuit can be torn down inside that window; publishing the freshly-opened session
/// and observing disposal must be one atomic step so exactly one side (this method or /// and observing disposal must be one atomic step so exactly one side (this method or
@@ -258,7 +258,7 @@ public partial class TransportImport : ComponentBase, IDisposable
var loaded = await BundleImporter.LoadAsync( var loaded = await BundleImporter.LoadAsync(
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress); stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
// Register row 103 (P6-6 lesser instance): the component may have been // Register row 103: the component may have been
// disposed while LoadAsync was in flight (navigation away mid-upload). // disposed while LoadAsync was in flight (navigation away mid-upload).
// Publish and re-check disposal atomically under _sessionLock so exactly // Publish and re-check disposal atomically under _sessionLock so exactly
// one side tears the freshly-opened session down. // one side tears the freshly-opened session down.
@@ -278,9 +278,9 @@ public partial class TransportImport : ComponentBase, IDisposable
} }
catch (ArgumentException) catch (ArgumentException)
{ {
// Encrypted bundle, no passphrase supplied — caller advances to Step 2. // Encrypted bundle, no passphrase supplied — caller advances to the passphrase step.
// We deliberately do NOT set _errorMessage here; the page surfaces // We deliberately do NOT set _errorMessage here; the page surfaces
// an empty Step-2 prompt instead. // an empty passphrase prompt instead.
_session = null; _session = null;
throw; throw;
} }
@@ -977,7 +977,7 @@ public partial class TransportImport : ComponentBase, IDisposable
/// Register row 103: the flag is set and the session taken under the same lock /// Register row 103: the flag is set and the session taken under the same lock
/// <see cref="TryLoadAsync"/> publishes through, so a <see cref="TryLoadAsync"/> still /// <see cref="TryLoadAsync"/> publishes through, so a <see cref="TryLoadAsync"/> still
/// awaiting <see cref="IBundleImporter.LoadAsync"/> either hands the session over here /// awaiting <see cref="IBundleImporter.LoadAsync"/> either hands the session over here
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s P6-6 fix). /// or abandons it itself — never neither (mirrors <c>DebugView</c>'s fix).
/// </para> /// </para>
/// </summary> /// </summary>
public void Dispose() public void Dispose()
@@ -138,6 +138,8 @@ public partial class FlowGraphView : ComponentBase
} }
/// <summary>Formats a coordinate culture-invariantly.</summary> /// <summary>Formats a coordinate culture-invariantly.</summary>
/// <param name="v">The value to format.</param>
/// <returns>The value formatted with up to two decimals using the invariant culture.</returns>
public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture); public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom)); private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom));
@@ -199,6 +201,8 @@ public partial class FlowGraphView : ComponentBase
=> n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify; => n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
/// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary> /// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary>
/// <param name="outcome">The folded outcome of the outbound step.</param>
/// <returns>The short pill label for the outcome.</returns>
public static string PillText(FlowStepOutcome outcome) => outcome switch public static string PillText(FlowStepOutcome outcome) => outcome switch
{ {
FlowStepOutcome.Ok => "Ok", FlowStepOutcome.Ok => "Ok",
@@ -224,6 +228,9 @@ public partial class FlowGraphView : ComponentBase
/// sync database call's operation detail (<c>write · 3 rows</c>) kept in front of it. The projector's /// sync database call's operation detail (<c>write · 3 rows</c>) kept in front of it. The projector's
/// subtitle already leads with the audit status, which the pill now carries, so that head is dropped. /// subtitle already leads with the audit status, which the pill now carries, so that head is dropped.
/// </summary> /// </summary>
/// <param name="kind">The kind of the outbound step.</param>
/// <param name="subtitle">The step's projected subtitle, which may lead with the audit status.</param>
/// <returns>The mode text shown after the status pill.</returns>
public static string OutboundMode(FlowStepKind kind, string? subtitle) public static string OutboundMode(FlowStepKind kind, string? subtitle)
{ {
if (kind == FlowStepKind.Notify) return "notify"; if (kind == FlowStepKind.Notify) return "notify";
@@ -243,6 +250,8 @@ public partial class FlowGraphView : ComponentBase
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge /// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
/// never silently drops a line. /// never silently drops a line.
/// </summary> /// </summary>
/// <param name="s">The step whose log lines are counted; may be null.</param>
/// <returns>The error, warning and info line counts.</returns>
internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s) internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s)
{ {
if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0); if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0);
@@ -260,6 +269,8 @@ public partial class FlowGraphView : ComponentBase
} }
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary> /// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
/// <param name="counts">The per-severity log line counts.</param>
/// <returns>The hover text listing the non-zero levels.</returns>
internal static string LogTitle((int Errors, int Warnings, int Info) counts) internal static string LogTitle((int Errors, int Warnings, int Info) counts)
{ {
var (errors, warnings, info) = counts; var (errors, warnings, info) = counts;
@@ -273,12 +284,14 @@ public partial class FlowGraphView : ComponentBase
/// <summary> /// <summary>
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where /// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace. Parsing is /// the type is the exception's name without its namespace. Parsing is
/// <see cref="ExceptionHeader.Parse"/> (final review M1 — the same parser the step detail panel /// <see cref="ExceptionHeader.Parse"/> (the same parser the step detail panel
/// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows /// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows
/// its <c>" ---&gt; "</c> chain to the innermost exception. Falls back to the line itself when it /// its <c>" ---&gt; "</c> chain to the innermost exception. Falls back to the line itself when it
/// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail. /// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail.
/// Null for every non-failed step. /// Null for every non-failed step.
/// </summary> /// </summary>
/// <param name="s">The step to build the subtitle for.</param>
/// <returns>The exception subtitle, or null for a step that did not fail.</returns>
internal static string? ExceptionSubtitle(FlowStep s) internal static string? ExceptionSubtitle(FlowStep s)
{ {
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null; if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
@@ -305,6 +318,8 @@ public partial class FlowGraphView : ComponentBase
/// than one row — the submit→terminal wall time with a leading <c>+</c> (<c>+4.1 s</c>), since that /// than one row — the submit→terminal wall time with a leading <c>+</c> (<c>+4.1 s</c>), since that
/// span is elapsed time downstream of the script, not time the script spent (node anatomy). /// span is elapsed time downstream of the script, not time the script spent (node anatomy).
/// </summary> /// </summary>
/// <param name="n">The layout node to describe.</param>
/// <returns>The formatted duration text for the node's head row.</returns>
public static string Duration(FlowLayoutNode n) public static string Duration(FlowLayoutNode n)
{ {
if (n.Step?.DurationMs is not int ms) if (n.Step?.DurationMs is not int ms)
@@ -317,6 +332,9 @@ public partial class FlowGraphView : ComponentBase
} }
/// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary> /// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary>
/// <param name="text">The label text; may be null.</param>
/// <param name="max">The maximum number of characters to keep.</param>
/// <returns>The text, clipped to fit when it is longer than the maximum.</returns>
public static string Clip(string? text, int max) public static string Clip(string? text, int max)
{ {
if (string.IsNullOrEmpty(text)) return string.Empty; if (string.IsNullOrEmpty(text)) return string.Empty;
@@ -329,6 +347,8 @@ public partial class FlowGraphView : ComponentBase
/// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the /// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the
/// shared head is shown once in the lane gutter and each node shows the tail that tells it apart. /// shared head is shown once in the lane gutter and each node shows the tail that tells it apart.
/// </summary> /// </summary>
/// <param name="titles">The node titles on the lane.</param>
/// <returns>The shared dotted prefix, or null when there is none worth showing.</returns>
public static string? CommonDottedPrefix(IReadOnlyList<string> titles) public static string? CommonDottedPrefix(IReadOnlyList<string> titles)
{ {
if (titles.Count < 2) return null; if (titles.Count < 2) return null;
@@ -352,6 +372,8 @@ public partial class FlowGraphView : ComponentBase
/// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than /// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment. /// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
/// </summary> /// </summary>
/// <param name="text">The title text; may be null.</param>
/// <returns>The text with zero-width spaces inserted at preferred break points.</returns>
public static string WrapHints(string? text) public static string WrapHints(string? text)
{ {
if (string.IsNullOrEmpty(text)) return string.Empty; if (string.IsNullOrEmpty(text)) return string.Empty;
@@ -373,6 +395,9 @@ public partial class FlowGraphView : ComponentBase
/// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated /// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated
/// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through. /// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through.
/// </summary> /// </summary>
/// <param name="text">The text to strip; may be null.</param>
/// <param name="prefix">The prefix to remove; may be null.</param>
/// <returns>The text with the prefix removed.</returns>
public static string? StripPrefix(string? text, string? prefix) public static string? StripPrefix(string? text, string? prefix)
{ {
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text; if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text;
@@ -385,6 +410,14 @@ public partial class FlowGraphView : ComponentBase
/// <c>&lt;text&gt;</c> tag for its own markup-mode switch and refuses attributes on it, /// <c>&lt;text&gt;</c> tag for its own markup-mode switch and refuses attributes on it,
/// so the SVG element of the same name cannot be written literally inside a code block. /// so the SVG element of the same name cannot be written literally inside a code block.
/// </summary> /// </summary>
/// <param name="x">The x coordinate.</param>
/// <param name="y">The y coordinate.</param>
/// <param name="cssClass">The CSS class for the element.</param>
/// <param name="content">The text content, encoded by this method.</param>
/// <param name="anchor">The optional <c>text-anchor</c> value.</param>
/// <param name="title">The optional tooltip title.</param>
/// <param name="dataTest">The optional <c>data-test</c> attribute value.</param>
/// <returns>The encoded SVG text element markup.</returns>
public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null) public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null)
{ {
var sb = new StringBuilder("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"'); var sb = new StringBuilder("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"');
@@ -427,6 +460,10 @@ public partial class FlowGraphView : ComponentBase
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line). /// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
/// Kept in step with those two path builders. /// Kept in step with those two path builders.
/// </summary> /// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <param name="isAsync">True when the connector is an async branch.</param>
/// <returns>The midpoint of the connector's vertical run.</returns>
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync) public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
{ {
var x1 = a.X + NodeWidth; var x1 = a.X + NodeWidth;
@@ -460,6 +497,7 @@ public partial class FlowGraphView : ComponentBase
/// </summary> /// </summary>
/// <param name="graph">The real graph, or null.</param> /// <param name="graph">The real graph, or null.</param>
/// <param name="predicted">The prediction, or null.</param> /// <param name="predicted">The prediction, or null.</param>
/// <returns>The computed layout.</returns>
public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted) public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted)
{ {
var hasReal = graph is { Steps.Count: > 0 }; var hasReal = graph is { Steps.Count: > 0 };
@@ -652,6 +690,8 @@ public partial class FlowGraphView : ComponentBase
/// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c> /// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c>
/// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused. /// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused.
/// </summary> /// </summary>
/// <param name="distinctTargets">The distinct external targets in first-seen order.</param>
/// <returns>The gutter sublabel text.</returns>
public static string ExternalSublabel(IReadOnlyList<string> distinctTargets) public static string ExternalSublabel(IReadOnlyList<string> distinctTargets)
{ {
if (distinctTargets.Count == 0) return string.Empty; if (distinctTargets.Count == 0) return string.Empty;
@@ -666,6 +706,9 @@ public partial class FlowGraphView : ComponentBase
/// crosses a node sitting between the two lanes. Falls back to <see cref="OrthogonalPath"/> when /// crosses a node sitting between the two lanes. Falls back to <see cref="OrthogonalPath"/> when
/// the target is not below the source. /// the target is not below the source.
/// </summary> /// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <returns>The SVG path data for the connector.</returns>
public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b) public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b)
{ {
if (b.Y <= a.Y) if (b.Y <= a.Y)
@@ -682,6 +725,9 @@ public partial class FlowGraphView : ComponentBase
/// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right /// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right
/// through the inter-column gap. A backwards edge (target left of source) loops under. /// through the inter-column gap. A backwards edge (target left of source) loops under.
/// </summary> /// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <returns>The SVG path data for the connector.</returns>
public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b) public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b)
{ {
var x1 = a.X + NodeWidth; var x1 = a.X + NodeWidth;
@@ -58,8 +58,8 @@ public interface IDialogService
/// button's styling.</param> /// button's styling.</param>
/// <param name="confirmLabel">Verb rendered on the confirm button. Only /// <param name="confirmLabel">Verb rendered on the confirm button. Only
/// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is /// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is
/// blank; the other severities fall back to "Confirm". The row-28 rule still /// blank; the other severities fall back to "Confirm". The rule to name the
/// applies — name the action.</param> /// action still applies.</param>
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns> /// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null); Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null);
@@ -8,11 +8,17 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
private readonly StringBuilder _retained = new(Math.Max(0, capacity)); private readonly StringBuilder _retained = new(Math.Max(0, capacity));
private readonly object _gate = new(); private readonly object _gate = new();
private long _dropped; private long _dropped;
/// <summary>Number of characters currently retained.</summary>
public int RetainedCharacters { get { lock (_gate) return _retained.Length; } } public int RetainedCharacters { get { lock (_gate) return _retained.Length; } }
/// <summary>Number of characters discarded because the budget was reached.</summary>
public long DroppedCharacters { get { lock (_gate) return _dropped; } } public long DroppedCharacters { get { lock (_gate) return _dropped; } }
/// <inheritdoc />
public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); } public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); }
/// <inheritdoc />
public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); } public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); }
/// <inheritdoc />
public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count)); public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count));
/// <inheritdoc />
public override void Write(ReadOnlySpan<char> buffer) public override void Write(ReadOnlySpan<char> buffer)
{ {
lock (_gate) lock (_gate)
@@ -22,24 +28,32 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
_dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take; _dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take;
} }
} }
/// <inheritdoc />
public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } } public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } }
/// <inheritdoc />
public override void WriteLine() => Write(NewLine); public override void WriteLine() => Write(NewLine);
/// <inheritdoc />
public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); } public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); }
} }
/// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary> /// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary>
public sealed class BoundedSandboxStream(int capacity) : MemoryStream public sealed class BoundedSandboxStream(int capacity) : MemoryStream
{ {
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
/// <inheritdoc />
public override void Write(ReadOnlySpan<byte> buffer) public override void Write(ReadOnlySpan<byte> buffer)
{ {
if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget."); if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget.");
var bounded = buffer.ToArray(); var bounded = buffer.ToArray();
base.Write(bounded, 0, bounded.Length); base.Write(bounded, 0, bounded.Length);
} }
/// <inheritdoc />
public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); } public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); }
/// <inheritdoc />
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; } { cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; }
/// <inheritdoc />
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; } { cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; }
} }
@@ -521,7 +521,7 @@ public class SandboxAttributeAccessor
/// <summary> /// <summary>
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c> /// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
/// overload (Gitea #36): present for editor/compile parity, throws /// overload: present for editor/compile parity, throws
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport). /// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
/// </summary> /// </summary>
/// <param name="values">The attribute values to write as a batch.</param> /// <param name="values">The attribute values to write as a batch.</param>
@@ -21,10 +21,22 @@ public static class SandboxWorkerProtocol
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open); private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
/// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary> /// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary>
/// <param name="request">The sandbox run request to execute in the child process.</param>
/// <param name="services">The service provider supplying host services for the run.</param>
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
/// <param name="caller">Cancellation token from the caller.</param>
/// <returns>The sandbox run result.</returns>
public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services, public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller) ISharedScriptCatalog catalog, CancellationToken caller)
=> await RunCoreAsync(request, services, catalog, caller); => await RunCoreAsync(request, services, catalog, caller);
/// <summary>Core run implementation; exposes a callback that reports the child process id once started.</summary>
/// <param name="request">The sandbox run request to execute in the child process.</param>
/// <param name="services">The service provider supplying host services for the run.</param>
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
/// <param name="caller">Cancellation token from the caller.</param>
/// <param name="started">Optional callback invoked with the child process id after launch.</param>
/// <returns>The sandbox run result.</returns>
internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services, internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null) ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null)
{ {
@@ -163,6 +175,7 @@ public static class SandboxWorkerProtocol
} }
/// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary> /// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task RunWorkerAsync() public static async Task RunWorkerAsync()
{ {
var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput(); var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput();
@@ -195,15 +208,21 @@ public static class SandboxWorkerProtocol
} }
finally { _gate.Release(); } finally { _gate.Release(); }
} }
/// <inheritdoc />
public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]); public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]);
/// <inheritdoc />
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default) public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
=> (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json); => (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json);
/// <inheritdoc />
public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default) public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default)
=> (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!; => (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null) public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!; => (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null) public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!; => (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default) public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default)
{ {
var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!; var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!;
@@ -213,11 +232,15 @@ public static class SandboxWorkerProtocol
if (info.Open) await connection.OpenAsync(cancellationToken); if (info.Open) await connection.OpenAsync(cancellationToken);
return connection; return connection;
} }
/// <inheritdoc />
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct) public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct)); => ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct));
/// <inheritdoc />
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct); public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct);
/// <inheritdoc />
public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct) public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct)); => ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct));
/// <inheritdoc />
public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct) public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
=> (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!; => (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!;
} }
@@ -181,7 +181,7 @@ public class ScriptAnalysisService
/// <summary> /// <summary>
/// Shared options for the per-Test-Run return-value JSON serialize in /// Shared options for the per-Test-Run return-value JSON serialize in
/// <see cref="SerializeReturn"/> (perf remediation, arch-review WP1.5) — avoids /// <see cref="SerializeReturn"/> — avoids
/// allocating a new <see cref="JsonSerializerOptions"/> per run. Settings preserved /// allocating a new <see cref="JsonSerializerOptions"/> per run. Settings preserved
/// exactly. /// exactly.
/// </summary> /// </summary>
@@ -237,6 +237,9 @@ public class ScriptAnalysisService
=> SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct); => SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct);
/// <summary>Execution core invoked only by the disposable worker executable.</summary> /// <summary>Execution core invoked only by the disposable worker executable.</summary>
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
/// <param name="ct">Cancellation token for the run.</param>
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct) public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(request.Code)) if (string.IsNullOrWhiteSpace(request.Code))
@@ -537,6 +540,9 @@ public class ScriptAnalysisService
return result; return result;
} }
/// <summary>Converts a <see cref="JsonElement"/> into the equivalent CLR object (string, number, bool, list, dictionary or null).</summary>
/// <param name="element">The JSON element to convert.</param>
/// <returns>The converted CLR value.</returns>
internal static object? JsonElementToObject(JsonElement element) internal static object? JsonElementToObject(JsonElement element)
{ {
return element.ValueKind switch return element.ValueKind switch
@@ -111,12 +111,11 @@ public static class ServiceCollectionExtensions
// The fan-out itself stays scoped (it holds scoped repositories + the snapshot // The fan-out itself stays scoped (it holds scoped repositories + the snapshot
// client), but the page resolves it through SharedAlarmSummaryService — a process // client), but the page resolves it through SharedAlarmSummaryService — a process
// singleton that memoizes the fan-out per site with single-flight, so N circuits // singleton that memoizes the fan-out per site with single-flight, so N circuits
// watching one site cost ONE fan-out per window instead of N (arch-review WP2.4). // watching one site cost ONE fan-out per window instead of N.
services.AddScoped<AlarmSummaryService>(); services.AddScoped<AlarmSummaryService>();
services.AddSingleton<IAlarmSummaryService, SharedAlarmSummaryService>(); services.AddSingleton<IAlarmSummaryService, SharedAlarmSummaryService>();
// Process-level memoized point-in-time KPI snapshots (arch-review WP2.4, finding // Process-level memoized point-in-time KPI snapshots. Singleton on purpose: the memo has to outlive any one
// #8's per-circuit half). Singleton on purpose: the memo has to outlive any one
// circuit for N dashboards to share a single SQL round per refresh window. // circuit for N dashboards to share a single SQL round per refresh window.
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>(); services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger /// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
/// exports.</c> footer line so an operator can tell a truncated download from /// exports.</c> footer line so an operator can tell a truncated download from
/// a complete one. The header row contains the same 24 columns, in the same /// a complete one. The header row contains the same 24 columns, in the same
/// order, as the ManagementService CLI's CSV export (arch-review 2026-08-19 /// order, as the ManagementService CLI's CSV export (the two CSV writers
/// P6-7 — the two CSV writers were found to have drifted and are kept aligned). /// had drifted and are kept aligned).
/// </para> /// </para>
/// </summary> /// </summary>
public interface IAuditLogExportService public interface IAuditLogExportService
@@ -162,7 +162,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// CSV helpers — 24 columns, kept aligned with the ManagementService CLI's // CSV helpers — 24 columns, kept aligned with the ManagementService CLI's
// CSV export (arch-review 2026-08-19 P6-7) // CSV export
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary>The 24 column names, in <see cref="AuditEventView"/> declaration order.</summary> /// <summary>The 24 column names, in <see cref="AuditEventView"/> declaration order.</summary>
@@ -179,8 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
/// cleanly on another. Free-text columns are run through /// cleanly on another. Free-text columns are run through
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a /// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as /// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
/// a formula or DDE trigger by a spreadsheet application (CWE-1236; /// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this
/// arch-review 2026-08-19 P6-7). GUID/enum/numeric/date/bool columns this
/// formatter generates itself are never neutralized. /// formatter generates itself are never neutralized.
/// </summary> /// </summary>
/// <param name="evt">The audit event view to format as a CSV row.</param> /// <param name="evt">The audit event view to format as a CSV row.</param>
@@ -70,6 +70,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// </summary> /// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param> /// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param> /// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
/// <param name="auth">Optional authentication state provider used to resolve the current user.</param>
public AuditLogQueryService( public AuditLogQueryService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null) ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public sealed class AuthorizedManualFailoverService(AuthenticationStateProvider auth, IManualFailoverService backend) public sealed class AuthorizedManualFailoverService(AuthenticationStateProvider auth, IManualFailoverService backend)
{ {
/// <summary>Fails over central only for a currently authorized administrator.</summary> /// <summary>Fails over central only for a currently authorized administrator.</summary>
/// <returns>The failover outcome message, or <c>null</c> when none was reported.</returns>
public async Task<string?> FailOverCentralAsync() public async Task<string?> FailOverCentralAsync()
=> await backend.FailOverCentralAsync(await auth.RequireRoleAsync(Roles.Administrator)); => await backend.FailOverCentralAsync(await auth.RequireRoleAsync(Roles.Administrator));
/// <summary>Fails over a site only for a currently authorized administrator.</summary> /// <summary>Fails over a site only for a currently authorized administrator.</summary>
/// <param name="siteId">The site identifier to fail over.</param>
/// <returns>The outcome of the site failover.</returns>
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId) public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId)
=> await backend.FailOverSiteAsync(siteId, await auth.RequireRoleAsync(Roles.Administrator)); => await backend.FailOverSiteAsync(siteId, await auth.RequireRoleAsync(Roles.Administrator));
} }
@@ -12,6 +12,7 @@ public enum BindingConnectionState { None, Single, Mixed }
/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary> /// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
public sealed record BindingGroupMember(BindingRow Row, string MemberTail) public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
{ {
/// <summary>True when the row has no bound (or a non-positive) connection id.</summary>
public bool IsUnbound => Row.ConnectionId is null or <= 0; public bool IsUnbound => Row.ConnectionId is null or <= 0;
/// <summary>Effective reference: override if set, else the default.</summary> /// <summary>Effective reference: override if set, else the default.</summary>
public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override; public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
@@ -32,6 +33,7 @@ public sealed record BindingGroup(
int? CommonConnectionId, int? CommonConnectionId,
int UnboundCount) int UnboundCount)
{ {
/// <summary>True when this group holds the instance's direct (non-module) attributes.</summary>
public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey; public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
} }
@@ -44,12 +46,14 @@ public static class BindingGroupBuilder
{ {
public const string InstanceGroupKey = "(instance)"; public const string InstanceGroupKey = "(instance)";
/// <summary>Groups the data-sourced rows by module and infers each group's shared connection and object prefix.</summary>
/// <param name="rows">The data-sourced flattened attributes with their current binding state.</param> /// <param name="rows">The data-sourced flattened attributes with their current binding state.</param>
/// <param name="knownConnectionIds"> /// <param name="knownConnectionIds">
/// The site's data connection ids. When supplied, a row whose connection id is not /// The site's data connection ids. When supplied, a row whose connection id is not
/// in the set is treated as UNBOUND — a binding to a deleted connection renders as /// in the set is treated as UNBOUND — a binding to a deleted connection renders as
/// "— none —" and the deploy gate rejects it, so the pills must agree. /// "— none —" and the deploy gate rejects it, so the pills must agree.
/// </param> /// </param>
/// <returns>One <see cref="BindingGroup"/> per module (or the instance group).</returns>
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null) public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null)
{ {
if (knownConnectionIds is not null) if (knownConnectionIds is not null)
@@ -153,6 +157,10 @@ public static class BindingGroupBuilder
/// Save path is an upsert and a removal could not persist anyway. Surrounding /// Save path is an upsert and a removal could not persist anyway. Surrounding
/// whitespace and a trailing '.' are tolerated. /// whitespace and a trailing '.' are tolerated.
/// </summary> /// </summary>
/// <param name="group">The group whose members are rewritten.</param>
/// <param name="connectionId">The connection to bind, or null to keep each member's current connection.</param>
/// <param name="objectPath">The object path prefix, or blank to clear the overrides.</param>
/// <returns>One change per group member.</returns>
public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath) public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath)
{ {
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.'); var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
@@ -12,6 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth) public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth)
{ {
/// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary> /// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary>
/// <typeparam name="T">The result type of the operation.</typeparam>
/// <param name="instanceId">The existing instance id, or null when creating.</param>
/// <param name="createSiteId">The target site id when creating an instance.</param>
/// <param name="operation">The operation to run, given the instance service and the acting username.</param>
/// <returns>The operation's result.</returns>
public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId, public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId,
Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation) Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation)
{ {
@@ -36,6 +41,8 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
} }
/// <summary>Executes one command through the same guarded boundary as the management actor.</summary> /// <summary>Executes one command through the same guarded boundary as the management actor.</summary>
/// <param name="command">The configuration command to execute.</param>
/// <returns>The command's result, if any.</returns>
public async Task<object?> ExecuteAsync(object command) public async Task<object?> ExecuteAsync(object command)
{ {
var principal = (await auth.GetAuthoritativeStateAsync()).User; var principal = (await auth.GetAuthoritativeStateAsync()).User;
@@ -52,6 +59,9 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
public static class ConfigurationCommandExtensions public static class ConfigurationCommandExtensions
{ {
/// <summary>Submits a database-only configuration mutation with current server-side authority.</summary> /// <summary>Submits a database-only configuration mutation with current server-side authority.</summary>
/// <param name="services">The service provider used to resolve the command service.</param>
/// <param name="command">The configuration command to execute.</param>
/// <returns>The command's result, if any.</returns>
public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command) public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command)
=> services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command); => services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command);
} }
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// NAME keys back to <c>DataConnection.Id</c> using the same site repository the /// NAME keys back to <c>DataConnection.Id</c> using the same site repository the
/// design page loads from. Health reports key connections by <c>Name</c>, the /// design page loads from. Health reports key connections by <c>Name</c>, the
/// page renders nodes by id, so the join is per-site Name → Id; doing it per-site /// page renders nodes by id, so the join is per-site Name → Id; doing it per-site
/// avoids cross-site name collisions (two sites can each own a "PLC-1"). /// avoids cross-site name collisions (two sites can each own a "MainPlc").
/// </remarks> /// </remarks>
public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService
{ {
@@ -59,6 +59,8 @@ public sealed class FlowDebugService : IFlowDebugService
/// the run itself at <c>timeoutSeconds + RunGrace</c> (see <c>FlowDebugRunner.RunMethodAsync</c>), /// the run itself at <c>timeoutSeconds + RunGrace</c> (see <c>FlowDebugRunner.RunMethodAsync</c>),
/// so the client must wait at least that long or it cuts the run off before the server does. /// so the client must wait at least that long or it cuts the run off before the server does.
/// </summary> /// </summary>
/// <param name="timeoutSeconds">The method's own timeout in seconds.</param>
/// <returns>The Ask timeout to use for the run.</returns>
public static TimeSpan RunAskTimeoutFor(int timeoutSeconds) public static TimeSpan RunAskTimeoutFor(int timeoutSeconds)
{ {
var t = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)) + FlowDebugRunner.RunGrace + ClientSlack; var t = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)) + FlowDebugRunner.RunGrace + ClientSlack;
@@ -69,6 +71,8 @@ public sealed class FlowDebugService : IFlowDebugService
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the /// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
/// trigger-wait window plus <see cref="ClientSlack"/>. /// trigger-wait window plus <see cref="ClientSlack"/>.
/// </summary> /// </summary>
/// <param name="waitSeconds">The trigger-wait window in seconds.</param>
/// <returns>The Ask timeout to use for the run.</returns>
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds) public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack; => TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
@@ -110,6 +114,14 @@ public sealed class FlowDebugService : IFlowDebugService
IReadOnlyList<FlowScriptOption> Options); IReadOnlyList<FlowScriptOption> Options);
/// <summary>Initializes the service.</summary> /// <summary>Initializes the service.</summary>
/// <param name="holder">Holder for the management actor reference.</param>
/// <param name="auth">Provides the caller's authentication state.</param>
/// <param name="inboundApi">Repository for inbound API methods.</param>
/// <param name="templates">Repository for templates and instances.</param>
/// <param name="pipeline">Flattening pipeline used to resolve instance scripts.</param>
/// <param name="comms">Communication service for central-to-site requests.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="sites">Repository for sites.</param>
public FlowDebugService( public FlowDebugService(
ManagementActorHolder holder, ManagementActorHolder holder,
AuthenticationStateProvider auth, AuthenticationStateProvider auth,
@@ -44,7 +44,7 @@ public interface IAlarmSummaryService
/// <summary> /// <summary>
/// Builds an <see cref="AlarmSummaryResult"/> directly from an in-memory live /// Builds an <see cref="AlarmSummaryResult"/> directly from an in-memory live
/// snapshot (plan #10, Task 5) — the current alarms served by /// snapshot — the current alarms served by
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.ISiteAlarmLiveCache.GetCurrentAlarms"/> /// <see cref="ZB.MOM.WW.ScadaBridge.Communication.ISiteAlarmLiveCache.GetCurrentAlarms"/>
/// — instead of fanning out per-instance snapshot Asks. Each /// — instead of fanning out per-instance snapshot Asks. Each
/// <see cref="AlarmStateChanged"/> is flattened to one /// <see cref="AlarmStateChanged"/> is flattened to one
@@ -28,6 +28,9 @@ public sealed record FlowDebugOutcome(
public bool Success => Graph != null && Error == null; public bool Success => Graph != null && Error == null;
/// <summary>Creates a failed outcome.</summary> /// <summary>Creates a failed outcome.</summary>
/// <param name="error">The error message.</param>
/// <param name="errorCode">The machine-readable error code.</param>
/// <returns>A failed outcome carrying the error.</returns>
public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode); public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode);
} }
@@ -83,18 +86,21 @@ public interface IFlowDebugService
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary> /// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
/// <param name="instanceId">The instance id.</param> /// <param name="instanceId">The instance id.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The instance's scripts ordered by canonical name.</returns>
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default); Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary> /// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
/// <param name="instanceId">The instance id.</param> /// <param name="instanceId">The instance id.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param> /// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The predicted flow for the script.</returns>
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default); Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);
/// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary> /// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary>
/// <param name="apiMethodId">The method id.</param> /// <param name="apiMethodId">The method id.</param>
/// <param name="parameters">Flat JSON object of parameters, or null.</param> /// <param name="parameters">Flat JSON object of parameters, or null.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the run, including the built flow graph.</returns>
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default); Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary> /// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
@@ -103,11 +109,13 @@ public interface IFlowDebugService
/// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param> /// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param>
/// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param> /// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the run, including the built flow graph.</returns>
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default); Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default);
/// <summary>Rebuilds a past flow from the central audit store.</summary> /// <summary>Rebuilds a past flow from the central audit store.</summary>
/// <param name="executionId">Any execution id in the chain.</param> /// <param name="executionId">Any execution id in the chain.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the replay, including the rebuilt flow graph.</returns>
Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default); Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
@@ -120,6 +128,7 @@ public interface IFlowDebugService
/// </summary> /// </summary>
/// <param name="step">The outbound step to retry.</param> /// <param name="step">The outbound step to retry.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the retry request.</returns>
Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default); Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
@@ -128,5 +137,6 @@ public interface IFlowDebugService
/// </summary> /// </summary>
/// <param name="step">The outbound step to discard.</param> /// <param name="step">The outbound step to discard.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the discard request.</returns>
Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default); Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
} }
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary> /// <summary>
/// Process-level memoized point-in-time KPI snapshots shared by every Blazor circuit on /// Process-level memoized point-in-time KPI snapshots shared by every Blazor circuit on
/// this node (arch-review WP2.4, finding #8 — the per-circuit polling half). /// this node (the per-circuit polling half).
/// <para> /// <para>
/// Every KPI here is a <em>global, point-in-time aggregate</em>: it does not vary by user, /// Every KPI here is a <em>global, point-in-time aggregate</em>: it does not vary by user,
/// site scope, or role, so N circuits asking for it inside one refresh window are asking /// site scope, or role, so N circuits asking for it inside one refresh window are asking
@@ -13,6 +13,9 @@ public sealed class PollGate
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0; public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
/// <summary>Runs and observes a posted refresh, releasing the gate even if dispatch is rejected.</summary> /// <summary>Runs and observes a posted refresh, releasing the gate even if dispatch is rejected.</summary>
/// <param name="dispatch">Delegate that posts the refresh.</param>
/// <param name="onError">Callback invoked with any exception thrown by <paramref name="dispatch"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RunAsync(Func<Task> dispatch, Action<Exception> onError) public async Task RunAsync(Func<Task> dispatch, Action<Exception> onError)
{ {
if (!TryEnter()) return; if (!TryEnter()) return;
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary> /// <summary>
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/> (arch-review WP2.4). /// Process-level memoizing façade over <see cref="AlarmSummaryService"/>.
/// <para> /// <para>
/// The Alarm Summary page polls every 15s <em>per circuit</em>, and each poll fans one debug /// The Alarm Summary page polls every 15s <em>per circuit</em>, and each poll fans one debug
/// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site /// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site
@@ -17,6 +17,9 @@ public static class TemplatePathFormatter
/// Builds the display path for <paramref name="template"/> by walking its /// Builds the display path for <paramref name="template"/> by walking its
/// folder chain upward through <paramref name="foldersById"/>. /// folder chain upward through <paramref name="foldersById"/>.
/// </summary> /// </summary>
/// <param name="template">The template whose path is built.</param>
/// <param name="foldersById">Folders keyed by id, used to walk the parent chain.</param>
/// <returns>The folder-qualified display path.</returns>
public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById) public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById)
{ {
var parts = new List<string> { template.Name }; var parts = new List<string> { template.Name };
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// <summary> /// <summary>
/// Pure decision logic for the simultaneous-cold-start split-brain guard (Gitea #33). /// Pure decision logic for the simultaneous-cold-start split-brain guard.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -119,7 +119,7 @@ public class ClusterOptions
/// The simultaneous-cold-start split-brain guard (<c>ScadaBridge:Cluster:BootstrapGuard</c>). /// The simultaneous-cold-start split-brain guard (<c>ScadaBridge:Cluster:BootstrapGuard</c>).
/// Default OFF — a dark switch, so existing deployments and tests keep Akka's config-driven /// Default OFF — a dark switch, so existing deployments and tests keep Akka's config-driven
/// self-first auto-join byte-identical. See <see cref="ClusterBootstrapGuard"/> for the /// self-first auto-join byte-identical. See <see cref="ClusterBootstrapGuard"/> for the
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime (Gitea #33). /// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime.
/// </summary> /// </summary>
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new(); public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
} }
@@ -82,7 +82,7 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
} }
/// <summary> /// <summary>
/// When the bootstrap guard is enabled (Gitea #33), its timing knobs must be positive. A /// When the bootstrap guard is enabled, its timing knobs must be positive. A
/// zero/negative <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/> in particular /// zero/negative <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/> in particular
/// silently degrades the guard to "never wait, always conclude the partner is dead" — the /// silently degrades the guard to "never wait, always conclude the partner is dead" — the
/// higher node would form alone immediately and re-open the very split the guard exists to /// higher node would form alone immediately and re-open the very split the guard exists to
@@ -292,6 +292,7 @@ public interface IAuditLogRepository
/// </remarks> /// </remarks>
/// <param name="executionId">Any execution id in the chain; the implementation walks to the root and back down.</param> /// <param name="executionId">Any execution id in the chain; the implementation walks to the root and back down.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <param name="permittedSiteIds">Optional site scope restricting which sites' rows are visible; <c>null</c> means unrestricted.</param>
/// <returns>A task that resolves to the full execution tree rooted at the topmost ancestor, one node per distinct execution.</returns> /// <returns>A task that resolves to the full execution tree rooted at the topmost ancestor, one node per distinct execution.</returns>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, Guid executionId,
@@ -307,6 +308,7 @@ public interface IAuditLogRepository
/// </summary> /// </summary>
/// <param name="executionId">Any execution id in the chain.</param> /// <param name="executionId">Any execution id in the chain.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <param name="permittedSiteIds">Optional site scope restricting which sites' rows are visible; <c>null</c> means unrestricted.</param>
/// <returns>The canonical audit events that make up the flow, oldest first.</returns> /// <returns>The canonical audit events that make up the flow, oldest first.</returns>
Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync( Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(
Guid executionId, Guid executionId,
@@ -93,7 +93,7 @@ public interface INotificationOutboxRepository
/// is its own transaction. /// is its own transaction.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <b>Why this exists (arch-review 2026-08-19 P5-4).</b> The operator retry/ /// <b>Why this exists.</b> The operator retry/
/// discard handlers are read-check-write across two round trips: /// discard handlers are read-check-write across two round trips:
/// <see cref="GetByIdAsync"/> reads the row, the handler checks its /// <see cref="GetByIdAsync"/> reads the row, the handler checks its
/// in-memory <c>Status</c>, then a write persists the transition. A detached /// in-memory <c>Status</c>, then a write persists the transition. A detached
@@ -93,7 +93,7 @@ public interface ISecuredWriteRepository
/// the compare-and-swap guard for the approve-vs-reject race: a stale-read reject /// the compare-and-swap guard for the approve-vs-reject race: a stale-read reject
/// used to land a blind full-row update while a concurrent approve's multi-second /// used to land a blind full-row update while a concurrent approve's multi-second
/// site relay was still in flight, marking the row <c>Rejected</c> while the plant /// site relay was still in flight, marking the row <c>Rejected</c> while the plant
/// write physically executed (arch-review 2026-08-19 P6-1). The conditional /// write physically executed. The conditional
/// <c>WHERE Status='Pending'</c> makes exactly one of approve/reject/expiry win. /// <c>WHERE Status='Pending'</c> makes exactly one of approve/reject/expiry win.
/// The loser observes <c>false</c> and must record nothing — no status change and no /// The loser observes <c>false</c> and must record nothing — no status change and no
/// reject audit row. /// reject audit row.
@@ -252,6 +252,12 @@ public interface ITemplateEngineRepository
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Reads one bounded, untracked page of topology instance rows for a site/area.</summary> /// <summary>Reads one bounded, untracked page of topology instance rows for a site/area.</summary>
/// <param name="siteId">The site ID.</param>
/// <param name="areaId">The area ID to filter by, or null for every area.</param>
/// <param name="skip">The number of rows to skip.</param>
/// <param name="take">The maximum number of rows to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to a read-only page of instances.</returns>
Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default); Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default);
/// <summary>Retrieves an instance by unique name.</summary> /// <summary>Retrieves an instance by unique name.</summary>
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
public interface IConfigurationCommandTransaction public interface IConfigurationCommandTransaction
{ {
/// <summary>Each database retry supplies a fresh scope; the operation must not perform external side effects.</summary> /// <summary>Each database retry supplies a fresh scope; the operation must not perform external side effects.</summary>
/// <param name="operation">The database-only operation to run within the transaction.</param>
/// <returns>The operation's result, if any.</returns>
Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation); Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation);
} }
@@ -11,6 +13,7 @@ public interface IConfigurationCommandTransaction
public interface IConfigurationCommitActions public interface IConfigurationCommitActions
{ {
/// <summary>Queues an idempotent local maintenance action.</summary> /// <summary>Queues an idempotent local maintenance action.</summary>
/// <param name="action">The action to run after a successful commit.</param>
void Add(Func<Task> action); void Add(Func<Task> action);
} }
@@ -2,5 +2,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>Private worker handoff for an existing configured SQL connection. Never log or expose the returned secret.</summary> /// <summary>Private worker handoff for an existing configured SQL connection. Never log or expose the returned secret.</summary>
public interface ISandboxDatabaseConnectionSource public interface ISandboxDatabaseConnectionSource
{ {
/// <summary>Resolves the connection string for a configured connection.</summary>
/// <param name="connectionName">The configured database connection name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The connection string; a secret that must never be logged or exposed.</returns>
Task<string> GetSandboxConnectionStringAsync(string connectionName, CancellationToken cancellationToken); Task<string> GetSandboxConnectionStringAsync(string connectionName, CancellationToken cancellationToken);
} }
@@ -110,7 +110,7 @@ public interface ISiteAuditQueue
/// <param name="sinceUtc">Lower bound timestamp (UTC).</param> /// <param name="sinceUtc">Lower bound timestamp (UTC).</param>
/// <param name="batchSize">Maximum number of rows to return.</param> /// <param name="batchSize">Maximum number of rows to return.</param>
/// <param name="afterId"> /// <param name="afterId">
/// Composite-keyset tiebreak cursor (WP2.3), mirroring /// Composite-keyset tiebreak cursor, mirroring
/// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/>: when non-null it is the /// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/>: when non-null it is the
/// <see cref="AuditEvent.EventId"/> ("D" GUID form) of the last row already consumed at /// <see cref="AuditEvent.EventId"/> ("D" GUID form) of the last row already consumed at
/// <paramref name="sinceUtc"/>, and only rows strictly after the composite /// <paramref name="sinceUtc"/>, and only rows strictly after the composite
@@ -125,7 +125,7 @@ public interface ISiteAuditQueue
DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default); DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default);
/// <summary> /// <summary>
/// Reconciliation-pull commit surface, cursor form (WP2.3): flips every row at or before /// Reconciliation-pull commit surface, cursor form: flips every row at or before
/// the composite cursor <c>(<paramref name="sinceUtc"/>, <paramref name="afterId"/>)</c> /// the composite cursor <c>(<paramref name="sinceUtc"/>, <paramref name="afterId"/>)</c>
/// from <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>/ /// from <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>/
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/> to /// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Forwarded"/> to
@@ -231,6 +231,12 @@ public interface ISiteAuditQueue
/// <c>(OccurredAtUtc, EventId)</c>, at most <paramref name="limit"/> rows. Independent of /// <c>(OccurredAtUtc, EventId)</c>, at most <paramref name="limit"/> rows. Independent of
/// forwarding state — moves no cursor and flips nothing. At most 64 ids per call. /// forwarding state — moves no cursor and flips nothing. At most 64 ids per call.
/// </summary> /// </summary>
/// <param name="executionIds">Execution ids to match against <c>ExecutionId</c> or <c>ParentExecutionId</c>.</param>
/// <param name="sinceUtc">Lower bound on <c>OccurredAtUtc</c> (UTC).</param>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="afterEventId">Optional <c>EventId</c> keyset cursor for paging past rows already consumed.</param>
/// <returns>A task that resolves to the matching audit events ordered by <c>(OccurredAtUtc, EventId)</c>.</returns>
Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync( Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null); IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null);
} }
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
/// <param name="CorrelationId">Correlation id echoed on the reply.</param> /// <param name="CorrelationId">Correlation id echoed on the reply.</param>
/// <param name="AlarmsOnly"> /// <param name="AlarmsOnly">
/// When <c>true</c> the site builds ONLY the alarm half of the snapshot and returns an /// When <c>true</c> the site builds ONLY the alarm half of the snapshot and returns an
/// empty <see cref="DebugViewSnapshot.AttributeValues"/> list (wire efficiency, WP2.3). /// empty <see cref="DebugViewSnapshot.AttributeValues"/> list (wire efficiency).
/// Set by the central per-site live alarm cache, whose seed/reconcile fan-out discards /// Set by the central per-site live alarm cache, whose seed/reconcile fan-out discards
/// every attribute row anyway. Defaults to <c>false</c> so the Debug View and every other /// every attribute row anyway. Defaults to <c>false</c> so the Debug View and every other
/// caller keep the full snapshot; additive on the wire /// caller keep the full snapshot; additive on the wire
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
/// <summary> /// <summary>
/// One site node's latest deployed-secrets mirror outcome, as carried on /// One site node's latest deployed-secrets mirror outcome, as carried on
/// <see cref="SiteHealthReport.SecretMirrorStatuses"/> (review finding M1). /// <see cref="SiteHealthReport.SecretMirrorStatuses"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Every site node runs its own <c>DeployedSecretsMirror</c> and records its current /// Every site node runs its own <c>DeployedSecretsMirror</c> and records its current
@@ -75,7 +75,7 @@ public record SiteHealthReport(
public double? ScriptOldestBusyAgeSeconds { get; init; } public double? ScriptOldestBusyAgeSeconds { get; init; }
/// <summary> /// <summary>
/// WP3.1: script-execution worker threads the stuck-script watchdog has DETACHED and /// Script-execution worker threads the stuck-script watchdog has DETACHED and
/// replaced — a script wedged past its deadline plus grace in uninterruptible blocking /// replaced — a script wedged past its deadline plus grace in uninterruptible blocking
/// I/O, so the pool started a fresh thread and the wedged one will exit only when its /// I/O, so the pool started a fresh thread and the wedged one will exit only when its
/// body finally returns. Point-in-time, refreshed by <c>ScriptSchedulerStatsReporter</c>. /// body finally returns. Point-in-time, refreshed by <c>ScriptSchedulerStatsReporter</c>.
@@ -85,7 +85,7 @@ public record SiteHealthReport(
public int DetachedScriptThreads { get; init; } public int DetachedScriptThreads { get; init; }
/// <summary> /// <summary>
/// WP3.1: per-interval count of script and alarm on-trigger runs SHED because /// Per-interval count of script and alarm on-trigger runs SHED because
/// <c>MaxConcurrentRunsPerScript</c> runs were already in flight for that script. Raw /// <c>MaxConcurrentRunsPerScript</c> runs were already in flight for that script. Raw
/// per-interval count (drained on collect) like <see cref="ScriptErrorCount"/>. A /// per-interval count (drained on collect) like <see cref="ScriptErrorCount"/>. A
/// sustained non-zero value means a trigger is firing faster than its script completes; /// sustained non-zero value means a trigger is firing faster than its script completes;
@@ -136,8 +136,7 @@ public record SiteHealthReport(
public long? LocalDbOplogBacklog { get; init; } public long? LocalDbOplogBacklog { get; init; }
/// <summary> /// <summary>
/// WP2.6d (arch-review misc — site stream alarm-vs-attribute shared buffer): /// Cumulative count of alarm state changes dropped from
/// cumulative count of alarm state changes dropped from
/// <c>SiteStreamManager</c>'s dedicated alarm publish queue because it was at /// <c>SiteStreamManager</c>'s dedicated alarm publish queue because it was at
/// capacity — i.e. the site-wide alarm hand-off itself fell behind, not a /// capacity — i.e. the site-wide alarm hand-off itself fell behind, not a
/// per-subscriber buffer. Point-in-time (not reset on collect), refreshed by /// per-subscriber buffer. Point-in-time (not reset on collect), refreshed by
@@ -154,9 +153,9 @@ public record SiteHealthReport(
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Tri-state, and the distinction is the whole point (deferred-work register row 73). /// Tri-state, and the distinction is the whole point.
/// <b>Null = not reported</b>: a central self-report, where the notion does not apply, /// <b>Null = not reported</b>: a central self-report, where the notion does not apply,
/// or a site node running a pre-row-73 build. <b><see langword="true"/></b> = the /// or a site node running an older build. <b><see langword="true"/></b> = the
/// reconciler is running. <b><see langword="false"/></b> = the node booted WITHOUT it, /// reconciler is running. <b><see langword="false"/></b> = the node booted WITHOUT it,
/// because no <c>IDeploymentConfigFetcher</c> was resolvable. /// because no <c>IDeploymentConfigFetcher</c> was resolvable.
/// </para> /// </para>
@@ -175,12 +174,12 @@ public record SiteHealthReport(
public bool? StartupReconcilerActive { get; init; } public bool? StartupReconcilerActive { get; init; }
/// <summary> /// <summary>
/// Every site node's latest deployed-secrets mirror outcome (review finding M1) — one /// Every site node's latest deployed-secrets mirror outcome — one
/// entry per node that has recorded a row in the replicated <c>secret_mirror_status</c> /// entry per node that has recorded a row in the replicated <c>secret_mirror_status</c>
/// table, including the STANDBY node, whose own health report is never sent. /// table, including the STANDBY node, whose own health report is never sent.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <b>Null = no data</b>: a central self-report, a pre-M1 site build, a site whose /// <b>Null = no data</b>: a central self-report, an older site build, a site whose
/// reporter has not run yet, or a failed read. An EMPTY list means the feature is wired /// reporter has not run yet, or a failed read. An EMPTY list means the feature is wired
/// but no node has recorded a status yet. A node entry with an empty /// but no node has recorded a status yet. A node entry with an empty
/// <see cref="SecretMirrorNodeStatus.Failures"/> list is a healthy mirror. Names and /// <see cref="SecretMirrorNodeStatus.Failures"/> list is a healthy mirror. Names and
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
/// <param name="Value">Canonical string form of the new value.</param> /// <param name="Value">Canonical string form of the new value.</param>
/// <param name="Timestamp">UTC timestamp of the command.</param> /// <param name="Timestamp">UTC timestamp of the command.</param>
/// <param name="SourceExecutionId"> /// <param name="SourceExecutionId">
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the /// ParentExecutionId tag-cascade: the <c>ExecutionId</c> of the
/// execution issuing this write — a site script run /// execution issuing this write — a site script run
/// (<c>ScriptRuntimeContext.SetAttribute</c>) or the inbound API request behind a /// (<c>ScriptRuntimeContext.SetAttribute</c>) or the inbound API request behind a
/// <c>Route.To(...).SetAttributes(...)</c>. Additive and nullable; <c>null</c> /// <c>Route.To(...).SetAttributes(...)</c>. Additive and nullable; <c>null</c>
@@ -59,7 +59,7 @@ public static class ScadaBridgeTelemetry
/// <summary> /// <summary>
/// Incremented each time a per-site live-alarm aggregator re-establishes its site-wide /// Incremented each time a per-site live-alarm aggregator re-establishes its site-wide
/// gRPC stream — a NodeA↔NodeB failover flip or a reconcile-driven reopen after the /// gRPC stream — a NodeA↔NodeB failover flip or a reconcile-driven reopen after the
/// stream was given up (plan #10, Task 6). A sustained climb signals a flapping site link. /// stream was given up. A sustained climb signals a flapping site link.
/// </summary> /// </summary>
private static readonly Counter<long> _liveAlarmStreamReconnects = private static readonly Counter<long> _liveAlarmStreamReconnects =
Meter.CreateCounter<long>("scadabridge.site.alarm_cache.reconnects", unit: "1", Meter.CreateCounter<long>("scadabridge.site.alarm_cache.reconnects", unit: "1",
@@ -67,7 +67,7 @@ public static class ScadaBridgeTelemetry
/// <summary> /// <summary>
/// Incremented for each live delta evicted from a per-site live-alarm aggregator's /// Incremented for each live delta evicted from a per-site live-alarm aggregator's
/// bounded pre-seed buffer (drop-oldest, WP2.3). Non-zero means a seed/reconcile /// bounded pre-seed buffer (drop-oldest). Non-zero means a seed/reconcile
/// fan-out ran long enough for the delta storm behind it to exceed the cap — the /// fan-out ran long enough for the delta storm behind it to exceed the cap — the
/// dropped transitions are recovered by the fan-out's authoritative snapshot, but a /// dropped transitions are recovered by the fan-out's authoritative snapshot, but a
/// sustained climb points at a slow site. /// sustained climb points at a slow site.
@@ -78,7 +78,7 @@ public static class ScadaBridgeTelemetry
/// <summary> /// <summary>
/// Incremented for each debug event evicted from a central debug session's bounded /// Incremented for each debug event evicted from a central debug session's bounded
/// pre-snapshot buffer (drop-oldest, WP2.3). The Debug View is lossy-under-backpressure /// pre-snapshot buffer (drop-oldest). The Debug View is lossy-under-backpressure
/// by design; this makes the loss measurable instead of unbounded memory growth. /// by design; this makes the loss measurable instead of unbounded memory growth.
/// </summary> /// </summary>
private static readonly Counter<long> _debugPreSnapshotDrops = private static readonly Counter<long> _debugPreSnapshotDrops =
@@ -79,7 +79,7 @@ public static class AuditQueryParamParsers
/// the filter dimension stays unconstrained. /// the filter dimension stays unconstrained.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Register row 99 (closure defect fixed by arch-review 2026-08-19 P6-3). Three /// Register row 99. Three
/// traps this closes that a bare /// traps this closes that a bare
/// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> does not: /// <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)"/> does not:
/// <list type="number"> /// <list type="number">
@@ -4,8 +4,15 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>Bounded replay data with independently retained chain-root and truncation metadata.</summary> /// <summary>Bounded replay data with independently retained chain-root and truncation metadata.</summary>
public sealed record TraceEventBatch(Guid RootExecutionId, IReadOnlyList<AuditEvent> Events, bool Truncated) : IReadOnlyList<AuditEvent> public sealed record TraceEventBatch(Guid RootExecutionId, IReadOnlyList<AuditEvent> Events, bool Truncated) : IReadOnlyList<AuditEvent>
{ {
/// <summary>Gets the trace event at the given position.</summary>
/// <param name="index">Zero-based position in <see cref="Events"/>.</param>
/// <returns>The event at <paramref name="index"/>.</returns>
public AuditEvent this[int index] => Events[index]; public AuditEvent this[int index] => Events[index];
/// <summary>Gets the number of events in the batch.</summary>
public int Count => Events.Count; public int Count => Events.Count;
/// <summary>Returns an enumerator over the batch's events.</summary>
/// <returns>An enumerator over <see cref="Events"/>.</returns>
public IEnumerator<AuditEvent> GetEnumerator() => Events.GetEnumerator(); public IEnumerator<AuditEvent> GetEnumerator() => Events.GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
} }
@@ -25,6 +25,8 @@ public static class OpcUaReferenceForm
/// prefix, or empty/whitespace (nothing to warn about). False only for a bare /// prefix, or empty/whitespace (nothing to warn about). False only for a bare
/// server-namespace index (<c>ns=&lt;n&gt;;…</c> with n ≥ 1). /// server-namespace index (<c>ns=&lt;n&gt;;…</c> with n ≥ 1).
/// </summary> /// </summary>
/// <param name="reference">The OPC UA node reference string to classify.</param>
/// <returns><see langword="true"/> when the reference is index-durable; otherwise <see langword="false"/>.</returns>
public static bool IsDurable(string? reference) public static bool IsDurable(string? reference)
{ {
if (string.IsNullOrWhiteSpace(reference)) if (string.IsNullOrWhiteSpace(reference))
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary> /// <summary>
/// Flow Debugger (final review M1): the ONE parser for the header line of a captured /// Flow Debugger: the ONE parser for the header line of a captured
/// exception detail. A .NET <c>Exception.ToString()</c> opens with /// exception detail. A .NET <c>Exception.ToString()</c> opens with
/// <c>Namespace.Type: message</c>, and a wrapper (an <c>AggregateException</c>, a rethrow) /// <c>Namespace.Type: message</c>, and a wrapper (an <c>AggregateException</c>, a rethrow)
/// chains its inner exceptions on that same first line with <c>" ---&gt; "</c>. /// chains its inner exceptions on that same first line with <c>" ---&gt; "</c>.
@@ -59,6 +59,10 @@ public static class FlowGraphBuilder
/// Builds the graph. <paramref name="truncated"/> and <paramref name="logsTruncated"/> are passed /// Builds the graph. <paramref name="truncated"/> and <paramref name="logsTruncated"/> are passed
/// through to <see cref="FlowGraph.Truncated"/> / <see cref="FlowGraph.LogsTruncated"/>. /// through to <see cref="FlowGraph.Truncated"/> / <see cref="FlowGraph.LogsTruncated"/>.
/// </summary> /// </summary>
/// <param name="events">The audit events to project into the graph.</param>
/// <param name="truncated">Whether the event set was truncated.</param>
/// <param name="logsTruncated">Whether the log rows were truncated.</param>
/// <returns>The built flow graph.</returns>
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false, bool logsTruncated = false) public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false, bool logsTruncated = false)
{ {
ArgumentNullException.ThrowIfNull(events); ArgumentNullException.ThrowIfNull(events);
@@ -338,8 +342,10 @@ public static class FlowGraphBuilder
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose /// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the /// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
/// projector uses for the <c>sync</c> / <c>cached</c> subtitle. Such steps hang off the chain on an /// projector uses for the <c>sync</c> / <c>cached</c> subtitle. Such steps hang off the chain on an
/// <see cref="FlowEdge.Async"/> edge (D4). /// <see cref="FlowEdge.Async"/> edge.
/// </summary> /// </summary>
/// <param name="step">The step to classify.</param>
/// <returns>True when the step is not awaited by the script.</returns>
public static bool IsAsync(FlowStep step) public static bool IsAsync(FlowStep step)
{ {
if (step.Kind == FlowStepKind.Notify) return true; if (step.Kind == FlowStepKind.Notify) return true;
@@ -352,9 +358,12 @@ public static class FlowGraphBuilder
/// <summary> /// <summary>
/// Orders the rows of one outbound operation when timestamps tie: submit before attempts before the /// Orders the rows of one outbound operation when timestamps tie: submit before attempts before the
/// resolve, then non-terminal before terminal statuses — so "first" and "latest" (D3) never fall back /// resolve, then non-terminal before terminal statuses — so "first" and "latest" never fall back
/// to GUID order for rows written in the same tick. /// to GUID order for rows written in the same tick.
/// </summary> /// </summary>
/// <param name="kind">The audit row kind.</param>
/// <param name="status">The audit row status.</param>
/// <returns>The rank used to order the rows.</returns>
public static int LifecycleRank(string? kind, string? status) public static int LifecycleRank(string? kind, string? status)
{ {
var k = kind switch var k = kind switch
@@ -444,6 +453,8 @@ public static class FlowGraphBuilder
} }
/// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary> /// <summary>Display rank used to break timestamp ties: spawners before what they spawn.</summary>
/// <param name="kind">The step kind.</param>
/// <returns>The display rank for the kind.</returns>
public static int KindRank(FlowStepKind kind) => kind switch public static int KindRank(FlowStepKind kind) => kind switch
{ {
FlowStepKind.InboundRequest => 0, FlowStepKind.InboundRequest => 0,
@@ -14,12 +14,18 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
public static class FlowStepProjector public static class FlowStepProjector
{ {
/// <summary>Projects an audit event onto a flow step.</summary> /// <summary>Projects an audit event onto a flow step.</summary>
/// <param name="evt">The audit event.</param>
/// <returns>The projected flow step.</returns>
public static FlowStep Project(AuditEvent evt) public static FlowStep Project(AuditEvent evt)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
return Project(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson)); return Project(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
} }
/// <summary>Projects an audit event and its already-deserialized details onto a flow step.</summary>
/// <param name="evt">The audit event.</param>
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
/// <returns>The projected flow step.</returns>
internal static FlowStep Project(AuditEvent evt, AuditDetails d) internal static FlowStep Project(AuditEvent evt, AuditDetails d)
{ {
var extra = ScriptTraceExtra.TryParse(d.Extra); var extra = ScriptTraceExtra.TryParse(d.Extra);
@@ -52,6 +58,8 @@ public static class FlowStepProjector
} }
/// <summary>True for a <c>ScriptTrace.ScriptLog</c> row — a log line, never a step.</summary> /// <summary>True for a <c>ScriptTrace.ScriptLog</c> row — a log line, never a step.</summary>
/// <param name="evt">The audit event.</param>
/// <returns><see langword="true"/> when the event is a script log line.</returns>
public static bool IsLogLine(AuditEvent evt) public static bool IsLogLine(AuditEvent evt)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
@@ -59,6 +67,9 @@ public static class FlowStepProjector
return IsLogLine(d); return IsLogLine(d);
} }
/// <summary>True when the details describe a <c>ScriptTrace.ScriptLog</c> row.</summary>
/// <param name="d">The deserialized audit details of the event.</param>
/// <returns><see langword="true"/> when the details describe a script log line.</returns>
internal static bool IsLogLine(AuditDetails d) internal static bool IsLogLine(AuditDetails d)
{ {
return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal) return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)
@@ -66,12 +77,18 @@ public static class FlowStepProjector
} }
/// <summary>Projects a <c>ScriptLog</c> row onto its line. Level/source default to <c>info</c>/<c>script</c> when the extra is missing.</summary> /// <summary>Projects a <c>ScriptLog</c> row onto its line. Level/source default to <c>info</c>/<c>script</c> when the extra is missing.</summary>
/// <param name="evt">The audit event.</param>
/// <returns>The projected log line.</returns>
public static FlowLogLine ToLogLine(AuditEvent evt) public static FlowLogLine ToLogLine(AuditEvent evt)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
return ToLogLine(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson)); return ToLogLine(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
} }
/// <summary>Projects a <c>ScriptLog</c> row and its already-deserialized details onto its line.</summary>
/// <param name="evt">The audit event.</param>
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
/// <returns>The projected log line.</returns>
internal static FlowLogLine ToLogLine(AuditEvent evt, AuditDetails d) internal static FlowLogLine ToLogLine(AuditEvent evt, AuditDetails d)
{ {
var x = ScriptTraceExtra.TryParse(d.Extra); var x = ScriptTraceExtra.TryParse(d.Extra);
@@ -89,12 +106,18 @@ public static class FlowStepProjector
} }
/// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary> /// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary>
/// <param name="evt">The audit event.</param>
/// <returns>The projected lifecycle row.</returns>
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt) public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
return ToLifecycleRow(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson)); return ToLifecycleRow(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
} }
/// <summary>Projects one outbound audit row and its already-deserialized details onto its lifecycle entry.</summary>
/// <param name="evt">The audit event.</param>
/// <param name="d">The deserialized audit details of <paramref name="evt"/>.</param>
/// <returns>The projected lifecycle row.</returns>
internal static FlowLifecycleRow ToLifecycleRow(AuditEvent evt, AuditDetails d) internal static FlowLifecycleRow ToLifecycleRow(AuditEvent evt, AuditDetails d)
{ {
return new FlowLifecycleRow( return new FlowLifecycleRow(
@@ -109,6 +132,9 @@ public static class FlowStepProjector
} }
/// <summary>Maps a channel/kind string pair onto a step kind.</summary> /// <summary>Maps a channel/kind string pair onto a step kind.</summary>
/// <param name="channel">The audit channel name.</param>
/// <param name="kind">The audit kind name.</param>
/// <returns>The matching step kind, or <see cref="FlowStepKind.Unknown"/>.</returns>
public static FlowStepKind ResolveKind(string? channel, string? kind) public static FlowStepKind ResolveKind(string? channel, string? kind)
{ {
if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)) if (string.Equals(channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal))
@@ -138,6 +164,9 @@ public static class FlowStepProjector
} }
/// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags; outbound API / DB / notify → External.</summary> /// <summary>Lane rule: route call / inbound / response → Central; script run / wait → Site; write → Tags; outbound API / DB / notify → External.</summary>
/// <param name="kind">The step kind.</param>
/// <param name="sourceSiteId">The originating site id, used to place unknown kinds.</param>
/// <returns>The lane the step is drawn on.</returns>
public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch public static FlowLane ResolveLane(FlowStepKind kind, string? sourceSiteId) => kind switch
{ {
FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central, FlowStepKind.InboundRequest or FlowStepKind.RouteCall or FlowStepKind.Response => FlowLane.Central,
@@ -148,6 +177,8 @@ public static class FlowStepProjector
}; };
/// <summary>True for the three outbound kinds that live on the External lane.</summary> /// <summary>True for the three outbound kinds that live on the External lane.</summary>
/// <param name="kind">The step kind.</param>
/// <returns><see langword="true"/> for an API call, DB call or notification.</returns>
public static bool IsOutbound(FlowStepKind kind) public static bool IsOutbound(FlowStepKind kind)
=> kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify; => kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
@@ -92,6 +92,9 @@ public sealed class FlowTraceCollector
/// the inline copy is filtered through. A null <paramref name="redactor"/> — a host that /// the inline copy is filtered through. A null <paramref name="redactor"/> — a host that
/// never called <c>AddAuditLog</c>, or a test — collects raw but is still capped. /// never called <c>AddAuditLog</c>, or a test — collects raw but is still capped.
/// </summary> /// </summary>
/// <param name="maxEvents">The maximum number of events kept inline.</param>
/// <param name="maxBytes">The maximum summed size of the events' details, in bytes.</param>
/// <param name="redactor">The redactor applied to the inline copy; null collects raw.</param>
public FlowTraceCollector( public FlowTraceCollector(
int maxEvents = DefaultMaxEvents, int maxEvents = DefaultMaxEvents,
int maxBytes = DefaultMaxBytes, int maxBytes = DefaultMaxBytes,
@@ -142,6 +145,7 @@ public sealed class FlowTraceCollector
/// where it builds <c>ScriptCallResult.TraceEvents</c>. Idempotent: a second call returns /// where it builds <c>ScriptCallResult.TraceEvents</c>. Idempotent: a second call returns
/// the same contents. /// the same contents.
/// </summary> /// </summary>
/// <returns>The collected events in emission order.</returns>
public IReadOnlyList<AuditEvent> Complete() public IReadOnlyList<AuditEvent> Complete()
{ {
lock (_gate) lock (_gate)
@@ -159,6 +163,7 @@ public sealed class FlowTraceCollector
/// test-only peek. A reply path must use <see cref="Complete"/>, otherwise an un-awaited call /// test-only peek. A reply path must use <see cref="Complete"/>, otherwise an un-awaited call
/// finishing after the reply could mutate the list the reply was built from. /// finishing after the reply could mutate the list the reply was built from.
/// </summary> /// </summary>
/// <returns>A copy of the events collected so far.</returns>
public IReadOnlyList<AuditEvent> Snapshot() public IReadOnlyList<AuditEvent> Snapshot()
{ {
lock (_gate) return _events.ToArray(); lock (_gate) return _events.ToArray();
@@ -68,9 +68,12 @@ public sealed record ScriptTraceExtra
}; };
/// <summary>Serializes to the compact camelCase JSON stored in <c>AuditDetails.Extra</c>.</summary> /// <summary>Serializes to the compact camelCase JSON stored in <c>AuditDetails.Extra</c>.</summary>
/// <returns>The serialized JSON string.</returns>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>Parses <c>AuditDetails.Extra</c>; returns <c>null</c> for null/blank/invalid JSON — never throws.</summary> /// <summary>Parses <c>AuditDetails.Extra</c>; returns <c>null</c> for null/blank/invalid JSON — never throws.</summary>
/// <param name="json">The raw <c>AuditDetails.Extra</c> JSON text.</param>
/// <returns>The parsed value, or <c>null</c> when the input is null, blank or invalid.</returns>
public static ScriptTraceExtra? TryParse(string? json) public static ScriptTraceExtra? TryParse(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) return null; if (string.IsNullOrWhiteSpace(json)) return null;
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
/// <summary> /// <summary>
/// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a /// Which leg of a reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> handshake a
/// <see cref="HandshakeResult"/> describes (Gitea #36, design 2026-09-10 §4). /// <see cref="HandshakeResult"/> describes.
/// </summary> /// </summary>
public enum HandshakePhase public enum HandshakePhase
{ {
@@ -14,7 +14,7 @@ public enum HandshakePhase
} }
/// <summary> /// <summary>
/// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload (Gitea #36): /// Result of the reset-aware <c>Attributes.WriteBatchAndWaitAsync</c> overload:
/// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> the outcome belongs to so a /// like <see cref="WaitResult"/>, plus the <see cref="Phase"/> the outcome belongs to so a
/// script can report "timed out waiting for the reset echo" separately from "timed out /// script can report "timed out waiting for the reset echo" separately from "timed out
/// waiting for the device to complete". A reset that is never echoed means the batch and /// waiting for the device to complete". A reset that is never echoed means the batch and
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// How a KPI metric must be folded when an hour of one-minute /// How a KPI metric must be folded when an hour of one-minute
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample"/> rows is /// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample"/> rows is
/// aggregated into a single <c>KpiRollupHourly</c> row ("KPI History &amp; Trends" /// aggregated into a single <c>KpiRollupHourly</c> row ("KPI History &amp; Trends"
/// hourly rollups, deferred #22). The correct fold differs by metric semantics, and /// hourly rollups). The correct fold differs by metric semantics, and
/// choosing the wrong one silently corrupts long-range trend charts. /// choosing the wrong one silently corrupts long-range trend charts.
/// </summary> /// </summary>
public enum KpiRollupAggregation public enum KpiRollupAggregation
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
public static class ScriptTriggerAttribute public static class ScriptTriggerAttribute
{ {
/// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary> /// <summary>True for the trigger kinds the Flow Debugger fires by WRITING the monitored attribute.</summary>
/// <param name="triggerType">The trigger type name.</param>
/// <returns>True when the trigger type is a write kind.</returns>
public static bool IsWriteKind(string? triggerType) => public static bool IsWriteKind(string? triggerType) =>
string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase) string.Equals(triggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)
|| string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase); || string.Equals(triggerType, "Conditional", StringComparison.OrdinalIgnoreCase);
@@ -20,6 +22,10 @@ public static class ScriptTriggerAttribute
/// false (and null) for every other kind, and for a write kind whose config is missing, /// false (and null) for every other kind, and for a write kind whose config is missing,
/// malformed, not an object, or names no attribute. /// malformed, not an object, or names no attribute.
/// </summary> /// </summary>
/// <param name="triggerType">The trigger type name.</param>
/// <param name="triggerConfigurationJson">The trigger configuration JSON.</param>
/// <param name="attribute">Receives the monitored attribute name, or null.</param>
/// <returns>True when a monitored attribute was read.</returns>
public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute) public static bool TryRead(string? triggerType, string? triggerConfigurationJson, out string? attribute)
{ {
attribute = null; attribute = null;
@@ -20,7 +20,7 @@ public static class SecretReferenceScanner
/// <summary> /// <summary>
/// The single parser for a <c>secret:&lt;name&gt;</c> reference value, shared by this scanner /// The single parser for a <c>secret:&lt;name&gt;</c> reference value, shared by this scanner
/// (what central ships) and every adapter that resolves the reference at connect time (what /// (what central ships) and every adapter that resolves the reference at connect time (what
/// the site looks up), so both sides always agree on the name (final review M3). The value is /// the site looks up), so both sides always agree on the name. The value is
/// trimmed, the <c>secret:</c> prefix matches ordinal-ignore-case, and the remainder is /// trimmed, the <c>secret:</c> prefix matches ordinal-ignore-case, and the remainder is
/// trimmed; an empty remainder is not a reference. The config-expander form /// trimmed; an empty remainder is not a reference. The config-expander form
/// <c>${secret:…}</c> is a different syntax and never matches. /// <c>${secret:…}</c> is a different syntax and never matches.
@@ -45,6 +45,8 @@ public static class SecretReferenceScanner
/// string values and returns the distinct names found, ordinal-sorted, each with the ordinal-sorted, /// string values and returns the distinct names found, ordinal-sorted, each with the ordinal-sorted,
/// de-duplicated list of connection names that referenced it. /// de-duplicated list of connection names that referenced it.
/// </summary> /// </summary>
/// <param name="connections">The data connection artifacts to scan.</param>
/// <returns>The distinct referenced secrets, each with the connections that reference it.</returns>
public static IReadOnlyList<SecretReference> Scan(IEnumerable<DataConnectionArtifact> connections) public static IReadOnlyList<SecretReference> Scan(IEnumerable<DataConnectionArtifact> connections)
{ {
var found = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal); var found = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
@@ -52,7 +52,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
internal static TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5); internal static TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary> /// <summary>
/// Hard deadline on the initial <see cref="DebugViewSnapshot"/> (WP2.3). The site builds /// Hard deadline on the initial <see cref="DebugViewSnapshot"/> The site builds
/// it in milliseconds; if none arrives inside this window the site never answered (the /// it in milliseconds; if none arrives inside this window the site never answered (the
/// Ask was lost, the singleton moved mid-request, the instance actor is wedged) and the /// Ask was lost, the singleton moved mid-request, the instance actor is wedged) and the
/// session must FAIL rather than sit in the buffering phase accumulating live events /// session must FAIL rather than sit in the buffering phase accumulating live events
@@ -125,7 +125,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
/// <see cref="AlarmStateChanged"/>) that arrived before the snapshot was delivered. /// <see cref="AlarmStateChanged"/>) that arrived before the snapshot was delivered.
/// Flushed (with per-entity dedup against the snapshot) when the snapshot arrives, /// Flushed (with per-entity dedup against the snapshot) when the snapshot arrives,
/// then never used again. Bounded by <see cref="MaxPreSnapshotBuffer"/> with drop-oldest /// then never used again. Bounded by <see cref="MaxPreSnapshotBuffer"/> with drop-oldest
/// eviction (WP2.3): a snapshot that never arrives used to buffer without limit on the /// eviction: a snapshot that never arrives used to buffer without limit on the
/// central node. Mutated only on the actor thread. /// central node. Mutated only on the actor thread.
/// </summary> /// </summary>
private readonly Queue<object> _preSnapshotBuffer = new(); private readonly Queue<object> _preSnapshotBuffer = new();
@@ -243,7 +243,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_snapshotDelivered = true; _snapshotDelivered = true;
}); });
// Hard snapshot deadline (WP2.3). Nothing else ends a session stuck in the // Hard snapshot deadline. Nothing else ends a session stuck in the
// buffering phase: the site's reply was lost, so no gRPC error fires, the stream // buffering phase: the site's reply was lost, so no gRPC error fires, the stream
// keeps delivering events, and stream traffic does not renew the orphan net (which // keeps delivering events, and stream traffic does not renew the orphan net (which
// measures the consumer). Fail the session so the consumer is told and can reopen. // measures the consumer). Fail the session so the consumer is told and can reopen.
@@ -351,7 +351,7 @@ public class DebugStreamBridgeActor : ReceiveActor, IWithTimers
_consumerLastSeenUtc = DateTime.UtcNow; _consumerLastSeenUtc = DateTime.UtcNow;
}); });
// Orphan safety net, CONSUMER-measured (WP2.3 follow-up). A periodic self-tick // Orphan safety net, CONSUMER-measured. A periodic self-tick
// compares the consumer-last-seen stamp against ConsumerIdleTimeout; the previous // compares the consumer-last-seen stamp against ConsumerIdleTimeout; the previous
// SetReceiveTimeout(5 min) measured the mailbox instead, and once stream events were // SetReceiveTimeout(5 min) measured the mailbox instead, and once stream events were
// (correctly) marked INotInfluenceReceiveTimeout nothing recurring reset it — the // (correctly) marked INotInfluenceReceiveTimeout nothing recurring reset it — the
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <summary> /// <summary>
/// One-per-site aggregator on the active central node backing the operator Alarm /// One-per-site aggregator on the active central node backing the operator Alarm
/// Summary live cache (plan #10, Task 4). Holds a transient in-memory /// Summary live cache. Holds a transient in-memory
/// <c>Dictionary&lt;AlarmKey, AlarmStateChanged&gt;</c> of the whole site's current /// <c>Dictionary&lt;AlarmKey, AlarmStateChanged&gt;</c> of the whole site's current
/// alarm state — NO persistence (locked <c>[PERM]</c>: no central alarm store). Created /// alarm state — NO persistence (locked <c>[PERM]</c>: no central alarm store). Created
/// and torn down by <see cref="SiteAlarmLiveCacheService"/> under viewer reference-count. /// and torn down by <see cref="SiteAlarmLiveCacheService"/> under viewer reference-count.
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
/// <b>Failover + drift:</b> a gRPC error flips NodeA↔NodeB with the same retry budget + /// <b>Failover + drift:</b> a gRPC error flips NodeA↔NodeB with the same retry budget +
/// stability window as <see cref="DebugStreamBridgeActor"/>. A re-seed runs <b>once per /// stability window as <see cref="DebugStreamBridgeActor"/>. A re-seed runs <b>once per
/// successful (re)connect</b> — driven by the stream's connected callback, not by each /// successful (re)connect</b> — driven by the stream's connected callback, not by each
/// reconnect ATTEMPT (WP2.3: a site outage used to fan a full snapshot out per retry, all /// reconnect ATTEMPT (a site outage used to fan a full snapshot out per retry, all
/// of them against the very site that is unreachable). A periodic reconcile snapshot /// of them against the very site that is unreachable). A periodic reconcile snapshot
/// (<see cref="_reconcileInterval"/>, default 60s, jittered per site so N aggregators do /// (<see cref="_reconcileInterval"/>, default 60s, jittered per site so N aggregators do
/// not fan out in lockstep) remains the drift/backstop: it corrects instance-set drift and /// not fan out in lockstep) remains the drift/backstop: it corrects instance-set drift and
@@ -68,7 +68,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private const string OpenDeadlineTimerKey = "alarm-grpc-open-deadline"; private const string OpenDeadlineTimerKey = "alarm-grpc-open-deadline";
/// <summary> /// <summary>
/// Production default for the stream open-attempt deadline (arch-review 2026-08-19 P3-4). /// Production default for the stream open-attempt deadline.
/// Comfortably above <c>SiteStreamGrpcClient.ConnectedHeaderTimeout</c> (10 s) so a /// Comfortably above <c>SiteStreamGrpcClient.ConnectedHeaderTimeout</c> (10 s) so a
/// normally-slow connect is never pre-empted, and comfortably below the 60 s reconcile /// normally-slow connect is never pre-empted, and comfortably below the 60 s reconcile
/// interval so the tick that follows always finds the flag released. /// interval so the tick that follows always finds the flag released.
@@ -125,7 +125,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// <summary> /// <summary>
/// Ordered buffer of live deltas that arrived while a fan-out was in flight, capped at /// Ordered buffer of live deltas that arrived while a fan-out was in flight, capped at
/// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (WP2.3 — a stuck fan-out /// <see cref="MaxBufferedDeltas"/> with drop-oldest eviction (a stuck fan-out
/// used to buffer without bound). Dropping the OLDEST is the right eviction here: the /// used to buffer without bound). Dropping the OLDEST is the right eviction here: the
/// fan-out that follows rebuilds the cache authoritatively, so an evicted delta is /// fan-out that follows rebuilds the cache authoritatively, so an evicted delta is
/// superseded rather than lost, while the newest transitions — the ones the snapshot may /// superseded rather than lost, while the newest transitions — the ones the snapshot may
@@ -195,7 +195,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// <summary> /// <summary>
/// Deadline on ONE stream open attempt, after which <see cref="_openInFlight"/> is /// Deadline on ONE stream open attempt, after which <see cref="_openInFlight"/> is
/// released so <see cref="OnReconcileTick"/> can reopen (arch-review 2026-08-19 P3-4). /// released so <see cref="OnReconcileTick"/> can reopen.
/// <para> /// <para>
/// The three outcomes that clear the flag — connected, error, graceful completion — are /// The three outcomes that clear the flag — connected, error, graceful completion — are
/// all events the peer has to produce. A stream that produces NONE of them pins the flag: /// all events the peer has to produce. A stream that produces NONE of them pins the flag:
@@ -244,7 +244,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
private const int BufferWarnThreshold = 10_000; private const int BufferWarnThreshold = 10_000;
private bool _bufferWarned; private bool _bufferWarned;
/// <inheritdoc /> /// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!; public ITimerScheduler Timers { get; set; } = null!;
/// <summary> /// <summary>
@@ -263,7 +263,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// The second argument is the live-stream liveness flag: <c>false</c> means the site-wide /// The second argument is the live-stream liveness flag: <c>false</c> means the site-wide
/// gRPC stream is currently down (fault-exhausted or gracefully completed), so the cache /// gRPC stream is currently down (fault-exhausted or gracefully completed), so the cache
/// is only as fresh as the last reconcile and the page must fall back to polling /// is only as fresh as the last reconcile and the page must fall back to polling
/// (WP2.3 carried residual — <c>IsLive</c> used to stay true across a dead stream). /// (<c>IsLive</c> used to stay true across a dead stream).
/// </param> /// </param>
/// <param name="grpcFactory">Factory caching one gRPC client per (site, endpoint).</param> /// <param name="grpcFactory">Factory caching one gRPC client per (site, endpoint).</param>
/// <param name="grpcNodeAAddress">gRPC address of the site's node A.</param> /// <param name="grpcNodeAAddress">gRPC address of the site's node A.</param>
@@ -284,7 +284,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// up to +20%). Zero disables jitter, which is what tests want for determinism. /// up to +20%). Zero disables jitter, which is what tests want for determinism.
/// </param> /// </param>
/// <param name="openAttemptTimeout"> /// <param name="openAttemptTimeout">
/// Deadline on one gRPC stream open attempt (P3-4); <c>null</c> takes /// Deadline on one gRPC stream open attempt; <c>null</c> takes
/// <see cref="DefaultOpenAttemptTimeout"/> (30 s) and <see cref="TimeSpan.Zero"/> or less /// <see cref="DefaultOpenAttemptTimeout"/> (30 s) and <see cref="TimeSpan.Zero"/> or less
/// disables the deadline. See <see cref="_openAttemptTimeout"/>. /// disables the deadline. See <see cref="_openAttemptTimeout"/>.
/// </param> /// </param>
@@ -350,7 +350,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
OnStreamConnected(); OnStreamConnected();
}); });
// The open attempt produced none of its three outcomes inside the deadline (P3-4): // The open attempt produced none of its three outcomes inside the deadline:
// release the in-flight flag so the reconcile tick's reopen guard can run again. // release the in-flight flag so the reconcile tick's reopen guard can run again.
// Generation-fenced like every other stream callback, and a no-op once the attempt // Generation-fenced like every other stream callback, and a no-op once the attempt
// has already resolved (a late timer racing its own stream's connect). // has already resolved (a late timer racing its own stream's connect).
@@ -486,7 +486,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
/// Periodic reconcile backstop: re-run the snapshot fan-out (corrects instance-set drift /// Periodic reconcile backstop: re-run the snapshot fan-out (corrects instance-set drift
/// + any missed delta) UNLESS a CONNECT- OR FAILOVER-DRIVEN seed already completed inside /// + any missed delta) UNLESS a CONNECT- OR FAILOVER-DRIVEN seed already completed inside
/// this window — that seed makes the tick redundant, and running both was the "full /// this window — that seed makes the tick redundant, and running both was the "full
/// unconditional snapshot every 60s" waste (WP2.3). A tick's OWN fan-out never arms that /// unconditional snapshot every 60s" waste. A tick's OWN fan-out never arms that
/// skip: it used to, which made steady state alternate fan-out/skip and halved the /// skip: it used to, which made steady state alternate fan-out/skip and halved the
/// effective reconcile rate. If the live stream was previously given up, self-heal it by /// effective reconcile rate. If the live stream was previously given up, self-heal it by
/// resetting the retry budget and reopening, so a sustained outage never permanently kills /// resetting the retry budget and reopening, so a sustained outage never permanently kills
@@ -700,7 +700,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
return; return;
} }
// Backoff retry (WP2.3): the seed leg used to have none — a site returning errors // Backoff retry: the seed leg used to have none — a site returning errors
// was re-fanned at full reconcile cadence forever. Retry at reconnectDelay doubling // was re-fanned at full reconcile cadence forever. Retry at reconnectDelay doubling
// up to MaxSeedRetryBackoffMultiplier × the reconcile interval; the periodic // up to MaxSeedRetryBackoffMultiplier × the reconcile interval; the periodic
// reconcile remains the floor once the backoff saturates. // reconcile remains the floor once the backoff saturates.
@@ -848,7 +848,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
if (_stopped) return; if (_stopped) return;
// The stream is not live again until the server actually accepts it — liveness flips // The stream is not live again until the server actually accepts it — liveness flips
// on GrpcAlarmStreamConnected, not on the attempt (WP2.3 carried residual). // on GrpcAlarmStreamConnected, not on the attempt.
var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress; var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
_log.Info("Opening site-alarm gRPC stream for {0} to {1}", _siteIdentifier, endpoint); _log.Info("Opening site-alarm gRPC stream for {0} to {1}", _siteIdentifier, endpoint);
@@ -861,7 +861,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
_openInFlight = true; _openInFlight = true;
var generation = ++_streamGeneration; var generation = ++_streamGeneration;
// Bound this attempt (P3-4). Single-shot on a shared key, so a re-open replaces the // Bound this attempt. Single-shot on a shared key, so a re-open replaces the
// previous attempt's deadline rather than stacking one. // previous attempt's deadline rather than stacking one.
if (_openAttemptTimeout > TimeSpan.Zero) if (_openAttemptTimeout > TimeSpan.Zero)
{ {
@@ -1005,7 +1005,7 @@ public sealed class SiteAlarmAggregatorActor : ReceiveActor, IWithTimers
CleanupGrpc(); CleanupGrpc();
// A completed stream is NOT live: without this the cache kept reporting live for up // A completed stream is NOT live: without this the cache kept reporting live for up
// to a full reconcile interval after the site closed the feed (WP2.3 residual). // to a full reconcile interval after the site closed the feed.
if (wasLive && _seeded) if (wasLive && _seeded)
Publish(); Publish();
} }
@@ -1080,6 +1080,6 @@ internal sealed record ReconnectAlarmStream;
internal sealed record GrpcAlarmStreamStable; internal sealed record GrpcAlarmStreamStable;
/// <summary>Internal: a stream OPEN attempt produced none of connected/error/completed inside its /// <summary>Internal: a stream OPEN attempt produced none of connected/error/completed inside its
/// deadline (P3-4), stamped with the generation it was armed for so a late timer from a superseded /// deadline, stamped with the generation it was armed for so a late timer from a superseded
/// attempt is ignored. Releases the in-flight guard only — it is not a fault.</summary> /// attempt is ignored. Releases the in-flight guard only — it is not a fault.</summary>
internal sealed record GrpcAlarmStreamOpenTimedOut(int Generation); internal sealed record GrpcAlarmStreamOpenTimedOut(int Generation);
@@ -76,6 +76,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
/// pass a stub so they do not need to load Akka.Cluster into the <c>TestKit</c> /// pass a stub so they do not need to load Akka.Cluster into the <c>TestKit</c>
/// ActorSystem. /// ActorSystem.
/// </param> /// </param>
/// <param name="failOverRole">
/// Optional delegate mapping a site identifier to the cluster role used for failover/singleton
/// lookups; <c>null</c> uses the default role resolution.
/// </param>
/// <param name="transport"> /// <param name="transport">
/// The site→central transport. Production always passes the Host-built gRPC /// The site→central transport. Production always passes the Host-built gRPC
/// <see cref="Grpc.GrpcCentralTransport"/>. <c>null</c> (used by TestKit suites that only /// <see cref="Grpc.GrpcCentralTransport"/>. <c>null</c> (used by TestKit suites that only
@@ -149,7 +149,7 @@ public class CommunicationOptions
/// <summary> /// <summary>
/// Send-channel capacity for the site-wide alarm stream (<c>SubscribeSite</c>). Larger /// Send-channel capacity for the site-wide alarm stream (<c>SubscribeSite</c>). Larger
/// than the Debug View's (WP2.3): that feed backs the operator Alarm Summary, where a /// than the Debug View's: that feed backs the operator Alarm Summary, where a
/// silently dropped transition is a missed alarm rather than a missed diagnostic frame, /// silently dropped transition is a missed alarm rather than a missed diagnostic frame,
/// and an alarm burst arriving during a WAN stall must survive the stall. /// and an alarm burst arriving during a WAN stall must survive the stall.
/// </summary> /// </summary>
@@ -211,9 +211,9 @@ public class CommunicationOptions
/// </summary> /// </summary>
public TimeSpan PendingDeploymentPurgeInterval { get; set; } = TimeSpan.FromHours(1); public TimeSpan PendingDeploymentPurgeInterval { get; set; } = TimeSpan.FromHours(1);
// ── Aggregated live alarm cache (plan #10, Task 4) ─────────────────────────── // ── Aggregated live alarm cache ───────────────────────────────────────────────
// Introduced by Task 4 with sane defaults; Task 6 formalizes eager validation // Sane defaults, with eager validation (positive interval/linger, positive
// (positive interval/linger, positive concurrency/subscriber cap) and telemetry. // concurrency/subscriber cap) and telemetry.
/// <summary> /// <summary>
/// How long the shared per-site live alarm aggregator keeps its gRPC stream + /// How long the shared per-site live alarm aggregator keeps its gRPC stream +
@@ -256,7 +256,7 @@ public class CommunicationOptions
/// <summary> /// <summary>
/// Random jitter added to each per-site reconcile tick, as a fraction of /// Random jitter added to each per-site reconcile tick, as a fraction of
/// <see cref="LiveAlarmCacheReconcileInterval"/> (WP2.3). Without it every aggregator /// <see cref="LiveAlarmCacheReconcileInterval"/>. Without it every aggregator
/// started by one central failover fans its whole-site snapshot out on the same 60s /// started by one central failover fans its whole-site snapshot out on the same 60s
/// boundary forever. Zero disables jitter. /// boundary forever. Zero disables jitter.
/// </summary> /// </summary>
@@ -113,7 +113,7 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
+ "entries (a site node must list at least one central gRPC endpoint; central nodes " + "entries (a site node must list at least one central gRPC endpoint; central nodes "
+ "leave it empty)."); + "leave it empty).");
// ── Aggregated live alarm cache (plan #10, Task 6) ─────────────────────── // ── Aggregated live alarm cache ──────────────────────────────────────────
// Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator // Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator
// immediately when the last viewer leaves), only a negative value is invalid. // immediately when the last viewer leaves), only a negative value is invalid.
builder.RequireThat(options.LiveAlarmCacheLinger >= TimeSpan.Zero, builder.RequireThat(options.LiveAlarmCacheLinger >= TimeSpan.Zero,
@@ -230,7 +230,7 @@ public class CommunicationService
// RouteToSetAttributes / RouteToWaitForAttribute), driven from // RouteToSetAttributes / RouteToWaitForAttribute), driven from
// InboundAPI/CommunicationServiceInstanceRouter and sharing IntegrationTimeout. // InboundAPI/CommunicationServiceInstanceRouter and sharing IntegrationTimeout.
// The earlier generic IntegrationCallRequest primitive was never wired to a // The earlier generic IntegrationCallRequest primitive was never wired to a
// producer or a site handler and was removed as dead code (Gitea #32). // producer or a site handler and was removed as dead code.
// ── Pattern 5: Debug View ── // ── Pattern 5: Debug View ──
@@ -803,7 +803,7 @@ public class CommunicationService
} }
/// <summary> /// <summary>
/// Asks a site to gracefully fail over its own two-node pair (Task 10). Central and each /// Asks a site to gracefully fail over its own two-node pair. Central and each
/// site are SEPARATE Akka clusters, so central cannot act on site membership directly — /// site are SEPARATE Akka clusters, so central cannot act on site membership directly —
/// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against /// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against
/// its site-specific role and acks the outcome. /// its site-specific role and acks the outcome.
@@ -82,7 +82,7 @@ public class DebugStreamService : IDisposable
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param> /// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A debug stream session with the initial snapshot.</returns> /// <returns>A debug stream session with the initial snapshot.</returns>
/// <remarks> /// <remarks>
/// Virtual purely as a test seam: the consumers' dispose-vs-start race (P6-6) can only /// Virtual purely as a test seam: the consumers' dispose-vs-start race can only
/// be driven deterministically by a stub that holds the start open. Behaviour and the /// be driven deterministically by a stub that holds the start open. Behaviour and the
/// keepalive/orphan contract are unchanged. /// keepalive/orphan contract are unchanged.
/// </remarks> /// </remarks>
@@ -114,7 +114,7 @@ public class DebugStreamService : IDisposable
instanceUniqueName = instance.UniqueName; instanceUniqueName = instance.UniqueName;
siteIdentifier = site.SiteIdentifier; siteIdentifier = site.SiteIdentifier;
// Single-endpoint site (arch-review 2026-08-19 P3-3, applying the same N9 // Single-endpoint site (applying the same
// degeneration SiteAlarmLiveCacheService.ResolveSiteAsync already uses): reuse the // degeneration SiteAlarmLiveCacheService.ResolveSiteAsync already uses): reuse the
// sole configured endpoint for both slots, so the bridge actor's NodeA↔NodeB // sole configured endpoint for both slots, so the bridge actor's NodeA↔NodeB
// failover flip degenerates to a reconnect against the same node. Requiring BOTH // failover flip degenerates to a reconnect against the same node. Requiring BOTH
@@ -11,10 +11,16 @@ public static class AuditWireBudget
public const int InitialBytes = 7; public const int InitialBytes = 7;
/// <summary> /// <summary>
/// Appends one row to a field-1 repeated-message collection if it fits. A row larger than the /// Appends one row to a repeated-message collection if it fits. A row larger than the
/// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained /// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained
/// locally and fail explicitly with their identity; they are never acknowledged or truncated. /// locally and fail explicitly with their identity; they are never acknowledged or truncated.
/// </summary> /// </summary>
/// <typeparam name="T">The protobuf message type of the rows.</typeparam>
/// <param name="rows">The repeated field to append to.</param>
/// <param name="row">The row to append.</param>
/// <param name="wireBytes">The running wire size of the batch; increased by the row's size when appended.</param>
/// <param name="rowIdentity">Identity of the row, used in the error raised when it exceeds the hard ceiling.</param>
/// <returns><c>true</c> when the row was appended; <c>false</c> when it does not fit in the current batch.</returns>
public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity) public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity)
where T : IMessage<T> where T : IMessage<T>
{ {
@@ -268,7 +268,7 @@ public sealed class CentralChannelProvider : IDisposable
return TimeSpan.FromTicks(scaled >= cap || scaled < 0 ? cap : scaled); return TimeSpan.FromTicks(scaled >= cap || scaled < 0 ? cap : scaled);
} }
/// <inheritdoc /> /// <summary>Stops the failback timer and disposes all cached gRPC channels.</summary>
public void Dispose() public void Dispose()
{ {
lock (_gate) lock (_gate)
@@ -74,7 +74,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
public static class CentralControlDtoMapper public static class CentralControlDtoMapper
{ {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Notification Outbox (#21) // Notification Outbox
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary> /// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary>
@@ -216,7 +216,7 @@ public static class CentralControlDtoMapper
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Audit Log (#23) ingest — envelopes only; rows go through the existing mappers // Audit Log ingest — envelopes only; rows go through the existing mappers
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
@@ -437,7 +437,7 @@ public static class CentralControlDtoMapper
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Health Monitoring (#11) // Health Monitoring
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary> /// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary>
@@ -559,7 +559,7 @@ public static class CentralControlDtoMapper
dto.SiteAuditBacklog = snapshot; dto.SiteAuditBacklog = snapshot;
} }
// Review finding M1: wrapper presence keeps null ("no data") distinct from empty. // Wrapper presence keeps null ("no data") distinct from empty.
if (msg.SecretMirrorStatuses is { } mirrorStatuses) if (msg.SecretMirrorStatuses is { } mirrorStatuses)
{ {
var wrapper = new SecretMirrorStatusListDto(); var wrapper = new SecretMirrorStatusListDto();
@@ -277,21 +277,15 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
return CentralControlDtoMapper.ToDto(ack); return CentralControlDtoMapper.ToDto(ack);
} }
/// <summary> // Application heartbeat — always answers OK, even when this node is not ready.
/// Application heartbeat — <b>always answers OK</b>, even when this node is not ready. // The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply,
/// </summary> // and the site's heartbeat timer must never take a fault (a failing heartbeat that raised
/// <remarks> // an error would be a self-inflicted outage on a purely informational signal). So this is
/// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply, // the one RPC that does not go through RequireReady: a heartbeat arriving before the actor
/// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised // exists is logged and dropped, exactly as the actor itself drops one that arrives before
/// an error would be a self-inflicted outage on a purely informational signal). So this is // ICentralHealthAggregator is resolvable. Liveness is still detected — the aggregator's
/// the one RPC that does not go through <see cref="RequireReady"/>: a heartbeat arriving // offline timeout fires when the heartbeats stop landing.
/// before the actor exists is logged and dropped, exactly as the actor itself drops one /// <inheritdoc />
/// that arrives before <c>ICentralHealthAggregator</c> is resolvable. Liveness is still
/// detected — the aggregator's offline timeout fires when the heartbeats stop landing.
/// </remarks>
/// <param name="request">The heartbeat.</param>
/// <param name="context">The gRPC call context.</param>
/// <returns>An empty reply, always.</returns>
public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context) public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context)
{ {
var central = _central; var central = _central;
@@ -8,14 +8,20 @@ public static class CentralControlSiteIdentity
private static readonly object IdentityKey = new(); private static readonly object IdentityKey = new();
/// <summary>Stores an identity only after the site's credential has been verified.</summary> /// <summary>Stores an identity only after the site's credential has been verified.</summary>
/// <param name="context">The call context to record the identity on.</param>
/// <param name="siteId">The verified site identifier.</param>
public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId; public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId;
/// <summary>Requires a credential-verified identity on the current call.</summary> /// <summary>Requires a credential-verified identity on the current call.</summary>
/// <param name="context">The call context carrying the verified identity.</param>
/// <returns>The verified site identifier.</returns>
public static string RequireVerified(ServerCallContext context) => public static string RequireVerified(ServerCallContext context) =>
context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site) context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site)
? site : throw Denied(); ? site : throw Denied();
/// <summary>Checks every ownership field before dispatching any part of a central control request.</summary> /// <summary>Checks every ownership field before dispatching any part of a central control request.</summary>
/// <param name="request">The inbound request whose ownership fields are checked.</param>
/// <param name="context">The call context carrying the verified site identity.</param>
public static void ValidateRequest(object request, ServerCallContext context) public static void ValidateRequest(object request, ServerCallContext context)
{ {
var site = RequireVerified(context); var site = RequireVerified(context);
@@ -9,14 +9,20 @@ public static class GrpcRetrySafety
public const string DispatchTrailer = "scadabridge-dispatch"; public const string DispatchTrailer = "scadabridge-dispatch";
/// <summary>Creates a readiness rejection that proves no work was dispatched.</summary> /// <summary>Creates a readiness rejection that proves no work was dispatched.</summary>
/// <param name="detail">Human-readable rejection detail placed on the status.</param>
/// <returns>An <see cref="RpcException"/> with <c>Unavailable</c> status and the not-dispatched trailer.</returns>
public static RpcException NotDispatched(string detail) => new( public static RpcException NotDispatched(string detail) => new(
new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } }); new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } });
/// <summary>True only for an explicit rejection from the server before it dispatched work.</summary> /// <summary>True only for an explicit rejection from the server before it dispatched work.</summary>
/// <param name="error">The RPC failure to inspect.</param>
/// <returns><c>true</c> when the trailer proves the server never dispatched the command.</returns>
public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable
&& error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started"); && error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started");
/// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary> /// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary>
/// <param name="error">The original RPC failure whose trailers are preserved.</param>
/// <returns>An <see cref="RpcException"/> with <c>Aborted</c> status marking the outcome as unknown.</returns>
public static RpcException OutcomeUnknown(RpcException error) => new( public static RpcException OutcomeUnknown(RpcException error) => new(
new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."), new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."),
error.Trailers); error.Trailers);
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <para> /// <para>
/// <b>Per-call deadlines match today's Ask timeouts exactly</b> — see <see cref="ResolveDeadline"/>. /// <b>Per-call deadlines match today's Ask timeouts exactly</b> — see <see cref="ResolveDeadline"/>.
/// An unknown/unconfigured site now fails fast: it surfaces to the caller as a /// An unknown/unconfigured site now fails fast: it surfaces to the caller as a
/// <see cref="Status.Failure"/> immediately (WP1.6) instead of being warned-and-dropped to let the /// <see cref="Status.Failure"/> immediately instead of being warned-and-dropped to let the
/// caller's Ask burn its full timeout. A transport fault takes the same <see cref="Status.Failure"/> /// caller's Ask burn its full timeout. A transport fault takes the same <see cref="Status.Failure"/>
/// path, which the S&amp;F/audit layers already treat as transient. /// path, which the S&amp;F/audit layers already treat as transient.
/// </para> /// </para>
@@ -83,7 +83,7 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
} }
catch (SiteChannelUnavailableException ex) catch (SiteChannelUnavailableException ex)
{ {
// WP1.6: a known-dead send (no configured channel for this site) used to warn-and-drop, // A known-dead send (no configured channel for this site) used to warn-and-drop,
// leaving the caller's Ask to burn its full timeout for a failure we already know about. // leaving the caller's Ask to burn its full timeout for a failure we already know about.
// Fail fast instead — same Status.Failure completion path a transport fault takes below — // Fail fast instead — same Status.Failure completion path a transport fault takes below —
// so S&F/audit treat it as transient immediately rather than tens of seconds later. // so S&F/audit treat it as transient immediately rather than tens of seconds later.
@@ -130,6 +130,8 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
} }
/// <summary>Only read-only commands can replay after a response may have been lost.</summary> /// <summary>Only read-only commands can replay after a response may have been lost.</summary>
/// <param name="message">The command message about to be sent.</param>
/// <returns><c>true</c> when the message is a read-only command safe to replay; otherwise <c>false</c>.</returns>
internal static bool IsRetrySafe(object message) => message is internal static bool IsRetrySafe(object message) => message is
DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Nullable <b>collections</b> are wrapped in a per-collection message, so /// Nullable <b>collections</b> are wrapped in a per-collection message, so
/// the null/empty distinction becomes a message-presence question. proto3 /// the null/empty distinction becomes a message-presence question. proto3
/// <c>repeated</c>/<c>map</c> alone collapses the two — precisely the silent /// <c>repeated</c>/<c>map</c> alone collapses the two — precisely the silent
/// data-loss class the transport round-trip guard caught in PLAN-05 T8. /// data-loss class the transport round-trip guard caught in the transport round-trip tests.
/// </item> /// </item>
/// <item> /// <item>
/// <c>Guid?</c> members ride as a <c>string</c> holding the "D" form, with /// <c>Guid?</c> members ride as a <c>string</c> holding the "D" form, with
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// Raised when a site has no usable gRPC channel — an unknown site, or a site with neither /// Raised when a site has no usable gRPC channel — an unknown site, or a site with neither
/// <c>GrpcNodeAAddress</c> nor <c>GrpcNodeBAddress</c> configured. <see cref="GrpcSiteTransport"/> /// <c>GrpcNodeAAddress</c> nor <c>GrpcNodeBAddress</c> configured. <see cref="GrpcSiteTransport"/>
/// treats this as a known-dead send: it fails the caller's <c>Ask</c> fast with an /// treats this as a known-dead send: it fails the caller's <c>Ask</c> fast with an
/// <c>Akka.Actor.Status.Failure</c> wrapping this exception (WP1.6) instead of warning and dropping /// <c>Akka.Actor.Status.Failure</c> wrapping this exception instead of warning and dropping
/// the message, so the caller does not burn its full Ask timeout on a failure already known at send /// the message, so the caller does not burn its full Ask timeout on a failure already known at send
/// time (central still never buffers). /// time (central still never buffers).
/// </summary> /// </summary>
@@ -51,8 +51,7 @@ public sealed class SitePairChannelProvider : IDisposable
private static readonly TimeSpan FailbackSweepInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan FailbackSweepInterval = TimeSpan.FromSeconds(1);
/// <summary> /// <summary>
/// How long a replaced/removed channel is PARKED before it is disposed (arch-review /// How long a replaced/removed channel is PARKED before it is disposed. Active calls retain the channel beyond this minimum until completion.
/// 2026-08-19 P3-2). Active calls retain the channel beyond this minimum until completion.
/// </summary> /// </summary>
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5); internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
@@ -366,7 +365,7 @@ public sealed class SitePairChannelProvider : IDisposable
/// <summary> /// <summary>
/// Parks a channel that is no longer reachable through <see cref="_sites"/> instead of /// Parks a channel that is no longer reachable through <see cref="_sites"/> instead of
/// disposing it inline (arch-review 2026-08-19 P3-2). /// disposing it inline.
/// <para> /// <para>
/// <b>Why deferred.</b> <see cref="ExecuteAsync{T}"/> reads the channel under /// <b>Why deferred.</b> <see cref="ExecuteAsync{T}"/> reads the channel under
/// <c>pair.Gate</c> but invokes the RPC OUTSIDE it, so an inline /// <c>pair.Gate</c> but invokes the RPC OUTSIDE it, so an inline
@@ -472,7 +471,7 @@ public sealed class SitePairChannelProvider : IDisposable
} }
} }
/// <inheritdoc /> /// <summary>Disposes every pooled channel and stops the failback sweep and parked-channel disposal.</summary>
public void Dispose() public void Dispose()
{ {
lock (_lifecycleGate) lock (_lifecycleGate)
@@ -500,6 +499,7 @@ public sealed class SitePairChannelProvider : IDisposable
private sealed class SitePair(string siteId) private sealed class SitePair(string siteId)
{ {
/// <summary>The site identifier this pair of node channels belongs to.</summary>
public string SiteId { get; } = siteId; public string SiteId { get; } = siteId;
public readonly object Gate = new(); public readonly object Gate = new();
public string? NodeAEndpoint; public string? NodeAEndpoint;
@@ -515,12 +515,15 @@ public sealed class SitePairChannelProvider : IDisposable
private TimeSpan _backoff = InitialBackoff; private TimeSpan _backoff = InitialBackoff;
/// <summary>Sets the failback backoff to <paramref name="delay"/> and schedules the next probe that far ahead.</summary>
/// <param name="delay">The delay before the next failback probe.</param>
public void ScheduleFailback(TimeSpan delay) public void ScheduleFailback(TimeSpan delay)
{ {
_backoff = delay; _backoff = delay;
NextProbeUtc = DateTime.UtcNow + _backoff; NextProbeUtc = DateTime.UtcNow + _backoff;
} }
/// <summary>Doubles the failback backoff (capped at the maximum) and schedules the next probe.</summary>
public void BumpFailbackBackoff() public void BumpFailbackBackoff()
{ {
var next = TimeSpan.FromTicks(Math.Min(_backoff.Ticks * 2, MaxBackoff.Ticks)); var next = TimeSpan.FromTicks(Math.Min(_backoff.Ticks * 2, MaxBackoff.Ticks));
@@ -528,6 +531,7 @@ public sealed class SitePairChannelProvider : IDisposable
NextProbeUtc = DateTime.UtcNow + _backoff; NextProbeUtc = DateTime.UtcNow + _backoff;
} }
/// <summary>Resets the backoff to its initial value and clears the scheduled failback probe.</summary>
public void ResetFailback() public void ResetFailback()
{ {
_backoff = InitialBackoff; _backoff = InitialBackoff;
@@ -122,6 +122,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Creates a test-only instance that has no gRPC channel. Used to test /// Creates a test-only instance that has no gRPC channel. Used to test
/// Unsubscribe and Dispose behavior without needing a real endpoint. /// Unsubscribe and Dispose behavior without needing a real endpoint.
/// </summary> /// </summary>
/// <param name="options">Optional communication options; defaults are used when null.</param>
/// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns> /// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns>
internal static SiteStreamGrpcClient CreateForTesting(CommunicationOptions? options = null) => new(options ?? new CommunicationOptions()); internal static SiteStreamGrpcClient CreateForTesting(CommunicationOptions? options = null) => new(options ?? new CommunicationOptions());
@@ -146,8 +147,8 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is /// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is
/// <b>cancelled only</b> — never disposed from here. Internal for testability. /// <b>cancelled only</b> — never disposed from here. Internal for testability.
/// <para> /// <para>
/// <b>Cancel-only is the contract</b> (arch-review 2026-08-19 P3-1, the client half of the /// <b>Cancel-only is the contract</b> (the client half of the
/// row-47 class): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and /// shared-token-source disposal race): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
/// still reads <c>cts.Token</c> (opening the call, <c>ReadAllAsync</c>, the /// still reads <c>cts.Token</c> (opening the call, <c>ReadAllAsync</c>, the
/// <c>IsCancellationRequested</c> checks), so disposing it from the REPLACING path throws /// <c>IsCancellationRequested</c> checks), so disposing it from the REPLACING path throws
/// <see cref="ObjectDisposedException"/> inside the prior stream — and, worse, out of the /// <see cref="ObjectDisposedException"/> inside the prior stream — and, worse, out of the
@@ -172,7 +173,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Cancels a subscription token source, swallowing <see cref="ObjectDisposedException"/>. /// Cancels a subscription token source, swallowing <see cref="ObjectDisposedException"/>.
/// A CTS whose owning stream loop has already unwound and disposed it is simply a /// A CTS whose owning stream loop has already unwound and disposed it is simply a
/// subscription that no longer needs cancelling; the caller must never see that race as a /// subscription that no longer needs cancelling; the caller must never see that race as a
/// failure (arch-review 2026-08-19 P3-1). /// failure.
/// </summary> /// </summary>
/// <param name="cts">The token source to cancel.</param> /// <param name="cts">The token source to cancel.</param>
private static void CancelQuietly(CancellationTokenSource cts) private static void CancelQuietly(CancellationTokenSource cts)
@@ -249,7 +250,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
} }
// This call OWNS `cts` — it created it, so it disposes it once the stream loop has // This call OWNS `cts` — it created it, so it disposes it once the stream loop has
// fully unwound (P3-1). No other path may dispose it while this one is still reading // fully unwound. No other path may dispose it while this one is still reading
// cts.Token; a replacing subscription cancels only. // cts.Token; a replacing subscription cancels only.
try try
{ {
@@ -270,15 +271,15 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <summary> /// <summary>
/// Opens the site-wide, <b>alarm-only</b> server-streaming subscription /// Opens the site-wide, <b>alarm-only</b> server-streaming subscription
/// (<c>SubscribeSite</c>) for a whole site rather than a single instance. This is /// (<c>SubscribeSite</c>) for a whole site rather than a single instance. This is
/// the central per-site feed that backs the aggregated Alarm Summary live cache /// the central per-site feed that backs the aggregated Alarm Summary live cache:
/// (plan #10): the site runtime's <c>SubscribeSiteAlarms</c> hub drops the /// the site runtime's <c>SubscribeSiteAlarms</c> hub drops the
/// per-instance filter and carries only <see cref="AlarmStateChanged"/> events for /// per-instance filter and carries only <see cref="AlarmStateChanged"/> events for
/// <em>all</em> instances on the site (attributes are deliberately excluded — the /// <em>all</em> instances on the site (attributes are deliberately excluded — the
/// summary never shows them and they are far higher-volume). /// summary never shows them and they are far higher-volume).
/// <para> /// <para>
/// The callback is deliberately <b>typed</b> as <see cref="AlarmStateChanged"/> /// The callback is deliberately <b>typed</b> as <see cref="AlarmStateChanged"/>
/// rather than the generic <c>Action&lt;object&gt;</c> used by /// rather than the generic <c>Action&lt;object&gt;</c> used by
/// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so Task 4's /// <see cref="SubscribeAsync"/>: this stream is alarm-only by contract, so the
/// per-site cache consumes an alarm delta directly with no downstream type test. /// per-site cache consumes an alarm delta directly with no downstream type test.
/// The mapping is still shared via <see cref="ConvertToDomainEvent"/>; a non-alarm /// The mapping is still shared via <see cref="ConvertToDomainEvent"/>; a non-alarm
/// event (which should never appear on this stream) is defensively ignored rather /// event (which should never appear on this stream) is defensively ignored rather
@@ -504,7 +505,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// <summary> /// <summary>
/// Cancels an active subscription by correlation ID. Cancel-only: the subscription's own /// Cancels an active subscription by correlation ID. Cancel-only: the subscription's own
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> call disposes the token /// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> call disposes the token
/// source once its stream loop unwinds (P3-1). Cancelling an entry whose owner already /// source once its stream loop unwinds. Cancelling an entry whose owner already
/// disposed it — or unsubscribing after the client was disposed — is a silent no-op. /// disposed it — or unsubscribing after the client was disposed — is a silent no-op.
/// </summary> /// </summary>
/// <param name="correlationId">Unique identifier of the subscription to cancel.</param> /// <param name="correlationId">Unique identifier of the subscription to cancel.</param>
@@ -664,7 +665,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// here is synchronous (CTS cancellation and <see cref="GrpcChannel.Dispose"/>), so a /// here is synchronous (CTS cancellation and <see cref="GrpcChannel.Dispose"/>), so a
/// synchronous <see cref="Dispose"/> can release everything without sync-over-async blocking. /// synchronous <see cref="Dispose"/> can release everything without sync-over-async blocking.
/// <para> /// <para>
/// Cancel-only, per-entry guarded (P3-1). The token sources are owned by their /// Cancel-only, per-entry guarded. The token sources are owned by their
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> calls, which dispose them /// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> calls, which dispose them
/// as their loops unwind in response to this cancellation; disposing them here raced those /// as their loops unwind in response to this cancellation; disposing them here raced those
/// loops, and one <see cref="ObjectDisposedException"/> aborted the whole foreach — which in /// loops, and one <see cref="ObjectDisposedException"/> aborted the whole foreach — which in
@@ -131,7 +131,7 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
/// <summary> /// <summary>
/// Asynchronously disposes all cached clients and clears the cache. Each client's disposal /// Asynchronously disposes all cached clients and clears the cache. Each client's disposal
/// is fault-isolated: one throwing client must not abort the loop and strand every later /// is fault-isolated: one throwing client must not abort the loop and strand every later
/// client's channel undisposed (arch-review 2026-08-19 P3-1). /// client's channel undisposed.
/// </summary> /// </summary>
/// <returns>A value task that completes when all clients have been disposed.</returns> /// <returns>A value task that completes when all clients have been disposed.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -39,7 +39,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
private volatile bool _shuttingDown; private volatile bool _shuttingDown;
private long _actorCounter; private long _actorCounter;
// Ask timeout is 30 s. The ingest actor's repo // Ask timeout is 30 s. The ingest actor's repo
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow // calls take under 100 ms in steady state; a generous timeout absorbs a slow
// MSSQL connection without surfacing as a gRPC failure on a healthy site. // MSSQL connection without surfacing as a gRPC failure on a healthy site.
// Shared with CentralCommunicationActor (same assembly) so the two audit-ingest // Shared with CentralCommunicationActor (same assembly) so the two audit-ingest
// transports use one source of truth for the timeout. // transports use one source of truth for the timeout.
@@ -275,7 +275,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// enriched AlarmStateUpdate, so it is reused unchanged. // enriched AlarmStateUpdate, so it is reused unchanged.
_streamSubscriber.SubscribeSiteAlarms, _streamSubscriber.SubscribeSiteAlarms,
"site-wide alarms", "site-wide alarms",
// Its OWN, much larger channel (WP2.3): sharing the Debug View's 1000-slot // Its OWN, much larger channel: sharing the Debug View's 1000-slot
// DropOldest meant an alarm burst during a WAN stall silently evicted operator- // DropOldest meant an alarm burst during a WAN stall silently evicted operator-
// visible transitions to make room for diagnostics traffic. // visible transitions to make room for diagnostics traffic.
_siteAlarmChannelCapacity, _siteAlarmChannelCapacity,
@@ -338,7 +338,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// //
// The removal is claimed FIRST, but nothing is cancelled until the concurrency cap // The removal is claimed FIRST, but nothing is cancelled until the concurrency cap
// has had its say, and a REPLACEMENT never consults the cap at all (arch-review // has had its say, and a REPLACEMENT never consults the cap at all (arch-review
// 2026-08-19 P3-5). Both halves matter: // 2026-08-19). Both halves matter:
// //
// * A replacement is net-neutral on the stream count (one entry out, one back in), // * A replacement is net-neutral on the stream count (one entry out, one back in),
// so refusing it can only ever destroy capacity, never protect it. The old code // so refusing it can only ever destroy capacity, never protect it. The old code
@@ -402,7 +402,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
{ {
// Lossy-under-backpressure is the debug-view spec; make the real // Lossy-under-backpressure is the debug-view spec; make the real
// loss visible: first eviction + every 500th thereafter, plus a // loss visible: first eviction + every 500th thereafter, plus a
// per-kind counter on the node (WP2.3). // per-kind counter on the node.
var n = Interlocked.Increment(ref dropped); var n = Interlocked.Increment(ref dropped);
Interlocked.Increment(ref _droppedStreamEvents); Interlocked.Increment(ref _droppedStreamEvents);
ScadaBridgeTelemetry.RecordSiteStreamEventDropped(streamKind); ScadaBridgeTelemetry.RecordSiteStreamEventDropped(streamKind);
@@ -447,8 +447,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
"Stream {CorrelationId} started for {Description} (subscription {SubscriptionId})", "Stream {CorrelationId} started for {Description} (subscription {SubscriptionId})",
correlationId, description, subscriptionId); correlationId, description, subscriptionId);
// Flush response headers NOW, while the relay is attached and before the first event // Flush response headers NOW, while the relay is attached and before the first event.
// (WP2.3). This is the client's "the site accepted my subscription" signal — the // This is the client's "the site accepted my subscription" signal — the
// per-site aggregator hangs its once-per-successful-reconnect re-seed off it. Without // per-site aggregator hangs its once-per-successful-reconnect re-seed off it. Without
// an explicit flush, ASP.NET Core defers headers to the first written message, so a // an explicit flush, ASP.NET Core defers headers to the first written message, so a
// quiet site would never report connected. Best-effort: a client that vanished between // quiet site would never report connected. Best-effort: a client that vanished between
@@ -516,12 +516,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
} }
} }
/// <summary>Retained wire method; ingest is served only by CentralControlService.</summary> /// <inheritdoc />
public override Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context) public override Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context)
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus( => Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
StatusCode.Unimplemented, "Use CentralControlService.IngestAuditEvents on central."))); StatusCode.Unimplemented, "Use CentralControlService.IngestAuditEvents on central.")));
/// <summary>Retained wire method; combined ingest is served only by CentralControlService.</summary> /// <inheritdoc />
public override Task<IngestAck> IngestCachedTelemetry(CachedTelemetryBatch request, ServerCallContext context) public override Task<IngestAck> IngestCachedTelemetry(CachedTelemetryBatch request, ServerCallContext context)
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus( => Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
StatusCode.Unimplemented, "Use CentralControlService.IngestCachedTelemetry on central."))); StatusCode.Unimplemented, "Use CentralControlService.IngestCachedTelemetry on central.")));
@@ -535,12 +535,11 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
/// <summary>Flow Debugger PullTraceEvents: server-side id cap, matching the site queue's.</summary> /// <summary>Flow Debugger PullTraceEvents: server-side id cap, matching the site queue's.</summary>
public const int MaxTraceExecutionIdsPerPull = 64; public const int MaxTraceExecutionIdsPerPull = 64;
/// <summary> // Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
/// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose // ExecutionId or ParentExecutionId is one of the requested ids. NOT a reconciliation
/// <c>ExecutionId</c> or <c>ParentExecutionId</c> is one of the requested ids. NOT a // path — moves no cursor, flips no ForwardState. Gated by the same
/// reconciliation path — moves no cursor, flips no ForwardState. Gated by the same // ControlPlaneAuthInterceptor prefix as PullAuditEvents.
/// <c>ControlPlaneAuthInterceptor</c> prefix as <see cref="PullAuditEvents"/>. /// <inheritdoc />
/// </summary>
public override async Task<PullTraceEventsResponse> PullTraceEvents( public override async Task<PullTraceEventsResponse> PullTraceEvents(
PullTraceEventsRequest request, PullTraceEventsRequest request,
ServerCallContext context) ServerCallContext context)
@@ -630,12 +629,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc) ? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc)
: DateTime.MinValue; : DateTime.MinValue;
// Composite-keyset cursor (WP2.3), mirroring PullSiteCalls: proto3 // Composite-keyset cursor, mirroring PullSiteCalls: proto3
// defaults an unset string to "", which the queue treats as "no cursor" // defaults an unset string to "", which the queue treats as "no cursor"
// (legacy inclusive >= behaviour), so an older central is unaffected. // (legacy inclusive >= behaviour), so an older central is unaffected.
var afterId = string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId; var afterId = string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId;
// AT-LEAST-ONCE (WP2.3). The incoming cursor is central's receipt for // AT-LEAST-ONCE. The incoming cursor is central's receipt for
// everything at or before it — that is the ONLY proof the site accepts. // everything at or before it — that is the ONLY proof the site accepts.
// Rows are flipped to Reconciled here, at the START of the NEXT pull, // Rows are flipped to Reconciled here, at the START of the NEXT pull,
// instead of right after the previous response was projected: a fault // instead of right after the previous response was projected: a fault
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary> /// <summary>
/// Central, transient, in-memory per-site live alarm cache (plan #10, Task 4). /// Central, transient, in-memory per-site live alarm cache.
/// A DI singleton shared by every Alarm Summary Blazor circuit on the active central /// A DI singleton shared by every Alarm Summary Blazor circuit on the active central
/// node. For each site with at least one active viewer it runs ONE shared per-site /// node. For each site with at least one active viewer it runs ONE shared per-site
/// aggregator that seeds from the existing debug-snapshot fan-out and stays warm on a /// aggregator that seeds from the existing debug-snapshot fan-out and stays warm on a
@@ -55,8 +55,8 @@ public interface ISiteAlarmLiveCache
/// <summary> /// <summary>
/// Whether the site's aggregator has completed its initial seed and is serving live /// Whether the site's aggregator has completed its initial seed and is serving live
/// state. The page can use this to decide whether to keep its 15s poll fallback /// state. The page can use this to decide whether to keep its 15s poll fallback:
/// (Task 5): <c>false</c> means "not live yet — keep polling"; <c>true</c> means the /// <c>false</c> means "not live yet — keep polling"; <c>true</c> means the
/// cache is authoritative. /// cache is authoritative.
/// </summary> /// </summary>
/// <param name="siteId">The numeric site id.</param> /// <param name="siteId">The numeric site id.</param>
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
sp.GetService<ISitePskProvider>())); sp.GetService<ISitePskProvider>()));
services.AddSingleton<DebugStreamService>(); services.AddSingleton<DebugStreamService>();
// Aggregated live alarm cache (plan #10, Task 4): transient, in-memory, shared // Aggregated live alarm cache transient, in-memory, shared
// per-site aggregator backing the operator Alarm Summary page. Singleton so every // per-site aggregator backing the operator Alarm Summary page. Singleton so every
// Blazor circuit shares one aggregator per site; started/stopped by viewer // Blazor circuit shares one aggregator per site; started/stopped by viewer
// reference-count. No persistence (locked [PERM]: no central alarm store). // reference-count. No persistence (locked [PERM]: no central alarm store).
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
/// <summary> /// <summary>
/// Default <see cref="ISiteAlarmLiveCache"/> — the DI singleton that owns the shared, /// Default <see cref="ISiteAlarmLiveCache"/> — the DI singleton that owns the shared,
/// reference-counted, transient per-site live alarm cache (plan #10, Task 4). For each /// reference-counted, transient per-site live alarm cache. For each
/// site with active Alarm Summary viewers it runs ONE /// site with active Alarm Summary viewers it runs ONE
/// <see cref="SiteAlarmAggregatorActor"/>, seeded by the existing debug-snapshot fan-out /// <see cref="SiteAlarmAggregatorActor"/>, seeded by the existing debug-snapshot fan-out
/// (<see cref="CommunicationService.RequestDebugSnapshotAsync"/>) and kept warm on a /// (<see cref="CommunicationService.RequestDebugSnapshotAsync"/>) and kept warm on a
@@ -246,7 +246,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
TimeSpan.FromSeconds(5), // reconnect delay — former ReconnectDelay static default TimeSpan.FromSeconds(5), // reconnect delay — former ReconnectDelay static default
TimeSpan.FromSeconds(60), // stability window — former StabilityWindow static default TimeSpan.FromSeconds(60), // stability window — former StabilityWindow static default
_options.LiveAlarmCacheReconcileJitterFraction, _options.LiveAlarmCacheReconcileJitterFraction,
// Open-attempt deadline (P3-4). Passed explicitly rather than defaulted: // Open-attempt deadline. Passed explicitly rather than defaulted:
// Props.Create takes an expression tree, which may not omit an optional // Props.Create takes an expression tree, which may not omit an optional
// argument (CS0854). // argument (CS0854).
SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout)); SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout));
@@ -426,9 +426,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
await gate.WaitAsync(ct); await gate.WaitAsync(ct);
try try
{ {
// Alarms-only (WP2.3): this fan-out discards snapshot.AttributeValues, so // Alarms-only: this fan-out discards snapshot.AttributeValues, so
// asking the site to build and ship them is pure wire waste. The flag is // asking the site to build and ship them is pure wire waste. The flag is
// additive on the wire; a pre-WP2.3 site ignores it and returns the full // additive on the wire; an older site ignores it and returns the full
// snapshot, which this loop reads exactly as before. // snapshot, which this loop reads exactly as before.
var request = new DebugSnapshotRequest( var request = new DebugSnapshotRequest(
instanceUniqueName, Guid.NewGuid().ToString("N"), AlarmsOnly: true); instanceUniqueName, Guid.NewGuid().ToString("N"), AlarmsOnly: true);
@@ -470,7 +470,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
entry.HasPublished = true; entry.HasPublished = true;
// Liveness is the STREAM's, not the cache's: a completed or given-up stream must // Liveness is the STREAM's, not the cache's: a completed or given-up stream must
// stop reporting live between reopen ticks, or the page grafts a freezing snapshot // stop reporting live between reopen ticks, or the page grafts a freezing snapshot
// over fresh poll data (WP2.3 carried residual). // over fresh poll data.
entry.StreamLive = streamLive; entry.StreamLive = streamLive;
subscribers = entry.Subscribers.ToArray(); subscribers = entry.Subscribers.ToArray();
} }
@@ -496,6 +496,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary> /// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
private sealed class AggregatorTerminationWatcher : ReceiveActor private sealed class AggregatorTerminationWatcher : ReceiveActor
{ {
/// <summary>Creates the watcher and begins watching the aggregator.</summary>
/// <param name="watched">The aggregator actor to watch.</param>
/// <param name="onTerminated">Callback invoked once when the watched actor terminates.</param>
public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated) public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
{ {
Context.Watch(watched); Context.Watch(watched);
@@ -551,11 +554,20 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>Per-site bookkeeping. All mutable fields are guarded by the service's <c>_lock</c>.</summary> /// <summary>Per-site bookkeeping. All mutable fields are guarded by the service's <c>_lock</c>.</summary>
private sealed class SiteEntry private sealed class SiteEntry
{ {
/// <summary>Creates the bookkeeping entry for a site.</summary>
/// <param name="siteId">The numeric site id.</param>
public SiteEntry(int siteId) => SiteId = siteId; public SiteEntry(int siteId) => SiteId = siteId;
/// <summary>The numeric site id this entry tracks.</summary>
public int SiteId { get; } public int SiteId { get; }
/// <summary>Active viewer subscriptions for the site.</summary>
public List<Subscription> Subscribers { get; } = new(); public List<Subscription> Subscribers { get; } = new();
/// <summary>The site's shared aggregator actor, or <c>null</c> when none is running.</summary>
public IActorRef? Actor { get; set; } public IActorRef? Actor { get; set; }
/// <summary>True while an aggregator start attempt is in progress.</summary>
public bool Starting { get; set; } public bool Starting { get; set; }
/// <summary>Current published snapshot the page renders. Swapped as a whole immutable list.</summary> /// <summary>Current published snapshot the page renders. Swapped as a whole immutable list.</summary>
@@ -578,7 +590,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// </summary> /// </summary>
public bool StreamLive { get; set; } public bool StreamLive { get; set; }
/// <summary>Timer that stops the aggregator after the last viewer leaves.</summary>
public Timer? LingerTimer { get; set; } public Timer? LingerTimer { get; set; }
/// <summary>Version counter that invalidates a stale linger-timer callback.</summary>
public int LingerVersion { get; set; } public int LingerVersion { get; set; }
/// <summary>Bounded retry timer armed when a start attempt failed to create the actor.</summary> /// <summary>Bounded retry timer armed when a start attempt failed to create the actor.</summary>
@@ -611,6 +626,7 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
{ {
public static readonly NoOpSubscription Instance = new(); public static readonly NoOpSubscription Instance = new();
private NoOpSubscription() { } private NoOpSubscription() { }
/// <summary>Does nothing; the rejected subscription was never registered.</summary>
public void Dispose() { } public void Dispose() { }
} }
@@ -620,6 +636,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
private readonly SiteAlarmLiveCacheService _owner; private readonly SiteAlarmLiveCacheService _owner;
private int _disposed; private int _disposed;
/// <summary>Creates a subscription handle for a viewer.</summary>
/// <param name="owner">The owning cache service.</param>
/// <param name="siteId">The numeric site id subscribed to.</param>
/// <param name="onChanged">Callback invoked when the site's cache changes.</param>
public Subscription(SiteAlarmLiveCacheService owner, int siteId, Action onChanged) public Subscription(SiteAlarmLiveCacheService owner, int siteId, Action onChanged)
{ {
_owner = owner; _owner = owner;
@@ -627,9 +647,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
OnChanged = onChanged; OnChanged = onChanged;
} }
/// <summary>The numeric site id subscribed to.</summary>
public int SiteId { get; } public int SiteId { get; }
/// <summary>Callback invoked when the site's cache changes.</summary>
public Action OnChanged { get; } public Action OnChanged { get; }
/// <summary>Unregisters this viewer from the owning cache; safe to call more than once.</summary>
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposed, 1) == 0) if (Interlocked.Exchange(ref _disposed, 1) == 0)
@@ -191,7 +191,7 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
// INCLUDE(Status) makes the audit KPI window query (count/aggregate by Status // INCLUDE(Status) makes the audit KPI window query (count/aggregate by Status
// over a recent OccurredAtUtc range) a covering index seek — the persisted // over a recent OccurredAtUtc range) a covering index seek — the persisted
// computed Status column rides along in the leaf without a key/RID lookup back // computed Status column rides along in the leaf without a key/RID lookup back
// into the clustered index per matching row (arch-review WP1.4). // into the clustered index per matching row.
builder.HasIndex(e => e.OccurredAtUtc) builder.HasIndex(e => e.OccurredAtUtc)
.IsDescending(true) .IsDescending(true)
.HasDatabaseName("IX_AuditLog_OccurredAtUtc") .HasDatabaseName("IX_AuditLog_OccurredAtUtc")
@@ -74,7 +74,7 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
// Covers ComputeKpisAsync's "DeliveredLastInterval" count // Covers ComputeKpisAsync's "DeliveredLastInterval" count
// (Status == Delivered && DeliveredAt >= deliveredSince): filtered to just the // (Status == Delivered && DeliveredAt >= deliveredSince): filtered to just the
// Delivered rows so the index stays small relative to the full table and the KPI // Delivered rows so the index stays small relative to the full table and the KPI
// query can seek it directly instead of scanning every status (arch-review WP1.4). // query can seek it directly instead of scanning every status.
// Status is stored as its enum name (HasConversion<string>() above), so the filter // Status is stored as its enum name (HasConversion<string>() above), so the filter
// predicate matches on the literal 'Delivered', not the underlying int value. // predicate matches on the literal 'Delivered', not the underlying int value.
// No N'' prefix: the value is ASCII-only (fine on SQL Server's nvarchar column) // No N'' prefix: the value is ASCII-only (fine on SQL Server's nvarchar column)
@@ -93,11 +93,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
// SourceSiteId first, and IX_Notifications_Delivered is filtered to // SourceSiteId first, and IX_Notifications_Delivered is filtered to
// Status='Delivered' only. So an unfiltered (or lightly filtered) page // Status='Delivered' only. So an unfiltered (or lightly filtered) page
// fell back to a full clustered-index scan + sort on every COUNT + page // fell back to a full clustered-index scan + sort on every COUNT + page
// request (arch-review 2026-08-19 P5-3, register row-35 class). With both // request. With both
// key columns descending, a forward scan of this index already delivers // key columns descending, a forward scan of this index already delivers
// rows in the requested order. Exact mirror of // rows in the requested order. Exact mirror of
// SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId // SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId.
// (2026-08-19, arch-review P5-2).
builder.HasIndex(n => new { n.CreatedAt, n.NotificationId }) builder.HasIndex(n => new { n.CreatedAt, n.NotificationId })
.IsDescending(true, true) .IsDescending(true, true)
.HasDatabaseName("IX_Notifications_Created_NotificationId"); .HasDatabaseName("IX_Notifications_Created_NotificationId");
@@ -99,7 +99,7 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
.IncludeProperties(nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status)); .IncludeProperties(nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status));
// Terminal backs the daily retention purge's predicate (TerminalAtUtc IS NOT NULL // Terminal backs the daily retention purge's predicate (TerminalAtUtc IS NOT NULL
// AND TerminalAtUtc < cutoff) and Task 11's MIN() slice anchor: filtered to the // AND TerminalAtUtc < cutoff) and the MIN() slice anchor: filtered to the
// terminal population so the purge seeks the expired tail instead of full-scanning // terminal population so the purge seeks the expired tail instead of full-scanning
// the 365-day table (arch-review 04 round 2, R6). Complements IX_SiteCalls_NonTerminal, // the 365-day table (arch-review 04 round 2, R6). Complements IX_SiteCalls_NonTerminal,
// which is filtered to IS NULL and unusable for this predicate. // which is filtered to IS NULL and unusable for this predicate.
@@ -114,7 +114,7 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
// (Source_Created requires an equality on SourceSite first; // (Source_Created requires an equality on SourceSite first;
// NonTerminal/Terminal are filtered to one side of TerminalAtUtc), so an // NonTerminal/Terminal are filtered to one side of TerminalAtUtc), so an
// unfiltered page previously fell back to a full clustered-index scan + // unfiltered page previously fell back to a full clustered-index scan +
// sort (arch-review 2026-08-19 P5-2, register row-35 class). // sort.
builder.HasIndex(s => new { s.CreatedAtUtc, s.TrackedOperationId }) builder.HasIndex(s => new { s.CreatedAtUtc, s.TrackedOperationId })
.IsDescending(true, true) .IsDescending(true, true)
.HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId"); .HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId");
@@ -10,7 +10,15 @@ public sealed class DataConnectionSecretConverter(IDataProtector protector) : Va
{ {
internal const string Prefix = "dp:protocol:v1:"; internal const string Prefix = "dp:protocol:v1:";
internal const string Purpose = EncryptedStringConverter.ProtectorPurpose + ".DataConnection"; internal const string Purpose = EncryptedStringConverter.ProtectorPurpose + ".DataConnection";
/// <summary>Encrypts plaintext protocol JSON and tags it with the versioned prefix.</summary>
/// <param name="protector">The data protector to encrypt with.</param>
/// <param name="plaintext">The plaintext to protect.</param>
/// <returns>The prefixed protected string.</returns>
internal static string Protect(IDataProtector protector, string plaintext) => Prefix + protector.Protect(plaintext); internal static string Protect(IDataProtector protector, string plaintext) => Prefix + protector.Protect(plaintext);
/// <summary>Decrypts a stored, versioned protected value.</summary>
/// <param name="protector">The data protector to decrypt with.</param>
/// <param name="stored">The stored value, which must carry the versioned prefix.</param>
/// <returns>The decrypted plaintext.</returns>
internal static string Unprotect(IDataProtector protector, string stored) internal static string Unprotect(IDataProtector protector, string stored)
=> stored.StartsWith(Prefix, StringComparison.Ordinal) => stored.StartsWith(Prefix, StringComparison.Ordinal)
? protector.Unprotect(stored[Prefix.Length..]) ? protector.Unprotect(stored[Prefix.Length..])
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
public static class DataConnectionSecretUpgrade public static class DataConnectionSecretUpgrade
{ {
/// <summary>Protects both configuration columns before repositories may materialize them.</summary> /// <summary>Protects both configuration columns before repositories may materialize them.</summary>
/// <param name="db">The configuration database context.</param>
/// <param name="cancellationToken">Token to cancel the upgrade.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task UpgradeAsync(ScadaBridgeDbContext db, CancellationToken cancellationToken = default) public static async Task UpgradeAsync(ScadaBridgeDbContext db, CancellationToken cancellationToken = default)
{ {
var connection = db.Database.GetDbConnection(); var connection = db.Database.GetDbConnection();

Some files were not shown because too many files have changed in this diff Show More