diff --git a/CHANGELOG.md b/CHANGELOG.md index 708e52a5..e9c78701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,39 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Dependencies + +- **Verbara.Sdk `2.4.0` → `2.7.0`** and **Verbara.Sdk.Pro `2.14.1-pro` → `2.17.0-pro`** — all 29 + consumed packages cascaded across `Directory.Packages.props` in one commit (7 `Verbara.Sdk.*` pins + + 22 `Verbara.Sdk.Pro.*` pins). `2.5.x` and `2.6.x` are skipped on purpose: `2.7.0` is one breaking + minor, so Pro migrated once and `2.17.0-pro` is the Pro build compiled against it. The resulting + compatibility triple — **Verbara.Sdk `2.7.0` × Verbara.Sdk.Pro `2.17.0-pro` × Verbara.Platform + (the next version cut from `main`)** — is what this entry records; per `Verbara.Sdk/ADR-0040` D3 + the cascade rides alone and cuts no version of its own. **No floor moved:** `dotnet restore` + reported no `NU1605`/`NU1109`, so every existing third-party central pin stays where it was (one is added, below). Transitively, + `OpenTelemetry` (not pinned here) resolves `1.19.1` instead of `1.17.0`; no package entered or left + the graph. +- **`OpenTelemetry.Exporter.Prometheus.AspNetCore` pinned at `1.19.1-beta.1`** — Sdk `2.7.0` raises + `OpenTelemetry` to `1.19.1` but still pulls exporter `1.15.2-beta.1`, which calls a type `1.19.1` + removed, so `GET /metrics` answered `200` with an empty body; the pin restores the samples and a + `/metrics` test now guards them. Removed when the Sdk ships the aligned exporter (Sdk `2.8.0`). +- **Behaviour Platform takes on — queued calls get an owner (H159).** Since Sdk `2.7.0` + (Verbara.Sdk#377) the SDK connects a queued call only when the queue reports that a member took it, + and emits one `CallConnectedEvent` per such call. A queue call answered by a Platform agent therefore + now drives the conversation to `Active` with that agent as its owner, delivers the screen-pop and + sets the agent `Busy`. **Operator note:** the AMI user must carry the `agent` read class, or the + queue's member-connect events never arrive and these calls stay unowned. A leadership failover can + still re-deliver the event; the existing `becameActive` gate in `VoiceConversationBridge` keeps it + idempotent, and only its comment changes. +- **`DeferredPrimaryAmiConnection` forwards the three `IAmiConnection` members Sdk `2.7.0` added** — + `ReportsEventActionOutcome`, the `SendEventGeneratingActionAsync(action, EventActionOutcome?, ct)` + overload and the `StateChanged` event — to the primary connection. The interface gives them default + bodies, so the wrapper compiled clean while answering `false`, dropping the outcome and raising + nothing; four tests now lock the forwarding. Platform does not call any of them yet. +- **Deliberately out of scope** (`Verbara.Sdk/ADR-0040` D4): `Microsoft.Extensions.TimeProvider.Testing` + stays at `10.10.0`, and neither `OpenTelemetry` itself nor `NATS.Client.Core` gains a direct pin. + The second hop, Sdk `2.8.0` (H142), is a separate change. decision_ref `Verbara.Sdk/ADR-0040`. + --- ## [2.25.0] - 2026-10-03 diff --git a/Directory.Packages.props b/Directory.Packages.props index 6edb74bf..0ef6bc72 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,9 +21,9 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs b/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs index f3c84c42..e9836d1b 100644 --- a/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs +++ b/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs @@ -1,4 +1,5 @@ using Verbara.Sdk; +using Verbara.Sdk.Ami.Connection; using Verbara.Sdk.Enums; using Verbara.Sdk.Live.Server; @@ -73,6 +74,21 @@ public IAsyncEnumerable SendEventGeneratingActionAsync( ManagerAction action, CancellationToken cancellationToken = default) => Primary.SendEventGeneratingActionAsync(action, cancellationToken); + // Sdk 2.7.0 added the next three members with default bodies (false / an overload that drops the outcome / + // an event that raises nothing). The interface asks a wrapper to forward the inner connection's answer, so + // the outcome property and its overload are forwarded together, and StateChanged with them. + public bool ReportsEventActionOutcome => Primary.ReportsEventActionOutcome; + + public IAsyncEnumerable SendEventGeneratingActionAsync( + ManagerAction action, EventActionOutcome? outcome, CancellationToken cancellationToken = default) + => Primary.SendEventGeneratingActionAsync(action, outcome, cancellationToken); + + public event Action? StateChanged + { + add => Primary.StateChanged += value; + remove => Primary.StateChanged -= value; + } + public IDisposable Subscribe(IObserver observer) => Primary.Subscribe(observer); diff --git a/src/Verbara.Platform.Api/Services/VoiceConversationBridge.cs b/src/Verbara.Platform.Api/Services/VoiceConversationBridge.cs index 918c0254..7c3bb271 100644 --- a/src/Verbara.Platform.Api/Services/VoiceConversationBridge.cs +++ b/src/Verbara.Platform.Api/Services/VoiceConversationBridge.cs @@ -266,10 +266,10 @@ private async Task OnCallConnectedAsync(CallConnectedEvent evt) return; } - // The SDK emits ≥2 CallConnectedEvents for one answered queue call (the agent-connect path - // is unconditional), and a leadership failover can re-emit too. The Queued→Offered→Active - // advance is the once-per-call signal: only the FIRST delivery actually moves the state, so - // gating the owner-assign + persist + capacity-reserve + Busy on it makes them idempotent. + // Since Sdk 2.7.0 (Verbara.Sdk#377) the SDK emits one CallConnectedEvent per queued call a + // member takes, but a leadership failover can re-deliver it. The Queued→Offered→Active advance + // is the once-per-call signal: only the FIRST delivery actually moves the state, so gating the + // owner-assign + persist + capacity-reserve + Busy on it keeps them idempotent. var oldState = conversation.State; if (ConversationStateMachine.CanTransition(conversation.State, ConversationState.Offered)) conversation.TransitionTo(ConversationState.Offered); diff --git a/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs b/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs index 5e69625d..9491f79b 100644 --- a/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs +++ b/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs @@ -3,6 +3,7 @@ using Verbara.Platform.Api.Services; using Verbara.Sdk; using Verbara.Sdk.Ami.Connection; +using Verbara.Sdk.Enums; using Verbara.Sdk.Live.Server; namespace Verbara.Platform.Api.Tests; @@ -55,4 +56,89 @@ public async Task DisposeAsync_ShouldNotThrow_WhenNoPrimaryServerConfigured() await connection.DisposeAsync(); } + // sdk-2-7-0-pin-cascade: Sdk 2.7.0 added three IAmiConnection members with DEFAULT bodies + // (ReportsEventActionOutcome => false, an outcome overload that drops the outcome, and a StateChanged + // event that raises nothing). A wrapper that does not override them compiles clean and silently answers + // with the defaults instead of the primary's. These tests seat a substituted primary and call the + // wrapper TYPED AS IAmiConnection, so they fail whenever a forwarding is missing. + private static (IAmiConnection Wrapper, IAmiConnection Primary) WrapperOverSubstitutedPrimary() + { + var primary = Substitute.For(); + var pool = EmptyPool(); + pool.AddExistingServer("primary", new VerbaraServer(primary, NullLogger.Instance)); + IAmiConnection wrapper = new DeferredPrimaryAmiConnection(pool); + return (wrapper, primary); + } + + [Fact] + public void ReportsEventActionOutcome_ShouldReturnPrimaryAnswer_WhenPrimaryReportsOutcomes() + { + var (wrapper, primary) = WrapperOverSubstitutedPrimary(); + primary.ReportsEventActionOutcome.Returns(true); + + Assert.True(wrapper.ReportsEventActionOutcome); + } + + [Fact] + public async Task SendEventGeneratingActionAsync_ShouldDelegateOutcomeOverloadToPrimary_WhenOutcomeIsSupplied() + { + var (wrapper, primary) = WrapperOverSubstitutedPrimary(); + var action = Substitute.For(); + var outcome = new EventActionOutcome(); + using var cts = new CancellationTokenSource(); + var expected = new ManagerEvent { EventType = "QueueMember" }; + var enumerated = false; + primary.SendEventGeneratingActionAsync(action, outcome, cts.Token).Returns(_ => Sequence()); + + var received = new List(); + await foreach (var evt in wrapper.SendEventGeneratingActionAsync(action, outcome, cts.Token)) + { + received.Add(evt); + } + + primary.Received(1).SendEventGeneratingActionAsync(action, outcome, cts.Token); + primary.DidNotReceive().SendEventGeneratingActionAsync(Arg.Any(), Arg.Any()); + Assert.True(enumerated); + Assert.Same(expected, Assert.Single(received)); + + async IAsyncEnumerable Sequence() + { + await Task.Yield(); + enumerated = true; + yield return expected; + } + } + + [Fact] + public void StateChanged_ShouldDeliverPrimaryChanges_WhenSubscribedThroughWrapper() + { + var (wrapper, primary) = WrapperOverSubstitutedPrimary(); + var change = new AmiConnectionStateChange( + AmiConnectionState.Connected, AmiConnectionState.Reconnecting, cause: null, byCaller: false); + var delivered = new List(); + + wrapper.StateChanged += delivered.Add; + primary.StateChanged += Raise.Event>(change); + + Assert.Same(change, Assert.Single(delivered)); + } + + [Fact] + public void StateChanged_ShouldStopDelivering_WhenUnsubscribedThroughWrapper() + { + var (wrapper, primary) = WrapperOverSubstitutedPrimary(); + var first = new AmiConnectionStateChange( + AmiConnectionState.Connected, AmiConnectionState.Reconnecting, cause: null, byCaller: false); + var second = new AmiConnectionStateChange( + AmiConnectionState.Reconnecting, AmiConnectionState.Connected, cause: null, byCaller: false); + var delivered = new List(); + Action handler = delivered.Add; + + wrapper.StateChanged += handler; + primary.StateChanged += Raise.Event>(first); + wrapper.StateChanged -= handler; + primary.StateChanged += Raise.Event>(second); + + Assert.Same(first, Assert.Single(delivered)); + } } diff --git a/tests/Verbara.Platform.Api.Tests/Observability/PrometheusScrapingEndpointTests.cs b/tests/Verbara.Platform.Api.Tests/Observability/PrometheusScrapingEndpointTests.cs new file mode 100644 index 00000000..a7f5b073 --- /dev/null +++ b/tests/Verbara.Platform.Api.Tests/Observability/PrometheusScrapingEndpointTests.cs @@ -0,0 +1,43 @@ +using System.Net; + +namespace Verbara.Platform.Api.Tests.Observability; + +/// +/// Guards the observable behaviour of app.MapPrometheusScrapingEndpoint(): a scrape of +/// /metrics must carry metric samples, not just a 200. Verbara.Sdk.OpenTelemetry 2.7.0 raised +/// OpenTelemetry to 1.19.1 while still pulling OpenTelemetry.Exporter.Prometheus.AspNetCore +/// 1.15.2-beta.1, whose serializer calls OpenTelemetry.Internal.MathHelper (gone in 1.19.1): +/// the exporter swallowed the failure and every scrape answered 200 with an empty body. +/// +public sealed class PrometheusScrapingEndpointTests : IClassFixture +{ + private readonly UnifiedPlatformApiFactory _factory; + + public PrometheusScrapingEndpointTests(UnifiedPlatformApiFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetMetrics_ShouldReturnMetricSamples_WhenRequestsWereServed() + { + var client = _factory.CreateAuthenticatedClient(); + + // Serve a few requests so the ASP.NET Core hosting meter records + // http.server.request.duration samples for the scrape to export. + for (var i = 0; i < 3; i++) + (await client.GetAsync("/health")).Dispose(); + + using var response = await client.GetAsync("/metrics"); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + body.Should().NotBeEmpty("a Prometheus scrape after served requests must carry samples"); + var sampleLines = body + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(line => !line.StartsWith('#')) + .ToList(); + sampleLines.Should().NotBeEmpty("the body must hold at least one metric sample line"); + body.Should().Contain("http_server_request_duration_seconds"); + } +}