Skip to content
Merged
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 42 additions & 32 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@

<ItemGroup>
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Verbara.Sdk.Data.Npgsql" Version="2.4.0" />
<PackageVersion Include="Verbara.Sdk.Cluster.Primitives" Version="2.4.0" />
<PackageVersion Include="Verbara.Sdk.Cluster.Postgres" Version="2.4.0" />
<PackageVersion Include="Verbara.Sdk.Data.Npgsql" Version="2.7.0" />
<PackageVersion Include="Verbara.Sdk.Cluster.Primitives" Version="2.7.0" />
<PackageVersion Include="Verbara.Sdk.Cluster.Postgres" Version="2.7.0" />
</ItemGroup>

<!-- DataProtection consumed by Verbara.Platform.Identity (ADR-0003 + ADR-0022
Expand All @@ -40,35 +40,45 @@
</ItemGroup>

<ItemGroup>
<PackageVersion Include="Verbara.Sdk.Hosting" Version="2.4.0" />
<PackageVersion Include="Verbara.Sdk.Push" Version="2.4.0" />
<PackageVersion Include="Verbara.Sdk.Resilience" Version="2.4.0" />
<PackageVersion Include="Verbara.Sdk.OpenTelemetry" Version="2.4.0" />
</ItemGroup>

<ItemGroup>
<PackageVersion Include="Verbara.Sdk.Pro.EventStore" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Analytics" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.CallAnalytics" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Dialer" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Dialer.Storage.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.EventStore.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.CallAnalytics.Storage.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Analytics.Storage.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Licensing" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.CsatRunner" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.AgentAssist" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.AgentAssist.Storage.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Routing" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Realtime" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Realtime.Storage.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Cluster" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Cluster.Storage.Postgres" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.MultiTenant" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Push" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Push.SignalR" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Storage.Common" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.OpenTelemetry" Version="2.14.1-pro" />
<PackageVersion Include="Verbara.Sdk.Hosting" Version="2.7.0" />
<PackageVersion Include="Verbara.Sdk.Push" Version="2.7.0" />
<PackageVersion Include="Verbara.Sdk.Resilience" Version="2.7.0" />
<PackageVersion Include="Verbara.Sdk.OpenTelemetry" Version="2.7.0" />
</ItemGroup>

<!-- Transitive pin (CentralPackageTransitivePinningEnabled above): Verbara.Sdk.OpenTelemetry
2.7.0 raises OpenTelemetry to 1.19.1 but still pulls OpenTelemetry.Exporter.Prometheus.AspNetCore
1.15.2-beta.1, whose PrometheusSerializer calls OpenTelemetry.Internal.MathHelper (removed in
1.19.1) - GET /metrics answers 200 with an empty body (ILC: "will always throw"). Guarded by
PrometheusScrapingEndpointTests. Remove when the Sdk ships the aligned exporter (cascade C16 /
Verbara.Sdk 2.8.0). -->
<ItemGroup>
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.19.1-beta.1" />
</ItemGroup>

<ItemGroup>
<PackageVersion Include="Verbara.Sdk.Pro.EventStore" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Analytics" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.CallAnalytics" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Dialer" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Dialer.Storage.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.EventStore.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.CallAnalytics.Storage.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Analytics.Storage.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Licensing" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.CsatRunner" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.AgentAssist" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.AgentAssist.Storage.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Routing" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Realtime" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Realtime.Storage.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Cluster" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Cluster.Storage.Postgres" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.MultiTenant" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Push" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Push.SignalR" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.Storage.Common" Version="2.17.0-pro" />
<PackageVersion Include="Verbara.Sdk.Pro.OpenTelemetry" Version="2.17.0-pro" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Verbara.Sdk;
using Verbara.Sdk.Ami.Connection;
using Verbara.Sdk.Enums;
using Verbara.Sdk.Live.Server;

Expand Down Expand Up @@ -73,6 +74,21 @@ public IAsyncEnumerable<ManagerEvent> 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<ManagerEvent> SendEventGeneratingActionAsync(
ManagerAction action, EventActionOutcome? outcome, CancellationToken cancellationToken = default)
=> Primary.SendEventGeneratingActionAsync(action, outcome, cancellationToken);

public event Action<AmiConnectionStateChange>? StateChanged
{
add => Primary.StateChanged += value;
remove => Primary.StateChanged -= value;
}

public IDisposable Subscribe(IObserver<ManagerEvent> observer)
=> Primary.Subscribe(observer);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IAmiConnection>();
var pool = EmptyPool();
pool.AddExistingServer("primary", new VerbaraServer(primary, NullLogger<VerbaraServer>.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<ManagerAction>();
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<ManagerEvent>();
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<ManagerAction>(), Arg.Any<CancellationToken>());
Assert.True(enumerated);
Assert.Same(expected, Assert.Single(received));

async IAsyncEnumerable<ManagerEvent> 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<AmiConnectionStateChange>();

wrapper.StateChanged += delivered.Add;
primary.StateChanged += Raise.Event<Action<AmiConnectionStateChange>>(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<AmiConnectionStateChange>();
Action<AmiConnectionStateChange> handler = delivered.Add;

wrapper.StateChanged += handler;
primary.StateChanged += Raise.Event<Action<AmiConnectionStateChange>>(first);
wrapper.StateChanged -= handler;
primary.StateChanged += Raise.Event<Action<AmiConnectionStateChange>>(second);

Assert.Same(first, Assert.Single(delivered));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Net;

namespace Verbara.Platform.Api.Tests.Observability;

/// <summary>
/// Guards the observable behaviour of <c>app.MapPrometheusScrapingEndpoint()</c>: a scrape of
/// <c>/metrics</c> must carry metric samples, not just a 200. Verbara.Sdk.OpenTelemetry 2.7.0 raised
/// <c>OpenTelemetry</c> to 1.19.1 while still pulling <c>OpenTelemetry.Exporter.Prometheus.AspNetCore</c>
/// 1.15.2-beta.1, whose serializer calls <c>OpenTelemetry.Internal.MathHelper</c> (gone in 1.19.1):
/// the exporter swallowed the failure and every scrape answered 200 with an empty body.
/// </summary>
public sealed class PrometheusScrapingEndpointTests : IClassFixture<UnifiedPlatformApiFactory>
{
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");
}
}
Loading