feat(host): register SiteCallAuditActor + CachedCallTelemetry forwarder/bridge (#22, #23 M3)

M3 Bundle F (Task F1) wires the cached-call audit pipeline through the
composition roots:

- Central: register SiteCallAuditActor as a cluster singleton + proxy
  (mirrors AuditLogIngestActor and NotificationOutboxActor). Program.cs
  calls .AddSiteCallAudit() on the central role.
- Site: register ICachedCallTelemetryForwarder + CachedCallLifecycleBridge
  in AddAuditLog (lazy factory — Central nodes degrade to audit-only
  emission because IOperationTrackingStore is site-only).
- Site: bind CachedCallLifecycleBridge to ICachedCallLifecycleObserver so
  StoreAndForwardService picks it up via DI.
- Site: introduce IStoreAndForwardSiteContext + Host adapter to surface the
  site id to StoreAndForwardService without creating a
  StoreAndForward -> HealthMonitoring project-reference cycle.
- ScriptExecutionActor resolves ICachedCallTelemetryForwarder per script
  scope and threads it into ScriptRuntimeContext.

CachedCallTelemetryForwarder's IOperationTrackingStore dependency is now
nullable so Central DI validation succeeds with the lazy registration; the
forwarder's tracking-half emission is a no-op when the store is absent.

Tests:
- AkkaHostedServiceAuditWiringTests: Central host builds with
  AddSiteCallAudit and resolves ICachedCallTelemetryForwarder; Site
  resolves the forwarder + bridge + observer + IStoreAndForwardSiteContext.
- Full solution: 194 Host tests green, 241 SiteRuntime tests green, every
  other suite unchanged.
This commit is contained in:
Joseph Doherty
2026-05-20 15:10:47 -04:00
parent 047988e4c8
commit 6fe23a4d9b
11 changed files with 291 additions and 5 deletions

View File

@@ -342,6 +342,35 @@ akka {{
"AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
grpcServer is not null);
// Site Call Audit (#22) — central singleton mirrors the AuditLogIngest
// and NotificationOutbox patterns. M3's dual-write transaction routes
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
// ISiteCallAuditRepository resolution, so this singleton is not on the
// M3 happy-path hot path; it exists so future direct-write callers
// (reconciliation puller, central→site Retry/Discard relay, KPI
// projector) Ask through a stable cluster proxy without further wiring.
// Like AuditLogIngestActor, the actor takes the root IServiceProvider
// and creates a fresh scope per message because ISiteCallAuditRepository
// is a scoped EF Core service.
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
_serviceProvider,
siteCallAuditLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
.WithSingletonName("site-call-audit"));
_actorSystem!.ActorOf(siteCallAuditSingletonProps, "site-call-audit-singleton");
var siteCallAuditProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/site-call-audit-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("site-call-audit"));
_actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
_logger.LogInformation("SiteCallAuditActor singleton created");
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
}

View File

@@ -16,6 +16,7 @@ using ScadaLink.ManagementService;
using ScadaLink.NotificationOutbox;
using ScadaLink.NotificationService;
using ScadaLink.Security;
using ScadaLink.SiteCallAudit;
using ScadaLink.TemplateEngine;
using Serilog;
@@ -82,6 +83,12 @@ try
// IAuditLogRepository. The site writer chain is still registered (lazy
// singletons) but is never resolved on a central node.
builder.Services.AddAuditLog(builder.Configuration);
// Site Call Audit (#22) — central node owns the SiteCallAuditActor
// singleton (M3 Bundle F). The extension itself currently registers
// nothing — actor Props are constructed inline in AkkaHostedService —
// but the call is here for symmetry with the other audit composition
// roots so future per-actor DI lands without touching Program.cs.
builder.Services.AddSiteCallAudit();
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();

View File

@@ -39,6 +39,7 @@
<ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
<ProjectReference Include="../ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
<ProjectReference Include="../ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
<ProjectReference Include="../ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
<ProjectReference Include="../ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />

View File

@@ -42,6 +42,14 @@ public static class SiteServiceRegistration
var siteDbPath = config["ScadaLink:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}");
services.AddDataConnectionLayer();
// Audit Log #23 (M3 Bundle F): adapter that surfaces the site id to
// StoreAndForwardService through DI WITHOUT introducing a
// StoreAndForward → HealthMonitoring project-reference cycle. Must be
// registered BEFORE AddStoreAndForward so the S&F factory resolves a
// non-empty SiteId at construction time (otherwise the S&F service is
// a singleton and the empty-string value would be cached for the
// lifetime of the process).
services.AddSingleton<ScadaLink.StoreAndForward.IStoreAndForwardSiteContext, StoreAndForwardSiteContext>();
services.AddStoreAndForward();
services.AddSiteEventLogging();

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host;
/// <summary>
/// Audit Log #23 (M3 Bundle F): Host-side adapter implementing the
/// optional <see cref="IStoreAndForwardSiteContext"/> the Store-and-Forward
/// service consults to stamp cached-call audit telemetry with the site id.
/// </summary>
/// <remarks>
/// Forwards <see cref="NodeOptions.SiteId"/> verbatim — the same value
/// <see cref="SiteIdentityProvider"/> exposes to HealthMonitoring. Defined as
/// a separate adapter (rather than reusing <see cref="SiteIdentityProvider"/>)
/// to avoid pulling HealthMonitoring into the StoreAndForward project's
/// dependency graph, which would create a project-reference cycle.
/// </remarks>
public class StoreAndForwardSiteContext : IStoreAndForwardSiteContext
{
public string SiteId { get; }
public StoreAndForwardSiteContext(IOptions<NodeOptions> nodeOptions)
{
// NodeOptions.SiteId is nullable; SiteServiceRegistration ONLY adds
// this binding on the site role, so a non-null site id is expected
// here. Mirror SiteIdentityProvider's hard fail so a missing site id
// surfaces at composition time rather than at the first cached call.
SiteId = nodeOptions.Value.SiteId
?? throw new InvalidOperationException(
"ScadaLink:Node:SiteId is required for the site role's StoreAndForward wiring.");
}
}