feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
[Collection(DockerIntegrationTestEnvironment.CollectionName)]
|
||||
public sealed class PostgreSqlAgentProvisioningIntegrationTests
|
||||
{
|
||||
private const string Category = "DockerIntegration";
|
||||
|
||||
[PostgreSqlIntegrationFact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Migrations_create_postgres_17_schema_and_required_indexes()
|
||||
{
|
||||
await using var postgres = BuildPostgreSql();
|
||||
await postgres.StartAsync();
|
||||
|
||||
await using var db = CreateDatabase(postgres.GetConnectionString());
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
Assert.Contains(
|
||||
"20260730224500_AddAgentProvisioningAndBoardIndexes",
|
||||
await db.Database.GetAppliedMigrationsAsync());
|
||||
|
||||
await using var connection = new NpgsqlConnection(
|
||||
postgres.GetConnectionString());
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var versionCommand = new NpgsqlCommand(
|
||||
"SELECT current_setting('server_version_num')::integer",
|
||||
connection);
|
||||
var version = Convert.ToInt32(await versionCommand.ExecuteScalarAsync());
|
||||
Assert.InRange(version, 170000, 179999);
|
||||
|
||||
await using var tableCommand = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_class
|
||||
WHERE relnamespace = 'public'::regnamespace
|
||||
AND relkind = 'r'
|
||||
AND relname IN (
|
||||
'AgentProposals',
|
||||
'AgentProvisionRequests',
|
||||
'OperationClaims',
|
||||
'OutboxEvents')
|
||||
""",
|
||||
connection);
|
||||
Assert.Equal(4L, Convert.ToInt64(await tableCommand.ExecuteScalarAsync()));
|
||||
|
||||
await using var indexCommand = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname IN (
|
||||
'IX_AgentProvisionRequests_Status_CreatedAt',
|
||||
'IX_OperationClaims_Operation_IdempotencyKeyHash',
|
||||
'IX_OutboxEvents_PublishedAt_Sequence',
|
||||
'IX_Tasks_State_UpdatedAt_Id_Board',
|
||||
'IX_Tasks_Done_UpdatedAt_Id',
|
||||
'IX_Tasks_ParentTaskId_State',
|
||||
'IX_Activity_TaskId_CreatedAt_Id')
|
||||
""",
|
||||
connection);
|
||||
Assert.Equal(7L, Convert.ToInt64(await indexCommand.ExecuteScalarAsync()));
|
||||
}
|
||||
|
||||
[PostgreSqlIntegrationFact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Concurrent_proposal_creates_share_one_claim_and_one_outbox_event()
|
||||
{
|
||||
await using var postgres = BuildPostgreSql();
|
||||
await postgres.StartAsync();
|
||||
var connectionString = postgres.GetConnectionString();
|
||||
var gateway = new ConcurrentApprovalGatewayConnector(
|
||||
synchronizeAgentLists: false);
|
||||
await MigrateAndSeedManagementAsync(connectionString, gateway);
|
||||
|
||||
const string idempotencyKey = "postgres-concurrent-proposal";
|
||||
var operations = Enumerable.Range(0, 8)
|
||||
.Select(_ => CreateProposalAsync(
|
||||
connectionString,
|
||||
gateway,
|
||||
idempotencyKey))
|
||||
.ToArray();
|
||||
var results = await Task.WhenAll(operations);
|
||||
|
||||
Assert.All(results, result => Assert.True(result.Ok));
|
||||
Assert.Single(results.Select(result => result.Proposal!.Id).Distinct());
|
||||
Assert.Contains(
|
||||
results,
|
||||
result => result.State == AgentProposalStates.AwaitingApproval);
|
||||
Assert.Contains(results, result => result.State == "idempotent_replay");
|
||||
|
||||
await using var verification = CreateDatabase(connectionString);
|
||||
var proposal = await verification.AgentProposals.SingleAsync();
|
||||
var claim = await verification.OperationClaims.SingleAsync();
|
||||
var outbox = await verification.OutboxEvents.SingleAsync();
|
||||
|
||||
Assert.Equal(proposal.Id, claim.ResourceId);
|
||||
Assert.Equal("agent-proposal.create", claim.Operation);
|
||||
Assert.Equal("completed", claim.State);
|
||||
Assert.Equal(proposal.Id.ToString("N"), outbox.AggregateId);
|
||||
Assert.Equal("agent.proposal.created", outbox.Type);
|
||||
Assert.DoesNotContain(
|
||||
idempotencyKey,
|
||||
claim.IdempotencyKeyHash,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var jsonTypeCommand = new NpgsqlCommand(
|
||||
"""SELECT pg_typeof("PayloadJson")::text FROM "OutboxEvents" LIMIT 1""",
|
||||
connection);
|
||||
Assert.Equal("jsonb", await jsonTypeCommand.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[PostgreSqlIntegrationFact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Concurrent_owner_approvals_queue_exactly_one_provision_request()
|
||||
{
|
||||
await using var postgres = BuildPostgreSql();
|
||||
await postgres.StartAsync();
|
||||
var connectionString = postgres.GetConnectionString();
|
||||
var gateway = new ConcurrentApprovalGatewayConnector(
|
||||
synchronizeAgentLists: true);
|
||||
await MigrateAndSeedManagementAsync(connectionString, gateway);
|
||||
|
||||
AgentProposalOperationDto created;
|
||||
await using (var creationDb = CreateDatabase(connectionString))
|
||||
{
|
||||
created = await CreateService(creationDb, gateway).CreateAsync(
|
||||
Proposal(),
|
||||
"manual",
|
||||
Invocation("postgres-approval-proposal"));
|
||||
}
|
||||
|
||||
var revision = created.Proposal!.Revision;
|
||||
var approvals = await Task.WhenAll(
|
||||
ApproveAsync(
|
||||
connectionString,
|
||||
gateway,
|
||||
created.Proposal.Id,
|
||||
revision,
|
||||
"postgres-approval-a"),
|
||||
ApproveAsync(
|
||||
connectionString,
|
||||
gateway,
|
||||
created.Proposal.Id,
|
||||
revision,
|
||||
"postgres-approval-b"));
|
||||
|
||||
var succeeded = Assert.Single(approvals, result => result.Ok);
|
||||
var conflicted = Assert.Single(approvals, result => !result.Ok);
|
||||
Assert.Equal(AgentProposalStates.Provisioning, succeeded.State);
|
||||
Assert.Equal("concurrency_conflict", conflicted.State);
|
||||
|
||||
await using var verification = CreateDatabase(connectionString);
|
||||
var proposal = await verification.AgentProposals.SingleAsync();
|
||||
var request = await verification.AgentProvisionRequests.SingleAsync();
|
||||
var claims = await verification.OperationClaims
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArrayAsync();
|
||||
var events = await verification.OutboxEvents
|
||||
.OrderBy(item => item.Sequence)
|
||||
.ToArrayAsync();
|
||||
|
||||
Assert.Equal(AgentProposalStates.Provisioning, proposal.Status);
|
||||
Assert.Equal(AgentProvisionRequestStates.Queued, request.Status);
|
||||
Assert.Equal(1, request.Attempt);
|
||||
Assert.Equal(3, claims.Length);
|
||||
Assert.Single(
|
||||
claims,
|
||||
item => item.Operation == "agent-proposal.approve"
|
||||
&& item.State == "completed");
|
||||
Assert.Single(
|
||||
claims,
|
||||
item => item.Operation == "agent-proposal.approve"
|
||||
&& item.ResultCode == "concurrency_conflict");
|
||||
Assert.Single(
|
||||
events,
|
||||
item => item.Type == "agent.provision.requested");
|
||||
}
|
||||
|
||||
private static PostgreSqlContainer BuildPostgreSql()
|
||||
=> new PostgreSqlBuilder("postgres:17-alpine")
|
||||
.WithDatabase("nexus_integration")
|
||||
.WithUsername("nexus_test")
|
||||
.WithPassword("nexus_test_password")
|
||||
.Build();
|
||||
|
||||
private static NexusDbContext CreateDatabase(string connectionString)
|
||||
=> new(new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.EnableDetailedErrors()
|
||||
.Options);
|
||||
|
||||
private static async Task MigrateAndSeedManagementAsync(
|
||||
string connectionString,
|
||||
IGatewayConnector gateway)
|
||||
{
|
||||
await using var db = CreateDatabase(connectionString);
|
||||
await db.Database.MigrateAsync();
|
||||
db.OpenClawConnectionProfiles.Add(new OpenClawConnectionProfile
|
||||
{
|
||||
Endpoint = "ws://127.0.0.1:18789/",
|
||||
DiscoverySource = "testcontainers",
|
||||
RequiredVersion = gateway.RequiredVersion,
|
||||
AdoptionState = OpenClawAdoptionStates.Adopted,
|
||||
ManagementEnabled = true,
|
||||
CapabilityHash = AgentProposalService.BuildCapabilityHash(gateway),
|
||||
Revision = 1
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<AgentProposalOperationDto> CreateProposalAsync(
|
||||
string connectionString,
|
||||
IGatewayConnector gateway,
|
||||
string idempotencyKey)
|
||||
{
|
||||
await using var db = CreateDatabase(connectionString);
|
||||
return await CreateService(db, gateway).CreateAsync(
|
||||
Proposal(),
|
||||
"manual",
|
||||
Invocation(idempotencyKey));
|
||||
}
|
||||
|
||||
private static async Task<AgentProposalOperationDto> ApproveAsync(
|
||||
string connectionString,
|
||||
IGatewayConnector gateway,
|
||||
Guid proposalId,
|
||||
int expectedRevision,
|
||||
string idempotencyKey)
|
||||
{
|
||||
await using var db = CreateDatabase(connectionString);
|
||||
return await CreateService(db, gateway).ApproveAsync(
|
||||
proposalId,
|
||||
new AgentProposalActionRequest(expectedRevision),
|
||||
Invocation(idempotencyKey));
|
||||
}
|
||||
|
||||
internal static AgentProposalService CreateService(
|
||||
NexusDbContext db,
|
||||
IGatewayConnector gateway,
|
||||
IOpenClawAgentConfigurationService? agentFiles = null)
|
||||
{
|
||||
var management = new OpenClawManagementState();
|
||||
management.SetEnabled(true);
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["OpenClawSetup:ExternalClientIdentitySupported"] = "true"
|
||||
})
|
||||
.Build();
|
||||
return new AgentProposalService(
|
||||
db,
|
||||
gateway,
|
||||
agentFiles ?? new ProposalAgentConfigurationService(),
|
||||
new StubOpenClawWriteGate(),
|
||||
Options.Create(new AgentProvisioningOptions()),
|
||||
new AgentProvisioningSignal(),
|
||||
NullLogger<AgentProposalService>.Instance);
|
||||
}
|
||||
|
||||
internal static CreateAgentProposalRequest Proposal()
|
||||
=> new(
|
||||
"Release Analyst",
|
||||
Role: "Release quality",
|
||||
Description: "Verify releases and report evidence.",
|
||||
Model: "openai/gpt-5.5",
|
||||
ClientRequestId: "postgres-proposal");
|
||||
|
||||
internal static OpenClawInvocationMetadata Invocation(string key)
|
||||
=> new(key, $"correlation-{key}", "bao", null);
|
||||
}
|
||||
|
||||
internal sealed class ConcurrentApprovalGatewayConnector(
|
||||
bool synchronizeAgentLists) : IGatewayConnector
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> bothInventoryReads =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int inventoryReads;
|
||||
|
||||
public GatewayConnectionState ConnectionState => GatewayConnectionState.Connected;
|
||||
public string? GatewayVersion => "2026.7.1";
|
||||
public string? RequiredVersion => "2026.7.1";
|
||||
public DateTimeOffset? LastConnectedAt => DateTimeOffset.UtcNow;
|
||||
public int ReconnectAttempts => 0;
|
||||
public string? StatusMessage => "ready";
|
||||
public string? DeviceId => "testcontainers-device";
|
||||
public bool DeviceTokenConfigured => true;
|
||||
public bool PairingRequired => false;
|
||||
public string? PairingRequestId => null;
|
||||
public int? ProtocolVersion => 4;
|
||||
public IReadOnlySet<string> AdvertisedMethods { get; } =
|
||||
new HashSet<string>(
|
||||
[
|
||||
"agents.list",
|
||||
"agents.create",
|
||||
"agents.files.get",
|
||||
"agents.files.set",
|
||||
"config.get"
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
public IReadOnlySet<string> AdvertisedEvents { get; } =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
public IReadOnlySet<string> GrantedScopes { get; } =
|
||||
new HashSet<string>(
|
||||
["operator.read", "operator.admin"],
|
||||
StringComparer.Ordinal);
|
||||
public DateTimeOffset? LastEventAt => null;
|
||||
|
||||
public bool Supports(string method) => AdvertisedMethods.Contains(method);
|
||||
|
||||
public async Task<JsonNode?> InvokeAsync(
|
||||
string method,
|
||||
object? parameters = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default,
|
||||
OpenClawInvocationContext? invocationContext = null)
|
||||
{
|
||||
if (method != "agents.list")
|
||||
{
|
||||
throw new OpenClawGatewayRpcException(
|
||||
"METHOD_NOT_FOUND",
|
||||
$"Unexpected integration-test method {method}.");
|
||||
}
|
||||
|
||||
if (synchronizeAgentLists)
|
||||
{
|
||||
if (Interlocked.Increment(ref inventoryReads) == 2)
|
||||
bothInventoryReads.TrySetResult(true);
|
||||
await bothInventoryReads.Task.WaitAsync(
|
||||
TimeSpan.FromSeconds(15),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return new JsonObject { ["agents"] = new JsonArray() };
|
||||
}
|
||||
|
||||
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
|
||||
=> [];
|
||||
}
|
||||
Reference in New Issue
Block a user