v2 single-rail 5.10: enforce single-rail — internalize emitters + shrink baseline to 2 callers

Plan §5.10 — final PR of the single-rail refactor. Rail B is gone; Invariant 1
(single caller set) + Invariant 2 (compile-time enforcement) are now active.

Classes internalized:
- TelemetryEventEmitter (public sealed → internal sealed; Emit → internal)
- EventTimelineEmitter (public sealed → internal sealed)
- BackPressureEventObserver (public sealed → internal sealed)
Consumers of these three live in the same Telemetry.Events namespace (plus
InternalsVisibleTo for the test assembly). Any future attempt to construct or
reference them from outside the assembly becomes a compile error — the
single-rail invariant is now load-bearing on the type system, not review.

EnrollmentOrchestrator scrubbed:
- Deleted the `public TelemetryEventEmitter EventEmitter` property (no external
  consumer after PR #1–#9).
- Deleted the `BuildTelemetryEventSink()` Action<EnrollmentEvent> factory
  (last caller removed in step 14 of Start, where it wired collectors; those
  now post via ISignalIngressSink exclusively).
- Dropped the `_eventEmitter` field — the emitter is built as a local in Start
  and handed to EventTimelineEmitter + BackPressureEventObserver inline. After
  Start returns, nothing in the orchestrator references it. Architecture
  baseline test shrinks by one as a direct consequence.

IComponentFactory cleanup:
- Removed `Action<EnrollmentEvent> onEnrollmentEvent` parameter from
  CreateCollectorHosts (interface + DefaultComponentFactory + call-site in
  EnrollmentOrchestrator.Start).
- DefaultComponentFactory.ImeLogHost constructor drops the now-dead
  onEnrollmentEvent parameter (declared but never used — relic from the
  pre-migration wire).
- ICollectorHost + IComponentFactory doc comments updated to reflect the new
  "post-via-ingress" contract.

Gather-rule internals (GatherRuleExecutor, GatherRuleContext, CommandCollector,
LogParserCollector) keep their internal Action<EnrollmentEvent> signature —
they are the orthogonal standalone CLI mode (plan §9), not a kernel path; the
session-mode GatherRuleExecutorHost already bridges the Action to an
InformationalEventPost in PR #6.

Architecture baseline test:
- `TelemetryEventEmitterCallersBaselineTests` promoted from transitional
  baseline (3 dependents) to strict enforcement: exactly the two permitted
  callers EventTimelineEmitter + BackPressureEventObserver. A third entry now
  fails the build — the review signal for any reintroduced bypass is loud.
- Dropped the `Current_baseline_is_a_superset_of_the_PR10_permitted_callers`
  sanity test (redundant once the enforcement set IS the permitted set).

Test fixtures updated:
- EnrollmentOrchestratorEventBridgeTests rewritten — the "Action<EnrollmentEvent>
  bridge" contract the old tests exercised is gone, so the tests covering it
  (Emit-from-host-reaches-transport, emitter-exception-does-not-propagate etc.)
  were removed. Remaining coverage: Start without factory, Start with factory
  spawns + starts hosts, factory receives ingress + clock, Stop stops + disposes
  hosts.
- EnrollmentOrchestratorWhiteGlovePatternTests fake factory updated to the
  new IComponentFactory signature.
- EnrollmentOrchestratorTests + EnrollmentOrchestratorOnIngressReadyTests:
  removed the `sut.EventEmitter` / `orchestrator.EventEmitter` assertions —
  the property no longer exists.

Verification: dotnet build AutopilotMonitor.sln clean; V2.Core test suite
559 / 559 green (down from 562 — 3 obsolete bridge tests removed, 0 new
failures); DecisionCore 108 / 108 green.

Soll-Zustand per plan §8 now holds in code:
- Single-rail Emission: exactly 2 callers of TelemetryEventEmitter.Emit.
- Compile-time enforcement: emitter class + method are internal; any bypass
  attempt breaks the build instead of slipping through review.
- No Action<EnrollmentEvent> parameter on ICollectorHost / IComponentFactory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-23 16:35:14 +02:00
parent 791eadcd7a
commit 877523594f
12 changed files with 71 additions and 267 deletions
@@ -9,18 +9,17 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Architecture
{
/// <summary>
/// Single-rail refactor plan §4.4 / §8 — baseline inventory of every type in
/// <c>AutopilotMonitor.Agent.V2.Core</c> that can reach
/// <see cref="TelemetryEventEmitter"/>. The test is a living document: as the migration PRs
/// (#1 through #9) remove bypass paths one by one, the baseline shrinks. PR #10 tightens
/// the expected set to only the two permitted callers — <see cref="EventTimelineEmitter"/>
/// (Rail A) and <see cref="BackPressureEventObserver"/> (documented meta exception).
/// Single-rail refactor plan §4.4 / §5.10 / §8 — enforcement gate on the
/// <see cref="TelemetryEventEmitter"/> caller set. PR #10 promoted this test from a
/// transitional baseline (3 dependents) to strict enforcement (exactly 2 permitted callers):
/// <see cref="EventTimelineEmitter"/> (Rail A) and <see cref="BackPressureEventObserver"/>
/// (documented meta exception — it cannot route through the ingress without creating a
/// cycle because it observes the ingress itself).
/// <para>
/// <b>Scope</b>: this test inspects the V2.Core assembly only. Program.cs lives in the
/// V2 executable assembly and reaches the emitter via
/// <see cref="EnrollmentOrchestrator.EventEmitter"/>; internalizing that property in PR #10
/// removes the reach automatically. Until then Program.cs is tracked via the plan's §2
/// inventory (B2–B6), not this test.
/// <b>Scope</b>: this test inspects the V2.Core assembly only. The emitter class itself is
/// <c>internal</c> post-PR #10, so there is no surface for the V2 executable assembly or
/// any external library to reach it — the inventory is complete with the two types that
/// live next to it in the <c>Telemetry.Events</c> namespace.
/// </para>
/// <para>
/// <b>Detection strategy</b>: reflection over fields, constructor parameters, method
@@ -34,55 +33,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Architecture
private static readonly Type EmitterType = typeof(TelemetryEventEmitter);
/// <summary>
/// Types permitted to reach <see cref="TelemetryEventEmitter"/> at the end of the
/// single-rail refactor (PR #10 enforcement gate). Kept separate from the transitional
/// baseline so that a single-line diff in PR #10 switches enforcement on:
/// <c>Assert.Equal(PermittedCallers, ActualCallers)</c>.
/// The two and only two types permitted to reach <see cref="TelemetryEventEmitter"/>
/// after the single-rail refactor. A third entry would mean someone reintroduced a
/// bypass; the test fails and reviewers are forced to reason about whether the new
/// caller belongs here (essentially never — the answer is almost always "route via
/// ISignalIngressSink + InformationalEventPost instead").
/// </summary>
private static readonly HashSet<string> PermittedCallersAfterPr10 = new HashSet<string>(StringComparer.Ordinal)
private static readonly HashSet<string> PermittedCallers = new HashSet<string>(StringComparer.Ordinal)
{
"AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter",
"AutopilotMonitor.Agent.V2.Core.Telemetry.Events.BackPressureEventObserver",
};
/// <summary>
/// Current transitional baseline (PR #0). Every additional entry is a documented
/// bypass that a later PR will remove. Adding a new bypass requires updating this list
/// explicitly — the review signal is loud because tests fail.
/// </summary>
private static readonly HashSet<string> ExpectedCurrentBaseline = new HashSet<string>(StringComparer.Ordinal)
{
// Rail A — engine effect path. Permitted after PR #10.
"AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter",
// Meta exception — cannot route through the ingress without a cycle. Permitted after PR #10.
"AutopilotMonitor.Agent.V2.Core.Telemetry.Events.BackPressureEventObserver",
// Owns the field and exposes it via the public EventEmitter property (BuildTelemetryEventSink
// factory + the ServerActionDispatcher emitEvent callback in Program.cs). Removed /
// internalized in PR #10 once those bypasses are themselves migrated.
"AutopilotMonitor.Agent.V2.Core.Orchestration.EnrollmentOrchestrator",
// PR #2 (5.2) migrated StartupEnvironmentProbes to InformationalEventPost — baseline
// shrunk by one. Next to fall: the ServerActionDispatcher / EnrollmentTerminationHandler
// callbacks, Periodic collectors, Enrollment SystemSignals trackers, Gather rules,
// Analyzers, DeviceInfoCollector (PR #3 – PR #9).
};
[Fact]
public void V2Core_types_with_structural_dependency_on_TelemetryEventEmitter_match_current_baseline()
public void V2Core_types_with_structural_dependency_on_TelemetryEventEmitter_match_permitted_callers()
{
var actual = FindStructuralDependents(EmitterType.Assembly);
Assert.Equal(ExpectedCurrentBaseline.OrderBy(x => x), actual.OrderBy(x => x));
}
[Fact]
public void Current_baseline_is_a_superset_of_the_PR10_permitted_callers()
{
// Sanity guard: every permitted-after-PR10 caller must be present today, otherwise
// we've accidentally removed Rail A or the meta exception.
foreach (var permitted in PermittedCallersAfterPr10)
{
Assert.Contains(permitted, ExpectedCurrentBaseline);
}
Assert.Equal(PermittedCallers.OrderBy(x => x), actual.OrderBy(x => x));
}
private static ISet<string> FindStructuralDependents(Assembly assembly)
@@ -1,50 +1,45 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
#pragma warning disable xUnit1031
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// Orchestrator ↔ <see cref="IComponentFactory"/> lifecycle wiring. Plan §5.10 (single-rail
/// enforcement): the factory no longer receives an <c>Action&lt;EnrollmentEvent&gt;</c> sink
/// — collectors post via <see cref="ISignalIngressSink"/>. These tests assert Start creates
/// hosts + starts them, Stop stops + disposes them, and the factory receives the ingress +
/// clock it needs to wire its own posts.
/// </summary>
public sealed class EnrollmentOrchestratorEventBridgeTests
{
private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
/// <summary>Test host that lets a test manually fire enrollment events.</summary>
private sealed class FakeCollectorHost : ICollectorHost
{
private readonly Action<EnrollmentEvent> _sink;
public string Name { get; }
public int StartCalls { get; private set; }
public int StopCalls { get; private set; }
public int DisposeCalls { get; private set; }
public FakeCollectorHost(string name, Action<EnrollmentEvent> sink)
{
Name = name;
_sink = sink;
}
public FakeCollectorHost(string name) { Name = name; }
public void Start() => StartCalls++;
public void Stop() => StopCalls++;
public void Dispose() => DisposeCalls++;
public void Emit(EnrollmentEvent evt) => _sink(evt);
}
private sealed class FakeComponentFactory : IComponentFactory
{
public Action<EnrollmentEvent>? CapturedSink { get; private set; }
public readonly List<FakeCollectorHost> Hosts = new List<FakeCollectorHost>();
public readonly IReadOnlyList<string> HostNames;
@@ -56,7 +51,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
public IReadOnlyCollection<string>? CapturedWhiteGloveSealingPatternIds { get; private set; }
public ISignalIngressSink? CapturedIngress { get; private set; }
public IClock? CapturedClock { get; private set; }
@@ -64,18 +58,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock)
{
CapturedSink = onEnrollmentEvent;
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
CapturedIngress = ingress;
CapturedClock = clock;
foreach (var name in HostNames)
{
Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent));
Hosts.Add(new FakeCollectorHost(name));
}
return Hosts;
}
@@ -117,17 +109,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
public void Dispose() => Tmp.Dispose();
}
private static EnrollmentEvent Evt(string eventType) => new EnrollmentEvent
{
EventType = eventType,
Severity = EventSeverity.Info,
Source = "test",
Phase = EnrollmentPhase.Unknown,
Timestamp = At,
};
// ========================================================================= Wiring
[Fact]
public void Start_without_factory_does_not_throw_and_spawns_no_hosts()
{
@@ -152,14 +133,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
[Fact]
public void Start_with_factory_creates_all_hosts_and_captures_sink()
public void Start_with_factory_creates_all_hosts_and_starts_them()
{
using var rig = new Rig();
var sut = rig.Build();
sut.Start();
Assert.NotNull(rig.Factory.CapturedSink);
Assert.Equal(5, rig.Factory.Hosts.Count);
Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StartCalls));
@@ -167,87 +147,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
[Fact]
public void Emit_from_host_reaches_telemetry_transport()
public void Factory_receives_ingress_and_clock_from_orchestrator()
{
using var rig = new Rig();
var sut = rig.Build();
sut.Start();
var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!;
hello.Emit(Evt("hello_completed"));
var spool = GetSpool(sut);
var pending = spool.Peek(10);
Assert.Single(pending);
Assert.Equal(TelemetryItemKind.Event, pending[0].Kind);
Assert.Contains("hello_completed", pending[0].PayloadJson);
sut.Stop();
}
[Fact]
public void Emit_from_each_of_five_hosts_produces_five_transport_items()
{
using var rig = new Rig();
var sut = rig.Build();
sut.Start();
foreach (var host in rig.Factory.Hosts)
{
host.Emit(Evt($"event_from_{host.Name}"));
}
var pending = GetSpool(sut).Peek(10);
Assert.Equal(5, pending.Count);
foreach (var host in rig.Factory.Hosts)
{
Assert.Contains(pending, p => p.PayloadJson.Contains($"event_from_{host.Name}"));
}
sut.Stop();
}
[Fact]
public void Modern_deployment_host_emits_event_bridge_only_no_decision_signal()
{
using var rig = new Rig();
var sut = rig.Build();
sut.Start();
var modern = rig.Factory.Hosts.Find(h => h.Name == "ModernDeploymentTracker")!;
modern.Emit(Evt("modern_deployment_log"));
// Event reached transport (bridge works).
var pending = GetSpool(sut).Peek(10);
Assert.Single(pending);
Assert.Contains("modern_deployment_log", pending[0].PayloadJson);
// No decision-signal was posted — signal-log is empty (no SignalAdapter wraps
// ModernDeploymentTracker by design, Plan §4.x M4.3-Erkenntnis).
var signalLog = GetSignalLog(sut);
Assert.Empty(signalLog.ReadAll());
sut.Stop();
}
[Fact]
public void Emitter_exception_does_not_propagate_to_host_thread()
{
// Simulate: TelemetryEventEmitter throws (e.g. transport disposed mid-emit).
// Build a shared sink that throws on demand; wrap into a host that uses it directly.
// Easier path: dispose the transport post-Start so Enqueue throws, then fire an event.
using var rig = new Rig();
var sut = rig.Build();
sut.Start();
// Force a transport-level exception by disposing it mid-run.
GetTransport(sut).Dispose();
var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!;
// Must not throw — orchestrator wraps the emitter call in try/catch.
var ex = Record.Exception(() => hello.Emit(Evt("hello_after_dispose")));
Assert.Null(ex);
Assert.NotNull(rig.Factory.CapturedIngress);
Assert.NotNull(rig.Factory.CapturedClock);
Assert.Same(rig.Clock, rig.Factory.CapturedClock);
sut.Stop();
}
@@ -264,31 +173,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StopCalls));
Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.DisposeCalls));
}
// ========================================================================= Helpers
private static TelemetrySpool GetSpool(EnrollmentOrchestrator sut)
{
var field = typeof(EnrollmentOrchestrator).GetField(
"_spool",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
return (TelemetrySpool)field!.GetValue(sut)!;
}
private static TelemetryUploadOrchestrator GetTransport(EnrollmentOrchestrator sut)
{
var field = typeof(EnrollmentOrchestrator).GetField(
"_transport",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
return (TelemetryUploadOrchestrator)field!.GetValue(sut)!;
}
private static AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut)
{
var field = typeof(EnrollmentOrchestrator).GetField(
"_signalLog",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
return (AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter)field!.GetValue(sut)!;
}
}
}
@@ -138,7 +138,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
// Post-Start, observability accessors work (= Start completed, did not throw out).
Assert.NotNull(orchestrator.IngressSink);
Assert.NotNull(orchestrator.EventEmitter);
}
[Fact]
@@ -93,7 +93,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var sut = rig.Build();
Assert.Throws<InvalidOperationException>(() => sut.CurrentState);
Assert.Throws<InvalidOperationException>(() => sut.IngressSink);
Assert.Throws<InvalidOperationException>(() => sut.EventEmitter);
// Regression guard: Program.WireTelemetryServerResponse must run AFTER Start().
// Touching Transport pre-Start threw InvalidOperationException and crashed the agent
// on every VM after the first-run-ghost-detect fix let it reach this far.
@@ -123,7 +122,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.Equal("S1", sut.CurrentState.SessionId);
Assert.Equal("T1", sut.CurrentState.TenantId);
Assert.NotNull(sut.IngressSink);
Assert.NotNull(sut.EventEmitter);
Assert.False(sut.IsQuarantineRequested);
sut.Stop();
@@ -24,7 +24,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock)
@@ -91,7 +91,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock)
@@ -99,7 +98,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (onEnrollmentEvent == null) throw new ArgumentNullException(nameof(onEnrollmentEvent));
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -148,7 +146,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_imeLogHost = new ImeLogHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
ingress: ingress,
clock: clock,
@@ -518,7 +515,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public ImeLogHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
@@ -93,7 +93,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private EventSequenceCounter? _eventSequenceCounter;
private TelemetrySpool? _spool;
private TelemetryUploadOrchestrator? _transport;
private TelemetryEventEmitter? _eventEmitter;
private TelemetrySignalEmitter? _signalEmitter;
private TelemetryTransitionEmitter? _transitionEmitter;
private EventTimelineEmitter? _timelineEmitter;
@@ -222,10 +221,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public ISignalIngressSink IngressSink =>
(ISignalIngressSink?)_ingress ?? throw new InvalidOperationException("Orchestrator not started.");
/// <summary>Exposed für Sub-c-Wiring (Collector <c>onEventCollected</c>-Bridge).</summary>
public TelemetryEventEmitter EventEmitter =>
_eventEmitter ?? throw new InvalidOperationException("Orchestrator not started.");
/// <summary>
/// Exposed so Program.cs can subscribe to <see cref="TelemetryUploadOrchestrator.ServerResponseReceived"/>
/// for M4.6.ε DeviceBlocked / DeviceKillSignal / AdminAction / Actions plumbing.
@@ -321,13 +316,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_spool = new TelemetrySpool(_transportDirectory, _clock);
_transport = new TelemetryUploadOrchestrator(_spool, _uploader, _clock);
// 4) Event-Emitter-Kette.
// 4) Event-Emitter-Kette. Single-rail (Plan §5.10): der TelemetryEventEmitter wird
// lokal gebaut und nur in die zwei erlaubten Caller (EventTimelineEmitter,
// BackPressureEventObserver) injiziert. Kein Feld mehr auf dem Orchestrator —
// strukturelle Abhängigkeit erlischt damit, Architektur-Baseline-Test gate
// shrinks to exactly two permitted callers.
_eventSequenceCounter = new EventSequenceCounter(_eventSequencePersistence);
_eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId);
var eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId);
_signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId);
_transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId);
_timelineEmitter = new EventTimelineEmitter(_eventEmitter);
_backPressureObserver = new BackPressureEventObserver(_eventEmitter, _clock);
_timelineEmitter = new EventTimelineEmitter(eventEmitter);
_backPressureObserver = new BackPressureEventObserver(eventEmitter, _clock);
// 5) Deadlines + Classifiers.
_scheduler = new DeadlineScheduler(_clock);
@@ -441,14 +440,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
// 14) Collector-Hosts via Factory — onEnrollmentEvent bridged Telemetry-Events
// an den zentralen TelemetryEventEmitter. Exception-Swallow damit ein
// Collector-Thread nicht am Emitter-Fehler stirbt (Plan §2.7a).
// 14) Collector-Hosts via Factory — nach Plan §5.10 (single-rail enforcement) gibt
// es keine Action<EnrollmentEvent>-Bridge mehr; jede Collector-Emission fließt
// über den Ingress als InformationalEvent.
if (_componentFactory != null)
{
var safeSink = BuildTelemetryEventSink();
_collectorHosts = _componentFactory.CreateCollectorHosts(
_sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds,
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
ingress: _ingress, clock: _clock);
foreach (var host in _collectorHosts)
{
@@ -667,27 +665,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (!Directory.Exists(_transportDirectory)) Directory.CreateDirectory(_transportDirectory);
}
/// <summary>
/// Shared callback that collector hosts use for <c>onEventCollected</c>. Exception-safe:
/// an emitter failure (disk hiccup, transport closed) must never kill a collector thread.
/// </summary>
private Action<AutopilotMonitor.Shared.Models.EnrollmentEvent> BuildTelemetryEventSink()
{
return evt =>
{
if (evt == null || _eventEmitter == null) return;
try
{
_eventEmitter.Emit(evt);
}
catch (Exception ex)
{
_logger.Error(
$"EnrollmentOrchestrator: telemetry emit failed for event '{evt.EventType}'.", ex);
}
};
}
private void OnDeadlineFired(object? sender, DeadlineFiredEventArgs e)
{
if (Volatile.Read(ref _stopRequested) == 1 || _ingress == null) return;
@@ -5,14 +5,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Lifecycle-Abstraktion für einen einzelnen Collector im V2-Runtime.
/// Plan §2.1 / §4.x M4.4.5.c.
/// Plan §2.1 / §4.x M4.4.5.c / §5.10 (single-rail enforcement).
/// <para>
/// Ein Host wrappt einen konkreten Collector (z.B. <c>HelloTracker</c>,
/// <c>ShellCoreTracker</c>, <c>ModernDeploymentTracker</c>) und kapselt Start/Stop.
/// Die <c>Action&lt;EnrollmentEvent&gt;</c>-Callback für <c>onEventCollected</c> wird bei
/// der Host-Erstellung über die <see cref="IComponentFactory"/> injiziert — der
/// Orchestrator übergibt dort <see cref="Telemetry.Events.TelemetryEventEmitter.Emit"/>
/// als Sink.
/// Nach PR #10 posten Collectors ihre Events ausschließlich als
/// <c>InformationalEvent</c>-Signals über den geteilten <see cref="ISignalIngressSink"/>,
/// der über die <see cref="IComponentFactory"/> bei der Host-Erstellung injiziert wird.
/// </para>
/// <para>
/// <b>SignalAdapter-Wiring</b>: separat — <see cref="IComponentFactory"/> kann die
@@ -1,53 +1,37 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c + M4.5.b.
/// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c / §5.10 (single-rail enforcement).
/// <para>
/// Der <see cref="EnrollmentOrchestrator"/> delegiert an diese Factory, weil die konkreten
/// Collector-Ctoren heavy Production-Deps (Event-Log-Watcher, WMI, Registry) haben — Tests
/// liefern eine Fake-Factory, Prod liefert eine Default-Implementation, die HelloTracker,
/// ShellCoreTracker, ProvisioningStatusTracker, StallProbeCollector und
/// ModernDeploymentTracker baut und deren <c>onEventCollected</c>-Callbacks an den
/// übergebenen <paramref name="onEnrollmentEvent"/> bindet.
/// liefern eine Fake-Factory, Prod liefert eine Default-Implementation, die die Tracker
/// + Hosts baut. Nach PR #10 gibt es keinen <c>Action&lt;EnrollmentEvent&gt;</c>-Parameter
/// mehr — alle Collectors posten via <paramref name="ingress"/> (Signal-Rail) statt
/// direkt an den <see cref="Telemetry.Events.TelemetryEventEmitter"/>.
/// </para>
/// <para>
/// <b>ModernDeploymentTracker-Spezifikum</b>: der einzige Kollektor ohne SignalAdapter —
/// bridged nur Events, keine DecisionSignals. Default-Factory-Impls dürfen ihn aufbauen,
/// Tests brauchen ihn nicht zwingend.
/// <paramref name="whiteGloveSealingPatternIds"/> wird an den <c>ImeLogTrackerAdapter</c>
/// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null
/// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e.
/// </para>
/// </summary>
public interface IComponentFactory
{
/// <summary>
/// Baut alle Collector-Hosts und bindet deren Event-Emission an
/// <paramref name="onEnrollmentEvent"/> (typischerweise
/// <see cref="Telemetry.Events.TelemetryEventEmitter.Emit"/>).
/// Exceptions im Callback darf die Factory NICHT weiterreichen — der Orchestrator
/// wrappt das mit einem Exception-Swallow, damit ein Collector-Thread nicht stirbt.
/// <para>
/// <paramref name="whiteGloveSealingPatternIds"/> wird an den <c>ImeLogTrackerAdapter</c>
/// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null
/// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e.
/// </para>
/// <para>
/// <b>M4.5.b-Extension</b>: <paramref name="ingress"/> und <paramref name="clock"/> werden
/// durchgereicht, damit die Default-Factory SignalAdapter direkt mitwiren kann (Adapters
/// brauchen einen <see cref="ISignalIngressSink"/> + <see cref="IClock"/>, die erst im
/// Orchestrator gebaut werden).
/// </para>
/// Baut alle Collector-Hosts. Jeder Host ist selbst dafür zuständig, seine
/// <c>InformationalEventPost</c> aus (ingress, clock) zu konstruieren und Collector-Events
/// als <c>InformationalEvent</c>-Signals über <paramref name="ingress"/> zu posten.
/// </summary>
IReadOnlyList<ICollectorHost> CreateCollectorHosts(
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock);
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
/// <see cref="SignalIngress"/> — hier wird jedes reingereichte Ereignis emittiert.
/// </para>
/// </summary>
public sealed class BackPressureEventObserver : IBackPressureObserver
internal sealed class BackPressureEventObserver : IBackPressureObserver
{
internal const string EventType = "ingress_backpressure";
internal const string SourceId = "signal_ingress";
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
/// throw on a malformed phase value.
/// </para>
/// </summary>
public sealed class EventTimelineEmitter : IEventTimelineEmitter
internal sealed class EventTimelineEmitter : IEventTimelineEmitter
{
/// <summary>Default <see cref="EnrollmentEvent.Source"/> when no <see cref="SourceParamKey"/> override is provided.</summary>
internal const string SourceId = "decision_engine";
@@ -8,13 +8,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
{
/// <summary>
/// Zentrale Senke für alle <see cref="EnrollmentEvent"/>-Emissionen im V2-Agent.
/// Plan §2.7a / L.10.
/// Plan §2.7a / L.10 / §5.10 (single-rail enforcement).
/// <para>
/// Drei Aufrufer: <see cref="EventTimelineEmitter"/> (Reducer-Effekte),
/// Collector-Callbacks (via Orchestrator-Wiring in M4.4.5) und
/// <see cref="BackPressureEventObserver"/>. Alle nutzen denselben Pfad:
/// Sequence-Vergabe → Wire-Format-Serialisierung (Newtonsoft, Legacy-kompatibel) →
/// <see cref="TelemetryItemDraft"/> Kind=Event → <see cref="ITelemetryTransport.Enqueue"/>.
/// Nach PR #10 (single-rail enforcement) sind nur noch zwei Aufrufer erlaubt:
/// <see cref="EventTimelineEmitter"/> (Rail A, Reducer-Effekt-Pfad) und
/// <see cref="BackPressureEventObserver"/> (Meta-Ausnahme wegen zirkulärer
/// Ingress-Abhängigkeit). Beide wohnen im gleichen Namespace — der Typ ist
/// <c>internal</c>, sodass ein dritter Caller nur aus demselben Namespace
/// kompilieren kann (plus Test-Assembly via <c>InternalsVisibleTo</c>).
/// </para>
/// <para>
/// <b>Mutation</b>: setzt <see cref="EnrollmentEvent.Sequence"/>, <see cref="EnrollmentEvent.RowKey"/>,
@@ -22,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
/// Caller darf das Event nach <see cref="Emit"/> nicht weiter verwenden.
/// </para>
/// </summary>
public sealed class TelemetryEventEmitter
internal sealed class TelemetryEventEmitter
{
private readonly ITelemetryTransport _transport;
private readonly EventSequenceCounter _sequenceCounter;
@@ -49,7 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
/// Weist Sequence zu, serialisiert im Legacy-Wire-Format und enqueued via Transport.
/// Thread-safe (EventSequenceCounter + ITelemetryTransport.Enqueue beide unter Lock).
/// </summary>
public TelemetryItem Emit(EnrollmentEvent evt)
internal TelemetryItem Emit(EnrollmentEvent evt)
{
if (evt == null) throw new ArgumentNullException(nameof(evt));
if (string.IsNullOrEmpty(evt.EventType))