From 293833f0a6ad317cc8c80bfa1563e6a4ebd800f6 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 06:59:42 +0200 Subject: [PATCH 01/20] chore: complete xunit v4 platform migration --- Directory.Packages.props | 6 ++--- global.json | 3 +++ os/DigitalBrain.OS.AppHost/AppHost.cs | 6 +---- .../AppHostCleanupContracts.cs | 1 + .../AIProviderConfiguration.cs | 25 +++++++++++-------- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4ff58e..5c1e3818 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,8 +4,8 @@ - - + + @@ -37,4 +37,4 @@ - \ No newline at end of file + diff --git a/global.json b/global.json index ceef7c50..5c62de86 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "11.0.100-preview.6.26359.118", "rollForward": "latestFeature", "allowPrerelease": true + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs index d1c8a2bb..d291a24c 100644 --- a/os/DigitalBrain.OS.AppHost/AppHost.cs +++ b/os/DigitalBrain.OS.AppHost/AppHost.cs @@ -16,11 +16,7 @@ var brain = builder.AddDigitalBrain(ProductSurfaceResources.Brain); brain.AddModule(ai => ai - .WithLlm() - //.WithLlm() - //.WithLlm() - //.WithLlm() - ); + .WithLlm()); brain.AddModule(); brain.AddModule(); brain.AddModule(flutter => flutter diff --git a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs index a3ced2e5..2483cc80 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Xunit; using Xunit.Sdk; +using Xunit.v3; [assembly: Parallelization(Mode = ParallelMode.None)] diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs index 49683a96..3a28d9d7 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs @@ -25,11 +25,13 @@ public sealed class AIProviderConfiguration "file:///tmp/ollama", ]; - public static TheoryData ValidOllamaEndpoints => - [ - "http://ollama.example.test:11434", - "https://ollama.example.test:11435", - ]; + public static TheoryData SupportedOllamaModels => new() + { + { typeof(Llama32), "llama3.2", "http://ollama.example.test:11434" }, + { typeof(Gemma4), "gemma4:12b", "https://ollama.example.test:11435" }, + { typeof(Qwen35), "qwen3.5:9b", "http://ollama.example.test:11436" }, + { typeof(Granite41), "granite4.1:8b", "https://ollama.example.test:11437" }, + }; [Theory(DisplayName = "missing, blank, and malformed Ollama endpoints fail before client contact")] [MemberData(nameof(InvalidOllamaEndpoints))] @@ -43,18 +45,21 @@ public void InvalidOllamaEndpointsFailBeforeClientContact(string? endpoint) Assert.Contains(OllamaEndpointKey, exception.Message, StringComparison.Ordinal); } - [Theory(DisplayName = "an explicit HTTP(S) Ollama endpoint resolves only the selected Llama32 client")] - [MemberData(nameof(ValidOllamaEndpoints))] - public void ExplicitOllamaEndpointResolvesSelectedClient(string endpoint) + [Theory(DisplayName = "every supported Ollama model resolves with its expected default tag")] + [MemberData(nameof(SupportedOllamaModels))] + public void SupportedOllamaModelResolvesWithExpectedDefaultTag( + Type modelType, + string expectedTag, + string endpoint) { using var provider = Compose(endpoint); - var client = provider.GetRequiredKeyedService(typeof(Llama32)); + var client = provider.GetRequiredKeyedService(modelType); var ollama = Assert.IsAssignableFrom(client); Assert.Equal(new Uri(endpoint), ollama.Uri); Assert.False(string.Equals("localhost", ollama.Uri.Host, StringComparison.OrdinalIgnoreCase)); - Assert.Equal("llama3.2", ollama.SelectedModel); + Assert.Equal(expectedTag, ollama.SelectedModel); } private static ServiceProvider Compose(string? endpoint) From 40ed15a6bdeca372f0e9867458366448750eb9a5 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 07:08:23 +0200 Subject: [PATCH 02/20] feat: add observable service defaults --- DigitalBrain.slnx | 2 + Directory.Packages.props | 8 ++ .../DigitalBrain.OS.Host.csproj | 1 + os/DigitalBrain.OS.Host/Program.cs | 7 +- .../DigitalBrain.OS.Mcp.csproj | 1 + os/DigitalBrain.OS.Mcp/McpHost.cs | 3 - os/DigitalBrain.OS.Mcp/Program.cs | 3 + .../DigitalBrain.ServiceDefaults.Tests.csproj | 17 +++ .../ServiceDefaultsContracts.cs | 51 ++++++++ .../DigitalBrain.ServiceDefaults.csproj | 14 +++ .../ServiceDefaultsExtensions.cs | 118 ++++++++++++++++++ .../AIProviderConfiguration.cs | 4 +- .../Clients/AIClients.cs | 18 ++- .../UIEdgeVocabulary.cs | 4 - .../UIFixture.cs | 3 + .../DigitalBrain.Modules.Flutter.Http.csproj | 1 + .../Program.cs | 3 + .../UIEdgeContract.cs | 2 - .../UIHost.cs | 1 - 19 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj create mode 100644 src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx index b1eb4772..c186efa5 100644 --- a/DigitalBrain.slnx +++ b/DigitalBrain.slnx @@ -12,6 +12,8 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c1e3818..f8624bc6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,8 +24,16 @@ + + + + + + + + diff --git a/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj b/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj index d4d4b75b..2bc0b499 100644 --- a/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj +++ b/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj @@ -11,6 +11,7 @@ + diff --git a/os/DigitalBrain.OS.Host/Program.cs b/os/DigitalBrain.OS.Host/Program.cs index 0538e94f..9ed5ed8f 100644 --- a/os/DigitalBrain.OS.Host/Program.cs +++ b/os/DigitalBrain.OS.Host/Program.cs @@ -1,10 +1,9 @@ using DigitalBrain.Kernel; - -const string HealthPath = "/health"; -const string HealthResponse = "healthy"; +using DigitalBrain.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); builder.AddKeyedAzureTableServiceClient("brain-reminders"); builder.UseOrleans(silo => silo @@ -12,5 +11,5 @@ .AddDigitalBrainJournalStorage(builder.Configuration)); var app = builder.Build(); -app.MapGet(HealthPath, static () => Results.Ok(HealthResponse)); +app.MapDefaultEndpoints(); app.Run(); diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj index 6c10b917..08c22eca 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj +++ b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj @@ -12,6 +12,7 @@ + diff --git a/os/DigitalBrain.OS.Mcp/McpHost.cs b/os/DigitalBrain.OS.Mcp/McpHost.cs index fecae8c3..28e8c3c0 100644 --- a/os/DigitalBrain.OS.Mcp/McpHost.cs +++ b/os/DigitalBrain.OS.Mcp/McpHost.cs @@ -3,8 +3,6 @@ namespace DigitalBrain.OS.Mcp; internal static class McpHost { public const string EndpointPath = "/mcp"; - public const string HealthPath = "/health"; - public const string HealthResponse = "healthy"; public const string AskLlama32ToolName = "ask_llama32"; public const string DefaultLlama32Key = "default"; public const string ListActiveNeuronsToolName = "list_active_neurons"; @@ -15,7 +13,6 @@ public static WebApplication MapMcpHost(this WebApplication app) { ArgumentNullException.ThrowIfNull(app); - app.MapGet(HealthPath, static () => Results.Ok(HealthResponse)); app.MapMcp(EndpointPath); return app; } diff --git a/os/DigitalBrain.OS.Mcp/Program.cs b/os/DigitalBrain.OS.Mcp/Program.cs index 06111ac1..1c5f542a 100644 --- a/os/DigitalBrain.OS.Mcp/Program.cs +++ b/os/DigitalBrain.OS.Mcp/Program.cs @@ -1,10 +1,12 @@ using DigitalBrain.Aspire; using DigitalBrain.OS.Mcp; +using DigitalBrain.ServiceDefaults; using Microsoft.Extensions.AI; using Orleans.Serialization; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); builder.AddDigitalBrainClient(client => { @@ -19,5 +21,6 @@ .WithTools(); var app = builder.Build(); +app.MapDefaultEndpoints(); app.MapMcpHost(); app.Run(); diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj new file mode 100644 index 00000000..53b92bd0 --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj @@ -0,0 +1,17 @@ + + + Exe + $(NoWarn);CA2007;CA1515 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs new file mode 100644 index 00000000..02927790 --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs @@ -0,0 +1,51 @@ +using DigitalBrain.ServiceDefaults; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Xunit; + +namespace DigitalBrain.ServiceDefaultsTests; + +public sealed class ServiceDefaultsContracts +{ + [Fact(DisplayName = "service defaults expose healthy readiness and liveness endpoints")] + public async Task ServiceDefaultsExposeHealthyReadinessAndLivenessEndpoints() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] = ""; + builder.AddServiceDefaults(); + + await using var app = builder.Build(); + app.MapDefaultEndpoints(); + + var routes = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(static source => source.Endpoints) + .OfType() + .Select(static endpoint => endpoint.RoutePattern.RawText) + .Order(StringComparer.Ordinal) + .ToArray(); + var health = await app.Services + .GetRequiredService() + .CheckHealthAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HealthStatus.Healthy, health.Status); + Assert.Equal(["/alive", "/health"], routes); + } + + [Fact(DisplayName = "service defaults register tracing, metrics, and resilient HTTP clients")] + public async Task ServiceDefaultsRegisterTelemetryAndResilientHttpClients() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] = ""; + builder.AddServiceDefaults(); + + await using var app = builder.Build(); + + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + } +} diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj b/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj new file mode 100644 index 00000000..044872af --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs new file mode 100644 index 00000000..f66e569a --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace DigitalBrain.ServiceDefaults; + +public static class ServiceDefaultsExtensions +{ + private const string AlivePath = "/alive"; + private const string HealthPath = "/health"; + private const string TelemetryPrefix = "DigitalBrain"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + ConfigureOpenTelemetry(builder); + AddDefaultHealthChecks(builder); + + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + app.MapHealthChecks(HealthPath); + app.MapHealthChecks(AlivePath, new HealthCheckOptions + { + Predicate = static registration => + registration.Tags.Contains("live"), + }); + + return app; + } + + private static void AddDefaultHealthChecks(TBuilder builder) + where TBuilder : IHostApplicationBuilder + => builder.Services + .AddHealthChecks() + .AddCheck( + "self", + static () => HealthCheckResult.Healthy(), + ["live"]); + + private static void AddOpenTelemetryExporters(TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + if (!string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + } + + private static void ConfigureOpenTelemetry(TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var configuredSampleRatio = builder.Configuration.GetValue( + "Telemetry:Tracing:SampleRatio"); + var sampleRatio = Math.Clamp(configuredSampleRatio ?? 1d, 0d, 1d); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService( + serviceName: builder.Environment.ApplicationName, + serviceNamespace: TelemetryPrefix)) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(TelemetryPrefix) + .AddMeter($"{TelemetryPrefix}.*") + .AddMeter("Experimental.Microsoft.Extensions.AI") + .AddMeter("Microsoft.Orleans")) + .WithTracing(tracing => tracing + .SetSampler(new ParentBasedSampler( + new TraceIdRatioBasedSampler(sampleRatio))) + .AddSource(builder.Environment.ApplicationName) + .AddSource(TelemetryPrefix) + .AddSource($"{TelemetryPrefix}.*") + .AddSource("Experimental.Microsoft.Extensions.AI") + .AddSource("Microsoft.Orleans.Application") + .AddAspNetCoreInstrumentation(options => + options.Filter = context => + !context.Request.Path.StartsWithSegments( + HealthPath, + StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments( + AlivePath, + StringComparison.OrdinalIgnoreCase)) + .AddHttpClientInstrumentation()); + + AddOpenTelemetryExporters(builder); + } +} diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs index 3a28d9d7..a74582fc 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs @@ -55,8 +55,10 @@ public void SupportedOllamaModelResolvesWithExpectedDefaultTag( using var provider = Compose(endpoint); var client = provider.GetRequiredKeyedService(modelType); - var ollama = Assert.IsAssignableFrom(client); + var ollama = Assert.IsAssignableFrom( + client.GetService(typeof(IOllamaApiClient))); + Assert.NotSame(ollama, client); Assert.Equal(new Uri(endpoint), ollama.Uri); Assert.False(string.Equals("localhost", ollama.Uri.Host, StringComparison.OrdinalIgnoreCase)); Assert.Equal(expectedTag, ollama.SelectedModel); diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs index 7c0464e2..164caa1c 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs @@ -1,4 +1,5 @@ using System.ClientModel; +using System.Diagnostics.CodeAnalysis; using DigitalBrain.AI.Ollama; using DigitalBrain.AI.OpenAI; using Microsoft.Extensions.AI; @@ -12,6 +13,7 @@ namespace DigitalBrain.AI; internal static class AIClients { private const string ConfigurationRoot = "DigitalBrain:AI"; + private const string TelemetrySource = "DigitalBrain.AI"; internal static void Add(IServiceCollection services) { @@ -34,7 +36,11 @@ private static void AddOllamaModel(IServiceCollection services, string d typeof(TModel).Name, defaultTag)); - private static OllamaApiClient Ollama( + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The telemetry middleware owns and disposes the inner Ollama client.")] + private static IChatClient Ollama( IConfiguration configuration, string modelName, string defaultTag) @@ -51,7 +57,9 @@ private static OllamaApiClient Ollama( var tag = configuration[$"{ConfigurationRoot}:Ollama:{modelName}:Model"] ?? defaultTag; - return new OllamaApiClient(endpointUri, tag); + return new ChatClientBuilder(new OllamaApiClient(endpointUri, tag)) + .UseOpenTelemetry(sourceName: $"{TelemetrySource}.{modelName}") + .Build(); } private static IChatClient OpenAI(IConfiguration configuration) @@ -67,8 +75,12 @@ private static IChatClient OpenAI(IConfiguration configuration) options.Endpoint = new Uri(endpoint, UriKind.Absolute); } - return new OpenAIClient(new ApiKeyCredential(apiKey), options) + var client = new OpenAIClient(new ApiKeyCredential(apiKey), options) .GetChatClient(model) .AsIChatClient(); + + return new ChatClientBuilder(client) + .UseOpenTelemetry(sourceName: $"{TelemetrySource}.{nameof(Gpt56)}") + .Build(); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs index 5e23d638..fcc1e8ab 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs @@ -31,7 +31,6 @@ public void PublicEdgeVocabularyIsUIEdgeContract() or "IFlutter"); Assert.Equal("/health", UIEdgeContract.HealthPath); - Assert.Equal("healthy", UIEdgeContract.HealthResponse); Assert.Equal("/shells/{shellName}/scenes", UIEdgeContract.OpenScenePath); Assert.Equal("/shells/{shellName}/events", UIEdgeContract.ShellEventsPath); Assert.Equal( @@ -103,8 +102,5 @@ public async Task MapUIHostServesHealth() cancellationToken); Assert.Equal(HttpStatusCode.OK, health.StatusCode); - Assert.Equal( - $"\"{UIEdgeContract.HealthResponse}\"", - (await health.Content.ReadAsStringAsync(cancellationToken)).Trim()); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs index 5f10e8b3..5841adc0 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs @@ -1,5 +1,6 @@ using DigitalBrain.Flutter; using DigitalBrain.Flutter.Aspire.Hosting; +using DigitalBrain.ServiceDefaults; using DigitalBrain.Testing; namespace DigitalBrain.UI.Tests; @@ -31,11 +32,13 @@ public static async Task StartUIEdgeAsync( var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.AddServiceDefaults(); builder.Services.AddSingleton(test.Client); builder.Services.AddSingleton(test.Cluster.Client); builder.Services.AddUIEdgeServices(); var app = builder.Build(); + app.MapDefaultEndpoints(); app.MapUIHost(); await app.StartAsync(cancellationToken); return app; diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj index 3ed4a5fc..ee17f53a 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj @@ -11,6 +11,7 @@ + diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs index c658e4e8..df3c272b 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs @@ -1,12 +1,15 @@ using DigitalBrain.Aspire; +using DigitalBrain.ServiceDefaults; using DigitalBrain.UI; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); builder.AddDigitalBrainClient(); builder.Services.AddUIEdgeServices(); var app = builder.Build(); +app.MapDefaultEndpoints(); app.MapUIHost(); app.Run(); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs index c31a3aa5..46988995 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs @@ -10,8 +10,6 @@ public static class UIEdgeContract { public const string HealthPath = "/health"; - public const string HealthResponse = "healthy"; - public const string OpenScenePath = "/shells/{shellName}/scenes"; public const string ShellEventsPath = "/shells/{shellName}/events"; diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs index a818c535..7a98d7de 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs @@ -6,7 +6,6 @@ public static WebApplication MapUIHost(this WebApplication app) { ArgumentNullException.ThrowIfNull(app); - app.MapGet(UIEdgeContract.HealthPath, static () => Results.Ok(UIEdgeContract.HealthResponse)); app.MapUI(); app.MapChat(); return app; From 86e77229bbdb11b45bb33611a77bc911ede3a617 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 07:12:58 +0200 Subject: [PATCH 03/20] feat: make assistant product-aware --- os/DigitalBrain.OS.Behaviors/Assistant.cs | 39 ++++++++++++++++++- .../Features/Chat.feature | 8 ++++ .../Steps/ChatSteps.cs | 14 +++++++ .../Edges/ScriptedChatClient.cs | 15 +++++++ .../CapabilityToolProbes.cs | 4 ++ .../CapabilityToolSeam.cs | 26 +++++++++++++ .../ai/DigitalBrain.Modules.AI/Agent.cs | 8 +++- 7 files changed, 111 insertions(+), 3 deletions(-) diff --git a/os/DigitalBrain.OS.Behaviors/Assistant.cs b/os/DigitalBrain.OS.Behaviors/Assistant.cs index 085ee139..f7d83452 100644 --- a/os/DigitalBrain.OS.Behaviors/Assistant.cs +++ b/os/DigitalBrain.OS.Behaviors/Assistant.cs @@ -17,12 +17,30 @@ internal sealed class Assistant( public const string DefaultGmailAccount = "gmail"; public const string DefaultSalesforceAccount = "salesforce"; + protected override string? Instructions => + """ + You are DigitalBrain, the AI assistant inside the DigitalBrain personal agent system. + Be precise about your identity and never invent tools, integrations, or completed actions. + + You can provide general conversational help using the current chat context. In this + deployment you have one external action: enrich_account_from_email. Given an exact Gmail + message ID and a 15- or 18-character Salesforce Account ID, it reads that specific Gmail + message and creates a durable proposal to replace that Account's Description. The proposal + is not written to Salesforce until the owner separately gives explicit approval. + + You cannot search or list Gmail, send email, directly update Salesforce, approve a proposal, + or create accounts, contacts, or leads. Ask for both required IDs when either is missing. + Do not call the tool merely to describe your capabilities. Never describe a proposal as a + completed update. If authentication or a provider is unavailable, report that limitation. + """; + protected override IReadOnlyList Tools => [ Capability( EnrichAccountFromEmail, - "Read a Gmail message and propose it as the description of a Salesforce account. " - + "The proposal always waits for the owner's approval before anything is written.", + "Given an exact Gmail message ID and a 15- or 18-character Salesforce Account ID, " + + "read only that message and create an approval-gated Account Description proposal. " + + "This cannot search Gmail or directly write Salesforce.", ([Description("Salesforce account id, 15 or 18 characters")] string accountId, [Description("Gmail message id to read")] string messageId) => ProposeEnrichmentAsync(accountId, messageId)), @@ -30,6 +48,9 @@ internal sealed class Assistant( private async Task ProposeEnrichmentAsync(string accountId, string messageId) { + ValidateAccountId(accountId); + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + var message = await Gmail().ReadMessage(messageId, CancellationToken.None); var mutation = await Salesforce().ProposeAccountDescription( CommandId.New(), @@ -42,6 +63,20 @@ private async Task ProposeEnrichmentAsync(string accountId, string messa + $"It is {mutation.State} and will not be written until you approve it."; } + private static void ValidateAccountId(string accountId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accountId); + + if (accountId.Length is not (15 or 18) + || accountId.Any(static character => + !char.IsAsciiLetterOrDigit(character))) + { + throw new ArgumentException( + "A Salesforce Account ID must be a 15- or 18-character alphanumeric value.", + nameof(accountId)); + } + } + private IGmail Gmail() => GrainFactory.GetGrain( NeuronId.For(Id.Owner, DefaultGmailAccount).ToGrainId()); diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature b/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature index 49bb229a..d4bc6f22 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature @@ -11,6 +11,14 @@ Feature: The operating system answers through a behaviour Then the conversation "main" journals the user message "how is my account?" And the conversation "main" journals the assistant reply "Your account is up to date." + Scenario: a greeting stays conversational + Given a DigitalBrain for the default owner + And the conversation "main" is observed + And the assistant will reply "Hello! How can I help?" + When the owner sends "hi" to the conversation "main" + Then the conversation "main" journals the assistant reply "Hello! How can I help?" + And the assistant selects no external capability + Scenario: the conversation remembers earlier turns Given a DigitalBrain for the default owner And the conversation "main" is observed diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs index 4cdf102d..c59ad852 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs @@ -1,4 +1,5 @@ using DigitalBrain.Abstractions; +using DigitalBrain.AI; using DigitalBrain.Chat; using DigitalBrain.Testing; using Reqnroll; @@ -46,4 +47,17 @@ public async Task ThenTheTranscriptHasTurns(string conversation, int expected) Assert.Equal(expected, transcript.Turns.Count); } + + [Then("the assistant selects no external capability")] + public async Task ThenTheAssistantSelectsNoExternalCapability() + { + var selected = await world + .Neuron(ChatResponder.AssistantName) + .Outgoing + .ReadAsync( + afterSequence: 0, + world.CancellationToken); + + Assert.Empty(selected); + } } diff --git a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs index b1701b1a..d0d0ff18 100644 --- a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs +++ b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs @@ -8,6 +8,7 @@ public sealed class ScriptedChatClient : IChatClient private readonly Lock _gate = new(); private readonly Queue _replies = []; private int _callCount; + private ChatMessage[] _lastMessages = []; public int CallCount { @@ -20,6 +21,17 @@ public int CallCount } } + public IReadOnlyList LastMessages + { + get + { + lock (_gate) + { + return [.. _lastMessages]; + } + } + } + public void Reply(string text) { ArgumentException.ThrowIfNullOrWhiteSpace(text); @@ -41,6 +53,7 @@ public void Reset() lock (_gate) { _callCount = 0; + _lastMessages = []; _replies.Clear(); } } @@ -90,9 +103,11 @@ private ChatResponse NextResponse( cancellationToken.ThrowIfCancellationRequested(); ChatMessage reply; + var request = messages.ToArray(); lock (_gate) { _callCount++; + _lastMessages = request; reply = _replies.Count > 0 ? _replies.Dequeue() : throw new InvalidOperationException( diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs index 972e5ad1..62249874 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs @@ -41,6 +41,10 @@ public sealed class ToolAgentProbe( { public const string EnrichTool = "enrich_account_from_email"; public const string ProbeName = "enrichment"; + public const string ProbeInstructions = + "You are the capability seam probe. Use only the supplied tools."; + + protected override string? Instructions => ProbeInstructions; protected override IReadOnlyList Tools => [ diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs index f77f7b2b..cc2e596a 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs @@ -10,8 +10,34 @@ public sealed class CapabilityToolSeam(ModuleFixture fixture) private const string AccountId = "001AAAAAAAAAAAAAAA"; private const string MessageId = "msg-42"; private const string FinalReply = "Account updated."; + private const string IdentityPrompt = "Who are you?"; private const int SeamTimeout = 60_000; + [Fact(Timeout = SeamTimeout, DisplayName = + "agent instructions reach the model as the first system turn")] + public async Task AgentInstructionsReachModelAsFirstSystemTurn() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + test.Chat().Reply(FinalReply); + + await test.Client.Get(AgentName).Respond( + [new(Microsoft.Extensions.AI.ChatRole.User, IdentityPrompt)]); + + Assert.Collection( + test.Chat().LastMessages, + system => + { + Assert.Equal(Microsoft.Extensions.AI.ChatRole.System, system.Role); + Assert.Equal(ToolAgentProbe.ProbeInstructions, system.Text); + }, + user => + { + Assert.Equal(Microsoft.Extensions.AI.ChatRole.User, user.Role); + Assert.Equal(IdentityPrompt, user.Text); + }); + } + [Fact(Timeout = SeamTimeout, DisplayName = "a model tool call reaches the real neuron capability with the model's own arguments")] public async Task ModelToolCallInvokesNeuronCapability() diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs index d0b0410b..7f23edc9 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs @@ -18,6 +18,8 @@ protected Agent(IChatClient chatClient) protected abstract IReadOnlyList Tools { get; } + protected virtual string? Instructions => null; + protected static CapabilityTool Capability(string name, string description, Delegate invoke) => new(name, description, invoke); @@ -31,8 +33,12 @@ public async Task Respond(IReadOnlyList messages) Tools = [.. Tools.Select(tool => tool.BindTo(turnScheduler))], ToolMode = ChatToolMode.Auto, }; + var instructions = Instructions; + IReadOnlyList request = string.IsNullOrWhiteSpace(instructions) + ? messages + : [new ChatMessage(ChatRole.System, instructions), .. messages]; - var response = await _toolCallingClient.GetResponseAsync(messages, options); + var response = await _toolCallingClient.GetResponseAsync(request, options); foreach (var selected in SelectedCapabilities(response)) { From 811c470563cfe9f65a37b07c93cd426d833d8eb2 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 07:25:12 +0200 Subject: [PATCH 04/20] feat: close the MCP chat loop --- DigitalBrain.slnx | 1 + os/DigitalBrain.OS.AppHost/AppHost.cs | 7 +- .../ProductSurfaceResources.cs | 1 + os/DigitalBrain.OS.Mcp/AssemblyInfo.cs | 3 + .../DigitalBrain.OS.Mcp.csproj | 1 - .../DigitalBrainMcpTools.cs | 110 ++++++++++++++++-- os/DigitalBrain.OS.Mcp/McpHost.cs | 3 +- os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs | 8 ++ os/DigitalBrain.OS.Mcp/Program.cs | 8 +- .../DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs | 4 + .../ChatProductTool.cs | 47 ++++++++ .../DigitalBrain.OS.Mcp.Tests.csproj | 20 ++++ .../DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs | 19 +++ .../Neurons/INeuron.cs | 2 + .../Neurons/ISessionNeuron.cs | 1 + .../Neurons/NeuronCallTimeouts.cs | 6 + .../LongRunningNeuronContracts.cs | 24 ++++ .../DigitalBrain.Testing/AssemblyInfo.cs | 1 + .../IAgent.cs | 1 + .../AgentResponseTimeoutContracts.cs | 18 +++ 20 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 os/DigitalBrain.OS.Mcp/AssemblyInfo.cs create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs create mode 100644 src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs create mode 100644 src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs create mode 100644 src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx index c186efa5..ca9913aa 100644 --- a/DigitalBrain.slnx +++ b/DigitalBrain.slnx @@ -56,6 +56,7 @@ + diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs index d291a24c..adb77792 100644 --- a/os/DigitalBrain.OS.AppHost/AppHost.cs +++ b/os/DigitalBrain.OS.AppHost/AppHost.cs @@ -28,6 +28,7 @@ var silo = builder.AddProject(ProductSurfaceResources.Silo) .WithReference(brain); +#pragma warning disable ASPIREMCP001 builder.AddProject(ProductSurfaceResources.Mcp) .WithReference(brain.AsClient()) .WaitFor(silo) @@ -37,6 +38,10 @@ .WithHttpEndpoint( port: ProductSurfaceResources.McpHttpPort, name: ProductSurfaceResources.McpHttpEndpointName, - isProxied: false); + isProxied: false) + .WithMcpServer( + ProductSurfaceResources.McpPath, + ProductSurfaceResources.McpHttpEndpointName); +#pragma warning restore ASPIREMCP001 builder.Build().Run(); diff --git a/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs b/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs index 058fe4d7..95515de2 100644 --- a/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs +++ b/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs @@ -4,5 +4,6 @@ internal static class ProductSurfaceResources public const string Silo = "silo"; public const string Mcp = "digitalbrain-mcp"; public const string McpHttpEndpointName = "http"; + public const string McpPath = "/mcp"; public const int McpHttpPort = 5000; } diff --git a/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs b/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs new file mode 100644 index 00000000..57d16b52 --- /dev/null +++ b/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DigitalBrain.OS.Mcp.Tests")] diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj index 08c22eca..0b0b6bb6 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj +++ b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj @@ -9,7 +9,6 @@ - diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs index fcd7f6bb..759e6d3b 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs @@ -1,8 +1,8 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using DigitalBrain.AI.Ollama; +using DigitalBrain.Abstractions; +using DigitalBrain.Chat; using DigitalBrain.Client; -using Microsoft.Extensions.AI; using ModelContextProtocol.Server; namespace DigitalBrain.OS.Mcp; @@ -12,16 +12,106 @@ namespace DigitalBrain.OS.Mcp; "Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Constructed by the MCP server DI container via WithTools().")] -internal sealed class DigitalBrainMcpTools(IDigitalBrain brain) +internal sealed class DigitalBrainMcpTools(IDigitalBrain brain, IGrainFactory grains) { - [McpServerTool(Name = McpHost.AskLlama32ToolName)] - [Description("Ask the owner's Llama 3.2 neuron and return its ChatResponse.")] - public Task AskLlama32Async( - [Description("User prompt for Llama 3.2")] string prompt) + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + private const int DefaultTimeoutSeconds = 120; + private const int MaximumTimeoutSeconds = 180; + + [McpServerTool(Name = McpHost.SendChatMessageToolName)] + [Description( + "Send a message through the owner's DigitalBrain conversation and wait for the exact " + + "correlated assistant response. This is the same durable chat path used by the product UI.")] + public async Task SendChatMessageAsync( + [Description("Message to send to DigitalBrain")] string text, + [Description("Conversation name, for example 'main'")] string chatName = "main", + [Description("Maximum wait in seconds, from 1 through 180")] + int timeoutSeconds = DefaultTimeoutSeconds, + CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(chatName); + ArgumentOutOfRangeException.ThrowIfLessThan(timeoutSeconds, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan( + timeoutSeconds, + MaximumTimeoutSeconds); + + var chatId = NeuronId.For(brain.Owner, chatName); + var session = grains.GetGrain( + ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); + var baseline = await session.ReadNeuronJournal( + chatId, + JournalKind.Outgoing, + afterSequence: 0); + var commandId = CommandId.New(); + + await brain.Get(chatName).Send(new SendMessage(commandId, text)); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + return await WaitForResponseAsync( + session, + chatId, + chatName, + commandId, + baseline.ResumeSequence, + timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"DigitalBrain did not answer command '{commandId}' in conversation " + + $"'{chatName}' within {timeoutSeconds} seconds."); + } + } + + private static async Task WaitForResponseAsync( + ISessionNeuron session, + NeuronId chatId, + string chatName, + CommandId commandId, + long afterSequence, + CancellationToken cancellationToken) + { + var cursor = afterSequence; + CorrelationId? correlation = null; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await session.ReadNeuronJournal( + chatId, + JournalKind.Outgoing, + cursor); + + foreach (var delivery in page.Delta) + { + if (delivery.Synapse is UserMessaged messaged + && messaged.CommandId == commandId) + { + correlation = delivery.CorrelationId; + } + + if (delivery.Synapse is AssistantResponded response + && correlation is { } expected + && delivery.CorrelationId == expected) + { + return new ChatMessageResult( + chatName, + commandId.ToString(), + expected.Value.ToString("N"), + response.Text, + delivery.Sequence, + delivery.Timestamp); + } + } - return brain.Get(McpHost.DefaultLlama32Key).Respond( - [new ChatMessage(ChatRole.User, prompt)]); + cursor = page.ResumeSequence; + await Task.Delay(PollInterval, cancellationToken); + } } } diff --git a/os/DigitalBrain.OS.Mcp/McpHost.cs b/os/DigitalBrain.OS.Mcp/McpHost.cs index 28e8c3c0..5e35e373 100644 --- a/os/DigitalBrain.OS.Mcp/McpHost.cs +++ b/os/DigitalBrain.OS.Mcp/McpHost.cs @@ -3,8 +3,7 @@ namespace DigitalBrain.OS.Mcp; internal static class McpHost { public const string EndpointPath = "/mcp"; - public const string AskLlama32ToolName = "ask_llama32"; - public const string DefaultLlama32Key = "default"; + public const string SendChatMessageToolName = "send_chat_message"; public const string ListActiveNeuronsToolName = "list_active_neurons"; public const string ReadNeuronJournalToolName = "read_neuron_journal"; public const string ReadChatTranscriptToolName = "read_chat_transcript"; diff --git a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs index 145d955a..4cfe32d5 100644 --- a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs +++ b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs @@ -21,3 +21,11 @@ internal sealed record ChatTranscriptPage( IReadOnlyList Turns); internal sealed record ChatTranscriptTurn(string Speaker, string Text); + +internal sealed record ChatMessageResult( + string Chat, + string CommandId, + string CorrelationId, + string Response, + long Sequence, + DateTimeOffset Timestamp); diff --git a/os/DigitalBrain.OS.Mcp/Program.cs b/os/DigitalBrain.OS.Mcp/Program.cs index 1c5f542a..20a93d97 100644 --- a/os/DigitalBrain.OS.Mcp/Program.cs +++ b/os/DigitalBrain.OS.Mcp/Program.cs @@ -1,18 +1,12 @@ using DigitalBrain.Aspire; using DigitalBrain.OS.Mcp; using DigitalBrain.ServiceDefaults; -using Microsoft.Extensions.AI; -using Orleans.Serialization; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); -builder.AddDigitalBrainClient(client => -{ - client.Services.AddSerializer(serializer => serializer.AddJsonSerializer( - static type => type == typeof(ChatMessage) || type == typeof(ChatResponse))); -}); +builder.AddDigitalBrainClient(); builder.Services .AddMcpServer() diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..afab76ff --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using DigitalBrain.OS.Mcp.Tests; +using Xunit; + +[assembly: AssemblyFixture(typeof(OSMcpFixture))] diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs new file mode 100644 index 00000000..9b370980 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs @@ -0,0 +1,47 @@ +using DigitalBrain.Chat; +using DigitalBrain.Testing; +using Xunit; + +namespace DigitalBrain.OS.Mcp.Tests; + +public sealed class ChatProductTool(OSMcpFixture fixture) +{ + private const int TestTimeout = 60_000; + + [Fact( + Timeout = TestTimeout, + DisplayName = "send_chat_message returns the correlated assistant response")] + public async Task SendChatMessageReturnsCorrelatedAssistantResponse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + test.Chat().Reply("Hello from DigitalBrain."); + var tools = new DigitalBrainMcpTools(test.Client, test.Cluster.Client); + + var result = await tools.SendChatMessageAsync( + "hi", + "main", + timeoutSeconds: 10, + cancellationToken); + + Assert.Equal("main", result.Chat); + Assert.Equal("Hello from DigitalBrain.", result.Response); + Assert.NotEqual(Guid.Empty.ToString("N"), result.CommandId); + Assert.NotEqual(Guid.Empty.ToString("N"), result.CorrelationId); + Assert.True(result.Sequence > 0); + + var transcript = await test.Client.Get("main").Read(); + Assert.Collection( + transcript.Turns, + user => + { + Assert.True(user.FromUser); + Assert.Equal("hi", user.Text); + }, + assistant => + { + Assert.False(assistant.FromUser); + Assert.Equal(result.Response, assistant.Text); + }); + } +} diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj b/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj new file mode 100644 index 00000000..5167e537 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + $(NoWarn);CA2007;CA1515 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs new file mode 100644 index 00000000..86fcbebc --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs @@ -0,0 +1,19 @@ +using DigitalBrain.AI; +using DigitalBrain.AI.Ollama; +using DigitalBrain.Chat; +using DigitalBrain.OS; +using DigitalBrain.Testing; + +namespace DigitalBrain.OS.Mcp.Tests; + +public sealed class OSMcpFixture : DigitalBrainFixture +{ + protected override void Configure(DigitalBrainTestBuilder brain) + { + ArgumentNullException.ThrowIfNull(brain); + brain.AddModule(); + brain.AddModule(); + brain.AddModule(); + brain.ConfigureScriptedChat(typeof(Gemma4)); + } +} diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs index eac309b6..1afc542c 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs @@ -3,9 +3,11 @@ namespace DigitalBrain.Abstractions; public partial interface INeuron : IGrainWithStringKey { [Alias(nameof(Deliver))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task Deliver(SynapseDelivery delivery); [Alias(nameof(ReadJournal))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task ReadJournal(JournalKind kind, long afterSequence); [Alias(nameof(Watch))] diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs index 22ce4d60..3c728344 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs @@ -20,6 +20,7 @@ static NeuronId ForOwner(OwnerId owner) Task Emit(Synapse synapse); [Alias(nameof(ReadNeuronJournal))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task ReadNeuronJournal(NeuronId subject, JournalKind kind, long afterSequence); [Alias(nameof(WatchNeuron))] diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs new file mode 100644 index 00000000..e883cf23 --- /dev/null +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs @@ -0,0 +1,6 @@ +namespace DigitalBrain.Abstractions; + +public static class NeuronCallTimeouts +{ + public const string LongRunning = "00:05:00"; +} diff --git a/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs new file mode 100644 index 00000000..542316cc --- /dev/null +++ b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using DigitalBrain.Abstractions; +using Xunit; + +namespace DigitalBrain.Testing.Tests; + +public sealed class LongRunningNeuronContracts +{ + [Theory] + [InlineData(typeof(INeuron), nameof(INeuron.Deliver))] + [InlineData(typeof(INeuron), nameof(INeuron.ReadJournal))] + [InlineData(typeof(ISessionNeuron), nameof(ISessionNeuron.ReadNeuronJournal))] + public void LongRunningNeuronCallsDeclareProductTimeout( + Type contract, + string methodName) + { + ArgumentNullException.ThrowIfNull(contract); + var method = contract.GetMethod(methodName); + var timeout = method?.GetCustomAttribute(); + + Assert.NotNull(timeout); + Assert.Equal(TimeSpan.FromMinutes(5), timeout.Timeout); + } +} diff --git a/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs b/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs index cd281df6..d4101c09 100644 --- a/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs +++ b/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("DigitalBrain.Mcp.Tests")] [assembly: InternalsVisibleTo("DigitalBrain.Modules.Flutter.Http.Tests")] +[assembly: InternalsVisibleTo("DigitalBrain.OS.Mcp.Tests")] diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs index d34bb21f..bb939e42 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs @@ -7,5 +7,6 @@ namespace DigitalBrain.AI; public partial interface IAgent : INeuron { [Alias(nameof(Respond))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task Respond(IReadOnlyList messages); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs new file mode 100644 index 00000000..b19537cb --- /dev/null +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using Xunit; + +namespace DigitalBrain.ModuleTests; + +public sealed class AgentResponseTimeoutContracts +{ + [Fact] + public void AgentResponsesDeclareProductTimeout() + { + var method = typeof(DigitalBrain.AI.IAgent) + .GetMethod(nameof(DigitalBrain.AI.IAgent.Respond)); + var timeout = method?.GetCustomAttribute(); + + Assert.NotNull(timeout); + Assert.Equal(TimeSpan.FromMinutes(5), timeout.Timeout); + } +} From 7e6460199b2c43332528118dc170a4a48ecd25ad Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 07:38:48 +0200 Subject: [PATCH 05/20] feat: automate live product verification Adds a self-cleaning Aspire proof for resource health, the exact MCP catalog, a real Gemma4 turn, durable transcript correlation, and telemetry hygiene. Updates the repository status to the measured observable product state.\n\nNo unconsumed product surface was added; the verifier is linked from the contributor workflow. All capability claims were checked by the script's live successful run. The three pre-existing Flutter generated-file changes remain unstaged and untouched. --- CLAUDE.md | 18 +-- README.md | 15 ++- scripts/verify-product.ps1 | 269 +++++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 scripts/verify-product.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 0ab2e935..a0241e77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ the live system. Tests are the regression net, not the proof. 3. Drive the real scenario through the real edge — HTTP to `digitalbrain-ui`, or the Flutter shell. 4. **Read the journal** via `digitalbrain-mcp`: `read_neuron_journal`, `read_chat_transcript`, `list_active_neurons`. Confirm the expected synapses fired, one correlation id, right order. -5. Cross-check Aspire — but only for what the table below says works. +5. Cross-check Aspire against structured logs and spans. 6. Root gate for regression. 7. Only now claim, quoting what you saw. @@ -78,20 +78,20 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow |---|---| | `list_resources`, `list_console_logs` | works — console output is noisy with Azurite spam | | `digitalbrain-mcp` journals | works, and is authoritative | -| `list_structured_logs` | **empty** | -| `list_traces` | **no application spans** — `dotnet-cli` only | -| GenAI spans, metrics | **do not exist** | +| Structured logs | works — MCP invocation completion includes tool name and error state | +| Application traces | works — ASP.NET, Orleans and kernel spans carry causal identifiers | +| GenAI spans, metrics | works — provider, model, duration, token usage and finish reason; no prompt or response content | -No host calls `ConfigureOpenTelemetry`, no chat client calls `UseOpenTelemetry`, and the kernel's -`ActivitySource("DigitalBrain")` has no exporter. **This is the top open defect.** Until it lands, -step 5 confirms health and console output only — say that, rather than implying traces were checked. -Update this table when you fix it. +`scripts/verify-product.ps1` is the one-command live oracle. It builds Release, waits for the product +resources, checks the exact MCP catalog, drives a real Gemma4 turn, confirms the durable transcript +and correlation, checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is +specified. ## Gates ```powershell dotnet build DigitalBrain.slnx -c Release -dotnet test DigitalBrain.slnx -c Release --logger "console;verbosity=minimal" +dotnet test DigitalBrain.slnx -c Release ``` **Never `--filter` for the completion gate** — a project-scoped run has already missed a failing diff --git a/README.md b/README.md index 7a4e1fed..d02819c5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ await brain.SendAsync( ``` The owner-bound `IDigitalBrain` facade enters through a session; neurons call typed capabilities such -as `ILlama32` inside the brain. The same vocabulary will later carry approved C# behaviors generated +as `IGemma4` inside the brain. The same vocabulary will later carry approved C# behaviors generated from natural language. ## The shape of it @@ -22,7 +22,7 @@ from natural language. - **A synapse is a fact** — a thin record, broadcast, no reply. **An interface method is a request** — directed at a capability, and it replies. Both are journaled; neither is privileged. - **Modules own vocabulary** — synapse records and neuron interfaces, resolved at compile time. -- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.ILlama32` is identity, not +- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.IGemma4` is identity, not a lookup result. - **Journals are the audit source**, recording causal facts only — never arguments, prompts or secrets. Telemetry is a projection and never replaces them. @@ -37,11 +37,12 @@ The plan of record. Nothing is shipped unless it says Built. | Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** | | Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** | | Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** | +| Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** | | Time — durable one-shot `ICountdown` and its recovery tests | **Built** | | Time — reminders, recurring interval/calendar scheduling, DST | Designed | | Product chrome polish, multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed | | Behavior rail — proposal, approval, installation, execution, rollback | Designed | -| Observability spine — host OpenTelemetry, instrumented chat clients, GenAI spans | **Not built — top open defect** | +| Observability spine — host OpenTelemetry, structured logs, causal kernel spans, GenAI spans and metrics | **Built** | `DigitalBrain.Behaviors` is a packable SDK foundation (authoring interfaces, constrained context, manifests, artifact identities) and holds the canonical artifact codec. It is not a compiler, builder, @@ -70,6 +71,14 @@ git clean -fdx aspire run ``` +[scripts/verify-product.ps1](scripts/verify-product.ps1) performs the Release build and a live, +self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals, and +OpenTelemetry: + +```powershell +./scripts/verify-product.ps1 +``` + [CLAUDE.md](CLAUDE.md) is the working discipline for every agent and contributor: the gates, the verification ladder, and the traps. A green test suite is necessary, not sufficient — it proves the code holds, not that a behaviour works. diff --git a/scripts/verify-product.ps1 b/scripts/verify-product.ps1 new file mode 100644 index 00000000..f56b516d --- /dev/null +++ b/scripts/verify-product.ps1 @@ -0,0 +1,269 @@ +[CmdletBinding()] +param( + [string]$Prompt = "Who are you and what can you actually do?", + [string]$ChatName = "product-verification-$([Guid]::NewGuid().ToString('N'))", + [ValidateRange(1, 180)] + [int]$TimeoutSeconds = 180, + [switch]$KeepRunning +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repository = Split-Path -Parent $PSScriptRoot +$appHost = Join-Path $repository "os/DigitalBrain.OS.AppHost/DigitalBrain.OS.AppHost.csproj" +$started = $false + +function Invoke-Checked { + param( + [Parameter(Mandatory)] + [string]$Command, + [Parameter(Mandatory)] + [string[]]$Arguments + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$Command $($Arguments -join ' ')' exited with code $LASTEXITCODE." + } +} + +function Invoke-AspireJson { + param( + [Parameter(Mandatory)] + [string[]]$Arguments + ) + + $output = @(& aspire @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "'aspire $($Arguments -join ' ')' exited with code $LASTEXITCODE." + } + + $lines = @($output | ForEach-Object { $_.ToString() }) + if ($lines.Count -eq 0) { + return $null + } + + $jsonStart = 0..($lines.Count - 1) | + Where-Object { $lines[$_].TrimStart() -match "^[\[\{]" } | + Select-Object -First 1 + if ($null -eq $jsonStart) { + return $null + } + + return ($lines[$jsonStart..($lines.Count - 1)] -join "`n") | + ConvertFrom-Json +} + +function Require { + param( + [Parameter(Mandatory)] + [bool]$Condition, + [Parameter(Mandatory)] + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +Push-Location $repository +try { + & aspire stop --apphost $appHost --non-interactive 2>$null + + Invoke-Checked dotnet @("build", "DigitalBrain.slnx", "-c", "Release") + Invoke-Checked aspire @( + "start", + "--apphost", $appHost, + "--format", "Json", + "--non-interactive") + $started = $true + + foreach ($resource in @( + "silo", + "digitalbrain-mcp", + "digitalbrain-ui", + "brain-ai-gemma4", + "digitalbrain-flutter")) { + Invoke-Checked aspire @( + "wait", $resource, + "--apphost", $appHost, + "--timeout", "180", + "--non-interactive") + } + + $tools = @() + for ($attempt = 1; $attempt -le 90 -and $tools.Count -eq 0; $attempt++) { + $tools = @( + Invoke-AspireJson @( + "mcp", "tools", + "--apphost", $appHost, + "--format", "Json", + "--non-interactive")) + if ($tools.Count -eq 0) { + Start-Sleep -Seconds 1 + } + } + $toolNames = @($tools | ForEach-Object tool) + Write-Host "Discovered MCP tools: $($toolNames -join ', ')" + $expectedTools = @( + "list_active_neurons", + "read_chat_transcript", + "read_neuron_journal", + "send_chat_message") + Require ` + -Condition ($toolNames.Count -eq $expectedTools.Count) ` + -Message "Expected $($expectedTools.Count) MCP tools but found $($toolNames.Count)." + foreach ($tool in $expectedTools) { + Require ` + -Condition ($toolNames -contains $tool) ` + -Message "MCP tool '$tool' was not discovered." + } + Require ` + -Condition ($toolNames -notcontains "ask_llama32") ` + -Message "The retired model-specific MCP tool is still exposed." + + $sendInput = @{ + text = $Prompt + chatName = $ChatName + timeoutSeconds = $TimeoutSeconds + } | ConvertTo-Json -Compress + $result = Invoke-AspireJson @( + "mcp", "call", + "digitalbrain-mcp", "send_chat_message", + "--input", $sendInput, + "--apphost", $appHost, + "--non-interactive") + + Require ` + -Condition (-not [string]::IsNullOrWhiteSpace($result.response)) ` + -Message "DigitalBrain returned an empty assistant response." + Require ` + -Condition ($result.commandId -match "^[0-9a-f]{32}$") ` + -Message "The MCP result did not return a valid command ID." + Require ` + -Condition ($result.correlationId -match "^[0-9a-f]{32}$") ` + -Message "The MCP result did not return a valid correlation ID." + + $chatInput = @{ chatName = $ChatName } | ConvertTo-Json -Compress + $transcript = Invoke-AspireJson @( + "mcp", "call", + "digitalbrain-mcp", "read_chat_transcript", + "--input", $chatInput, + "--apphost", $appHost, + "--non-interactive") + Require ` + -Condition ($transcript.turns.Count -eq 2) ` + -Message "Expected one user and one assistant transcript turn." + Require ` + -Condition ( + $transcript.turns[0].speaker -eq "you" -and + $transcript.turns[0].text -eq $Prompt) ` + -Message "The durable transcript does not contain the sent user turn." + Require ` + -Condition ( + $transcript.turns[1].speaker -eq "brain" -and + $transcript.turns[1].text -eq $result.response) ` + -Message "The durable transcript does not contain the returned assistant turn." + + $journalInput = @{ + grainType = "chat" + name = $ChatName + kind = "outgoing" + afterSequence = 0 + } | ConvertTo-Json -Compress + $journal = Invoke-AspireJson @( + "mcp", "call", + "digitalbrain-mcp", "read_neuron_journal", + "--input", $journalInput, + "--apphost", $appHost, + "--non-interactive") + Require ` + -Condition ($journal.entries.Count -eq 2) ` + -Message "Expected exactly two outgoing chat journal entries." + Require ` + -Condition ( + $journal.entries[0].synapse -eq "UserMessaged" -and + $journal.entries[1].synapse -eq "AssistantResponded") ` + -Message "Chat journal entries are missing or out of order." + Require ` + -Condition ( + $journal.entries[0].correlation -eq $result.correlationId -and + $journal.entries[1].correlation -eq $result.correlationId) ` + -Message "Chat journal entries do not share the MCP result correlation." + + $genAi = $null + for ($attempt = 1; $attempt -le 60 -and $null -eq $genAi; $attempt++) { + $genAiSpans = @( + Invoke-AspireJson @( + "otel", "spans", + "--apphost", $appHost, + "--format", "Json", + "--limit", "50", + "--search", "gen_ai", + "--non-interactive")) + $genAi = $genAiSpans | + Where-Object { + $_.name -like "chat gemma4:*" -and + $_.attributes.'gen_ai.operation.name' -eq "chat" -and + $_.attributes.'gen_ai.provider.name' -eq "ollama" + } | + Select-Object -First 1 + if ($null -eq $genAi) { + Start-Sleep -Seconds 1 + } + } + Require ` + -Condition ($null -ne $genAi) ` + -Message "No Gemma4 OpenTelemetry chat span was exported." + Require ` + -Condition ( + -not $genAi.attributes.PSObject.Properties[ + "gen_ai.input.messages"] -and + -not $genAi.attributes.PSObject.Properties[ + "gen_ai.output.messages"]) ` + -Message "The GenAI span exported sensitive message content." + + $completion = $null + for ($attempt = 1; $attempt -le 60 -and $null -eq $completion; $attempt++) { + $toolLogs = @( + Invoke-AspireJson @( + "otel", "logs", + "--apphost", $appHost, + "--format", "Json", + "--limit", "20", + "--search", "send_chat_message", + "--non-interactive")) + $completion = $toolLogs | + Where-Object { + $_.attributes.ToolName -eq "send_chat_message" -and + $_.attributes.IsError -eq "false" + } | + Select-Object -First 1 + if ($null -eq $completion) { + Start-Sleep -Seconds 1 + } + } + Require ` + -Condition ($null -ne $completion) ` + -Message "No successful structured MCP completion log was exported." + + [pscustomobject]@{ + Chat = $result.chat + CommandId = $result.commandId + CorrelationId = $result.correlationId + Response = $result.response + GenAiTraceId = $genAi.traceId + GenAiDurationMs = $genAi.durationMs + InputTokens = $genAi.attributes.'gen_ai.usage.input_tokens' + OutputTokens = $genAi.attributes.'gen_ai.usage.output_tokens' + } | Format-List +} +finally { + Pop-Location + + if ($started -and -not $KeepRunning) { + & aspire stop --apphost $appHost --non-interactive + } +} From 8b4b6f68f73bea72f70297ef040db1dd7b692cbe Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 07:47:12 +0200 Subject: [PATCH 06/20] feat: add observable Flutter workspace Adds responsive Chat, Activity, and Brain surfaces over one shared durable chat projection. Activity exposes sequence and command facts without message content, while Brain describes only the product's measured proposal-gated capabilities.\n\nEvery added surface has a current UI consumer. Claims were checked by Flutter/Dart analysis and tests, the Windows Release build, and the 253-test root MTP gate. The three pre-existing Flutter generated-file changes remain unstaged and untouched. --- README.md | 3 +- .../shell/lib/activity_screen.dart | 146 +++++ .../shell/lib/brain_screen.dart | 279 +++++++++ .../shell/lib/brain_theme.dart | 126 +++- .../shell/lib/chat_screen.dart | 557 +++++++++++++----- .../digitalbrain_flutter/shell/lib/main.dart | 2 +- .../shell/test/chat_screen_test.dart | 110 +++- 7 files changed, 1048 insertions(+), 175 deletions(-) create mode 100644 clients/digitalbrain_flutter/shell/lib/activity_screen.dart create mode 100644 clients/digitalbrain_flutter/shell/lib/brain_screen.dart diff --git a/README.md b/README.md index d02819c5..abc13fdf 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ The plan of record. Nothing is shipped unless it says Built. | Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** | | Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** | | Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** | +| Product shell — responsive Chat, content-safe Activity, capability-accurate Brain surfaces | **Built** | | Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** | | Time — durable one-shot `ICountdown` and its recovery tests | **Built** | | Time — reminders, recurring interval/calendar scheduling, DST | Designed | -| Product chrome polish, multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed | +| Multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed | | Behavior rail — proposal, approval, installation, execution, rollback | Designed | | Observability spine — host OpenTelemetry, structured logs, causal kernel spans, GenAI spans and metrics | **Built** | diff --git a/clients/digitalbrain_flutter/shell/lib/activity_screen.dart b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart new file mode 100644 index 00000000..999ba9f9 --- /dev/null +++ b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart @@ -0,0 +1,146 @@ +import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'brain_theme.dart'; + +final class ActivityScreen extends StatelessWidget { + const ActivityScreen({super.key, required this.turns}); + + final List turns; + + @override + Widget build(BuildContext context) { + return ColoredBox( + key: const Key('activity_screen'), + color: BrainPalette.surface, + child: turns.isEmpty + ? const _EmptyActivity() + : Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980), + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 32, + ), + children: [ + const _ActivityHeader(), + const SizedBox(height: 24), + for (final turn in turns.reversed) + _ActivityEntry(turn: turn), + ], + ), + ), + ), + ); + } +} + +final class _ActivityHeader extends StatelessWidget { + const _ActivityHeader(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Activity', style: BrainType.heading), + SizedBox(height: 8), + Text( + 'Durable conversation facts, newest first. Message content stays in Chat.', + style: BrainType.bodyMuted, + ), + ], + ); + } +} + +final class _EmptyActivity extends StatelessWidget { + const _EmptyActivity(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.timeline_rounded, + size: 34, + color: BrainPalette.textMuted, + ), + const SizedBox(height: 16), + const Text('No activity yet.', style: BrainType.empty), + const SizedBox(height: 7), + Text( + 'Journal facts appear after the conversation begins.', + style: BrainType.body.copyWith(color: BrainPalette.textMuted), + ), + ], + ), + ); + } +} + +final class _ActivityEntry extends StatelessWidget { + const _ActivityEntry({required this.turn}); + + final ChatTurnEvent turn; + + @override + Widget build(BuildContext context) { + final color = turn.fromUser ? BrainPalette.owner : BrainPalette.signal; + final event = turn.fromUser ? 'UserMessaged' : 'AssistantResponded'; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: BrainPalette.surfaceRaised, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + turn.fromUser + ? Icons.north_east_rounded + : Icons.south_west_rounded, + color: color, + size: 17, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(event, style: BrainType.metaStrong.copyWith(color: color)), + const SizedBox(height: 6), + Wrap( + spacing: 18, + runSpacing: 5, + children: [ + Text( + 'sequence ${turn.sequence.toString().padLeft(3, '0')}', + style: BrainType.meta, + ), + Text('command ${turn.commandId}', style: BrainType.meta), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart new file mode 100644 index 00000000..c5256634 --- /dev/null +++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart @@ -0,0 +1,279 @@ +import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'brain_theme.dart'; + +final class BrainScreen extends StatelessWidget { + const BrainScreen({ + super.key, + required this.chatName, + required this.turns, + this.statusMessage, + }); + + final String chatName; + final List turns; + final String? statusMessage; + + @override + Widget build(BuildContext context) { + final connected = statusMessage == null || statusMessage!.isEmpty; + final lastSequence = turns.isEmpty ? '—' : '${turns.last.sequence}'; + + return ColoredBox( + key: const Key('brain_screen'), + color: BrainPalette.surface, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + children: [ + const Text('Brain', style: BrainType.heading), + const SizedBox(height: 8), + const Text( + 'What this DigitalBrain can do, and what remains under your control.', + style: BrainType.bodyMuted, + ), + const SizedBox(height: 26), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _MetricCard( + label: 'Runtime', + value: connected ? 'Connected' : 'Offline', + accent: connected + ? BrainPalette.success + : BrainPalette.signal, + ), + _MetricCard(label: 'Conversation', value: chatName), + _MetricCard(label: 'Last sequence', value: lastSequence), + ], + ), + const SizedBox(height: 30), + const _SectionLabel('CAPABILITIES'), + const SizedBox(height: 12), + const _CapabilityCard( + icon: Icons.chat_bubble_outline_rounded, + title: 'General assistant', + body: + 'Conversation, explanation, drafting, and reasoning in the current chat.', + ), + const SizedBox(height: 12), + const _CapabilityCard( + icon: Icons.compare_arrows_rounded, + title: 'Gmail message → Salesforce Account description', + body: + 'Creates a reviewable enrichment proposal from an exact Gmail message ID and a Salesforce Account ID.', + badge: 'Approval required', + ), + const SizedBox(height: 30), + const _SectionLabel('BOUNDARIES'), + const SizedBox(height: 12), + const _BoundaryCard(), + if (!connected) ...[ + const SizedBox(height: 20), + _ConnectionNotice(message: statusMessage!), + ], + ], + ), + ), + ), + ); + } +} + +final class _MetricCard extends StatelessWidget { + const _MetricCard({ + required this.label, + required this.value, + this.accent = BrainPalette.textPrimary, + }); + + final String label; + final String value; + final Color accent; + + @override + Widget build(BuildContext context) { + return Container( + width: 190, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: BrainPalette.surfaceRaised, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: BrainType.meta), + const SizedBox(height: 9), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: BrainType.metric.copyWith(color: accent), + ), + ], + ), + ); + } +} + +final class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text(text, style: BrainType.meta); + } +} + +final class _CapabilityCard extends StatelessWidget { + const _CapabilityCard({ + required this.icon, + required this.title, + required this.body, + this.badge, + }); + + final IconData icon; + final String title; + final String body; + final String? badge; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: BrainPalette.surfaceRaised, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: BrainPalette.signal.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(11), + ), + child: Icon(icon, color: BrainPalette.signal, size: 19), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(title, style: BrainType.cardTitle)), + if (badge != null) _Badge(label: badge!), + ], + ), + const SizedBox(height: 7), + Text(body, style: BrainType.bodyMuted), + ], + ), + ), + ], + ), + ); + } +} + +final class _Badge extends StatelessWidget { + const _Badge({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: BrainPalette.owner.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: BrainPalette.owner.withValues(alpha: 0.3)), + ), + child: Text( + label, + style: BrainType.metaStrong.copyWith(color: BrainPalette.owner), + ), + ); + } +} + +final class _BoundaryCard extends StatelessWidget { + const _BoundaryCard(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: BrainPalette.surfaceSunken, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BoundaryLine('No Gmail search, listing, or sending'), + SizedBox(height: 11), + _BoundaryLine('No direct Salesforce writes'), + SizedBox(height: 11), + _BoundaryLine('No account, contact, or lead creation'), + ], + ), + ); + } +} + +final class _BoundaryLine extends StatelessWidget { + const _BoundaryLine(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon( + Icons.remove_circle_outline_rounded, + color: BrainPalette.textMuted, + size: 16, + ), + const SizedBox(width: 10), + Expanded(child: Text(text, style: BrainType.bodyMuted)), + ], + ); + } +} + +final class _ConnectionNotice extends StatelessWidget { + const _ConnectionNotice({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: BrainPalette.signal.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.25)), + ), + child: Text(message, style: BrainType.bodyMuted), + ); + } +} diff --git a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart index afd811c2..4150cc93 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart @@ -1,24 +1,72 @@ import 'package:flutter/material.dart'; -/// The conversation is a journal you can talk to, so the surface reads as an -/// instrument rather than a messenger: deep slate-indigo, two voices separated -/// by colour temperature, and a monospace rail carrying real journal sequence. abstract final class BrainPalette { - static const surface = Color(0xFF14161D); - static const surfaceRaised = Color(0xFF1C1F29); - static const line = Color(0xFF2A2E3B); - static const textPrimary = Color(0xFFE6E8EF); - static const textMuted = Color(0xFF8A90A3); + static const navigation = Color(0xFF101219); + static const surface = Color(0xFF141720); + static const surfaceRaised = Color(0xFF1B1F2A); + static const surfaceSunken = Color(0xFF11141B); + static const line = Color(0xFF292E3B); + static const lineStrong = Color(0xFF363C4C); + static const textPrimary = Color(0xFFE9EBF2); + static const textMuted = Color(0xFF969CAF); + static const textFaint = Color(0xFF5E6474); + static const signal = Color(0xFFE09261); + static const owner = Color(0xFF7B9BE3); + static const success = Color(0xFF65C5A0); +} - /// The brain answering. - static const signal = Color(0xFFD98A5B); +abstract final class BrainTheme { + static ThemeData dark() { + final scheme = ColorScheme.fromSeed( + seedColor: BrainPalette.signal, + brightness: Brightness.dark, + surface: BrainPalette.surface, + ); - /// The owner speaking. - static const owner = Color(0xFF6E8FD4); + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: BrainPalette.surface, + colorScheme: scheme.copyWith( + primary: BrainPalette.signal, + secondary: BrainPalette.owner, + surface: BrainPalette.surface, + ), + dividerColor: BrainPalette.line, + navigationRailTheme: NavigationRailThemeData( + backgroundColor: BrainPalette.navigation, + indicatorColor: BrainPalette.signal.withValues(alpha: 0.14), + selectedIconTheme: const IconThemeData( + color: BrainPalette.signal, + size: 21, + ), + unselectedIconTheme: const IconThemeData( + color: BrainPalette.textMuted, + size: 20, + ), + selectedLabelTextStyle: BrainType.metaStrong.copyWith( + color: BrainPalette.signal, + ), + unselectedLabelTextStyle: BrainType.meta, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: BrainPalette.navigation, + indicatorColor: BrainPalette.signal.withValues(alpha: 0.14), + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? BrainType.metaStrong.copyWith(color: BrainPalette.signal) + : BrainType.meta, + ), + ), + tooltipTheme: const TooltipThemeData( + decoration: BoxDecoration(color: BrainPalette.surfaceRaised), + textStyle: BrainType.meta, + ), + ); + } } abstract final class BrainType { - /// Windows ships Cascadia; the fallbacks keep the rail monospace elsewhere. static const monoFamily = 'Cascadia Mono'; static const monoFallback = ['Consolas', 'Menlo', 'monospace']; @@ -34,6 +82,33 @@ abstract final class BrainType { color: BrainPalette.textPrimary, ); + static const heading = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.8, + color: BrainPalette.textPrimary, + ); + + static const cardTitle = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.15, + color: BrainPalette.textPrimary, + ); + + static const metric = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: BrainPalette.textPrimary, + ); + static const body = TextStyle( fontFamily: bodyFamily, fontFamilyFallback: bodyFallback, @@ -42,21 +117,38 @@ abstract final class BrainType { color: BrainPalette.textPrimary, ); + static const bodyMuted = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 14, + height: 1.5, + color: BrainPalette.textMuted, + ); + static const meta = TextStyle( fontFamily: monoFamily, fontFamilyFallback: monoFallback, fontSize: 11, fontWeight: FontWeight.w500, - letterSpacing: 0.5, + letterSpacing: 0.45, color: BrainPalette.textMuted, ); + static const metaStrong = TextStyle( + fontFamily: monoFamily, + fontFamilyFallback: monoFallback, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.45, + color: BrainPalette.textPrimary, + ); + static const empty = TextStyle( fontFamily: bodyFamily, fontFamilyFallback: bodyFallback, - fontSize: 17, - fontWeight: FontWeight.w500, - letterSpacing: -0.2, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, color: BrainPalette.textPrimary, ); } diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart index 0fa71573..b9faa34b 100644 --- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart @@ -4,6 +4,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'activity_screen.dart'; +import 'brain_screen.dart'; import 'brain_theme.dart'; typedef SendMessage = Future Function(String text); @@ -27,17 +29,8 @@ final class BrainChatApp extends StatelessWidget { return MaterialApp( title: 'DigitalBrain', debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - scaffoldBackgroundColor: BrainPalette.surface, - colorScheme: ColorScheme.fromSeed( - seedColor: BrainPalette.signal, - brightness: Brightness.dark, - surface: BrainPalette.surface, - ), - ), - home: BrainChatScreen( + theme: BrainTheme.dark(), + home: _BrainWorkspace( chatName: chatName, turns: turns, onSend: onSend, @@ -47,9 +40,8 @@ final class BrainChatApp extends StatelessWidget { } } -final class BrainChatScreen extends StatefulWidget { - const BrainChatScreen({ - super.key, +final class _BrainWorkspace extends StatefulWidget { + const _BrainWorkspace({ required this.chatName, this.turns, this.onSend, @@ -62,19 +54,17 @@ final class BrainChatScreen extends StatefulWidget { final String? statusMessage; @override - State createState() => _BrainChatScreenState(); + State<_BrainWorkspace> createState() => _BrainWorkspaceState(); } -final class _BrainChatScreenState extends State { +final class _BrainWorkspaceState extends State<_BrainWorkspace> { + static const _compactBreakpoint = 720.0; + final _turns = []; final _seen = {}; - final _composer = TextEditingController(); - final _composerFocus = FocusNode(); - final _scroll = ScrollController(); - StreamSubscription? _subscription; - bool _awaitingBrain = false; - String? _failure; + int _destination = 0; + String? _streamFailure; @override void initState() { @@ -83,28 +73,307 @@ final class _BrainChatScreenState extends State { } @override - void didUpdateWidget(covariant BrainChatScreen oldWidget) { + void didUpdateWidget(covariant _BrainWorkspace oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.chatName != widget.chatName) { + _turns.clear(); + _seen.clear(); + } if (!identical(oldWidget.turns, widget.turns)) { - _subscription?.cancel(); + unawaited(_subscription?.cancel()); _listen(widget.turns); } } void _listen(Stream? turns) { - _subscription = turns?.listen((turn) { - if (!mounted || !_seen.add(turn.sequence)) { - return; - } - setState(() { - _turns.add(turn); - _turns.sort((a, b) => a.sequence.compareTo(b.sequence)); - if (!turn.fromUser) { - _awaitingBrain = false; + _streamFailure = null; + _subscription = turns?.listen( + (turn) { + if (!mounted || !_seen.add(turn.sequence)) { + return; } - }); + setState(() { + _turns.add(turn); + _turns.sort((a, b) => a.sequence.compareTo(b.sequence)); + }); + }, + onError: (Object error) { + if (mounted) { + setState(() => _streamFailure = '$error'); + } + }, + ); + } + + String? get _statusMessage => widget.statusMessage ?? _streamFailure; + + void _selectDestination(int index) { + if (_destination != index) { + setState(() => _destination = index); + } + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final projectedTurns = List.unmodifiable(_turns); + final pages = [ + BrainChatScreen( + chatName: widget.chatName, + turns: projectedTurns, + onSend: widget.onSend, + ), + ActivityScreen(turns: projectedTurns), + BrainScreen( + chatName: widget.chatName, + turns: projectedTurns, + statusMessage: _statusMessage, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < _compactBreakpoint; + final content = Column( + children: [ + _StatusBar( + chatName: widget.chatName, + section: _sectionName(_destination), + message: _statusMessage, + ), + Expanded( + child: IndexedStack(index: _destination, children: pages), + ), + ], + ); + + return Scaffold( + body: compact + ? content + : Row( + children: [ + _WorkspaceRail( + selectedIndex: _destination, + onSelected: _selectDestination, + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded(child: content), + ], + ), + bottomNavigationBar: compact + ? _WorkspaceNavigationBar( + selectedIndex: _destination, + onSelected: _selectDestination, + ) + : null, + ); + }, + ); + } +} + +String _sectionName(int index) => switch (index) { + 0 => 'Chat', + 1 => 'Activity', + _ => 'Brain', +}; + +final class _WorkspaceRail extends StatelessWidget { + const _WorkspaceRail({required this.selectedIndex, required this.onSelected}); + + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return NavigationRail( + backgroundColor: BrainPalette.navigation, + minWidth: 88, + groupAlignment: -0.78, + labelType: NavigationRailLabelType.all, + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + leading: const Padding( + padding: EdgeInsets.only(top: 10, bottom: 28), + child: _BrainMark(), + ), + destinations: _destinations(), + ); + } +} + +final class _WorkspaceNavigationBar extends StatelessWidget { + const _WorkspaceNavigationBar({ + required this.selectedIndex, + required this.onSelected, + }); + + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.forum_outlined, key: Key('destination_chat')), + selectedIcon: Icon(Icons.forum, key: Key('destination_chat')), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')), + selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')), + label: 'Activity', + ), + NavigationDestination( + icon: Icon(Icons.hub_outlined, key: Key('destination_brain')), + selectedIcon: Icon(Icons.hub, key: Key('destination_brain')), + label: 'Brain', + ), + ], + ); + } +} + +List _destinations() => const [ + NavigationRailDestination( + icon: Icon(Icons.forum_outlined, key: Key('destination_chat')), + selectedIcon: Icon(Icons.forum, key: Key('destination_chat')), + label: Text('Chat'), + ), + NavigationRailDestination( + icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')), + selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')), + label: Text('Activity'), + ), + NavigationRailDestination( + icon: Icon(Icons.hub_outlined, key: Key('destination_brain')), + selectedIcon: Icon(Icons.hub, key: Key('destination_brain')), + label: Text('Brain'), + ), +]; + +final class _BrainMark extends StatelessWidget { + const _BrainMark(); + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: BrainPalette.signal.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.4)), + ), + child: const Icon( + Icons.graphic_eq_rounded, + color: BrainPalette.signal, + size: 20, + ), + ); + } +} + +final class _StatusBar extends StatelessWidget { + const _StatusBar({ + required this.chatName, + required this.section, + this.message, + }); + + final String chatName; + final String section; + final String? message; + + @override + Widget build(BuildContext context) { + final offline = message != null && message!.isNotEmpty; + + return Container( + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: const BoxDecoration( + color: BrainPalette.surfaceRaised, + border: Border(bottom: BorderSide(color: BrainPalette.line)), + ), + child: Row( + children: [ + const Text('DigitalBrain', style: BrainType.title), + const SizedBox(width: 10), + Container(width: 1, height: 16, color: BrainPalette.lineStrong), + const SizedBox(width: 10), + Text(section, style: BrainType.metaStrong), + const Spacer(), + Text('chat:$chatName', style: BrainType.meta), + const SizedBox(width: 14), + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: offline ? BrainPalette.signal : BrainPalette.success, + boxShadow: [ + BoxShadow( + color: (offline ? BrainPalette.signal : BrainPalette.success) + .withValues(alpha: 0.35), + blurRadius: 8, + ), + ], + ), + ), + const SizedBox(width: 8), + Text(offline ? 'not connected' : 'connected', style: BrainType.meta), + ], + ), + ); + } +} + +final class BrainChatScreen extends StatefulWidget { + const BrainChatScreen({ + super.key, + required this.chatName, + required this.turns, + this.onSend, + }); + + final String chatName; + final List turns; + final SendMessage? onSend; + + @override + State createState() => _BrainChatScreenState(); +} + +final class _BrainChatScreenState extends State { + final _composer = TextEditingController(); + final _composerFocus = FocusNode(); + final _scroll = ScrollController(); + + bool _awaitingBrain = false; + int _awaitingAfterSequence = 0; + String? _failure; + + @override + void didUpdateWidget(covariant BrainChatScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.turns.length != oldWidget.turns.length) { + if (_awaitingBrain && + widget.turns.any( + (turn) => !turn.fromUser && turn.sequence > _awaitingAfterSequence, + )) { + setState(() => _awaitingBrain = false); + } _scrollToLatest(); - }); + } } void _scrollToLatest() { @@ -130,6 +399,9 @@ final class _BrainChatScreenState extends State { _composer.clear(); setState(() { _awaitingBrain = true; + _awaitingAfterSequence = widget.turns.isEmpty + ? 0 + : widget.turns.last.sequence; _failure = null; }); @@ -148,7 +420,6 @@ final class _BrainChatScreenState extends State { @override void dispose() { - _subscription?.cancel(); _composer.dispose(); _composerFocus.dispose(); _scroll.dispose(); @@ -157,15 +428,15 @@ final class _BrainChatScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: Column( + return ColoredBox( + color: BrainPalette.surface, + child: Column( children: [ - _StatusBar(chatName: widget.chatName, message: widget.statusMessage), Expanded( - child: _turns.isEmpty && !_awaitingBrain + child: widget.turns.isEmpty && !_awaitingBrain ? const _EmptyJournal() : _Journal( - turns: _turns, + turns: widget.turns, awaitingBrain: _awaitingBrain, controller: _scroll, ), @@ -183,45 +454,6 @@ final class _BrainChatScreenState extends State { } } -final class _StatusBar extends StatelessWidget { - const _StatusBar({required this.chatName, this.message}); - - final String chatName; - final String? message; - - @override - Widget build(BuildContext context) { - final offline = message != null && message!.isNotEmpty; - - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 20), - decoration: const BoxDecoration( - color: BrainPalette.surfaceRaised, - border: Border(bottom: BorderSide(color: BrainPalette.line)), - ), - child: Row( - children: [ - const Text('DigitalBrain', style: BrainType.title), - const SizedBox(width: 12), - Text('chat:$chatName', style: BrainType.meta), - const Spacer(), - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: offline ? BrainPalette.signal : BrainPalette.owner, - ), - ), - const SizedBox(width: 8), - Text(offline ? 'not connected' : 'connected', style: BrainType.meta), - ], - ), - ); - } -} - final class _EmptyJournal extends StatelessWidget { const _EmptyJournal(); @@ -231,8 +463,22 @@ final class _EmptyJournal extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: BrainPalette.surfaceRaised, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: BrainPalette.line), + ), + child: const Icon( + Icons.chat_bubble_outline_rounded, + color: BrainPalette.textMuted, + ), + ), + const SizedBox(height: 18), const Text('Nothing yet.', style: BrainType.empty), - const SizedBox(height: 8), + const SizedBox(height: 7), Text( 'Ask your brain to do something.', style: BrainType.body.copyWith(color: BrainPalette.textMuted), @@ -243,8 +489,6 @@ final class _EmptyJournal extends StatelessWidget { } } -/// The signature: every turn keeps its real journal sequence in a monospace -/// rail. Order here is durable kernel truth, not scroll position. final class _Journal extends StatelessWidget { const _Journal({ required this.turns, @@ -261,11 +505,11 @@ final class _Journal extends StatelessWidget { return Align( alignment: Alignment.topCenter, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), + constraints: const BoxConstraints(maxWidth: 820), child: ListView.builder( key: const Key('chat_journal'), controller: controller, - padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 34, horizontal: 28), itemCount: turns.length + (awaitingBrain ? 1 : 0), itemBuilder: (context, index) { if (index == turns.length) { @@ -290,12 +534,12 @@ final class _JournalTurn extends StatelessWidget { return Padding( key: Key('turn_${turn.sequence}'), - padding: const EdgeInsets.only(bottom: 26), + padding: const EdgeInsets.only(bottom: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 44, + width: 48, child: Padding( padding: const EdgeInsets.only(top: 3), child: Text( @@ -304,17 +548,24 @@ final class _JournalTurn extends StatelessWidget { ), ), ), - Container(width: 2, height: 20, color: voice), - const SizedBox(width: 16), + Container( + width: 3, + height: 22, + decoration: BoxDecoration( + color: voice, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( turn.fromUser ? 'you' : 'brain', - style: BrainType.meta.copyWith(color: voice), + style: BrainType.metaStrong.copyWith(color: voice), ), - const SizedBox(height: 6), + const SizedBox(height: 7), SelectableText(turn.text, style: BrainType.body), ], ), @@ -331,16 +582,23 @@ final class _AwaitingTurn extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: 26), + padding: const EdgeInsets.only(bottom: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(width: 44), - Container(width: 2, height: 20, color: BrainPalette.signal), - const SizedBox(width: 16), + const SizedBox(width: 48), + Container( + width: 3, + height: 22, + decoration: BoxDecoration( + color: BrainPalette.signal, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 18), Text( 'thinking', - style: BrainType.meta.copyWith(color: BrainPalette.signal), + style: BrainType.metaStrong.copyWith(color: BrainPalette.signal), ), ], ), @@ -357,8 +615,8 @@ final class _FailureNotice extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - color: BrainPalette.surfaceRaised, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 11), + color: BrainPalette.signal.withValues(alpha: 0.08), child: Text( message, style: BrainType.meta.copyWith(color: BrainPalette.signal), @@ -387,61 +645,72 @@ final class _Composer extends StatelessWidget { color: BrainPalette.surfaceRaised, border: Border(top: BorderSide(color: BrainPalette.line)), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.enter): _SubmitIntent(), - }, - child: Actions( - actions: { - _SubmitIntent: CallbackAction<_SubmitIntent>( - onInvoke: (_) { - unawaited(onSubmit()); - return null; - }, - ), + constraints: const BoxConstraints(maxWidth: 820), + child: Container( + decoration: BoxDecoration( + color: BrainPalette.surfaceSunken, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.lineStrong), + ), + padding: const EdgeInsets.only(left: 16, right: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): + _SubmitIntent(), }, - child: TextField( - key: const Key('chat_composer'), - controller: controller, - focusNode: focusNode, - enabled: enabled, - autofocus: true, - maxLines: 4, - minLines: 1, - style: BrainType.body, - cursorColor: BrainPalette.signal, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - hintText: 'Ask your brain…', - hintStyle: BrainType.body - .copyWith(color: BrainPalette.textMuted), + child: Actions( + actions: { + _SubmitIntent: CallbackAction<_SubmitIntent>( + onInvoke: (_) { + unawaited(onSubmit()); + return null; + }, + ), + }, + child: TextField( + key: const Key('chat_composer'), + controller: controller, + focusNode: focusNode, + enabled: enabled, + autofocus: true, + maxLines: 4, + minLines: 1, + style: BrainType.body, + cursorColor: BrainPalette.signal, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 13, + ), + hintText: 'Ask your brain…', + hintStyle: BrainType.body.copyWith( + color: BrainPalette.textMuted, + ), + ), ), ), ), ), - ), - const SizedBox(width: 12), - TextButton( - key: const Key('chat_send'), - onPressed: enabled ? () => unawaited(onSubmit()) : null, - style: TextButton.styleFrom( - foregroundColor: BrainPalette.signal, - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + const SizedBox(width: 8), + IconButton( + key: const Key('chat_send'), + onPressed: enabled ? () => unawaited(onSubmit()) : null, + tooltip: 'Send', + color: BrainPalette.signal, + disabledColor: BrainPalette.textFaint, + icon: const Icon(Icons.arrow_upward_rounded), ), - child: const Text('Send', style: BrainType.meta), - ), - ], + ], + ), ), ), ), diff --git a/clients/digitalbrain_flutter/shell/lib/main.dart b/clients/digitalbrain_flutter/shell/lib/main.dart index c95e2f64..ade58424 100644 --- a/clients/digitalbrain_flutter/shell/lib/main.dart +++ b/clients/digitalbrain_flutter/shell/lib/main.dart @@ -28,4 +28,4 @@ void main() { : (text) => edge.sendMessage(chatName: chat, text: text), ), ); -} \ No newline at end of file +} diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart index 7112ec2c..84584ac5 100644 --- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart +++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart @@ -6,13 +6,96 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent( - sequence: sequence, - fromUser: fromUser, - text: text, - commandId: 'c$sequence', - ); + sequence: sequence, + fromUser: fromUser, + text: text, + commandId: 'c$sequence', +); void main() { + testWidgets('the workspace exposes Chat, Activity, and Brain destinations', ( + tester, + ) async { + await tester.pumpWidget(const BrainChatApp(chatName: 'main')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('destination_chat')), findsOneWidget); + expect(find.byKey(const Key('destination_activity')), findsOneWidget); + expect(find.byKey(const Key('destination_brain')), findsOneWidget); + + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('activity_screen')), findsOneWidget); + expect(find.text('No activity yet.'), findsOneWidget); + + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('brain_screen')), findsOneWidget); + expect(find.text('General assistant'), findsOneWidget); + expect( + find.text('Gmail message → Salesforce Account description'), + findsOneWidget, + ); + expect(find.text('Approval required'), findsOneWidget); + }); + + testWidgets('activity shows journal facts without message content', ( + tester, + ) async { + final turns = StreamController(); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}), + ); + + turns.add(_turn(1, true, 'private customer message')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + + expect(find.text('UserMessaged'), findsOneWidget); + expect(find.text('sequence 001'), findsOneWidget); + expect(find.text('command c1'), findsOneWidget); + expect(find.text('private customer message'), findsNothing); + }); + + testWidgets('the shared event projection survives destination changes', ( + tester, + ) async { + final turns = StreamController(); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}), + ); + + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + turns.add(_turn(9, false, 'arrived while activity was open')); + await tester.pumpAndSettle(); + + expect(find.text('AssistantResponded'), findsOneWidget); + + await tester.tap(find.byKey(const Key('destination_chat'))); + await tester.pumpAndSettle(); + expect(find.text('arrived while activity was open'), findsOneWidget); + expect(find.text('009'), findsOneWidget); + }); + + testWidgets('narrow windows use bottom navigation', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const BrainChatApp(chatName: 'main')); + await tester.pumpAndSettle(); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(NavigationRail), findsNothing); + }); + testWidgets('an empty conversation invites the owner to act', (tester) async { await tester.pumpWidget(const BrainChatApp(chatName: 'main')); await tester.pump(); @@ -22,8 +105,9 @@ void main() { expect(find.byKey(const Key('chat_journal')), findsNothing); }); - testWidgets('each turn carries its journal sequence and speaker', - (tester) async { + testWidgets('each turn carries its journal sequence and speaker', ( + tester, + ) async { final turns = StreamController(); addTearDown(turns.close); @@ -60,8 +144,9 @@ void main() { expect(find.text('only once'), findsOneWidget); }); - testWidgets('sending hands the text to the edge and awaits the brain', - (tester) async { + testWidgets('sending hands the text to the edge and awaits the brain', ( + tester, + ) async { final sent = []; final turns = StreamController(); addTearDown(turns.close); @@ -91,15 +176,16 @@ void main() { expect(find.text('Done.'), findsOneWidget); }); - testWidgets('a disconnected edge disables sending and says so', - (tester) async { + testWidgets('a disconnected edge disables sending and says so', ( + tester, + ) async { await tester.pumpWidget( const BrainChatApp(chatName: 'main', statusMessage: 'no edge'), ); await tester.pump(); expect(find.text('not connected'), findsOneWidget); - final send = tester.widget(find.byKey(const Key('chat_send'))); + final send = tester.widget(find.byKey(const Key('chat_send'))); expect(send.onPressed, isNull); }); } From 9334a152bc836e4ab4c339fabfbd19bf445027fb Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 08:11:21 +0200 Subject: [PATCH 07/20] feat: render live brain topology --- CLAUDE.md | 4 +- README.md | 6 +- .../bin/digitalbrain_host.dart | 3 +- .../lib/digitalbrain_flutter.dart | 18 +- .../lib/src/edge_client.dart | 38 +- .../lib/src/shell_surface.dart | 4 +- .../lib/src/sse_frames.dart | 4 +- .../shell/lib/brain_screen.dart | 333 ++++++++++++-- .../shell/lib/brain_topology_canvas.dart | 420 ++++++++++++++++++ .../shell/lib/chat_screen.dart | 42 +- .../digitalbrain_flutter/shell/lib/main.dart | 1 + .../shell/test/chat_screen_test.dart | 132 ++++++ .../test/edge_client_test.dart | 88 +++- .../test/shell_surface_test.dart | 102 +++-- .../lib/src/ui_edge_models.dart | 89 +++- .../test/ui_edge_models_test.dart | 64 ++- scripts/verify-product.ps1 | 58 +++ .../FlutterHostingProjectionSupport.cs | 29 +- .../Hosting/FlutterHostingUIEdgeContracts.cs | 14 +- .../Hosting/HostingProjectionContracts.cs | 25 +- .../DigitalBrainHostingExtensions.cs | 25 +- ...talBrain.Modules.Flutter.Http.Tests.csproj | 1 + .../UIEdgeRoundTrip.cs | 77 ++++ .../UIEdgeSse.cs | 66 +++ .../UIEdgeVocabulary.cs | 1 + .../UIFixture.cs | 8 + .../BrainEndpoints.cs | 23 + .../BrainTopologyReader.cs | 52 +++ .../BrainTopologySnapshot.cs | 14 + .../ChatEventFeed.cs | 14 +- .../ChatTurnEvent.cs | 7 +- .../UIEdgeContract.cs | 2 + .../UIEdgeServices.cs | 1 + .../UIHost.cs | 1 + 34 files changed, 1620 insertions(+), 146 deletions(-) create mode 100644 clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs diff --git a/CLAUDE.md b/CLAUDE.md index a0241e77..a45d66a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,8 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow `scripts/verify-product.ps1` is the one-command live oracle. It builds Release, waits for the product resources, checks the exact MCP catalog, drives a real Gemma4 turn, confirms the durable transcript -and correlation, checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is -specified. +and correlation, confirms the configured modules and active chat neuron through the UI topology, +checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is specified. ## Gates diff --git a/README.md b/README.md index abc13fdf..e8a1b92d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The plan of record. Nothing is shipped unless it says Built. | Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** | | Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** | | Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** | -| Product shell — responsive Chat, content-safe Activity, capability-accurate Brain surfaces | **Built** | +| Product shell — responsive Chat, content-safe Activity, live 3D-projected Brain topology, pulses and inspector | **Built** | | Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** | | Time — durable one-shot `ICountdown` and its recovery tests | **Built** | | Time — reminders, recurring interval/calendar scheduling, DST | Designed | @@ -73,8 +73,8 @@ aspire run ``` [scripts/verify-product.ps1](scripts/verify-product.ps1) performs the Release build and a live, -self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals, and -OpenTelemetry: +self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals, +live UI topology, and OpenTelemetry: ```powershell ./scripts/verify-product.ps1 diff --git a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart index 329a4a6d..97fb459e 100644 --- a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart +++ b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart @@ -6,7 +6,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; Future main(List args) async { final shell = _arg(args, '--shell') ?? DigitalBrainHostEnv.resolveShell(); final open = _arg(args, '--open'); - final reconnectSeconds = int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2; + final reconnectSeconds = + int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2; final client = DigitalBrainUiEdgeClient.fromEnvironment(); final surface = ShellSurfaceController(); diff --git a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart index eae8b094..13c1e5aa 100644 --- a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart +++ b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart @@ -1,12 +1,16 @@ -export 'package:digitalbrain_wire/digitalbrain_wire.dart' show - ActivateControlRequest, - ChatTurnEvent, - OpenSceneRequest, - SceneOpenedEvent, - SendMessageRequest; +export 'package:digitalbrain_wire/digitalbrain_wire.dart' + show + ActivateControlRequest, + BrainModule, + BrainNeuron, + BrainTopologySnapshot, + ChatTurnEvent, + OpenSceneRequest, + SceneOpenedEvent, + SendMessageRequest; export 'src/edge_client.dart'; export 'src/host_environment.dart'; export 'src/shell_surface.dart'; export 'src/sse_chat_frames.dart'; -export 'src/sse_frames.dart'; \ No newline at end of file +export 'src/sse_frames.dart'; diff --git a/clients/digitalbrain_flutter/lib/src/edge_client.dart b/clients/digitalbrain_flutter/lib/src/edge_client.dart index 4c36e21d..31a3ac2b 100644 --- a/clients/digitalbrain_flutter/lib/src/edge_client.dart +++ b/clients/digitalbrain_flutter/lib/src/edge_client.dart @@ -8,11 +8,9 @@ import 'sse_chat_frames.dart'; import 'sse_frames.dart'; final class DigitalBrainUiEdgeClient { - DigitalBrainUiEdgeClient({ - required this.baseUri, - http.Client? httpClient, - }) : _http = httpClient ?? http.Client(), - _ownsClient = httpClient == null; + DigitalBrainUiEdgeClient({required this.baseUri, http.Client? httpClient}) + : _http = httpClient ?? http.Client(), + _ownsClient = httpClient == null; factory DigitalBrainUiEdgeClient.fromEnvironment({ http.Client? httpClient, @@ -148,6 +146,36 @@ final class DigitalBrainUiEdgeClient { } } + Future readBrainTopology() async { + final uri = baseUri.replace(path: '/brain/topology'); + final response = await _http.get(uri); + if (response.statusCode != 200) { + throw StateError( + 'brain-topology failed: ${response.statusCode} ${response.body}', + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('brain-topology response is not an object'); + } + + return BrainTopologySnapshot.fromJson(Map.from(decoded)); + } + + Stream watchBrainTopology({ + Duration pollInterval = const Duration(seconds: 2), + }) async* { + while (true) { + try { + yield await readBrainTopology(); + } on Object catch (error, stackTrace) { + yield* Stream.error(error, stackTrace); + } + await Future.delayed(pollInterval); + } + } + void close() { if (_ownsClient) { _http.close(); diff --git a/clients/digitalbrain_flutter/lib/src/shell_surface.dart b/clients/digitalbrain_flutter/lib/src/shell_surface.dart index 6b81ebb6..d1cacfa6 100644 --- a/clients/digitalbrain_flutter/lib/src/shell_surface.dart +++ b/clients/digitalbrain_flutter/lib/src/shell_surface.dart @@ -26,7 +26,9 @@ final class ShellSurfaceController { title: event.title, sequence: event.sequence, ); - final index = _scenes.indexWhere((scene) => scene.sceneKey == view.sceneKey); + final index = _scenes.indexWhere( + (scene) => scene.sceneKey == view.sceneKey, + ); if (index >= 0) { _scenes[index] = view; } else { diff --git a/clients/digitalbrain_flutter/lib/src/sse_frames.dart b/clients/digitalbrain_flutter/lib/src/sse_frames.dart index 88273729..d1123940 100644 --- a/clients/digitalbrain_flutter/lib/src/sse_frames.dart +++ b/clients/digitalbrain_flutter/lib/src/sse_frames.dart @@ -55,9 +55,7 @@ final class SseSceneOpenedParser { if (decoded is! Map) { return null; } - return SceneOpenedEvent.fromJson( - Map.from(decoded), - ); + return SceneOpenedEvent.fromJson(Map.from(decoded)); } on FormatException { return null; } on TypeError { diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart index c5256634..8b227f3d 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart @@ -2,23 +2,57 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; import 'package:flutter/material.dart'; import 'brain_theme.dart'; +import 'brain_topology_canvas.dart'; -final class BrainScreen extends StatelessWidget { +final class BrainScreen extends StatefulWidget { const BrainScreen({ super.key, required this.chatName, required this.turns, + this.topology, this.statusMessage, }); final String chatName; final List turns; + final BrainTopologySnapshot? topology; final String? statusMessage; + @override + State createState() => _BrainScreenState(); +} + +final class _BrainScreenState extends State { + BrainTopologySelection? _selection; + + @override + void initState() { + super.initState(); + if (widget.turns.isNotEmpty) { + _selection = BrainPulseSelection(widget.turns.last); + } + } + + @override + void didUpdateWidget(covariant BrainScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.turns.isNotEmpty && + (oldWidget.turns.isEmpty || + widget.turns.last.sequence != oldWidget.turns.last.sequence || + widget.turns.last.correlationId != + oldWidget.turns.last.correlationId)) { + setState(() => _selection = BrainPulseSelection(widget.turns.last)); + } + } + @override Widget build(BuildContext context) { - final connected = statusMessage == null || statusMessage!.isEmpty; - final lastSequence = turns.isEmpty ? '—' : '${turns.last.sequence}'; + final connected = + widget.statusMessage == null || widget.statusMessage!.isEmpty; + final lastSequence = widget.turns.isEmpty + ? '—' + : '${widget.turns.last.sequence}'; + final pulse = widget.turns.lastOrNull; return ColoredBox( key: const Key('brain_screen'), @@ -26,14 +60,14 @@ final class BrainScreen extends StatelessWidget { child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 980), + constraints: const BoxConstraints(maxWidth: 1120), child: ListView( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), children: [ const Text('Brain', style: BrainType.heading), const SizedBox(height: 8), const Text( - 'What this DigitalBrain can do, and what remains under your control.', + 'Live modules, owner-scoped neurons, and durable causal pulses.', style: BrainType.bodyMuted, ), const SizedBox(height: 26), @@ -48,10 +82,25 @@ final class BrainScreen extends StatelessWidget { ? BrainPalette.success : BrainPalette.signal, ), - _MetricCard(label: 'Conversation', value: chatName), + _MetricCard( + label: 'Modules', + value: '${widget.topology?.modules.length ?? 0}', + ), + _MetricCard( + label: 'Active neurons', + value: '${widget.topology?.neurons.length ?? 0}', + ), _MetricCard(label: 'Last sequence', value: lastSequence), ], ), + const SizedBox(height: 24), + _TopologyPanel( + topology: widget.topology, + pulse: pulse, + selection: _selection, + onSelected: (selection) => + setState(() => _selection = selection), + ), const SizedBox(height: 30), const _SectionLabel('CAPABILITIES'), const SizedBox(height: 12), @@ -75,7 +124,7 @@ final class BrainScreen extends StatelessWidget { const _BoundaryCard(), if (!connected) ...[ const SizedBox(height: 20), - _ConnectionNotice(message: statusMessage!), + _ConnectionNotice(message: widget.statusMessage!), ], ], ), @@ -85,6 +134,240 @@ final class BrainScreen extends StatelessWidget { } } +final class _TopologyPanel extends StatelessWidget { + const _TopologyPanel({ + required this.topology, + required this.pulse, + required this.selection, + required this.onSelected, + }); + + final BrainTopologySnapshot? topology; + final ChatTurnEvent? pulse; + final BrainTopologySelection? selection; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final snapshot = topology; + if (snapshot == null) { + return Container( + height: 260, + decoration: _panelDecoration(), + child: const Center( + child: Text('Waiting for live topology…', style: BrainType.bodyMuted), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 860; + final canvas = Container( + height: 430, + decoration: _panelDecoration(), + clipBehavior: Clip.antiAlias, + child: BrainTopologyCanvas( + topology: snapshot, + pulse: pulse, + onSelected: onSelected, + ), + ); + final explorer = _TopologyExplorer( + topology: snapshot, + selection: selection, + onSelected: onSelected, + ); + + if (wide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: canvas), + const SizedBox(width: 14), + SizedBox(width: 320, child: explorer), + ], + ); + } + + return Column(children: [canvas, const SizedBox(height: 14), explorer]); + }, + ); + } +} + +final class _TopologyExplorer extends StatelessWidget { + const _TopologyExplorer({ + required this.topology, + required this.selection, + required this.onSelected, + }); + + final BrainTopologySnapshot topology; + final BrainTopologySelection? selection; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('brain_inspector'), + constraints: const BoxConstraints(minHeight: 430), + padding: const EdgeInsets.all(18), + decoration: _panelDecoration(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('INSPECTOR', style: BrainType.meta), + const SizedBox(height: 14), + _SelectionDetails(selection: selection), + const SizedBox(height: 20), + const Text('MODULES', style: BrainType.meta), + const SizedBox(height: 8), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + for (var index = 0; index < topology.modules.length; index++) + ActionChip( + key: Key('topology_module_$index'), + label: Text(_moduleLabel(topology.modules[index].id)), + onPressed: () => + onSelected(BrainModuleSelection(topology.modules[index])), + ), + ], + ), + const SizedBox(height: 18), + const Text('ACTIVE NEURONS', style: BrainType.meta), + const SizedBox(height: 8), + for (var index = 0; index < topology.neurons.length; index++) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: TextButton( + key: Key('topology_neuron_$index'), + onPressed: () => + onSelected(BrainNeuronSelection(topology.neurons[index])), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + topology.neurons[index].id, + overflow: TextOverflow.ellipsis, + style: BrainType.meta, + ), + ), + ), + ), + ], + ), + ); + } +} + +final class _SelectionDetails extends StatelessWidget { + const _SelectionDetails({required this.selection}); + + final BrainTopologySelection? selection; + + @override + Widget build(BuildContext context) { + return switch (selection) { + BrainPulseSelection(:final turn) => _PulseDetails(turn: turn), + BrainNeuronSelection(:final neuron) => _NeuronDetails(neuron: neuron), + BrainModuleSelection(:final module) => _ModuleDetails(module: module), + null => const Text( + 'Select a module or neuron. New chat turns open their causal pulse automatically.', + style: BrainType.bodyMuted, + ), + }; + } +} + +final class _PulseDetails extends StatelessWidget { + const _PulseDetails({required this.turn}); + + final ChatTurnEvent turn; + + @override + Widget build(BuildContext context) { + return Column( + key: const Key('brain_pulse_details'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(turn.synapse, style: BrainType.cardTitle), + const SizedBox(height: 10), + _InspectorField(label: 'neuron', value: turn.neuronId), + _InspectorField(label: 'caller', value: turn.caller), + _InspectorField(label: 'correlation', value: turn.correlationId), + _InspectorField(label: 'command', value: turn.commandId), + _InspectorField(label: 'sequence', value: '${turn.sequence}'), + _InspectorField( + label: 'timestamp', + value: turn.timestamp.toIso8601String(), + ), + ], + ); + } +} + +final class _NeuronDetails extends StatelessWidget { + const _NeuronDetails({required this.neuron}); + + final BrainNeuron neuron; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(neuron.grainType, style: BrainType.cardTitle), + const SizedBox(height: 10), + _InspectorField(label: 'id', value: neuron.id), + _InspectorField(label: 'identity', value: neuron.identity), + _InspectorField(label: 'silo', value: neuron.silo), + ], + ); + } +} + +final class _ModuleDetails extends StatelessWidget { + const _ModuleDetails({required this.module}); + + final BrainModule module; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_moduleLabel(module.id), style: BrainType.cardTitle), + const SizedBox(height: 10), + _InspectorField(label: 'module id', value: module.id), + ], + ); + } +} + +final class _InspectorField extends StatelessWidget { + const _InspectorField({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: BrainType.meta), + const SizedBox(height: 2), + SelectableText(value, style: BrainType.metaStrong), + ], + ), + ); + } +} + final class _MetricCard extends StatelessWidget { const _MetricCard({ required this.label, @@ -101,11 +384,7 @@ final class _MetricCard extends StatelessWidget { return Container( width: 190, padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: BrainPalette.surfaceRaised, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: BrainPalette.line), - ), + decoration: _panelDecoration(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -129,9 +408,7 @@ final class _SectionLabel extends StatelessWidget { final String text; @override - Widget build(BuildContext context) { - return Text(text, style: BrainType.meta); - } + Widget build(BuildContext context) => Text(text, style: BrainType.meta); } final class _CapabilityCard extends StatelessWidget { @@ -151,11 +428,7 @@ final class _CapabilityCard extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: BrainPalette.surfaceRaised, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: BrainPalette.line), - ), + decoration: _panelDecoration(), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -219,11 +492,7 @@ final class _BoundaryCard extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: BrainPalette.surfaceSunken, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: BrainPalette.line), - ), + decoration: _panelDecoration(color: BrainPalette.surfaceSunken), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -277,3 +546,17 @@ final class _ConnectionNotice extends StatelessWidget { ); } } + +BoxDecoration _panelDecoration({Color color = BrainPalette.surfaceRaised}) => + BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ); + +String _moduleLabel(String id) { + final type = id.split('.').last; + return type.endsWith('Module') + ? type.substring(0, type.length - 'Module'.length) + : type; +} diff --git a/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart new file mode 100644 index 00000000..fe0349e2 --- /dev/null +++ b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart @@ -0,0 +1,420 @@ +import 'dart:math' as math; + +import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'brain_theme.dart'; + +sealed class BrainTopologySelection { + const BrainTopologySelection(); +} + +final class BrainModuleSelection extends BrainTopologySelection { + const BrainModuleSelection(this.module); + + final BrainModule module; +} + +final class BrainNeuronSelection extends BrainTopologySelection { + const BrainNeuronSelection(this.neuron); + + final BrainNeuron neuron; +} + +final class BrainPulseSelection extends BrainTopologySelection { + const BrainPulseSelection(this.turn); + + final ChatTurnEvent turn; +} + +final class BrainTopologyCanvas extends StatefulWidget { + const BrainTopologyCanvas({ + super.key, + required this.topology, + required this.onSelected, + this.pulse, + }); + + final BrainTopologySnapshot topology; + final ChatTurnEvent? pulse; + final ValueChanged onSelected; + + @override + State createState() => _BrainTopologyCanvasState(); +} + +final class _BrainTopologyCanvasState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulse; + double _rotationX = -0.18; + double _rotationY = 0.42; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + if (_hasPulseTarget(widget.topology, widget.pulse)) { + _pulse.forward(); + } + } + + @override + void didUpdateWidget(covariant BrainTopologyCanvas oldWidget) { + super.didUpdateWidget(oldWidget); + final pulseChanged = + widget.pulse?.sequence != oldWidget.pulse?.sequence || + widget.pulse?.correlationId != oldWidget.pulse?.correlationId; + final hadTarget = _hasPulseTarget(oldWidget.topology, oldWidget.pulse); + final hasTarget = _hasPulseTarget(widget.topology, widget.pulse); + if (!hasTarget) { + if (pulseChanged || hadTarget) { + _pulse.reset(); + } + } else if (pulseChanged || !hadTarget) { + _pulse.forward(from: 0); + } + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + void _rotate(DragUpdateDetails details) { + setState(() { + _rotationY += details.delta.dx * 0.008; + _rotationX = (_rotationX + details.delta.dy * 0.008).clamp(-1.0, 1.0); + }); + } + + @override + Widget build(BuildContext context) { + final disableAnimations = MediaQuery.disableAnimationsOf(context); + final hasPulseTarget = _hasPulseTarget(widget.topology, widget.pulse); + + return Semantics( + key: const Key('brain_topology_canvas'), + label: + 'Interactive three-dimensional DigitalBrain topology. Drag to rotate; use the topology list to inspect accessible node details.', + image: true, + child: LayoutBuilder( + builder: (context, constraints) { + final size = Size(constraints.maxWidth, constraints.maxHeight); + return AnimatedBuilder( + animation: _pulse, + builder: (context, _) { + final projected = _projectTopology( + widget.topology, + size, + _rotationX, + _rotationY, + ); + final pulseValue = disableAnimations ? 1.0 : _pulse.value; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: _rotate, + onTapUp: (details) { + final selected = _hitTest(projected, details.localPosition); + if (selected != null) { + widget.onSelected(selected.selection); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: _TopologyPainter( + nodes: projected, + pulse: widget.pulse, + pulseValue: pulseValue, + ), + ), + if (hasPulseTarget) + const IgnorePointer(key: Key('brain_pulse')), + ], + ), + ); + }, + ); + }, + ), + ); + } +} + +bool _hasPulseTarget(BrainTopologySnapshot topology, ChatTurnEvent? pulse) => + pulse != null && + topology.neurons.any((neuron) => neuron.id == pulse.neuronId); + +final class _GraphNode { + const _GraphNode({ + required this.id, + required this.label, + required this.module, + required this.selection, + required this.x, + required this.y, + required this.z, + }); + + final String id; + final String label; + final bool module; + final BrainTopologySelection selection; + final double x; + final double y; + final double z; +} + +final class _ProjectedNode { + const _ProjectedNode({ + required this.node, + required this.center, + required this.radius, + required this.depth, + }); + + final _GraphNode node; + final Offset center; + final double radius; + final double depth; + + BrainTopologySelection get selection => node.selection; +} + +List<_ProjectedNode> _projectTopology( + BrainTopologySnapshot topology, + Size size, + double rotationX, + double rotationY, +) { + final graph = <_GraphNode>[ + ..._placeModules(topology.modules), + ..._placeNeurons(topology.neurons), + ]; + final base = math.min(size.width, size.height) * 0.36; + final center = Offset(size.width * 0.5, size.height * 0.51); + final cosY = math.cos(rotationY); + final sinY = math.sin(rotationY); + final cosX = math.cos(rotationX); + final sinX = math.sin(rotationX); + + final projected = <_ProjectedNode>[]; + for (final node in graph) { + final xY = node.x * cosY + node.z * sinY; + final zY = -node.x * sinY + node.z * cosY; + final yX = node.y * cosX - zY * sinX; + final zX = node.y * sinX + zY * cosX; + final perspective = 1.0 / (1.85 - zX * 0.36); + final radius = (node.module ? 10.0 : 6.0) * (0.72 + perspective); + + projected.add( + _ProjectedNode( + node: node, + center: Offset( + center.dx + xY * base * perspective, + center.dy + yX * base * perspective, + ), + radius: radius, + depth: zX, + ), + ); + } + + projected.sort((a, b) => a.depth.compareTo(b.depth)); + return projected; +} + +Iterable<_GraphNode> _placeModules(List modules) sync* { + for (var index = 0; index < modules.length; index++) { + final position = _spherePosition(index, modules.length, 0.88, 0); + final module = modules[index]; + yield _GraphNode( + id: module.id, + label: _moduleLabel(module.id), + module: true, + selection: BrainModuleSelection(module), + x: position.x, + y: position.y, + z: position.z, + ); + } +} + +Iterable<_GraphNode> _placeNeurons(List neurons) sync* { + for (var index = 0; index < neurons.length; index++) { + final position = _spherePosition(index, neurons.length, 0.62, 1.3); + final neuron = neurons[index]; + yield _GraphNode( + id: neuron.id, + label: neuron.grainType, + module: false, + selection: BrainNeuronSelection(neuron), + x: position.x, + y: position.y, + z: position.z, + ); + } +} + +({double x, double y, double z}) _spherePosition( + int index, + int count, + double radius, + double phase, +) { + if (count <= 1) { + return (x: 0, y: 0, z: radius); + } + final y = 1 - (2 * (index + 0.5) / count); + final ring = math.sqrt(math.max(0, 1 - y * y)); + final theta = index * math.pi * (3 - math.sqrt(5)) + phase; + return ( + x: math.cos(theta) * ring * radius, + y: y * radius, + z: math.sin(theta) * ring * radius, + ); +} + +_ProjectedNode? _hitTest(List<_ProjectedNode> nodes, Offset position) { + for (final node in nodes.reversed) { + if ((node.center - position).distance <= node.radius + 8) { + return node; + } + } + return null; +} + +String _moduleLabel(String id) { + final type = id.split('.').last; + return type.endsWith('Module') + ? type.substring(0, type.length - 'Module'.length) + : type; +} + +final class _TopologyPainter extends CustomPainter { + const _TopologyPainter({ + required this.nodes, + required this.pulse, + required this.pulseValue, + }); + + final List<_ProjectedNode> nodes; + final ChatTurnEvent? pulse; + final double pulseValue; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width * 0.5, size.height * 0.51); + final hull = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = BrainPalette.lineStrong.withValues(alpha: 0.55); + final radius = math.min(size.width, size.height) * 0.34; + canvas.drawOval( + Rect.fromCenter( + center: center, + width: radius * 2.15, + height: radius * 1.55, + ), + hull, + ); + canvas.drawOval( + Rect.fromCenter( + center: center, + width: radius * 1.35, + height: radius * 2.05, + ), + hull..color = BrainPalette.line.withValues(alpha: 0.5), + ); + + final pulseTarget = pulse == null + ? null + : nodes.where((node) => node.node.id == pulse!.neuronId).firstOrNull; + final pulseSource = pulse == null + ? null + : nodes.where((node) => node.node.id == pulse!.caller).firstOrNull; + if (pulseTarget != null) { + final wave = math.sin(pulseValue * math.pi).abs(); + final path = Path() + ..moveTo( + (pulseSource?.center ?? center).dx, + (pulseSource?.center ?? center).dy, + ) + ..quadraticBezierTo( + center.dx, + center.dy - radius * 0.45, + pulseTarget.center.dx, + pulseTarget.center.dy, + ); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..color = BrainPalette.signal.withValues(alpha: 0.25 + wave * 0.7), + ); + canvas.drawCircle( + pulseTarget.center, + pulseTarget.radius + 8 + wave * 16, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = BrainPalette.signal.withValues(alpha: 0.7 - wave * 0.3), + ); + } + + for (final projected in nodes) { + final node = projected.node; + final color = node.module ? BrainPalette.signal : BrainPalette.owner; + final depthAlpha = (0.5 + (projected.depth + 1) * 0.22).clamp(0.35, 1.0); + canvas.drawCircle( + projected.center, + projected.radius * 1.8, + Paint() + ..color = color.withValues(alpha: 0.06 * depthAlpha) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), + ); + canvas.drawCircle( + projected.center, + projected.radius, + Paint()..color = color.withValues(alpha: depthAlpha), + ); + canvas.drawCircle( + projected.center, + projected.radius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = BrainPalette.textPrimary.withValues(alpha: 0.3), + ); + + if (node.module || node.id == pulse?.neuronId) { + final text = TextPainter( + text: TextSpan( + text: node.label, + style: BrainType.meta.copyWith( + color: BrainPalette.textPrimary.withValues(alpha: depthAlpha), + ), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: 120); + text.paint( + canvas, + projected.center + Offset(-text.width / 2, projected.radius + 7), + ); + } + } + } + + @override + bool shouldRepaint(covariant _TopologyPainter oldDelegate) => + oldDelegate.nodes != nodes || + oldDelegate.pulse != pulse || + oldDelegate.pulseValue != pulseValue; +} diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart index b9faa34b..3cf59c26 100644 --- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart @@ -15,12 +15,14 @@ final class BrainChatApp extends StatelessWidget { super.key, required this.chatName, this.turns, + this.topology, this.onSend, this.statusMessage, }); final String chatName; final Stream? turns; + final Stream? topology; final SendMessage? onSend; final String? statusMessage; @@ -33,6 +35,7 @@ final class BrainChatApp extends StatelessWidget { home: _BrainWorkspace( chatName: chatName, turns: turns, + topology: topology, onSend: onSend, statusMessage: statusMessage, ), @@ -44,12 +47,14 @@ final class _BrainWorkspace extends StatefulWidget { const _BrainWorkspace({ required this.chatName, this.turns, + this.topology, this.onSend, this.statusMessage, }); final String chatName; final Stream? turns; + final Stream? topology; final SendMessage? onSend; final String? statusMessage; @@ -63,13 +68,17 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> { final _turns = []; final _seen = {}; StreamSubscription? _subscription; + StreamSubscription? _topologySubscription; + BrainTopologySnapshot? _topology; int _destination = 0; - String? _streamFailure; + String? _turnFailure; + String? _topologyFailure; @override void initState() { super.initState(); _listen(widget.turns); + _listenTopology(widget.topology); } @override @@ -83,10 +92,32 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> { unawaited(_subscription?.cancel()); _listen(widget.turns); } + if (!identical(oldWidget.topology, widget.topology)) { + unawaited(_topologySubscription?.cancel()); + _listenTopology(widget.topology); + } + } + + void _listenTopology(Stream? topology) { + _topologySubscription = topology?.listen( + (snapshot) { + if (mounted) { + setState(() { + _topology = snapshot; + _topologyFailure = null; + }); + } + }, + onError: (Object error) { + if (mounted) { + setState(() => _topologyFailure = '$error'); + } + }, + ); } void _listen(Stream? turns) { - _streamFailure = null; + _turnFailure = null; _subscription = turns?.listen( (turn) { if (!mounted || !_seen.add(turn.sequence)) { @@ -99,13 +130,14 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> { }, onError: (Object error) { if (mounted) { - setState(() => _streamFailure = '$error'); + setState(() => _turnFailure = '$error'); } }, ); } - String? get _statusMessage => widget.statusMessage ?? _streamFailure; + String? get _statusMessage => + widget.statusMessage ?? _turnFailure ?? _topologyFailure; void _selectDestination(int index) { if (_destination != index) { @@ -116,6 +148,7 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> { @override void dispose() { unawaited(_subscription?.cancel()); + unawaited(_topologySubscription?.cancel()); super.dispose(); } @@ -132,6 +165,7 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> { BrainScreen( chatName: widget.chatName, turns: projectedTurns, + topology: _topology, statusMessage: _statusMessage, ), ]; diff --git a/clients/digitalbrain_flutter/shell/lib/main.dart b/clients/digitalbrain_flutter/shell/lib/main.dart index ade58424..c6f16780 100644 --- a/clients/digitalbrain_flutter/shell/lib/main.dart +++ b/clients/digitalbrain_flutter/shell/lib/main.dart @@ -23,6 +23,7 @@ void main() { chatName: chat, statusMessage: status, turns: edge?.watchChatTurns(chatName: chat), + topology: edge?.watchBrainTopology(), onSend: edge == null ? null : (text) => edge.sendMessage(chatName: chat, text: text), diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart index 84584ac5..95ce09cd 100644 --- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart +++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart @@ -10,6 +10,33 @@ ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent( fromUser: fromUser, text: text, commandId: 'c$sequence', + synapse: fromUser ? 'UserMessaged' : 'AssistantResponded', + neuronId: 'chat:owner/main', + caller: 'session:owner/session', + correlationId: 'correlation-$sequence', + timestamp: DateTime.utc(2026, 7, 28, 8, 0, sequence), +); + +BrainTopologySnapshot _topology() => BrainTopologySnapshot( + modules: const [ + BrainModule(id: 'DigitalBrain.Chat.ChatModule'), + BrainModule(id: 'DigitalBrain.AI.AIModule'), + ], + neurons: const [ + BrainNeuron( + id: 'chat:owner/main', + grainType: 'chat', + identity: 'owner/main', + silo: 'silo-1', + ), + ], + observedAt: DateTime.utc(2026, 7, 28, 8), +); + +BrainTopologySnapshot _topologyWithoutNeuron() => BrainTopologySnapshot( + modules: _topology().modules, + neurons: const [], + observedAt: DateTime.utc(2026, 7, 28, 8), ); void main() { @@ -31,6 +58,11 @@ void main() { await tester.tap(find.byKey(const Key('destination_brain'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('brain_screen')), findsOneWidget); + await tester.scrollUntilVisible( + find.text('General assistant'), + 300, + scrollable: find.byType(Scrollable), + ); expect(find.text('General assistant'), findsOneWidget); expect( find.text('Gmail message → Salesforce Account description'), @@ -96,6 +128,106 @@ void main() { expect(find.byType(NavigationRail), findsNothing); }); + testWidgets('Brain renders live modules and active neurons', (tester) async { + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget); + expect(find.byKey(const Key('topology_module_0')), findsOneWidget); + expect(find.byKey(const Key('topology_module_1')), findsOneWidget); + expect(find.text('Chat'), findsWidgets); + expect(find.text('AI'), findsOneWidget); + expect(find.text('chat:owner/main'), findsOneWidget); + }); + + testWidgets('Brain clears a transient topology failure after recovery', ( + tester, + ) async { + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.addError(StateError('topology temporarily unavailable')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.text('Offline'), findsOneWidget); + + topology.add(_topology()); + await tester.pumpAndSettle(); + + expect(find.text('Connected'), findsOneWidget); + }); + + testWidgets('a chat turn pulses Brain and opens correlation inspector', ( + tester, + ) async { + final topology = StreamController(); + final turns = StreamController(); + addTearDown(topology.close); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'main', + topology: topology.stream, + turns: turns.stream, + ), + ); + topology.add(_topology()); + turns.add(_turn(9, true, 'private pulse content')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_pulse')), findsOneWidget); + expect(find.byKey(const Key('brain_inspector')), findsOneWidget); + expect(find.text('correlation-9'), findsOneWidget); + expect(find.text('chat:owner/main'), findsWidgets); + expect(find.text('private pulse content'), findsNothing); + }); + + testWidgets('a pulse waits for its neuron to appear in live topology', ( + tester, + ) async { + final topology = StreamController(); + final turns = StreamController(); + addTearDown(topology.close); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'main', + topology: topology.stream, + turns: turns.stream, + ), + ); + topology.add(_topologyWithoutNeuron()); + turns.add(_turn(12, false, 'not exposed by the topology view')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_pulse')), findsNothing); + + await tester.pump(const Duration(milliseconds: 1200)); + topology.add(_topology()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_pulse')), findsOneWidget); + }); + testWidgets('an empty conversation invites the owner to act', (tester) async { await tester.pumpWidget(const BrainChatApp(chatName: 'main')); await tester.pump(); diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart index e7c9f031..ee731504 100644 --- a/clients/digitalbrain_flutter/test/edge_client_test.dart +++ b/clients/digitalbrain_flutter/test/edge_client_test.dart @@ -16,11 +16,7 @@ void main() { }), ); - await client.openScene( - shellName: 'desk', - sceneKey: 'home', - title: 'Home', - ); + await client.openScene(shellName: 'desk', sceneKey: 'home', title: 'Home'); expect(seen, isNotNull); expect(seen!.method, 'POST'); @@ -153,4 +149,86 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," throwsA(isA()), ); }); + + test( + 'watchBrainTopology polls live modules and neurons without restart', + () async { + var requestCount = 0; + final client = DigitalBrainUiEdgeClient( + baseUri: Uri.parse('http://ui.example:5080'), + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect( + request.url.toString(), + 'http://ui.example:5080/brain/topology', + ); + requestCount++; + return http.Response( + jsonEncode({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': [ + if (requestCount > 1) + { + 'id': 'chat:owner/main', + 'grainType': 'chat', + 'identity': 'owner/main', + 'silo': 'silo-1', + }, + ], + 'observedAt': '2026-07-28T08:00:00Z', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final snapshots = await client + .watchBrainTopology(pollInterval: Duration.zero) + .take(2) + .toList(); + + expect(snapshots.first.neurons, isEmpty); + expect(snapshots.last.neurons.single.id, 'chat:owner/main'); + }, + ); + + test( + 'watchBrainTopology reports a transient failure and keeps polling', + () async { + var requestCount = 0; + final client = DigitalBrainUiEdgeClient( + baseUri: Uri.parse('http://ui.example:5080'), + httpClient: MockClient((request) async { + requestCount++; + if (requestCount == 1) { + return http.Response('temporarily unavailable', 503); + } + return http.Response( + jsonEncode({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': const [], + 'observedAt': '2026-07-28T08:00:00Z', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + final errors = []; + + final snapshot = await client + .watchBrainTopology(pollInterval: Duration.zero) + .handleError(errors.add) + .first; + + expect(requestCount, 2); + expect(errors, hasLength(1)); + expect(snapshot.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + }, + ); } diff --git a/clients/digitalbrain_flutter/test/shell_surface_test.dart b/clients/digitalbrain_flutter/test/shell_surface_test.dart index edbde8f0..cc7ad0bf 100644 --- a/clients/digitalbrain_flutter/test/shell_surface_test.dart +++ b/clients/digitalbrain_flutter/test/shell_surface_test.dart @@ -2,59 +2,65 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; import 'package:test/test.dart'; void main() { - test('ShellSurfaceController projects SceneOpened without dropping prior scenes', () { - final surface = ShellSurfaceController(); + test( + 'ShellSurfaceController projects SceneOpened without dropping prior scenes', + () { + final surface = ShellSurfaceController(); - surface.apply( - const SceneOpenedEvent( - sequence: 1, - sceneKey: 'home', - title: 'Home', - commandId: 'a', - shell: 'shell:dev/desk', - ), - ); - surface.apply( - const SceneOpenedEvent( - sequence: 2, - sceneKey: 'countdown', - title: 'Countdown', - commandId: 'b', - shell: 'shell:dev/desk', - ), - ); + surface.apply( + const SceneOpenedEvent( + sequence: 1, + sceneKey: 'home', + title: 'Home', + commandId: 'a', + shell: 'shell:dev/desk', + ), + ); + surface.apply( + const SceneOpenedEvent( + sequence: 2, + sceneKey: 'countdown', + title: 'Countdown', + commandId: 'b', + shell: 'shell:dev/desk', + ), + ); - expect(surface.scenes.map((s) => s.sceneKey), ['home', 'countdown']); - expect(surface.latest?.title, 'Countdown'); - expect(surface.latest?.sequence, 2); - }); + expect(surface.scenes.map((s) => s.sceneKey), ['home', 'countdown']); + expect(surface.latest?.title, 'Countdown'); + expect(surface.latest?.sequence, 2); + }, + ); - test('ShellSurfaceController replaces same sceneKey with newer projection', () { - final surface = ShellSurfaceController(); + test( + 'ShellSurfaceController replaces same sceneKey with newer projection', + () { + final surface = ShellSurfaceController(); - surface.apply( - const SceneOpenedEvent( - sequence: 1, - sceneKey: 'home', - title: 'Home', - commandId: 'a', - shell: 'shell:dev/desk', - ), - ); - surface.apply( - const SceneOpenedEvent( - sequence: 4, - sceneKey: 'home', - title: 'Home again', - commandId: 'c', - shell: 'shell:dev/desk', - ), - ); + surface.apply( + const SceneOpenedEvent( + sequence: 1, + sceneKey: 'home', + title: 'Home', + commandId: 'a', + shell: 'shell:dev/desk', + ), + ); + surface.apply( + const SceneOpenedEvent( + sequence: 4, + sceneKey: 'home', + title: 'Home again', + commandId: 'c', + shell: 'shell:dev/desk', + ), + ); - expect(surface.scenes, hasLength(1)); - expect(surface.latest?.title, 'Home again'); - expect(surface.latest?.sequence, 4); - }); + expect(surface.scenes, hasLength(1)); + expect(surface.latest?.title, 'Home again'); + expect(surface.latest?.sequence, 4); + }, + ); test( 'latest is most recently applied even when replace is not list-last', diff --git a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart index b3e622de..c482d5ef 100644 --- a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart +++ b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart @@ -4,10 +4,7 @@ final class OpenSceneRequest { final String sceneKey; final String title; - Map toJson() => { - 'sceneKey': sceneKey, - 'title': title, - }; + Map toJson() => {'sceneKey': sceneKey, 'title': title}; } final class ActivateControlRequest { @@ -17,9 +14,9 @@ final class ActivateControlRequest { final String? sceneKey; Map toJson() => { - 'intent': intent, - if (sceneKey != null) 'sceneKey': sceneKey, - }; + 'intent': intent, + if (sceneKey != null) 'sceneKey': sceneKey, + }; } final class SceneOpenedEvent { @@ -62,12 +59,22 @@ final class ChatTurnEvent { required this.fromUser, required this.text, required this.commandId, + required this.synapse, + required this.neuronId, + required this.caller, + required this.correlationId, + required this.timestamp, }); final int sequence; final bool fromUser; final String text; final String commandId; + final String synapse; + final String neuronId; + final String caller; + final String correlationId; + final DateTime timestamp; factory ChatTurnEvent.fromJson(Map json) { return ChatTurnEvent( @@ -75,6 +82,74 @@ final class ChatTurnEvent { fromUser: json['fromUser'] as bool, text: json['text'] as String, commandId: json['commandId'] as String, + synapse: json['synapse'] as String, + neuronId: json['neuronId'] as String, + caller: json['caller'] as String, + correlationId: json['correlationId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String).toUtc(), + ); + } +} + +final class BrainModule { + const BrainModule({required this.id}); + + final String id; + + factory BrainModule.fromJson(Map json) { + return BrainModule(id: json['id'] as String); + } +} + +final class BrainNeuron { + const BrainNeuron({ + required this.id, + required this.grainType, + required this.identity, + required this.silo, + }); + + final String id; + final String grainType; + final String identity; + final String silo; + + factory BrainNeuron.fromJson(Map json) { + return BrainNeuron( + id: json['id'] as String, + grainType: json['grainType'] as String, + identity: json['identity'] as String, + silo: json['silo'] as String, + ); + } +} + +final class BrainTopologySnapshot { + const BrainTopologySnapshot({ + required this.modules, + required this.neurons, + required this.observedAt, + }); + + final List modules; + final List neurons; + final DateTime observedAt; + + factory BrainTopologySnapshot.fromJson(Map json) { + return BrainTopologySnapshot( + modules: (json['modules'] as List) + .map( + (module) => + BrainModule.fromJson(Map.from(module! as Map)), + ) + .toList(growable: false), + neurons: (json['neurons'] as List) + .map( + (neuron) => + BrainNeuron.fromJson(Map.from(neuron! as Map)), + ) + .toList(growable: false), + observedAt: DateTime.parse(json['observedAt'] as String).toUtc(), ); } } diff --git a/clients/digitalbrain_wire/test/ui_edge_models_test.dart b/clients/digitalbrain_wire/test/ui_edge_models_test.dart index 6c102a70..34962b5e 100644 --- a/clients/digitalbrain_wire/test/ui_edge_models_test.dart +++ b/clients/digitalbrain_wire/test/ui_edge_models_test.dart @@ -18,21 +18,65 @@ void main() { expect(event.shell, 'shell:dev/desk'); }); - test('OpenSceneRequest encodes camelCase for POST /shells/{shell}/scenes', () { - expect( - const OpenSceneRequest(sceneKey: 'home', title: 'Home').toJson(), - {'sceneKey': 'home', 'title': 'Home'}, - ); - }); + test( + 'OpenSceneRequest encodes camelCase for POST /shells/{shell}/scenes', + () { + expect(const OpenSceneRequest(sceneKey: 'home', title: 'Home').toJson(), { + 'sceneKey': 'home', + 'title': 'Home', + }); + }, + ); test('ActivateControlRequest omits null sceneKey', () { - expect( - const ActivateControlRequest(intent: 'submit').toJson(), - {'intent': 'submit'}, - ); + expect(const ActivateControlRequest(intent: 'submit').toJson(), { + 'intent': 'submit', + }); expect( const ActivateControlRequest(intent: 'submit', sceneKey: 'home').toJson(), {'intent': 'submit', 'sceneKey': 'home'}, ); }); + + test('ChatTurnEvent carries durable pulse identity from the UI edge', () { + final event = ChatTurnEvent.fromJson({ + 'sequence': 9, + 'fromUser': true, + 'text': 'hello', + 'commandId': 'command-9', + 'synapse': 'UserMessaged', + 'neuronId': 'chat:owner/main', + 'caller': 'session:owner/session', + 'correlationId': 'correlation-9', + 'timestamp': '2026-07-28T08:00:00Z', + }); + + expect(event.synapse, 'UserMessaged'); + expect(event.neuronId, 'chat:owner/main'); + expect(event.caller, 'session:owner/session'); + expect(event.correlationId, 'correlation-9'); + expect(event.timestamp, DateTime.utc(2026, 7, 28, 8)); + }); + + test('BrainTopologySnapshot carries configured modules and live neurons', () { + final topology = BrainTopologySnapshot.fromJson({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': [ + { + 'id': 'chat:owner/main', + 'grainType': 'chat', + 'identity': 'owner/main', + 'silo': 'silo-1', + }, + ], + 'observedAt': '2026-07-28T08:00:00Z', + }); + + expect(topology.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + expect(topology.neurons.single.id, 'chat:owner/main'); + expect(topology.neurons.single.grainType, 'chat'); + expect(topology.observedAt, DateTime.utc(2026, 7, 28, 8)); + }); } diff --git a/scripts/verify-product.ps1 b/scripts/verify-product.ps1 index f56b516d..1a9b56e1 100644 --- a/scripts/verify-product.ps1 +++ b/scripts/verify-product.ps1 @@ -93,6 +93,21 @@ try { "--non-interactive") } + $uiDescription = Invoke-AspireJson @( + "describe", "digitalbrain-ui", + "--apphost", $appHost, + "--format", "Json", + "--non-interactive") + $uiResource = @($uiDescription.resources) | + Where-Object displayName -eq "digitalbrain-ui" | + Select-Object -First 1 + $uiUrl = @($uiResource.urls) | + Where-Object name -eq "http" | + Select-Object -ExpandProperty url -First 1 + Require ` + -Condition (-not [string]::IsNullOrWhiteSpace($uiUrl)) ` + -Message "Aspire did not expose the digitalbrain-ui HTTP endpoint." + $tools = @() for ($attempt = 1; $attempt -le 90 -and $tools.Count -eq 0; $attempt++) { $tools = @( @@ -193,6 +208,47 @@ try { $journal.entries[1].correlation -eq $result.correlationId) ` -Message "Chat journal entries do not share the MCP result correlation." + $expectedModules = @( + "DigitalBrain.AI.AIModule", + "DigitalBrain.Chat.ChatModule", + "DigitalBrain.Flutter.FlutterModule", + "DigitalBrain.Google.GoogleModule", + "DigitalBrain.OS.OSBehaviorsModule", + "DigitalBrain.Salesforce.SalesforceModule") + $expectedChatNeuron = "chat:dev/$ChatName" + $topology = $null + $moduleIds = @() + $activeChat = $null + for ($attempt = 1; $attempt -le 30 -and $null -eq $activeChat; $attempt++) { + try { + $topology = Invoke-RestMethod -Uri "$uiUrl/brain/topology" + $moduleIds = @($topology.modules | ForEach-Object id) + $activeChat = @($topology.neurons) | + Where-Object id -eq $expectedChatNeuron | + Select-Object -First 1 + } + catch { + $topology = $null + } + if ($null -eq $activeChat) { + Start-Sleep -Seconds 1 + } + } + Require ` + -Condition ($null -ne $topology) ` + -Message "The UI edge did not return a live brain topology." + Require ` + -Condition ($moduleIds.Count -eq $expectedModules.Count) ` + -Message "Expected $($expectedModules.Count) live modules but found $($moduleIds.Count)." + foreach ($module in $expectedModules) { + Require ` + -Condition ($moduleIds -contains $module) ` + -Message "Live topology is missing configured module '$module'." + } + Require ` + -Condition ($null -ne $activeChat) ` + -Message "Live topology never exposed active neuron '$expectedChatNeuron'." + $genAi = $null for ($attempt = 1; $attempt -le 60 -and $null -eq $genAi; $attempt++) { $genAiSpans = @( @@ -258,6 +314,8 @@ try { GenAiDurationMs = $genAi.durationMs InputTokens = $genAi.attributes.'gen_ai.usage.input_tokens' OutputTokens = $genAi.attributes.'gen_ai.usage.output_tokens' + TopologyModules = $moduleIds.Count + ActiveNeuron = $activeChat.id } | Format-List } finally { diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs index f0197439..cdfad830 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs @@ -116,7 +116,9 @@ public static void AssertExclusiveFlutterHostEnvironment(HashSet environ environment); } - public static void AssertExclusiveUIProductEnvironment(IReadOnlyDictionary environment) + public static void AssertClientSafeUIProductEnvironment( + IReadOnlyDictionary environment, + params string[] modules) { var productKeys = environment.Keys .Where(static key => @@ -125,12 +127,25 @@ public static void AssertExclusiveUIProductEnvironment(IReadOnlyDictionary(StringComparer.Ordinal) - { - FlutterHostingExtensions.OwnerEnvironmentVariable, - }, - productKeys); + var expected = new HashSet(StringComparer.Ordinal) + { + FlutterHostingExtensions.OwnerEnvironmentVariable, + }; + for (var index = 0; index < modules.Length; index++) + { + expected.Add( + $"{DigitalBrainHostingExtensions.ModulesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"); + } + + Assert.Equal(expected, productKeys); + for (var index = 0; index < modules.Length; index++) + { + Assert.Equal( + modules[index], + environment[ + $"{DigitalBrainHostingExtensions.ModulesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"] + ?.ToString()); + } } public static async Task> EnvironmentKeysOf(IResource resource) diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs index f9f96d0f..52fc81de 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs @@ -1,5 +1,6 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using DigitalBrain.AI; using DigitalBrain.Aspire.Hosting; using DigitalBrain.Flutter; using DigitalBrain.Flutter.Aspire.Hosting; @@ -11,8 +12,8 @@ public sealed class FlutterHostingUIEdgeContracts { [Fact(DisplayName = "WithUIEdge projects " + FlutterHostingExtensions.DefaultUIResourceName - + " as AsClient with named http endpoint, exclusive owner env; omits flutter host")] - public async Task WithUIEdgeProjectsNamedHttpEndpointAsClientOnly() + + " as a client with late-bound module topology and no silo secrets")] + public async Task WithUIEdgeProjectsLateBoundModuleTopologyWithoutSiloSecrets() { var builder = DistributedApplication.CreateBuilder(); var brain = builder.AddDigitalBrain("brain"); @@ -22,6 +23,7 @@ public async Task WithUIEdgeProjectsNamedHttpEndpointAsClientOnly() options.ProjectPath = FlutterHostingProjectionSupport.UIProjectPath; options.Owner = "edge-owner"; })); + brain.AddModule(); var silo = builder .AddContainer("silo", "mcr.microsoft.com/dotnet/runtime") @@ -36,10 +38,16 @@ public async Task WithUIEdgeProjectsNamedHttpEndpointAsClientOnly() FlutterHostingProjectionSupport.AssertUIHasNamedHttpEndpoint(ui); var environment = await FlutterHostingProjectionSupport.EnvironmentOf(ui).ConfigureAwait(true); - FlutterHostingProjectionSupport.AssertExclusiveUIProductEnvironment(environment); + FlutterHostingProjectionSupport.AssertClientSafeUIProductEnvironment( + environment, + FlutterModule.Id.Value, + AIModule.Id.Value); Assert.Equal( "edge-owner", environment[FlutterHostingExtensions.OwnerEnvironmentVariable]?.ToString()); + Assert.DoesNotContain( + FlutterHostingProjectionSupport.JournalConnectionEnvironmentKey, + environment.Keys); Assert.Contains( ui.Annotations.OfType(), diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs index 5b9eda28..a817f9ec 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs @@ -19,7 +19,6 @@ public sealed class HostingProjectionContracts [ FlutterHostingProjectionSupport.JournalConnectionEnvironmentKey, ConfigurationEnvironment(DigitalBrainHostingExtensions.StateProtectionKeyConfigurationKey), - $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__0", ConfigurationEnvironment("DigitalBrain:AI:Ollama:Endpoint"), ConfigurationEnvironment("DigitalBrain:Integrations:Mcp:AuthorizationMode"), ConfigurationEnvironment("DigitalBrain:Google:Gmail:ClientId"), @@ -27,8 +26,8 @@ public sealed class HostingProjectionContracts ]; [Fact(DisplayName = - "WithReference(brain) projects journal, state-protection key, AI config, and OAuth; AsClient never does")] - public async Task CompleteBrainReferenceProjectsSiloOnlySecretsAndClientDoesNot() + "complete and client references share module topology while only the complete reference receives secrets")] + public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient() { var builder = DistributedApplication.CreateBuilder(); var brain = builder.AddDigitalBrain("brain"); @@ -50,11 +49,27 @@ public async Task CompleteBrainReferenceProjectsSiloOnlySecretsAndClientDoesNot( .EnvironmentKeysOf(silo.Resource) .ConfigureAwait(true); var clientEnvironment = await FlutterHostingProjectionSupport - .EnvironmentKeysOf(client.Resource) + .EnvironmentOf(client.Resource) .ConfigureAwait(true); + var clientEnvironmentKeys = clientEnvironment.Keys.ToHashSet(StringComparer.Ordinal); Assert.All(SiloOnlyEnvironmentKeys, key => Assert.Contains(key, siloEnvironment)); - Assert.All(SiloOnlyEnvironmentKeys, key => Assert.DoesNotContain(key, clientEnvironment)); + Assert.All(SiloOnlyEnvironmentKeys, key => Assert.DoesNotContain(key, clientEnvironmentKeys)); + Assert.Equal( + AIModule.Id.Value, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__0"] + ?.ToString()); + Assert.Equal( + GoogleModule.Id.Value, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__1"] + ?.ToString()); + Assert.Equal( + SalesforceModule.Id.Value, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__2"] + ?.ToString()); Assert.Contains( silo.Resource.Annotations.OfType(), diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs index b86e8f04..99e88457 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs @@ -78,12 +78,7 @@ public static IResourceBuilder WithReference( brain.StateProtectionKey); } - for (var index = 0; index < brain.Modules.Count; index++) - { - builder.WithEnvironment( - $"{ConfigurationEnvironment(ModulesConfigurationKey)}__{index}", - brain.Modules[index].Value); - } + ProjectModuleManifest(builder, brain); foreach (var projection in brain.Projections) { @@ -101,9 +96,25 @@ public static IResourceBuilder WithReference( ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(client); - return builder.WithReference(client.Brain.Orleans.AsClient()); + builder.WithReference(client.Brain.Orleans.AsClient()); + ProjectModuleManifest(builder, client.Brain); + return builder; } + private static void ProjectModuleManifest( + IResourceBuilder builder, + DigitalBrainBuilder brain) + where TResource : IResourceWithEnvironment + => builder.WithEnvironment(context => + { + for (var index = 0; index < brain.Modules.Count; index++) + { + context.EnvironmentVariables[ + $"{ConfigurationEnvironment(ModulesConfigurationKey)}__{index}"] = + brain.Modules[index].Value; + } + }); + private static string ConfigurationEnvironment(string configurationKey) => configurationKey.Replace(":", "__", StringComparison.Ordinal); } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj index d65d252f..21f09e3b 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs index abd78d16..149f4a9a 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs @@ -1,5 +1,6 @@ using System.Net; using DigitalBrain.Abstractions; +using DigitalBrain.Chat; using DigitalBrain.Flutter; using Xunit; @@ -132,6 +133,65 @@ public async Task HttpShellEventsRejectsNegativeAfterSequence() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Fact(DisplayName = + "HTTP brain topology projects configured modules and active owner neurons")] + public async Task HttpBrainTopologyProjectsRuntimeTruth() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + var shell = test.Neuron(UIFixture.DefaultShellName); + await test.Client.Get(UIFixture.DefaultShellName).Open( + new OpenScene(CommandId.New(), HomeSceneKey, HomeTitle)); + + await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); + using var http = CreateClient(app); + var topology = await http.GetFromJsonAsync( + UIEdgeContract.BrainTopologyPath, + cancellationToken); + + Assert.NotNull(topology); + Assert.Contains( + topology.Modules, + module => module.Id == FlutterModule.Id.Value); + Assert.Contains( + topology.Modules, + module => module.Id == ChatModule.Id.Value); + Assert.Contains( + topology.Neurons, + neuron => neuron.Id == shell.Id.ToString()); + } + + [Fact(DisplayName = + "SSE chat turn carries neuron, caller, command, correlation, and timestamp for live brain pulse")] + public async Task HttpChatTurnCarriesPulseIdentity() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + var chat = test.Neuron("pulse"); + + await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); + using var http = CreateClient(app, streaming: true); + await using var events = await OpenChatEventStreamAsync( + http, + "pulse", + cancellationToken); + + var command = new SendMessage(CommandId.New(), "hello"); + await test.Client.Get("pulse").Send(command); + var projected = await UIEdgeSse.ReadNextChatTurnAsync( + events.Reader, + cancellationToken); + + Assert.True(projected.Sequence > 0); + Assert.Equal(nameof(UserMessaged), projected.Synapse); + Assert.Equal(chat.Id.ToString(), projected.NeuronId); + Assert.False(string.IsNullOrWhiteSpace(projected.Caller)); + Assert.Equal(command.CommandId.ToString(), projected.CommandId); + Assert.True(Guid.TryParse(projected.CorrelationId, out var correlation)); + Assert.NotEqual(Guid.Empty, correlation); + Assert.NotEqual(default, projected.Timestamp); + } + private static async Task OpenShellEventStreamAsync( HttpClient http, CancellationToken cancellationToken) @@ -163,6 +223,23 @@ private static async Task OpenShellEventStreamAsync( return new ShellEventStream(streamResponse, body); } + private static async Task OpenChatEventStreamAsync( + HttpClient http, + string chatName, + CancellationToken cancellationToken) + { + using var streamRequest = new HttpRequestMessage( + HttpMethod.Get, + UIEdgeSse.ChatEvents(chatName, afterSequence: 0)); + var streamResponse = await http.SendAsync( + streamRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + Assert.Equal(HttpStatusCode.OK, streamResponse.StatusCode); + var body = await streamResponse.Content.ReadAsStreamAsync(cancellationToken); + return new ShellEventStream(streamResponse, body); + } + private static HttpClient CreateClient(WebApplication app, bool streaming = false) { var http = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs index 04de7e24..4826cf72 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs @@ -20,6 +20,9 @@ public static string ActivateControl(string sceneKey, string controlId) => .Replace("{sceneKey}", sceneKey, StringComparison.Ordinal) .Replace("{controlId}", controlId, StringComparison.Ordinal); + public static string ChatEvents(string chatName, long afterSequence = 0) => + $"{UIEdgeContract.ChatEventsPath.Replace("{chatName}", chatName, StringComparison.Ordinal)}?{UIEdgeContract.AfterSequenceQuery}={afterSequence}"; + public static async Task ReadNextSceneOpenedAsync( StreamReader reader, CancellationToken cancellationToken, @@ -97,4 +100,67 @@ public static async Task ReadNextSceneOpenedPayloadAsync( throw new TimeoutException( "SSE stream ended before a SceneOpened projection arrived — brain may have moved while UI projection is dead."); } + + public static async Task ReadNextChatTurnAsync( + StreamReader reader, + CancellationToken cancellationToken) + { + var payload = await ReadNextEventPayloadAsync( + reader, + UIEdgeContract.ChatTurnEvent, + cancellationToken); + return JsonSerializer.Deserialize( + payload, + EventJsonOptions) + ?? throw new InvalidOperationException( + "SSE chat-turn payload did not deserialize."); + } + + private static async Task ReadNextEventPayloadAsync( + StreamReader reader, + string expectedEvent, + CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + linked.CancelAfter(TimeSpan.FromSeconds(15)); + + string? dataLine = null; + string? eventName = null; + while (!linked.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(linked.Token); + if (line is null) + { + break; + } + + if (line.StartsWith(':', StringComparison.Ordinal)) + { + continue; + } + + if (line.StartsWith("event:", StringComparison.Ordinal)) + { + eventName = line["event:".Length..].Trim(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + dataLine = line["data:".Length..].Trim(); + continue; + } + + if (line.Length == 0 + && string.Equals(eventName, expectedEvent, StringComparison.Ordinal) + && dataLine is not null) + { + return dataLine; + } + } + + throw new TimeoutException( + $"SSE stream ended before a {expectedEvent} projection arrived."); + } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs index fcc1e8ab..39fa5273 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs @@ -40,6 +40,7 @@ public void PublicEdgeVocabularyIsUIEdgeContract() Assert.Equal("text/event-stream", UIEdgeContract.EventStreamContentType); Assert.Equal("no-cache", UIEdgeContract.CacheControlNoCache); Assert.Equal("scene-opened", UIEdgeContract.SceneOpenedEvent); + Assert.Equal("/brain/topology", UIEdgeContract.BrainTopologyPath); } [Fact(DisplayName = diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs index 5841adc0..f17d6c1b 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs @@ -1,3 +1,4 @@ +using DigitalBrain.Chat; using DigitalBrain.Flutter; using DigitalBrain.Flutter.Aspire.Hosting; using DigitalBrain.ServiceDefaults; @@ -33,6 +34,12 @@ public static async Task StartUIEdgeAsync( var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.AddServiceDefaults(); + builder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["DigitalBrain:Modules:0"] = FlutterModule.Id.Value, + ["DigitalBrain:Modules:1"] = ChatModule.Id.Value, + }); builder.Services.AddSingleton(test.Client); builder.Services.AddSingleton(test.Cluster.Client); builder.Services.AddUIEdgeServices(); @@ -50,5 +57,6 @@ protected override void Configure(DigitalBrainTestBuilder brain) { ArgumentNullException.ThrowIfNull(brain); brain.AddModule(); + brain.AddModule(); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs new file mode 100644 index 00000000..44cb53bc --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs @@ -0,0 +1,23 @@ +namespace DigitalBrain.UI; + +internal static class BrainEndpoints +{ + public static IEndpointRouteBuilder MapBrain( + this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + endpoints.MapGet( + UIEdgeContract.BrainTopologyPath, + static async Task ( + BrainTopologyReader topology, + CancellationToken cancellationToken) => + { + ArgumentNullException.ThrowIfNull(topology); + var snapshot = await topology.ReadAsync(cancellationToken); + return Results.Ok(snapshot); + }); + + return endpoints; + } +} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs new file mode 100644 index 00000000..46ae0e2d --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs @@ -0,0 +1,52 @@ +using DigitalBrain.Client; + +namespace DigitalBrain.UI; + +internal sealed class BrainTopologyReader( + IDigitalBrain brain, + IGrainFactory grains, + IConfiguration configuration) +{ + public async Task ReadAsync( + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var modules = configuration + .GetSection("DigitalBrain:Modules") + .GetChildren() + .Select(section => section.Value) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => new BrainModule(value!)) + .OrderBy(static module => module.Id, StringComparer.Ordinal) + .ToArray(); + + var statistics = await grains + .GetGrain(0) + .GetDetailedGrainStatistics(); + cancellationToken.ThrowIfCancellationRequested(); + + var ownerPrefix = $"{brain.Owner.Value}/"; + var neurons = statistics + .Where(statistic => statistic.GrainId.Key.ToString()! + .StartsWith(ownerPrefix, StringComparison.Ordinal)) + .Select(static statistic => + { + var type = statistic.GrainId.Type.ToString()!; + var key = statistic.GrainId.Key.ToString()!; + return new BrainNeuron( + $"{type}:{key}", + type, + key, + statistic.SiloAddress.ToString()); + }) + .OrderBy(static neuron => neuron.GrainType, StringComparer.Ordinal) + .ThenBy(static neuron => neuron.Identity, StringComparer.Ordinal) + .ToArray(); + + return new BrainTopologySnapshot( + modules, + neurons, + TimeProvider.System.GetUtcNow()); + } +} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs new file mode 100644 index 00000000..03065757 --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs @@ -0,0 +1,14 @@ +namespace DigitalBrain.UI; + +internal sealed record BrainTopologySnapshot( + IReadOnlyList Modules, + IReadOnlyList Neurons, + DateTimeOffset ObservedAt); + +internal sealed record BrainModule(string Id); + +internal sealed record BrainNeuron( + string Id, + string GrainType, + string Identity, + string Silo); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs index b6214baa..6d019ba7 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs @@ -64,7 +64,12 @@ private static IEnumerable ProjectTurns(JournalRead batch) delivery.Sequence, FromUser: true, messaged.Text, - messaged.CommandId.ToString()); + messaged.CommandId.ToString(), + nameof(UserMessaged), + messaged.Chat.ToString(), + delivery.Caller.ToString(), + delivery.CorrelationId.ToString(), + delivery.Timestamp); break; case AssistantResponded responded: @@ -72,7 +77,12 @@ private static IEnumerable ProjectTurns(JournalRead batch) delivery.Sequence, FromUser: false, responded.Text, - responded.CommandId.ToString()); + responded.CommandId.ToString(), + nameof(AssistantResponded), + responded.Chat.ToString(), + delivery.Caller.ToString(), + delivery.CorrelationId.ToString(), + delivery.Timestamp); break; } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs index f68b7c49..705c9560 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs @@ -4,4 +4,9 @@ internal sealed record ChatTurnEvent( long Sequence, bool FromUser, string Text, - string CommandId); + string CommandId, + string Synapse, + string NeuronId, + string Caller, + string CorrelationId, + DateTimeOffset Timestamp); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs index 46988995..ae392b8f 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs @@ -29,4 +29,6 @@ public static class UIEdgeContract public const string ChatEventsPath = "/chats/{chatName}/events"; public const string ChatTurnEvent = "chat-turn"; + + public const string BrainTopologyPath = "/brain/topology"; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs index a97a0b62..e0abd393 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs @@ -13,6 +13,7 @@ public static IServiceCollection AddUIEdgeServices(this IServiceCollection servi OwnerSessionJournal.Open( services.GetRequiredService(), services.GetRequiredService().Owner)); + services.TryAddSingleton(); return services; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs index 7a98d7de..77c54678 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs @@ -8,6 +8,7 @@ public static WebApplication MapUIHost(this WebApplication app) app.MapUI(); app.MapChat(); + app.MapBrain(); return app; } } From dc2d70cd7918450a33ddfbf4099f94a9b8cfd370 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 08:28:50 +0200 Subject: [PATCH 08/20] fix: harden live brain state Consumers: topology polling, capability rendering, inspector state, activity facts, and pulse visualization all consume this slice today. Verified: wire/client/shell analyses and tests, UI edge tests, Release root build and 255-test MTP gate, and Windows Release build. Preserved: three pre-existing generated Windows Flutter files remain unstaged. --- .../lib/src/edge_client.dart | 26 ++- .../shell/lib/activity_screen.dart | 6 +- .../shell/lib/brain_screen.dart | 121 +++++++++---- .../shell/lib/brain_topology_canvas.dart | 53 +++--- .../shell/lib/chat_screen.dart | 5 +- .../shell/test/chat_screen_test.dart | 160 +++++++++++++++++- .../test/edge_client_test.dart | 56 +++++- .../lib/src/ui_edge_models.dart | 6 +- .../test/ui_edge_models_test.dart | 3 +- .../UIEdgeRoundTrip.cs | 5 +- ...logySnapshot.cs => BrainTopologyModels.cs} | 2 +- .../BrainTopologyReader.cs | 14 +- 12 files changed, 383 insertions(+), 74 deletions(-) rename src/modules/flutter/DigitalBrain.Modules.Flutter.Http/{BrainTopologySnapshot.cs => BrainTopologyModels.cs} (93%) diff --git a/clients/digitalbrain_flutter/lib/src/edge_client.dart b/clients/digitalbrain_flutter/lib/src/edge_client.dart index 31a3ac2b..d818dc6a 100644 --- a/clients/digitalbrain_flutter/lib/src/edge_client.dart +++ b/clients/digitalbrain_flutter/lib/src/edge_client.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:digitalbrain_wire/digitalbrain_wire.dart'; @@ -146,9 +147,27 @@ final class DigitalBrainUiEdgeClient { } } - Future readBrainTopology() async { + Future readBrainTopology({ + Duration requestTimeout = const Duration(seconds: 5), + }) async { final uri = baseUri.replace(path: '/brain/topology'); - final response = await _http.get(uri); + final abort = Completer(); + final operation = () async { + final request = http.AbortableRequest( + 'GET', + uri, + abortTrigger: abort.future, + ); + final streamed = await _http.send(request); + return http.Response.fromStream(streamed); + }(); + final response = await operation.timeout( + requestTimeout, + onTimeout: () { + abort.complete(); + throw http.RequestAbortedException(uri); + }, + ); if (response.statusCode != 200) { throw StateError( 'brain-topology failed: ${response.statusCode} ${response.body}', @@ -165,10 +184,11 @@ final class DigitalBrainUiEdgeClient { Stream watchBrainTopology({ Duration pollInterval = const Duration(seconds: 2), + Duration requestTimeout = const Duration(seconds: 5), }) async* { while (true) { try { - yield await readBrainTopology(); + yield await readBrainTopology(requestTimeout: requestTimeout); } on Object catch (error, stackTrace) { yield* Stream.error(error, stackTrace); } diff --git a/clients/digitalbrain_flutter/shell/lib/activity_screen.dart b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart index 999ba9f9..b13d0d86 100644 --- a/clients/digitalbrain_flutter/shell/lib/activity_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart @@ -91,7 +91,6 @@ final class _ActivityEntry extends StatelessWidget { @override Widget build(BuildContext context) { final color = turn.fromUser ? BrainPalette.owner : BrainPalette.signal; - final event = turn.fromUser ? 'UserMessaged' : 'AssistantResponded'; return Container( margin: const EdgeInsets.only(bottom: 12), @@ -123,7 +122,10 @@ final class _ActivityEntry extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(event, style: BrainType.metaStrong.copyWith(color: color)), + Text( + turn.synapse, + style: BrainType.metaStrong.copyWith(color: color), + ), const SizedBox(height: 6), Wrap( spacing: 18, diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart index 8b227f3d..ab799419 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart @@ -23,6 +23,12 @@ final class BrainScreen extends StatefulWidget { } final class _BrainScreenState extends State { + static const _aiModule = 'DigitalBrain.AI.AIModule'; + static const _chatModule = 'DigitalBrain.Chat.ChatModule'; + static const _googleModule = 'DigitalBrain.Google.GoogleModule'; + static const _osModule = 'DigitalBrain.OS.OSBehaviorsModule'; + static const _salesforceModule = 'DigitalBrain.Salesforce.SalesforceModule'; + BrainTopologySelection? _selection; @override @@ -36,13 +42,91 @@ final class _BrainScreenState extends State { @override void didUpdateWidget(covariant BrainScreen oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.chatName != oldWidget.chatName) { + _selection = null; + return; + } + if (widget.turns.isNotEmpty && (oldWidget.turns.isEmpty || widget.turns.last.sequence != oldWidget.turns.last.sequence || widget.turns.last.correlationId != oldWidget.turns.last.correlationId)) { - setState(() => _selection = BrainPulseSelection(widget.turns.last)); + _selection = BrainPulseSelection(widget.turns.last); + return; + } + + final topology = widget.topology; + _selection = switch (_selection) { + BrainPulseSelection(:final turn) + when !widget.turns.any( + (candidate) => + candidate.sequence == turn.sequence && + candidate.correlationId == turn.correlationId, + ) => + null, + BrainNeuronSelection(:final neuron) + when topology == null || + !topology.neurons.any((candidate) => candidate.id == neuron.id) => + null, + BrainModuleSelection(:final module) + when topology == null || + !topology.modules.any((candidate) => candidate.id == module.id) => + null, + final selection => selection, + }; + } + + ({bool generalAssistant, bool accountEnrichment}) _capabilities() { + final modules = widget.topology?.modules.map((module) => module.id).toSet(); + final generalAssistant = + modules?.contains(_aiModule) == true && + modules?.contains(_chatModule) == true; + final accountEnrichment = + generalAssistant && + modules?.contains(_googleModule) == true && + modules?.contains(_osModule) == true && + modules?.contains(_salesforceModule) == true; + return ( + generalAssistant: generalAssistant, + accountEnrichment: accountEnrichment, + ); + } + + List _capabilityPanels() { + final capabilities = _capabilities(); + if (!capabilities.generalAssistant && !capabilities.accountEnrichment) { + return const []; } + + return [ + const SizedBox(height: 30), + const _SectionLabel('CAPABILITIES'), + const SizedBox(height: 12), + if (capabilities.generalAssistant) + const _CapabilityCard( + icon: Icons.chat_bubble_outline_rounded, + title: 'General assistant', + body: + 'Conversation, explanation, drafting, and reasoning in the current chat.', + ), + if (capabilities.generalAssistant && capabilities.accountEnrichment) + const SizedBox(height: 12), + if (capabilities.accountEnrichment) + const _CapabilityCard( + icon: Icons.compare_arrows_rounded, + title: 'Gmail message → Salesforce Account description', + body: + 'Creates a reviewable enrichment proposal from an exact Gmail message ID and a Salesforce Account ID.', + badge: 'Approval required', + ), + if (capabilities.accountEnrichment) ...[ + const SizedBox(height: 30), + const _SectionLabel('BOUNDARIES'), + const SizedBox(height: 12), + const _BoundaryCard(), + ], + ]; } @override @@ -101,27 +185,7 @@ final class _BrainScreenState extends State { onSelected: (selection) => setState(() => _selection = selection), ), - const SizedBox(height: 30), - const _SectionLabel('CAPABILITIES'), - const SizedBox(height: 12), - const _CapabilityCard( - icon: Icons.chat_bubble_outline_rounded, - title: 'General assistant', - body: - 'Conversation, explanation, drafting, and reasoning in the current chat.', - ), - const SizedBox(height: 12), - const _CapabilityCard( - icon: Icons.compare_arrows_rounded, - title: 'Gmail message → Salesforce Account description', - body: - 'Creates a reviewable enrichment proposal from an exact Gmail message ID and a Salesforce Account ID.', - badge: 'Approval required', - ), - const SizedBox(height: 30), - const _SectionLabel('BOUNDARIES'), - const SizedBox(height: 12), - const _BoundaryCard(), + ..._capabilityPanels(), if (!connected) ...[ const SizedBox(height: 20), _ConnectionNotice(message: widget.statusMessage!), @@ -230,7 +294,7 @@ final class _TopologyExplorer extends StatelessWidget { for (var index = 0; index < topology.modules.length; index++) ActionChip( key: Key('topology_module_$index'), - label: Text(_moduleLabel(topology.modules[index].id)), + label: Text(brainModuleLabel(topology.modules[index])), onPressed: () => onSelected(BrainModuleSelection(topology.modules[index])), ), @@ -322,7 +386,7 @@ final class _NeuronDetails extends StatelessWidget { const SizedBox(height: 10), _InspectorField(label: 'id', value: neuron.id), _InspectorField(label: 'identity', value: neuron.identity), - _InspectorField(label: 'silo', value: neuron.silo), + _InspectorField(label: 'placement', value: neuron.placement), ], ); } @@ -338,7 +402,7 @@ final class _ModuleDetails extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(_moduleLabel(module.id), style: BrainType.cardTitle), + Text(brainModuleLabel(module), style: BrainType.cardTitle), const SizedBox(height: 10), _InspectorField(label: 'module id', value: module.id), ], @@ -553,10 +617,3 @@ BoxDecoration _panelDecoration({Color color = BrainPalette.surfaceRaised}) => borderRadius: BorderRadius.circular(14), border: Border.all(color: BrainPalette.line), ); - -String _moduleLabel(String id) { - final type = id.split('.').last; - return type.endsWith('Module') - ? type.substring(0, type.length - 'Module'.length) - : type; -} diff --git a/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart index fe0349e2..d0869407 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart @@ -95,6 +95,12 @@ final class _BrainTopologyCanvasState extends State Widget build(BuildContext context) { final disableAnimations = MediaQuery.disableAnimationsOf(context); final hasPulseTarget = _hasPulseTarget(widget.topology, widget.pulse); + final localPulse = + hasPulseTarget && widget.pulse!.caller == widget.pulse!.neuronId; + final edgePulse = + hasPulseTarget && + !localPulse && + _hasNode(widget.topology, widget.pulse!.caller); return Semantics( key: const Key('brain_topology_canvas'), @@ -136,6 +142,10 @@ final class _BrainTopologyCanvasState extends State ), if (hasPulseTarget) const IgnorePointer(key: Key('brain_pulse')), + if (localPulse) + const IgnorePointer(key: Key('brain_local_pulse')), + if (edgePulse) + const IgnorePointer(key: Key('brain_edge_pulse')), ], ), ); @@ -151,6 +161,10 @@ bool _hasPulseTarget(BrainTopologySnapshot topology, ChatTurnEvent? pulse) => pulse != null && topology.neurons.any((neuron) => neuron.id == pulse.neuronId); +bool _hasNode(BrainTopologySnapshot topology, String id) => + topology.neurons.any((neuron) => neuron.id == id) || + topology.modules.any((module) => module.id == id); + final class _GraphNode { const _GraphNode({ required this.id, @@ -236,7 +250,7 @@ Iterable<_GraphNode> _placeModules(List modules) sync* { final module = modules[index]; yield _GraphNode( id: module.id, - label: _moduleLabel(module.id), + label: brainModuleLabel(module), module: true, selection: BrainModuleSelection(module), x: position.x, @@ -290,8 +304,8 @@ _ProjectedNode? _hitTest(List<_ProjectedNode> nodes, Offset position) { return null; } -String _moduleLabel(String id) { - final type = id.split('.').last; +String brainModuleLabel(BrainModule module) { + final type = module.id.split('.').last; return type.endsWith('Module') ? type.substring(0, type.length - 'Module'.length) : type; @@ -341,24 +355,23 @@ final class _TopologyPainter extends CustomPainter { : nodes.where((node) => node.node.id == pulse!.caller).firstOrNull; if (pulseTarget != null) { final wave = math.sin(pulseValue * math.pi).abs(); - final path = Path() - ..moveTo( - (pulseSource?.center ?? center).dx, - (pulseSource?.center ?? center).dy, - ) - ..quadraticBezierTo( - center.dx, - center.dy - radius * 0.45, - pulseTarget.center.dx, - pulseTarget.center.dy, + if (pulseSource != null && pulseSource.node.id != pulseTarget.node.id) { + final path = Path() + ..moveTo(pulseSource.center.dx, pulseSource.center.dy) + ..quadraticBezierTo( + center.dx, + center.dy - radius * 0.45, + pulseTarget.center.dx, + pulseTarget.center.dy, + ); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..color = BrainPalette.signal.withValues(alpha: 0.25 + wave * 0.7), ); - canvas.drawPath( - path, - Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2.4 - ..color = BrainPalette.signal.withValues(alpha: 0.25 + wave * 0.7), - ); + } canvas.drawCircle( pulseTarget.center, pulseTarget.radius + 8 + wave * 16, diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart index 3cf59c26..bb1cd9c3 100644 --- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart @@ -110,7 +110,10 @@ final class _BrainWorkspaceState extends State<_BrainWorkspace> { }, onError: (Object error) { if (mounted) { - setState(() => _topologyFailure = '$error'); + setState(() { + _topology = null; + _topologyFailure = '$error'; + }); } }, ); diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart index 95ce09cd..974a2efa 100644 --- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart +++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart @@ -5,14 +5,19 @@ import 'package:digitalbrain_flutter_shell/chat_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent( +ChatTurnEvent _turn( + int sequence, + bool fromUser, + String text, { + String? synapse, +}) => ChatTurnEvent( sequence: sequence, fromUser: fromUser, text: text, commandId: 'c$sequence', - synapse: fromUser ? 'UserMessaged' : 'AssistantResponded', + synapse: synapse ?? (fromUser ? 'UserMessaged' : 'AssistantResponded'), neuronId: 'chat:owner/main', - caller: 'session:owner/session', + caller: 'chat:owner/main', correlationId: 'correlation-$sequence', timestamp: DateTime.utc(2026, 7, 28, 8, 0, sequence), ); @@ -21,13 +26,17 @@ BrainTopologySnapshot _topology() => BrainTopologySnapshot( modules: const [ BrainModule(id: 'DigitalBrain.Chat.ChatModule'), BrainModule(id: 'DigitalBrain.AI.AIModule'), + BrainModule(id: 'DigitalBrain.Flutter.FlutterModule'), + BrainModule(id: 'DigitalBrain.Google.GoogleModule'), + BrainModule(id: 'DigitalBrain.OS.OSBehaviorsModule'), + BrainModule(id: 'DigitalBrain.Salesforce.SalesforceModule'), ], neurons: const [ BrainNeuron( id: 'chat:owner/main', grainType: 'chat', identity: 'owner/main', - silo: 'silo-1', + placement: 'cluster-1', ), ], observedAt: DateTime.utc(2026, 7, 28, 8), @@ -43,7 +52,13 @@ void main() { testWidgets('the workspace exposes Chat, Activity, and Brain destinations', ( tester, ) async { - await tester.pumpWidget(const BrainChatApp(chatName: 'main')); + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); await tester.pumpAndSettle(); expect(find.byKey(const Key('destination_chat')), findsOneWidget); @@ -64,6 +79,11 @@ void main() { scrollable: find.byType(Scrollable), ); expect(find.text('General assistant'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Gmail message → Salesforce Account description'), + 300, + scrollable: find.byType(Scrollable), + ); expect( find.text('Gmail message → Salesforce Account description'), findsOneWidget, @@ -71,6 +91,33 @@ void main() { expect(find.text('Approval required'), findsOneWidget); }); + testWidgets('Brain claims only capabilities declared by live modules', ( + tester, + ) async { + final topology = StreamController(); + addTearDown(topology.close); + final flutterOnly = BrainTopologySnapshot( + modules: const [BrainModule(id: 'DigitalBrain.Flutter.FlutterModule')], + neurons: const [], + observedAt: DateTime.utc(2026, 7, 28, 8), + ); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(flutterOnly); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.text('General assistant'), findsNothing); + expect( + find.text('Gmail message → Salesforce Account description'), + findsNothing, + ); + expect(find.text('Approval required'), findsNothing); + }); + testWidgets('activity shows journal facts without message content', ( tester, ) async { @@ -92,6 +139,27 @@ void main() { expect(find.text('private customer message'), findsNothing); }); + testWidgets('activity renders the authoritative synapse name', ( + tester, + ) async { + final turns = StreamController(); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}), + ); + turns.add( + _turn(2, true, 'private payload', synapse: 'ObservedCustomSynapse'), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + + expect(find.text('ObservedCustomSynapse'), findsOneWidget); + expect(find.text('UserMessaged'), findsNothing); + expect(find.text('private payload'), findsNothing); + }); + testWidgets('the shared event projection survives destination changes', ( tester, ) async { @@ -157,17 +225,26 @@ void main() { await tester.pumpWidget( BrainChatApp(chatName: 'main', topology: topology.stream), ); - topology.addError(StateError('topology temporarily unavailable')); + topology.add(_topology()); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('destination_brain'))); await tester.pumpAndSettle(); + expect(find.text('Connected'), findsOneWidget); + expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget); + + topology.addError(StateError('topology temporarily unavailable')); + await tester.pumpAndSettle(); + expect(find.text('Offline'), findsOneWidget); + expect(find.byKey(const Key('brain_topology_canvas')), findsNothing); + expect(find.text('Waiting for live topology…'), findsOneWidget); topology.add(_topology()); await tester.pumpAndSettle(); expect(find.text('Connected'), findsOneWidget); + expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget); }); testWidgets('a chat turn pulses Brain and opens correlation inspector', ( @@ -192,6 +269,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('brain_pulse')), findsOneWidget); + expect(find.byKey(const Key('brain_local_pulse')), findsOneWidget); + expect(find.byKey(const Key('brain_edge_pulse')), findsNothing); expect(find.byKey(const Key('brain_inspector')), findsOneWidget); expect(find.text('correlation-9'), findsOneWidget); expect(find.text('chat:owner/main'), findsWidgets); @@ -228,6 +307,75 @@ void main() { expect(find.byKey(const Key('brain_pulse')), findsOneWidget); }); + testWidgets('Brain clears a neuron selection when the neuron disappears', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('topology_neuron_0'))); + await tester.pumpAndSettle(); + + expect(find.text('cluster-1'), findsOneWidget); + + topology.add(_topologyWithoutNeuron()); + await tester.pumpAndSettle(); + + expect(find.text('cluster-1'), findsNothing); + expect( + find.text( + 'Select a module or neuron. New chat turns open their causal pulse automatically.', + ), + findsOneWidget, + ); + }); + + testWidgets('Brain clears causal selection when the chat changes', ( + tester, + ) async { + final topology = StreamController.broadcast(); + final turns = StreamController.broadcast(); + addTearDown(topology.close); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'main', + topology: topology.stream, + turns: turns.stream, + ), + ); + topology.add(_topology()); + turns.add(_turn(14, false, 'private turn')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.text('correlation-14'), findsOneWidget); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'other', + topology: topology.stream, + turns: turns.stream, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('correlation-14'), findsNothing); + }); + testWidgets('an empty conversation invites the owner to act', (tester) async { await tester.pumpWidget(const BrainChatApp(chatName: 'main')); await tester.pump(); diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart index ee731504..72663b52 100644 --- a/clients/digitalbrain_flutter/test/edge_client_test.dart +++ b/clients/digitalbrain_flutter/test/edge_client_test.dart @@ -174,7 +174,7 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," 'id': 'chat:owner/main', 'grainType': 'chat', 'identity': 'owner/main', - 'silo': 'silo-1', + 'placement': 'cluster-1', }, ], 'observedAt': '2026-07-28T08:00:00Z', @@ -231,4 +231,58 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," expect(snapshot.modules.single.id, 'DigitalBrain.Chat.ChatModule'); }, ); + + test('watchBrainTopology aborts a hung request and keeps polling', () async { + final httpClient = _AbortThenSucceedClient(); + final client = DigitalBrainUiEdgeClient( + baseUri: Uri.parse('http://ui.example:5080'), + httpClient: httpClient, + ); + final errors = []; + + final snapshot = await client + .watchBrainTopology( + pollInterval: Duration.zero, + requestTimeout: const Duration(milliseconds: 1), + ) + .handleError(errors.add) + .first; + + expect(httpClient.requests, 2); + expect(httpClient.sawAbortableRequest, isTrue); + expect(errors.single, isA()); + expect(snapshot.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + }); +} + +final class _AbortThenSucceedClient extends http.BaseClient { + int requests = 0; + bool sawAbortableRequest = false; + + @override + Future send(http.BaseRequest request) async { + requests++; + if (requests == 1) { + sawAbortableRequest = request is http.AbortableRequest; + final abortable = request as http.AbortableRequest; + await abortable.abortTrigger; + throw http.RequestAbortedException(request.url); + } + + return http.StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': const [], + 'observedAt': '2026-07-28T08:00:00Z', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } } diff --git a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart index c482d5ef..12fd5576 100644 --- a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart +++ b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart @@ -106,20 +106,20 @@ final class BrainNeuron { required this.id, required this.grainType, required this.identity, - required this.silo, + required this.placement, }); final String id; final String grainType; final String identity; - final String silo; + final String placement; factory BrainNeuron.fromJson(Map json) { return BrainNeuron( id: json['id'] as String, grainType: json['grainType'] as String, identity: json['identity'] as String, - silo: json['silo'] as String, + placement: json['placement'] as String, ); } } diff --git a/clients/digitalbrain_wire/test/ui_edge_models_test.dart b/clients/digitalbrain_wire/test/ui_edge_models_test.dart index 34962b5e..ea13bdbd 100644 --- a/clients/digitalbrain_wire/test/ui_edge_models_test.dart +++ b/clients/digitalbrain_wire/test/ui_edge_models_test.dart @@ -68,7 +68,7 @@ void main() { 'id': 'chat:owner/main', 'grainType': 'chat', 'identity': 'owner/main', - 'silo': 'silo-1', + 'placement': 'cluster-1', }, ], 'observedAt': '2026-07-28T08:00:00Z', @@ -77,6 +77,7 @@ void main() { expect(topology.modules.single.id, 'DigitalBrain.Chat.ChatModule'); expect(topology.neurons.single.id, 'chat:owner/main'); expect(topology.neurons.single.grainType, 'chat'); + expect(topology.neurons.single.placement, 'cluster-1'); expect(topology.observedAt, DateTime.utc(2026, 7, 28, 8)); }); } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs index 149f4a9a..1b37a348 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs @@ -159,6 +159,9 @@ await test.Client.Get(UIFixture.DefaultShellName).Open( Assert.Contains( topology.Neurons, neuron => neuron.Id == shell.Id.ToString()); + Assert.All( + topology.Neurons, + neuron => Assert.StartsWith("cluster-", neuron.Placement, StringComparison.Ordinal)); } [Fact(DisplayName = @@ -185,7 +188,7 @@ public async Task HttpChatTurnCarriesPulseIdentity() Assert.True(projected.Sequence > 0); Assert.Equal(nameof(UserMessaged), projected.Synapse); Assert.Equal(chat.Id.ToString(), projected.NeuronId); - Assert.False(string.IsNullOrWhiteSpace(projected.Caller)); + Assert.Equal(chat.Id.ToString(), projected.Caller); Assert.Equal(command.CommandId.ToString(), projected.CommandId); Assert.True(Guid.TryParse(projected.CorrelationId, out var correlation)); Assert.NotEqual(Guid.Empty, correlation); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs similarity index 93% rename from src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs rename to src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs index 03065757..1759bc59 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologySnapshot.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs @@ -11,4 +11,4 @@ internal sealed record BrainNeuron( string Id, string GrainType, string Identity, - string Silo); + string Placement); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs index 46ae0e2d..1cdf31c1 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs @@ -27,10 +27,18 @@ public async Task ReadAsync( cancellationToken.ThrowIfCancellationRequested(); var ownerPrefix = $"{brain.Owner.Value}/"; - var neurons = statistics + var ownerStatistics = statistics .Where(statistic => statistic.GrainId.Key.ToString()! .StartsWith(ownerPrefix, StringComparison.Ordinal)) - .Select(static statistic => + .ToArray(); + var placements = ownerStatistics + .Select(static statistic => statistic.SiloAddress) + .Distinct() + .OrderBy(static address => address.ToString(), StringComparer.Ordinal) + .Select(static (address, index) => (Address: address, Label: $"cluster-{index + 1}")) + .ToDictionary(static placement => placement.Address, static placement => placement.Label); + var neurons = ownerStatistics + .Select(statistic => { var type = statistic.GrainId.Type.ToString()!; var key = statistic.GrainId.Key.ToString()!; @@ -38,7 +46,7 @@ public async Task ReadAsync( $"{type}:{key}", type, key, - statistic.SiloAddress.ToString()); + placements[statistic.SiloAddress]); }) .OrderBy(static neuron => neuron.GrainType, StringComparer.Ordinal) .ThenBy(static neuron => neuron.Identity, StringComparer.Ordinal) From 7b4a64b88b9cdc1d8d777bb8c5896dd1806939c1 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 08:42:42 +0200 Subject: [PATCH 09/20] fix: project configured product capabilities Consumers: the UI topology resolves an Aspire-projected client manifest and Flutter renders only explicit product capabilities. Verified: 258-test root MTP gate, all client analyses/tests, Windows Release build, and live Gemma4 verification with six modules and two configured capabilities. Preserved: three pre-existing generated Windows Flutter files remain unstaged. --- .../lib/digitalbrain_flutter.dart | 1 + .../shell/lib/brain_screen.dart | 24 +++---- .../shell/test/chat_screen_test.dart | 35 ++++++++- .../test/edge_client_test.dart | 6 ++ .../lib/src/ui_edge_models.dart | 19 +++++ .../test/ui_edge_models_test.dart | 4 ++ scripts/verify-product.ps1 | 14 ++++ .../FlutterHostingProjectionSupport.cs | 20 +++++- .../FlutterHostingSelectionContracts.cs | 5 ++ .../Hosting/FlutterHostingUIEdgeContracts.cs | 11 ++- .../Hosting/HostingProjectionContracts.cs | 20 ++++++ .../DigitalBrainBuilder.cs | 13 ++++ .../DigitalBrainHostingExtensions.cs | 16 +++++ .../DigitalBrainModuleBuilder.cs | 3 + .../McpHosting.cs | 1 + .../AIHostingExtensions.cs | 3 + .../BrainCapabilityCatalogTests.cs | 71 +++++++++++++++++++ .../UIEdgeRoundTrip.cs | 1 + .../BrainCapabilityCatalog.cs | 59 +++++++++++++++ .../BrainTopologyModels.cs | 3 + .../BrainTopologyReader.cs | 1 + 21 files changed, 307 insertions(+), 23 deletions(-) create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs create mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs diff --git a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart index 13c1e5aa..07f0b863 100644 --- a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart +++ b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart @@ -1,6 +1,7 @@ export 'package:digitalbrain_wire/digitalbrain_wire.dart' show ActivateControlRequest, + BrainCapability, BrainModule, BrainNeuron, BrainTopologySnapshot, diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart index ab799419..2dfd7873 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart @@ -23,11 +23,9 @@ final class BrainScreen extends StatefulWidget { } final class _BrainScreenState extends State { - static const _aiModule = 'DigitalBrain.AI.AIModule'; - static const _chatModule = 'DigitalBrain.Chat.ChatModule'; - static const _googleModule = 'DigitalBrain.Google.GoogleModule'; - static const _osModule = 'DigitalBrain.OS.OSBehaviorsModule'; - static const _salesforceModule = 'DigitalBrain.Salesforce.SalesforceModule'; + static const _generalAssistant = 'assistant.general'; + static const _accountEnrichment = + 'account-enrichment.gmail-salesforce-description'; BrainTopologySelection? _selection; @@ -78,18 +76,12 @@ final class _BrainScreenState extends State { } ({bool generalAssistant, bool accountEnrichment}) _capabilities() { - final modules = widget.topology?.modules.map((module) => module.id).toSet(); - final generalAssistant = - modules?.contains(_aiModule) == true && - modules?.contains(_chatModule) == true; - final accountEnrichment = - generalAssistant && - modules?.contains(_googleModule) == true && - modules?.contains(_osModule) == true && - modules?.contains(_salesforceModule) == true; + final capabilities = widget.topology?.capabilities + .map((capability) => capability.id) + .toSet(); return ( - generalAssistant: generalAssistant, - accountEnrichment: accountEnrichment, + generalAssistant: capabilities?.contains(_generalAssistant) == true, + accountEnrichment: capabilities?.contains(_accountEnrichment) == true, ); } diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart index 974a2efa..b2cd7b3f 100644 --- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart +++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart @@ -31,6 +31,10 @@ BrainTopologySnapshot _topology() => BrainTopologySnapshot( BrainModule(id: 'DigitalBrain.OS.OSBehaviorsModule'), BrainModule(id: 'DigitalBrain.Salesforce.SalesforceModule'), ], + capabilities: const [ + BrainCapability(id: 'assistant.general'), + BrainCapability(id: 'account-enrichment.gmail-salesforce-description'), + ], neurons: const [ BrainNeuron( id: 'chat:owner/main', @@ -44,6 +48,7 @@ BrainTopologySnapshot _topology() => BrainTopologySnapshot( BrainTopologySnapshot _topologyWithoutNeuron() => BrainTopologySnapshot( modules: _topology().modules, + capabilities: _topology().capabilities, neurons: const [], observedAt: DateTime.utc(2026, 7, 28, 8), ); @@ -91,13 +96,14 @@ void main() { expect(find.text('Approval required'), findsOneWidget); }); - testWidgets('Brain claims only capabilities declared by live modules', ( + testWidgets('Brain claims only capabilities declared by the live manifest', ( tester, ) async { final topology = StreamController(); addTearDown(topology.close); final flutterOnly = BrainTopologySnapshot( modules: const [BrainModule(id: 'DigitalBrain.Flutter.FlutterModule')], + capabilities: const [], neurons: const [], observedAt: DateTime.utc(2026, 7, 28, 8), ); @@ -118,6 +124,33 @@ void main() { expect(find.text('Approval required'), findsNothing); }); + testWidgets('Brain makes no claims for installed but unconfigured modules', ( + tester, + ) async { + final topology = StreamController(); + addTearDown(topology.close); + final unconfigured = BrainTopologySnapshot( + modules: _topology().modules, + capabilities: const [], + neurons: const [], + observedAt: DateTime.utc(2026, 7, 28, 8), + ); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(unconfigured); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.text('General assistant'), findsNothing); + expect( + find.text('Gmail message → Salesforce Account description'), + findsNothing, + ); + }); + testWidgets('activity shows journal facts without message content', ( tester, ) async { diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart index 72663b52..042a61ac 100644 --- a/clients/digitalbrain_flutter/test/edge_client_test.dart +++ b/clients/digitalbrain_flutter/test/edge_client_test.dart @@ -168,6 +168,9 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], + 'capabilities': [ + {'id': 'assistant.general'}, + ], 'neurons': [ if (requestCount > 1) { @@ -192,6 +195,7 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," expect(snapshots.first.neurons, isEmpty); expect(snapshots.last.neurons.single.id, 'chat:owner/main'); + expect(snapshots.last.capabilities.single.id, 'assistant.general'); }, ); @@ -211,6 +215,7 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], + 'capabilities': const [], 'neurons': const [], 'observedAt': '2026-07-28T08:00:00Z', }), @@ -276,6 +281,7 @@ final class _AbortThenSucceedClient extends http.BaseClient { 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], + 'capabilities': const [], 'neurons': const [], 'observedAt': '2026-07-28T08:00:00Z', }), diff --git a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart index 12fd5576..d52b880d 100644 --- a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart +++ b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart @@ -101,6 +101,16 @@ final class BrainModule { } } +final class BrainCapability { + const BrainCapability({required this.id}); + + final String id; + + factory BrainCapability.fromJson(Map json) { + return BrainCapability(id: json['id'] as String); + } +} + final class BrainNeuron { const BrainNeuron({ required this.id, @@ -127,11 +137,13 @@ final class BrainNeuron { final class BrainTopologySnapshot { const BrainTopologySnapshot({ required this.modules, + required this.capabilities, required this.neurons, required this.observedAt, }); final List modules; + final List capabilities; final List neurons; final DateTime observedAt; @@ -143,6 +155,13 @@ final class BrainTopologySnapshot { BrainModule.fromJson(Map.from(module! as Map)), ) .toList(growable: false), + capabilities: (json['capabilities'] as List) + .map( + (capability) => BrainCapability.fromJson( + Map.from(capability! as Map), + ), + ) + .toList(growable: false), neurons: (json['neurons'] as List) .map( (neuron) => diff --git a/clients/digitalbrain_wire/test/ui_edge_models_test.dart b/clients/digitalbrain_wire/test/ui_edge_models_test.dart index ea13bdbd..dcfb5ae4 100644 --- a/clients/digitalbrain_wire/test/ui_edge_models_test.dart +++ b/clients/digitalbrain_wire/test/ui_edge_models_test.dart @@ -63,6 +63,9 @@ void main() { 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], + 'capabilities': [ + {'id': 'assistant.general'}, + ], 'neurons': [ { 'id': 'chat:owner/main', @@ -75,6 +78,7 @@ void main() { }); expect(topology.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + expect(topology.capabilities.single.id, 'assistant.general'); expect(topology.neurons.single.id, 'chat:owner/main'); expect(topology.neurons.single.grainType, 'chat'); expect(topology.neurons.single.placement, 'cluster-1'); diff --git a/scripts/verify-product.ps1 b/scripts/verify-product.ps1 index 1a9b56e1..af92bd8b 100644 --- a/scripts/verify-product.ps1 +++ b/scripts/verify-product.ps1 @@ -215,14 +215,19 @@ try { "DigitalBrain.Google.GoogleModule", "DigitalBrain.OS.OSBehaviorsModule", "DigitalBrain.Salesforce.SalesforceModule") + $expectedCapabilities = @( + "assistant.general", + "account-enrichment.gmail-salesforce-description") $expectedChatNeuron = "chat:dev/$ChatName" $topology = $null $moduleIds = @() + $capabilityIds = @() $activeChat = $null for ($attempt = 1; $attempt -le 30 -and $null -eq $activeChat; $attempt++) { try { $topology = Invoke-RestMethod -Uri "$uiUrl/brain/topology" $moduleIds = @($topology.modules | ForEach-Object id) + $capabilityIds = @($topology.capabilities | ForEach-Object id) $activeChat = @($topology.neurons) | Where-Object id -eq $expectedChatNeuron | Select-Object -First 1 @@ -245,6 +250,14 @@ try { -Condition ($moduleIds -contains $module) ` -Message "Live topology is missing configured module '$module'." } + Require ` + -Condition ($capabilityIds.Count -eq $expectedCapabilities.Count) ` + -Message "Expected $($expectedCapabilities.Count) configured capabilities but found $($capabilityIds.Count)." + foreach ($capability in $expectedCapabilities) { + Require ` + -Condition ($capabilityIds -contains $capability) ` + -Message "Live topology is missing configured capability '$capability'." + } Require ` -Condition ($null -ne $activeChat) ` -Message "Live topology never exposed active neuron '$expectedChatNeuron'." @@ -315,6 +328,7 @@ try { InputTokens = $genAi.attributes.'gen_ai.usage.input_tokens' OutputTokens = $genAi.attributes.'gen_ai.usage.output_tokens' TopologyModules = $moduleIds.Count + ConfiguredCapabilities = $capabilityIds.Count ActiveNeuron = $activeChat.id } | Format-List } diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs index cdfad830..c9df953c 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs @@ -118,7 +118,8 @@ public static void AssertExclusiveFlutterHostEnvironment(HashSet environ public static void AssertClientSafeUIProductEnvironment( IReadOnlyDictionary environment, - params string[] modules) + IReadOnlyList modules, + IReadOnlyList configuredFeatures) { var productKeys = environment.Keys .Where(static key => @@ -131,14 +132,19 @@ public static void AssertClientSafeUIProductEnvironment( { FlutterHostingExtensions.OwnerEnvironmentVariable, }; - for (var index = 0; index < modules.Length; index++) + for (var index = 0; index < modules.Count; index++) { expected.Add( $"{DigitalBrainHostingExtensions.ModulesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"); } + for (var index = 0; index < configuredFeatures.Count; index++) + { + expected.Add( + $"{DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"); + } Assert.Equal(expected, productKeys); - for (var index = 0; index < modules.Length; index++) + for (var index = 0; index < modules.Count; index++) { Assert.Equal( modules[index], @@ -146,6 +152,14 @@ public static void AssertClientSafeUIProductEnvironment( $"{DigitalBrainHostingExtensions.ModulesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"] ?.ToString()); } + for (var index = 0; index < configuredFeatures.Count; index++) + { + Assert.Equal( + configuredFeatures[index], + environment[ + $"{DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"] + ?.ToString()); + } } public static async Task> EnvironmentKeysOf(IResource resource) diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs index 3242b5d4..6a36c429 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs @@ -46,6 +46,11 @@ public async Task VocabularyOnlySelectionDoesNotStartOSSurface() entry.Value?.ToString(), FlutterModule.Id.Value, StringComparison.Ordinal)); + Assert.DoesNotContain( + siloEnvironment, + entry => entry.Key.StartsWith( + "DigitalBrain__ConfiguredFeatures__", + StringComparison.Ordinal)); FlutterHostingProjectionSupport.AssertNoOSSurfaceResources(builder); } diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs index 52fc81de..0771c920 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs @@ -1,6 +1,8 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using DigitalBrain.AI; +using DigitalBrain.AI.Aspire.Hosting; +using DigitalBrain.AI.Ollama; using DigitalBrain.Aspire.Hosting; using DigitalBrain.Flutter; using DigitalBrain.Flutter.Aspire.Hosting; @@ -23,7 +25,7 @@ public async Task WithUIEdgeProjectsLateBoundModuleTopologyWithoutSiloSecrets() options.ProjectPath = FlutterHostingProjectionSupport.UIProjectPath; options.Owner = "edge-owner"; })); - brain.AddModule(); + brain.AddModule(ai => ai.WithLlm()); var silo = builder .AddContainer("silo", "mcr.microsoft.com/dotnet/runtime") @@ -40,8 +42,11 @@ public async Task WithUIEdgeProjectsLateBoundModuleTopologyWithoutSiloSecrets() var environment = await FlutterHostingProjectionSupport.EnvironmentOf(ui).ConfigureAwait(true); FlutterHostingProjectionSupport.AssertClientSafeUIProductEnvironment( environment, - FlutterModule.Id.Value, - AIModule.Id.Value); + [FlutterModule.Id.Value, AIModule.Id.Value], + [AIHostingExtensions.LlmFeature]); + Assert.Equal( + AIHostingExtensions.LlmFeature, + environment["DigitalBrain__ConfiguredFeatures__0"]?.ToString()); Assert.Equal( "edge-owner", environment[FlutterHostingExtensions.OwnerEnvironmentVariable]?.ToString()); diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs index a817f9ec..76af2e1f 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs @@ -52,9 +52,14 @@ public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient .EnvironmentOf(client.Resource) .ConfigureAwait(true); var clientEnvironmentKeys = clientEnvironment.Keys.ToHashSet(StringComparer.Ordinal); + var configuredFeaturePrefix = ConfigurationEnvironment( + DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey); Assert.All(SiloOnlyEnvironmentKeys, key => Assert.Contains(key, siloEnvironment)); Assert.All(SiloOnlyEnvironmentKeys, key => Assert.DoesNotContain(key, clientEnvironmentKeys)); + Assert.DoesNotContain( + siloEnvironment, + key => key.StartsWith(configuredFeaturePrefix, StringComparison.Ordinal)); Assert.Equal( AIModule.Id.Value, clientEnvironment[ @@ -70,6 +75,21 @@ public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient clientEnvironment[ $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__2"] ?.ToString()); + Assert.Equal( + AIHostingExtensions.LlmFeature, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__0"] + ?.ToString()); + Assert.Equal( + "google.gmail", + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__1"] + ?.ToString()); + Assert.Equal( + "salesforce", + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__2"] + ?.ToString()); Assert.Contains( silo.Resource.Annotations.OfType(), diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs index f3707ce5..b45435bc 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs @@ -11,6 +11,7 @@ namespace DigitalBrain.Aspire.Hosting; public sealed class DigitalBrainBuilder { private readonly IDistributedApplicationBuilder _builder; + private readonly List _configuredFeatures = []; private readonly List _modules = []; private readonly List _projections = []; private readonly List _startupDependencies = []; @@ -35,6 +36,8 @@ internal DigitalBrainBuilder( internal IResourceBuilder Journal { get; } + internal IReadOnlyList ConfiguredFeatures => _configuredFeatures; + internal IReadOnlyList Modules => _modules; internal OrleansService Orleans { get; } @@ -79,6 +82,16 @@ internal void AddProjection(DigitalBrainModuleProjection projection) _projections.Add(projection); } + internal void ConfigureFeature(string feature) + { + ArgumentException.ThrowIfNullOrWhiteSpace(feature); + + if (!_configuredFeatures.Contains(feature, StringComparer.Ordinal)) + { + _configuredFeatures.Add(feature); + } + } + internal void RequireHealthyBeforeStart(IResource dependency) { ArgumentNullException.ThrowIfNull(dependency); diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs index 99e88457..c20174cf 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs @@ -9,6 +9,7 @@ public static class DigitalBrainHostingExtensions public const string JournalConnectionName = "journal"; public const string StateProtectionKeyConfigurationKey = "DigitalBrain:Security:StateProtectionKey"; public const string ModulesConfigurationKey = "DigitalBrain:Modules"; + public const string ConfiguredFeaturesConfigurationKey = "DigitalBrain:ConfiguredFeatures"; public static DigitalBrainBuilder AddDigitalBrain( this IDistributedApplicationBuilder builder, @@ -98,6 +99,7 @@ public static IResourceBuilder WithReference( builder.WithReference(client.Brain.Orleans.AsClient()); ProjectModuleManifest(builder, client.Brain); + ProjectConfiguredFeatureManifest(builder, client.Brain); return builder; } @@ -115,6 +117,20 @@ private static void ProjectModuleManifest( } }); + private static void ProjectConfiguredFeatureManifest( + IResourceBuilder builder, + DigitalBrainBuilder brain) + where TResource : IResourceWithEnvironment + => builder.WithEnvironment(context => + { + for (var index = 0; index < brain.ConfiguredFeatures.Count; index++) + { + context.EnvironmentVariables[ + $"{ConfigurationEnvironment(ConfiguredFeaturesConfigurationKey)}__{index}"] = + brain.ConfiguredFeatures[index]; + } + }); + private static string ConfigurationEnvironment(string configurationKey) => configurationKey.Replace(":", "__", StringComparison.Ordinal); } diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs index bc19e01b..d21a6be0 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs @@ -12,6 +12,9 @@ public sealed class DigitalBrainModuleBuilder internal void AddProjection(DigitalBrainModuleProjection projection) => Brain.AddProjection(projection); + internal void ConfigureFeature(string feature) + => Brain.ConfigureFeature(feature); + internal void RequireStateProtection() => Brain.RequireStateProtection(); } diff --git a/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs b/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs index 31660174..1abb4fb6 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs @@ -36,6 +36,7 @@ internal static void Register( } projection.Add(definition, application.Register(definition)); + module.ConfigureFeature(definition.Key); } private static McpApplicationParameters GetOrAddApplicationParameters( diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs index e55d2069..049ee109 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs @@ -9,6 +9,8 @@ namespace DigitalBrain.AI.Aspire.Hosting; public static class AIHostingExtensions { + public const string LlmFeature = "ai.llm"; + public static DigitalBrainModuleBuilder WithLlm( this DigitalBrainModuleBuilder module) where TModel : class, ILLM @@ -25,6 +27,7 @@ public static DigitalBrainModuleBuilder WithLlm( } state.Add(); + module.ConfigureFeature(LlmFeature); return module; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs new file mode 100644 index 00000000..59f52ea4 --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace DigitalBrain.UI.Tests; + +public sealed class BrainCapabilityCatalogTests +{ + private static readonly BrainModule[] ProductModules = + [ + new("DigitalBrain.AI.AIModule"), + new("DigitalBrain.Chat.ChatModule"), + new("DigitalBrain.Flutter.FlutterModule"), + new("DigitalBrain.Google.GoogleModule"), + new("DigitalBrain.OS.OSBehaviorsModule"), + new("DigitalBrain.Salesforce.SalesforceModule"), + ]; + + [Fact(DisplayName = + "all modules without configured providers claims no product capability")] + public void CompleteModuleSetWithoutConfiguredFeaturesClaimsNothing() + { + var capabilities = BrainCapabilityCatalog.Resolve( + ProductModules, + Configuration()); + + Assert.Empty(capabilities); + } + + [Fact(DisplayName = + "configured providers without OS responder claims no product capability")] + public void ConfiguredFeaturesWithoutOSResponderClaimsNothing() + { + var modules = ProductModules + .Where(module => module.Id != "DigitalBrain.OS.OSBehaviorsModule") + .ToArray(); + + var capabilities = BrainCapabilityCatalog.Resolve( + modules, + Configuration("ai.llm", "google.gmail", "salesforce")); + + Assert.Empty(capabilities); + } + + [Fact(DisplayName = + "complete configured product manifest claims general assistant and account enrichment")] + public void CompleteConfiguredProductManifestClaimsExactCapabilities() + { + var capabilities = BrainCapabilityCatalog.Resolve( + ProductModules, + Configuration("ai.llm", "google.gmail", "salesforce")); + + Assert.Equal( + [ + BrainCapabilityCatalog.GeneralAssistant, + BrainCapabilityCatalog.AccountEnrichment, + ], + capabilities.Select(capability => capability.Id)); + } + + private static IConfiguration Configuration(params string[] features) + { + var values = features + .Select((feature, index) => + new KeyValuePair( + $"DigitalBrain:ConfiguredFeatures:{index}", + feature)); + return new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + } +} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs index 1b37a348..20bbb268 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs @@ -159,6 +159,7 @@ await test.Client.Get(UIFixture.DefaultShellName).Open( Assert.Contains( topology.Neurons, neuron => neuron.Id == shell.Id.ToString()); + Assert.Empty(topology.Capabilities); Assert.All( topology.Neurons, neuron => Assert.StartsWith("cluster-", neuron.Placement, StringComparison.Ordinal)); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs new file mode 100644 index 00000000..d841ec25 --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs @@ -0,0 +1,59 @@ +namespace DigitalBrain.UI; + +internal static class BrainCapabilityCatalog +{ + internal const string GeneralAssistant = "assistant.general"; + internal const string AccountEnrichment = + "account-enrichment.gmail-salesforce-description"; + + private const string AIModule = "DigitalBrain.AI.AIModule"; + private const string ChatModule = "DigitalBrain.Chat.ChatModule"; + private const string GoogleModule = "DigitalBrain.Google.GoogleModule"; + private const string OSModule = "DigitalBrain.OS.OSBehaviorsModule"; + private const string SalesforceModule = "DigitalBrain.Salesforce.SalesforceModule"; + private const string LlmFeature = "ai.llm"; + private const string GmailFeature = "google.gmail"; + private const string SalesforceFeature = "salesforce"; + + internal static IReadOnlyList Resolve( + IReadOnlyList modules, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(modules); + ArgumentNullException.ThrowIfNull(configuration); + + var moduleIds = modules + .Select(static module => module.Id) + .ToHashSet(StringComparer.Ordinal); + var features = configuration + .GetSection("DigitalBrain:ConfiguredFeatures") + .GetChildren() + .Select(static section => section.Value) + .Where(static feature => !string.IsNullOrWhiteSpace(feature)) + .ToHashSet(StringComparer.Ordinal); + var generalAssistant = + moduleIds.Contains(AIModule) && + moduleIds.Contains(ChatModule) && + moduleIds.Contains(OSModule) && + features.Contains(LlmFeature); + var accountEnrichment = + generalAssistant && + moduleIds.Contains(GoogleModule) && + moduleIds.Contains(SalesforceModule) && + features.Contains(GmailFeature) && + features.Contains(SalesforceFeature); + + var capabilities = new List(2); + if (generalAssistant) + { + capabilities.Add(new BrainCapability(GeneralAssistant)); + } + + if (accountEnrichment) + { + capabilities.Add(new BrainCapability(AccountEnrichment)); + } + + return capabilities; + } +} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs index 1759bc59..92da6d16 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs @@ -2,11 +2,14 @@ namespace DigitalBrain.UI; internal sealed record BrainTopologySnapshot( IReadOnlyList Modules, + IReadOnlyList Capabilities, IReadOnlyList Neurons, DateTimeOffset ObservedAt); internal sealed record BrainModule(string Id); +internal sealed record BrainCapability(string Id); + internal sealed record BrainNeuron( string Id, string GrainType, diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs index 1cdf31c1..8ae317ac 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs @@ -54,6 +54,7 @@ public async Task ReadAsync( return new BrainTopologySnapshot( modules, + BrainCapabilityCatalog.Resolve(modules, configuration), neurons, TimeProvider.System.GetUtcNow()); } From 89e42a6f567e0176f1dfc267402a99f6dbf59260 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 08:53:20 +0200 Subject: [PATCH 10/20] fix: bind capabilities to configured model Project model-specific client-safe LLM features so the Gemma4-backed assistant is advertised only when Gemma4 is actually configured. Cover non-Gemma rejection and Gemma composition projection. --- .../Hosting/FlutterHostingUIEdgeContracts.cs | 6 ++--- .../Hosting/HostingProjectionContracts.cs | 2 +- .../AIHostingExtensions.cs | 25 +++++++++++-------- .../BrainCapabilityCatalogTests.cs | 19 +++++++++++--- .../BrainCapabilityCatalog.cs | 4 +-- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs index 0771c920..cb7a2541 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs @@ -25,7 +25,7 @@ public async Task WithUIEdgeProjectsLateBoundModuleTopologyWithoutSiloSecrets() options.ProjectPath = FlutterHostingProjectionSupport.UIProjectPath; options.Owner = "edge-owner"; })); - brain.AddModule(ai => ai.WithLlm()); + brain.AddModule(ai => ai.WithLlm()); var silo = builder .AddContainer("silo", "mcr.microsoft.com/dotnet/runtime") @@ -43,9 +43,9 @@ public async Task WithUIEdgeProjectsLateBoundModuleTopologyWithoutSiloSecrets() FlutterHostingProjectionSupport.AssertClientSafeUIProductEnvironment( environment, [FlutterModule.Id.Value, AIModule.Id.Value], - [AIHostingExtensions.LlmFeature]); + [AIHostingExtensions.Gemma4Feature]); Assert.Equal( - AIHostingExtensions.LlmFeature, + AIHostingExtensions.Gemma4Feature, environment["DigitalBrain__ConfiguredFeatures__0"]?.ToString()); Assert.Equal( "edge-owner", diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs index 76af2e1f..a22e78a9 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs @@ -76,7 +76,7 @@ public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__2"] ?.ToString()); Assert.Equal( - AIHostingExtensions.LlmFeature, + AIHostingExtensions.Llama32Feature, clientEnvironment[ $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__0"] ?.ToString()); diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs index 049ee109..cc3ff3e1 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs @@ -9,7 +9,11 @@ namespace DigitalBrain.AI.Aspire.Hosting; public static class AIHostingExtensions { - public const string LlmFeature = "ai.llm"; + public const string Llama32Feature = "ai.llm.llama32"; + public const string Gemma4Feature = "ai.llm.gemma4"; + public const string Qwen35Feature = "ai.llm.qwen35"; + public const string Granite41Feature = "ai.llm.granite41"; + public const string Gpt56Feature = "ai.llm.gpt56"; public static DigitalBrainModuleBuilder WithLlm( this DigitalBrainModuleBuilder module) @@ -26,8 +30,7 @@ public static DigitalBrainModuleBuilder WithLlm( module.AddProjection(state); } - state.Add(); - module.ConfigureFeature(LlmFeature); + module.ConfigureFeature(state.Add()); return module; } @@ -35,12 +38,12 @@ private sealed class AIHostingState(DigitalBrainBuilder brain) : DigitalBrainMod { private const string OllamaImageTag = "latest"; - private static readonly Dictionary OllamaModelCatalog = new() + private static readonly Dictionary OllamaModelCatalog = new() { - [typeof(Llama32)] = ("llama32", "llama3.2"), - [typeof(Gemma4)] = ("gemma4", "gemma4:12b"), - [typeof(Qwen35)] = ("qwen35", "qwen3.5:9b"), - [typeof(Granite41)] = ("granite41", "granite4.1:8b"), + [typeof(Llama32)] = ("llama32", "llama3.2", Llama32Feature), + [typeof(Gemma4)] = ("gemma4", "gemma4:12b", Gemma4Feature), + [typeof(Qwen35)] = ("qwen35", "qwen3.5:9b", Qwen35Feature), + [typeof(Granite41)] = ("granite41", "granite4.1:8b", Granite41Feature), }; private readonly HashSet _models = []; @@ -50,7 +53,7 @@ private sealed class AIHostingState(DigitalBrainBuilder brain) : DigitalBrainMod private IResourceBuilder? _gpt56; private IResourceBuilder? _openAIKey; - internal void Add() + internal string Add() where TModel : class, ILLM { var model = typeof(TModel); @@ -64,13 +67,13 @@ internal void Add() if (OllamaModelCatalog.TryGetValue(model, out var ollama)) { AddOllamaModel(model, ollama.ResourceSuffix, ollama.Tag); - return; + return ollama.Feature; } if (model == typeof(Gpt56)) { AddGpt56(); - return; + return Gpt56Feature; } throw new NotSupportedException( diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs index 59f52ea4..0dd0a324 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs @@ -36,18 +36,29 @@ public void ConfiguredFeaturesWithoutOSResponderClaimsNothing() var capabilities = BrainCapabilityCatalog.Resolve( modules, - Configuration("ai.llm", "google.gmail", "salesforce")); + Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); Assert.Empty(capabilities); } [Fact(DisplayName = - "complete configured product manifest claims general assistant and account enrichment")] - public void CompleteConfiguredProductManifestClaimsExactCapabilities() + "configured non-Gemma model claims no product capability")] + public void NonGemmaModelClaimsNothing() { var capabilities = BrainCapabilityCatalog.Resolve( ProductModules, - Configuration("ai.llm", "google.gmail", "salesforce")); + Configuration("ai.llm.llama32", "google.gmail", "salesforce")); + + Assert.Empty(capabilities); + } + + [Fact(DisplayName = + "configured Gemma product manifest claims general assistant and account enrichment")] + public void ConfiguredGemmaProductManifestClaimsExactCapabilities() + { + var capabilities = BrainCapabilityCatalog.Resolve( + ProductModules, + Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); Assert.Equal( [ diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs index d841ec25..bff2aaee 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs @@ -11,7 +11,7 @@ internal static class BrainCapabilityCatalog private const string GoogleModule = "DigitalBrain.Google.GoogleModule"; private const string OSModule = "DigitalBrain.OS.OSBehaviorsModule"; private const string SalesforceModule = "DigitalBrain.Salesforce.SalesforceModule"; - private const string LlmFeature = "ai.llm"; + private const string Gemma4Feature = "ai.llm.gemma4"; private const string GmailFeature = "google.gmail"; private const string SalesforceFeature = "salesforce"; @@ -35,7 +35,7 @@ internal static IReadOnlyList Resolve( moduleIds.Contains(AIModule) && moduleIds.Contains(ChatModule) && moduleIds.Contains(OSModule) && - features.Contains(LlmFeature); + features.Contains(Gemma4Feature); var accountEnrichment = generalAssistant && moduleIds.Contains(GoogleModule) && From 12affb652e322945af0b95ab9ea96ea33ab4bac1 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 16:53:43 +0200 Subject: [PATCH 11/20] style: prefer single-line C# invocations when they fit ~120 columns Collapse needlessly chopped method/ctor/new argument lists across product, OS, and fixture C#. Long or intentional multi-line forms left alone. Formatting only; no behavior changes. --- os/DigitalBrain.OS.Behaviors/Assistant.cs | 13 +--- os/DigitalBrain.OS.Behaviors/ChatResponder.cs | 3 +- .../ChatResponderNeuron.cs | 15 +--- .../PreRailBehaviorContext.cs | 6 +- .../DigitalBrainIntrospectionTools.cs | 7 +- .../DigitalBrainMcpTools.cs | 28 ++----- os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs | 11 +-- .../Steps/ActivationSteps.cs | 7 +- .../Steps/ChatSteps.cs | 4 +- .../Support/BrainWorld.cs | 3 +- .../BehaviorOSActivationBoot.cs | 4 +- .../CompositionChatEdge.cs | 4 +- .../ShellAndSurfaceCompositions.cs | 7 +- .../AppHostCleanupContracts.cs | 54 ++++--------- .../ChatProductTool.cs | 10 +-- .../FlutterHostingProjectionSupport.cs | 16 +--- .../Identity/GrainKeyEncoding.cs | 11 +-- .../DigitalBrainBuilder.cs | 22 ++---- .../DigitalBrainHostingExtensions.cs | 33 ++------ .../DigitalBrainClientHostingExtensions.cs | 4 +- .../ServiceDefaultsExtensions.cs | 25 ++---- .../BehaviorExecutionMetadata.cs | 6 +- .../IBehaviorProgram.cs | 5 +- .../DigitalBrain.Behaviors/IIntentProgram.cs | 5 +- .../Manifest/BehaviorCapabilityGrant.cs | 5 +- .../Manifest/BehaviorEntryPoints.cs | 4 +- .../Manifest/BehaviorIntentSchema.cs | 6 +- .../Manifest/BehaviorResourceLimits.cs | 5 +- .../Journals/JournalTally.cs | 4 +- .../Synapses/CapabilityCompleted.cs | 3 +- .../Synapses/CapabilityFailed.cs | 3 +- .../Synapses/CapabilityRejected.cs | 3 +- .../Synapses/DigitalBrainActivated.cs | 3 +- .../DigitalBrain.Client/DigitalBrainClient.cs | 4 +- .../DispatchManifestGenerator.Composition.cs | 4 +- .../DispatchManifestGenerator.Modules.cs | 28 ++----- .../DispatchManifestGenerator.Neurons.cs | 18 +---- .../Capabilities/CapabilityDelegation.cs | 11 +-- .../Capabilities/CapabilityDelegationState.cs | 4 +- .../Capabilities/CapabilityRequestContext.cs | 8 +- .../Filters/IncomingReificationFilter.cs | 10 +-- .../Filters/OutgoingReificationFilter.cs | 17 +--- .../Filters/OwnerBoundCallFilter.cs | 23 ++---- .../Hosting/DigitalBrainRuntime.cs | 4 +- .../DigitalBrain/Hosting/PinToSiloDirector.cs | 5 +- .../DigitalBrain/Neuron/DigitalBrainNeuron.cs | 3 +- .../Neuron/Neuron.Capability.Delegation.cs | 11 +-- .../Neuron/Neuron.Capability.Incoming.cs | 11 +-- .../DigitalBrain/Neuron/Neuron.Capability.cs | 37 ++------- .../DigitalBrain/Neuron/Neuron.Concurrency.cs | 6 +- .../DigitalBrain/Neuron/Neuron.Lifecycle.cs | 7 +- .../DigitalBrain/Neuron/Neuron.Messaging.cs | 16 +--- .../DigitalBrain/Neuron/Neuron.Turns.cs | 12 +-- .../DigitalBrain/Outbox/OutboxWakeup.cs | 22 ++---- .../DigitalBrain/ReminderSourceAllowlist.cs | 3 +- .../McpHosting.cs | 24 ++---- .../AccountEnrichmentProcessSample.cs | 11 +-- .../GmailReadMessage.cs | 8 +- .../DigitalBrain.Mcp.Tests/GoogleContracts.cs | 4 +- .../IntegrationsFixture.cs | 12 +-- .../IntegrationsHarness.cs | 34 ++------ .../mcp/DigitalBrain.Mcp.Tests/McpEdge.cs | 38 +++------ .../DigitalBrain.Mcp.Tests/McpEdgeHarness.cs | 18 +---- .../McpOAuthCallbacks.cs | 13 +--- .../SalesforceMutation.cs | 38 +++------ .../DigitalBrain.Mcp/DurableMcpTokenCache.cs | 4 +- .../HttpMcpClientSessionFactory.cs | 10 +-- .../LocalLoopbackMcpAuthorizationCallback.cs | 5 +- .../mcp/DigitalBrain.Mcp/McpOAuthOptions.cs | 20 +---- src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs | 12 +-- .../DigitalBrain.Mcp/McpToolFingerprint.cs | 4 +- .../CapabilityProbes.cs | 4 +- .../CapabilityReificationContracts.cs | 10 +-- .../ClockContracts.cs | 25 ++---- .../JournalEvidenceContracts.cs | 8 +- .../JournalFaultContracts.cs | 33 ++------ .../LongRunningNeuronContracts.cs | 4 +- .../RestartHostContracts.cs | 12 +-- .../TestOwnerContracts.cs | 8 +- .../Cluster/ControllableTimeProvider.cs | 28 ++----- .../Cluster/FixtureCluster.cs | 40 +++------- .../Cluster/TestReminderDeliveryCaller.cs | 10 +-- .../Cluster/TestReminderDeliveryService.cs | 14 +--- .../TestReminderDeliveryServiceClient.cs | 7 +- .../Cluster/TestReminderDriver.cs | 26 ++----- .../Cluster/TestReminderRegistry.cs | 48 +++--------- .../Diagnostics/BrainTestDiagnostics.cs | 4 +- .../DigitalBrainFixture.cs | 10 +-- .../DigitalBrainTestBuilder.cs | 24 ++---- .../Edges/ScriptedChatExtensions.cs | 5 +- .../Edges/TestEdgeRegistry.cs | 43 +++------- .../Faults/JournalFaultHandle.cs | 13 +--- .../Hosting/AppHostExclusiveLease.cs | 3 +- .../Hosting/HostedResource.cs | 3 +- .../Journals/TestJournal.Watch.cs | 27 ++----- .../Journals/TestJournal.cs | 19 ++--- .../RecordingJournalStorageProvider.cs | 22 ++---- .../testing/DigitalBrain.Testing/TestBrain.cs | 45 +++-------- .../testing/DigitalBrain.Testing/TestClock.cs | 37 +++------ .../DigitalBrain.Testing/TestNeuron.cs | 10 +-- .../testing/DigitalBrain.Testing/TestOwner.cs | 4 +- .../VolatileReminderTable.cs | 60 ++++---------- .../AIHostingExtensions.cs | 36 +++------ .../CapabilityToolSelected.cs | 3 +- .../AIProviderConfiguration.cs | 12 +-- .../DigitalBrain.Modules.AI.Tests/AISmoke.cs | 3 +- .../CapabilityToolProbes.cs | 10 +-- .../CapabilityToolSeam.cs | 10 +-- .../DurablePayloadProtectionStartup.cs | 12 +-- .../OrchestrationL1.cs | 10 +-- .../Clients/AIClients.cs | 10 +-- .../DigitalBrain.Modules.AI/Ollama/Gemma4.cs | 4 +- .../Ollama/Granite41.cs | 4 +- .../DigitalBrain.Modules.AI/Ollama/Llama32.cs | 4 +- .../DigitalBrain.Modules.AI/Ollama/Qwen35.cs | 4 +- .../DigitalBrain.Modules.AI/OpenAI/Gpt56.cs | 4 +- .../Orchestration/Concurrent.cs | 12 +-- .../Orchestration/DirectAgentSession.cs | 23 ++---- .../Orchestration/DirectOrchestrationShape.cs | 37 ++------- .../Orchestration/GroupChat.cs | 12 +-- .../Orchestration/MafParticipantAdapter.cs | 5 +- .../Orchestration/NeuronChatClient.cs | 7 +- .../Orchestration/OrchestrationDefinition.cs | 7 +- .../Tools/TurnBoundFunction.cs | 4 +- .../AssistantAnswered.cs | 4 +- .../ChatTranscript.cs | 3 +- .../ChatTurn.cs | 4 +- .../SendMessage.cs | 4 +- .../FlutterHostLaunch.cs | 15 +--- .../FlutterHostingExtensions.cs | 22 ++---- .../BrainCapabilityCatalogTests.cs | 20 ++--- .../LiveProductUINorthbound.cs | 22 ++---- .../UIEdgeRoundTrip.cs | 59 ++++---------- .../UIEdgeSse.cs | 18 ++--- .../UIEdgeVocabulary.cs | 8 +- .../UIFixture.cs | 4 +- .../BrainCapabilityCatalog.cs | 4 +- .../BrainEndpoints.cs | 3 +- .../BrainTopologyModels.cs | 6 +- .../BrainTopologyReader.cs | 14 +--- .../ChatEventFeed.cs | 13 +--- .../OwnerSessionJournal.cs | 14 +--- .../SceneOpenedEvent.cs | 7 +- .../ShellEventFeed.cs | 13 +--- .../UIEndpoints.cs | 9 +-- .../FlutterVocabulary.cs | 8 +- .../ShellSceneRoundTrip.cs | 46 +++-------- .../SceneNeuron.cs | 4 +- .../ShellBootNeuron.cs | 12 +-- .../ShellNeuron.cs | 6 +- .../GoogleHostingExtensions.cs | 3 +- .../Gmail/IGmail.cs | 4 +- .../Gmail/Gmail.cs | 4 +- .../Approve/Approve.cs | 16 +--- .../Invoke/Invoke.cs | 22 ++---- .../Salesforce.cs | 3 +- .../TaskLifecycle.Cancel.cs | 20 +---- .../TaskLifecycle.Outcomes.cs | 45 +++-------- .../TaskLifecycle.Start.cs | 16 +--- .../TaskLifecycle.cs | 18 +---- .../TasksHarnessModule.cs | 14 +--- .../TestVocabulary.cs | 10 +-- .../TaskNeuron.Attempts.cs | 5 +- .../TaskNeuron.Dispatch.cs | 5 +- .../TaskNeuron.Support.cs | 8 +- .../ClientEntryPointCapability.cs | 23 ++---- .../CountdownLifecycle.Validation.cs | 63 ++++----------- .../CountdownLifecycle.cs | 78 +++++-------------- .../CountdownRecovery.cs | 47 ++++------- .../TimeFixture.cs | 5 +- .../CountdownNeuron.Recovery.cs | 38 ++------- .../CountdownNeuron.State.cs | 26 ++----- .../CountdownNeuron.cs | 9 +-- .../AccountEnrichment.cs | 56 ++++--------- .../AccountEnrichmentSurface.cs | 5 +- .../CountdownSurface.cs | 5 +- .../DigitalBrain.Compositions/OpenHome.cs | 5 +- .../GreetingSynapses.cs | 6 +- .../GreetingBehavior.cs | 4 +- .../DigitalBrain.Quickstart/Greeter.cs | 4 +- 180 files changed, 617 insertions(+), 1989 deletions(-) diff --git a/os/DigitalBrain.OS.Behaviors/Assistant.cs b/os/DigitalBrain.OS.Behaviors/Assistant.cs index f7d83452..1b8b4d9a 100644 --- a/os/DigitalBrain.OS.Behaviors/Assistant.cs +++ b/os/DigitalBrain.OS.Behaviors/Assistant.cs @@ -9,8 +9,7 @@ namespace DigitalBrain.OS; -internal sealed class Assistant( - [FromKeyedServices(typeof(Gemma4))] IChatClient chatClient) +internal sealed class Assistant([FromKeyedServices(typeof(Gemma4))] IChatClient chatClient) : Agent(chatClient), IAssistant { public const string EnrichAccountFromEmail = "enrich_account_from_email"; @@ -71,17 +70,13 @@ private static void ValidateAccountId(string accountId) || accountId.Any(static character => !char.IsAsciiLetterOrDigit(character))) { - throw new ArgumentException( - "A Salesforce Account ID must be a 15- or 18-character alphanumeric value.", - nameof(accountId)); + throw new ArgumentException("A Salesforce Account ID must be a 15- or 18-character alphanumeric value.", nameof(accountId)); } } private IGmail Gmail() - => GrainFactory.GetGrain( - NeuronId.For(Id.Owner, DefaultGmailAccount).ToGrainId()); + => GrainFactory.GetGrain(NeuronId.For(Id.Owner, DefaultGmailAccount).ToGrainId()); private ISalesforce Salesforce() - => GrainFactory.GetGrain( - NeuronId.For(Id.Owner, DefaultSalesforceAccount).ToGrainId()); + => GrainFactory.GetGrain(NeuronId.For(Id.Owner, DefaultSalesforceAccount).ToGrainId()); } diff --git a/os/DigitalBrain.OS.Behaviors/ChatResponder.cs b/os/DigitalBrain.OS.Behaviors/ChatResponder.cs index 470fdb1e..4340a59f 100644 --- a/os/DigitalBrain.OS.Behaviors/ChatResponder.cs +++ b/os/DigitalBrain.OS.Behaviors/ChatResponder.cs @@ -18,8 +18,7 @@ public async ValueTask ExecuteAsync( ArgumentNullException.ThrowIfNull(context); cancellationToken.ThrowIfCancellationRequested(); - var answer = await context.Get(AssistantName).Respond( - [.. request.Transcript.Select(AsChatMessage)]); + var answer = await context.Get(AssistantName).Respond([.. request.Transcript.Select(AsChatMessage)]); return answer.Text; } diff --git a/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs b/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs index f4509dce..4e356147 100644 --- a/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs +++ b/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs @@ -39,26 +39,17 @@ public async Task HandleAsync(UserMessaged synapse, CancellationToken cancellati var context = new PreRailBehaviorContext( GrainFactory, - new BehaviorExecutionMetadata( - Id.Owner, - Identity, - Revision, - BehaviorExecutionId.New()), + new BehaviorExecutionMetadata(Id.Owner, Identity, Revision, BehaviorExecutionId.New()), TimeProvider, _state); var answer = await Program.ExecuteAsync(synapse, context, cancellationToken); - await SendAsync( - synapse.Chat, - new AssistantAnswered( - context.DeterministicCommandId(synapse.CommandId.ToString()), - answer)); + await SendAsync(synapse.Chat, new AssistantAnswered(context.DeterministicCommandId(synapse.CommandId.ToString()), answer)); await WriteStateAsync(CancellationToken.None); } private static BehaviorRevisionId RevisionOf(string seed) - => new(Convert.ToHexStringLower( - SHA256.HashData(Encoding.UTF8.GetBytes(seed)))); + => new(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(seed)))); } diff --git a/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs b/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs index 286f87ab..8a737ad4 100644 --- a/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs +++ b/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs @@ -21,8 +21,7 @@ public CommandId DeterministicCommandId(string purpose) { ArgumentException.ThrowIfNullOrWhiteSpace(purpose); - var seed = SHA256.HashData( - Encoding.UTF8.GetBytes($"{Execution.Behavior}/{Execution.Execution.Value}/{purpose}")); + var seed = SHA256.HashData(Encoding.UTF8.GetBytes($"{Execution.Behavior}/{Execution.Execution.Value}/{purpose}")); return new CommandId(new Guid(seed.AsSpan(0, 16))); } @@ -32,8 +31,7 @@ public TContract Get(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return grains.GetGrain( - NeuronId.For(Execution.Owner, name).ToGrainId()); + return grains.GetGrain(NeuronId.For(Execution.Owner, name).ToGrainId()); } public ValueTask ReadStateAsync(string key, CancellationToken cancellationToken = default) diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs index ab05f14c..9de0c10d 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs @@ -50,8 +50,7 @@ public async Task ReadNeuronJournalAsync( var direction = Enum.Parse(kind, ignoreCase: true); var neuron = new NeuronId(grainType, brain.Owner, name); - var session = grains.GetGrain( - ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); + var session = grains.GetGrain(ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); var read = await session.ReadNeuronJournal(neuron, direction, afterSequence); @@ -82,9 +81,7 @@ public async Task ReadChatTranscriptAsync( return new ChatTranscriptPage( chatName, [ - .. transcript.Turns.Select(turn => new ChatTranscriptTurn( - turn.FromUser ? "you" : "brain", - turn.Text)), + .. transcript.Turns.Select(turn => new ChatTranscriptTurn(turn.FromUser ? "you" : "brain", turn.Text)), ]); } } diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs index 759e6d3b..f6419551 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs @@ -32,34 +32,21 @@ public async Task SendChatMessageAsync( ArgumentException.ThrowIfNullOrWhiteSpace(text); ArgumentException.ThrowIfNullOrWhiteSpace(chatName); ArgumentOutOfRangeException.ThrowIfLessThan(timeoutSeconds, 1); - ArgumentOutOfRangeException.ThrowIfGreaterThan( - timeoutSeconds, - MaximumTimeoutSeconds); + ArgumentOutOfRangeException.ThrowIfGreaterThan(timeoutSeconds, MaximumTimeoutSeconds); var chatId = NeuronId.For(brain.Owner, chatName); - var session = grains.GetGrain( - ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); - var baseline = await session.ReadNeuronJournal( - chatId, - JournalKind.Outgoing, - afterSequence: 0); + var session = grains.GetGrain(ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); + var baseline = await session.ReadNeuronJournal(chatId, JournalKind.Outgoing, afterSequence: 0); var commandId = CommandId.New(); await brain.Get(chatName).Send(new SendMessage(commandId, text)); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); try { - return await WaitForResponseAsync( - session, - chatId, - chatName, - commandId, - baseline.ResumeSequence, - timeout.Token); + return await WaitForResponseAsync(session, chatId, chatName, commandId, baseline.ResumeSequence, timeout.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -83,10 +70,7 @@ private static async Task WaitForResponseAsync( while (true) { cancellationToken.ThrowIfCancellationRequested(); - var page = await session.ReadNeuronJournal( - chatId, - JournalKind.Outgoing, - cursor); + var page = await session.ReadNeuronJournal(chatId, JournalKind.Outgoing, cursor); foreach (var delivery in page.Delta) { diff --git a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs index 4cfe32d5..e56ba82e 100644 --- a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs +++ b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs @@ -7,18 +7,11 @@ internal sealed record NeuronJournalPage( bool Compacted, IReadOnlyList Entries); -internal sealed record JournaledSynapse( - long Sequence, - string Synapse, - string Caller, - string Correlation, - DateTimeOffset Timestamp); +internal sealed record JournaledSynapse(long Sequence, string Synapse, string Caller, string Correlation, DateTimeOffset Timestamp); internal sealed record ActiveNeuron(string GrainType, string Identity, string Silo); -internal sealed record ChatTranscriptPage( - string Chat, - IReadOnlyList Turns); +internal sealed record ChatTranscriptPage(string Chat, IReadOnlyList Turns); internal sealed record ChatTranscriptTurn(string Speaker, string Text); diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs index 7436a22d..ab519f82 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs @@ -24,8 +24,7 @@ public async Task ThenActivationIsJournaled() { var brain = world.Neuron(IDigitalBrainNeuron.InstanceName); - var activated = await brain.Outgoing.NextAsync( - world.CancellationToken); + var activated = await brain.Outgoing.NextAsync(world.CancellationToken); Assert.Equal(world.Brain.Client.Owner, activated.Synapse.Owner); } @@ -46,9 +45,7 @@ public async Task ThenActivationCountIs(int expected) { var brain = world.Neuron(IDigitalBrainNeuron.InstanceName); - var activations = await brain.Outgoing.ReadAsync( - afterSequence: 0, - world.CancellationToken); + var activations = await brain.Outgoing.ReadAsync(afterSequence: 0, world.CancellationToken); Assert.Equal(expected, activations.Count); } diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs index c59ad852..da6a0001 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs @@ -54,9 +54,7 @@ public async Task ThenTheAssistantSelectsNoExternalCapability() var selected = await world .Neuron(ChatResponder.AssistantName) .Outgoing - .ReadAsync( - afterSequence: 0, - world.CancellationToken); + .ReadAsync(afterSequence: 0, world.CancellationToken); Assert.Empty(selected); } diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs index a39da080..7685b215 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs @@ -26,8 +26,7 @@ internal async Task OpenAsync() { await CloseAsync(); - _deadline = CancellationTokenSource.CreateLinkedTokenSource( - TestContext.Current.CancellationToken); + _deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); _deadline.CancelAfter(ScenarioDeadline); _brain = await OSCluster.Fixture.CreateBrainAsync(_deadline.Token); } diff --git a/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs b/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs index 52ed97c0..ed5a9fd3 100644 --- a/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs +++ b/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs @@ -42,9 +42,7 @@ public async Task SecondActivateDoesNotEmitAgain() await test.Client.ActivateAsync(); - var activations = await brain.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var activations = await brain.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Single(activations); Assert.Equal(first.SynapseId, activations[0].SynapseId); } diff --git a/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs b/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs index 6644de66..f85614e4 100644 --- a/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs +++ b/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs @@ -69,9 +69,7 @@ internal void Reset() } } - private ChatResponse NextResponse( - IEnumerable messages, - CancellationToken cancellationToken) + private ChatResponse NextResponse(IEnumerable messages, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(messages); cancellationToken.ThrowIfCancellationRequested(); diff --git a/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs b/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs index 7e0cbf61..6845a543 100644 --- a/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs +++ b/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs @@ -99,12 +99,7 @@ public async Task AiPaneSurfaceOpensSceneAndResponds() var shell = test.Neuron(ShellName); test.Chat().Reply(PaneReply); - var response = await new AiPaneSurface().RunAsync( - test.Client, - ShellName, - ModelName, - PanePrompt, - cancellationToken); + var response = await new AiPaneSurface().RunAsync(test.Client, ShellName, ModelName, PanePrompt, cancellationToken); var opened = await shell.Outgoing.NextAsync(cancellationToken); Assert.Equal(AiPaneSurface.SceneKey, opened.Synapse.SceneKey); diff --git a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs index 2483cc80..af7b30cf 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs @@ -28,9 +28,7 @@ public async Task AHungAppHostDisposalCompletesWithinTheCleanupBudget() var applicationHost = new ControllableHost(hangDuringDispose: true); var running = await CreateRunningAppHost(applicationHost); var disposal = running.DisposeAsync().AsTask(); - var bound = Task.Delay( - TimeSpan.FromSeconds(1), - TestContext.Current.CancellationToken); + var bound = Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); Assert.Same(disposal, await Task.WhenAny(disposal, bound)); var failure = await Record.ExceptionAsync(() => disposal); @@ -53,17 +51,13 @@ public async Task AppHostCleanupPreservesTheFirstFailureAndRetainsLaterFailures( var later = new IOException("dispose failed"); var applicationHost = new ControllableHost(primary, later); var releases = 0; - var running = await CreateRunningAppHost( - applicationHost, - _ => Interlocked.Increment(ref releases)); + var running = await CreateRunningAppHost(applicationHost, _ => Interlocked.Increment(ref releases)); - var failure = await Record.ExceptionAsync( - () => running.DisposeAsync().AsTask()); + var failure = await Record.ExceptionAsync(() => running.DisposeAsync().AsTask()); Assert.NotNull(failure); Assert.Same(primary, failure.InnerException); - var secondary = Assert.IsType( - primary.Data["AppHost cleanup secondary failures"]); + var secondary = Assert.IsType(primary.Data["AppHost cleanup secondary failures"]); Assert.Same(later, Assert.Single(secondary.InnerExceptions)); Assert.Equal(1, applicationHost.StopCalls); Assert.Equal(1, applicationHost.AsyncDisposeCalls); @@ -80,9 +74,7 @@ public async Task AppHostCleanupIsIdempotent() { var applicationHost = new ControllableHost(); var releases = 0; - var running = await CreateRunningAppHost( - applicationHost, - _ => Interlocked.Increment(ref releases)); + var running = await CreateRunningAppHost(applicationHost, _ => Interlocked.Increment(ref releases)); await running.DisposeAsync(); await running.DisposeAsync(); @@ -103,16 +95,10 @@ private static async Task CreateRunningAppHost( { var application = new DistributedApplication(applicationHost); var lease = await AcquireLeaseAsync(CancellationToken.None); - var constructor = typeof(RunningAppHost).GetConstructors( - BindingFlags.NonPublic | BindingFlags.Instance) + var constructor = typeof(RunningAppHost) + .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance) .Single(); - return (RunningAppHost)constructor.Invoke( - [ - application, - lease, - release ?? (_ => { }), - TestCleanupBudget, - ]); + return (RunningAppHost)constructor.Invoke([application, lease, release ?? (_ => { }), TestCleanupBudget]); } private static async Task AssertLeaseReleasedExactlyOnceAsync() @@ -120,20 +106,14 @@ private static async Task AssertLeaseReleasedExactlyOnceAsync() using var timeout = new CancellationTokenSource(TestCleanupBudget); var lease = await AcquireLeaseAsync(timeout.Token); using var contenderTimeout = new CancellationTokenSource(TestCleanupBudget); - await Assert.ThrowsAnyAsync( - () => AcquireLeaseAsync(contenderTimeout.Token)); + await Assert.ThrowsAnyAsync(() => AcquireLeaseAsync(contenderTimeout.Token)); await DisposeLeaseAsync(lease); } - private static async Task AcquireLeaseAsync( - CancellationToken cancellationToken) + private static async Task AcquireLeaseAsync(CancellationToken cancellationToken) { - var leaseType = typeof(RunningAppHost).Assembly.GetType( - "DigitalBrain.Testing.AppHostExclusiveLease", - throwOnError: true)!; - var acquire = leaseType.GetMethod( - "AcquireAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; + var leaseType = typeof(RunningAppHost).Assembly.GetType("DigitalBrain.Testing.AppHostExclusiveLease", throwOnError: true)!; + var acquire = leaseType.GetMethod("AcquireAsync", BindingFlags.NonPublic | BindingFlags.Static)!; var leaseTask = (Task)acquire.Invoke(null, [cancellationToken])!; await leaseTask; return leaseTask.GetType().GetProperty("Result")!.GetValue(leaseTask)!; @@ -142,12 +122,9 @@ private static async Task AcquireLeaseAsync( private static Task DisposeLeaseAsync(object lease) { var dispose = lease.GetType().GetMethod( - nameof(IAsyncDisposable.DisposeAsync), - BindingFlags.Public | BindingFlags.Instance)!; + nameof(IAsyncDisposable.DisposeAsync), BindingFlags.Public | BindingFlags.Instance)!; var valueTask = dispose.Invoke(lease, null)!; - return (Task)valueTask.GetType().GetMethod("AsTask")!.Invoke( - valueTask, - null)!; + return (Task)valueTask.GetType().GetMethod("AsTask")!.Invoke(valueTask, null)!; } private sealed class ControllableHost : IHost, IAsyncDisposable @@ -212,8 +189,7 @@ public Task StopAsync(CancellationToken cancellationToken = default) private static ServiceProvider CreateServices() { var builder = Host.CreateApplicationBuilder(); - builder.Services.AddSingleton( - new DistributedApplicationModel(Array.Empty())); + builder.Services.AddSingleton(new DistributedApplicationModel(Array.Empty())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); return builder.Services.BuildServiceProvider(); diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs index 9b370980..7337463f 100644 --- a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs @@ -8,9 +8,7 @@ public sealed class ChatProductTool(OSMcpFixture fixture) { private const int TestTimeout = 60_000; - [Fact( - Timeout = TestTimeout, - DisplayName = "send_chat_message returns the correlated assistant response")] + [Fact(Timeout = TestTimeout, DisplayName = "send_chat_message returns the correlated assistant response")] public async Task SendChatMessageReturnsCorrelatedAssistantResponse() { var cancellationToken = TestContext.Current.CancellationToken; @@ -18,11 +16,7 @@ public async Task SendChatMessageReturnsCorrelatedAssistantResponse() test.Chat().Reply("Hello from DigitalBrain."); var tools = new DigitalBrainMcpTools(test.Client, test.Cluster.Client); - var result = await tools.SendChatMessageAsync( - "hi", - "main", - timeoutSeconds: 10, - cancellationToken); + var result = await tools.SendChatMessageAsync("hi", "main", timeoutSeconds: 10, cancellationToken); Assert.Equal("main", result.Chat); Assert.Equal("Hello from DigitalBrain.", result.Response); diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs index c9df953c..510f29b4 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs @@ -18,17 +18,11 @@ internal static class FlutterHostingProjectionSupport "DigitalBrain.Modules.Flutter.Http", "DigitalBrain.Modules.Flutter.Http.csproj"); - public static string FlutterClientDirectory => RepositoryAssets.Path( - "clients", - "digitalbrain_flutter"); + public static string FlutterClientDirectory => RepositoryAssets.Path("clients", "digitalbrain_flutter"); - public static string FlutterShellDirectory => Path.Combine( - FlutterClientDirectory, - "shell"); + public static string FlutterShellDirectory => Path.Combine(FlutterClientDirectory, "shell"); - public static async Task AssertShellDesktopLayoutAsync( - string shellDirectory, - CancellationToken cancellationToken = default) + public static async Task AssertShellDesktopLayoutAsync(string shellDirectory, CancellationToken cancellationToken = default) { Assert.True( File.Exists(Path.Combine(shellDirectory, "pubspec.yaml")), @@ -50,9 +44,7 @@ public static async Task AssertShellDesktopLayoutAsync( Assert.Contains("sdk: flutter", pubspec, StringComparison.OrdinalIgnoreCase); } - public static async Task AssertPureDartClientLayoutAsync( - string clientDirectory, - CancellationToken cancellationToken = default) + public static async Task AssertPureDartClientLayoutAsync(string clientDirectory, CancellationToken cancellationToken = default) { Assert.True( File.Exists(Path.Combine(clientDirectory, "pubspec.yaml")), diff --git a/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs b/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs index 3729c44f..804bd000 100644 --- a/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs +++ b/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs @@ -16,8 +16,7 @@ public void OwnerIdRejectsPartsThatWouldBreakGrainKeyEncoding(string value) [Theory] [MemberData(nameof(RejectedIdentityParts))] public void NeuronNameRejectsPartsThatWouldBreakGrainKeyEncoding(string name) - => Assert.Throws( - () => new NeuronId(nameof(NeuronId), new OwnerId(nameof(OwnerId)), name)); + => Assert.Throws(() => new NeuronId(nameof(NeuronId), new OwnerId(nameof(OwnerId)), name)); [Fact] public void OwnerIdRejectsNull() @@ -26,10 +25,7 @@ public void OwnerIdRejectsNull() [Fact] public void GrainKeyRoundTripsThroughOwnerAndName() { - var original = new NeuronId( - nameof(NeuronId), - new OwnerId(nameof(OwnerId)), - nameof(NeuronId.Name)); + var original = new NeuronId(nameof(NeuronId), new OwnerId(nameof(OwnerId)), nameof(NeuronId.Name)); Assert.Equal(original, NeuronId.FromGrainKey(original.Type, original.GrainKey)); } @@ -48,6 +44,5 @@ public void DistinctOwnersCannotProduceTheSameGrainKey() [InlineData("/x")] [InlineData("x/")] public void FromGrainKeyRejectsMalformedKeys(string grainKey) - => Assert.Throws( - () => NeuronId.FromGrainKey(nameof(NeuronId), grainKey)); + => Assert.Throws(() => NeuronId.FromGrainKey(nameof(NeuronId), grainKey)); } diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs index b45435bc..f0a54d51 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs @@ -50,9 +50,7 @@ internal DigitalBrainBuilder( internal IDistributedApplicationBuilder GetApplicationBuilder() => _builder; - internal TState GetOrAddState( - Func create, - out bool added) + internal TState GetOrAddState(Func create, out bool added) where TState : class { ArgumentNullException.ThrowIfNull(create); @@ -111,11 +109,7 @@ internal void RequireStateProtection() var name = $"{Name}-state-protection-key"; _stateProtectionKey = (_builder.ExecutionContext.IsRunMode - ? _builder.AddParameter( - name, - new StateProtectionKeyParameterDefault(), - secret: true, - persist: true) + ? _builder.AddParameter(name, new StateProtectionKeyParameterDefault(), secret: true, persist: true) : _builder.AddParameter(name, secret: true)) .WithDescription( "Base64-encoded 256-bit key shared by every silo that recovers encrypted durable module state."); @@ -125,8 +119,7 @@ internal void Select(ModuleId module) { if (_modules.Contains(module)) { - throw new InvalidOperationException( - $"{module} is already configured on brain '{Name}'. Add each module exactly once."); + throw new InvalidOperationException($"{module} is already configured on brain '{Name}'. Add each module exactly once."); } _modules.Add(module); @@ -137,12 +130,9 @@ internal void Select(ModuleId module) private sealed class StateProtectionKeyParameterDefault : ParameterDefault { public override string GetDefaultValue() - => Convert.ToBase64String( - RandomNumberGenerator.GetBytes(32)); + => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - public override void WriteToManifest( - ManifestPublishingContext context) - => throw new InvalidOperationException( - "Local state-protection defaults cannot be published."); + public override void WriteToManifest(ManifestPublishingContext context) + => throw new InvalidOperationException("Local state-protection defaults cannot be published."); } } diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs index c20174cf..4a93d972 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs @@ -11,9 +11,7 @@ public static class DigitalBrainHostingExtensions public const string ModulesConfigurationKey = "DigitalBrain:Modules"; public const string ConfiguredFeaturesConfigurationKey = "DigitalBrain:ConfiguredFeatures"; - public static DigitalBrainBuilder AddDigitalBrain( - this IDistributedApplicationBuilder builder, - string name) + public static DigitalBrainBuilder AddDigitalBrain(this IDistributedApplicationBuilder builder, string name) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -41,9 +39,7 @@ public static DigitalBrainBuilder AddModule(this DigitalBrainBuilder br where TModule : class, IModule, new() => brain.AddModule(static _ => { }); - public static DigitalBrainBuilder AddModule( - this DigitalBrainBuilder brain, - Action> configure) + public static DigitalBrainBuilder AddModule(this DigitalBrainBuilder brain, Action> configure) where TModule : class, IModule, new() { ArgumentNullException.ThrowIfNull(brain); @@ -53,9 +49,7 @@ public static DigitalBrainBuilder AddModule( return brain; } - public static IResourceBuilder WithReference( - this IResourceBuilder builder, - DigitalBrainBuilder brain) + public static IResourceBuilder WithReference(this IResourceBuilder builder, DigitalBrainBuilder brain) where TResource : IResourceWithEnvironment, IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); @@ -66,17 +60,12 @@ public static IResourceBuilder WithReference( foreach (var dependency in brain.StartupDependencies) { - builder.WithAnnotation(new WaitAnnotation( - dependency, - WaitType.WaitUntilHealthy, - exitCode: 0)); + builder.WithAnnotation(new WaitAnnotation(dependency, WaitType.WaitUntilHealthy, exitCode: 0)); } if (brain.StateProtectionKey is not null) { - builder.WithEnvironment( - ConfigurationEnvironment(StateProtectionKeyConfigurationKey), - brain.StateProtectionKey); + builder.WithEnvironment(ConfigurationEnvironment(StateProtectionKeyConfigurationKey), brain.StateProtectionKey); } ProjectModuleManifest(builder, brain); @@ -89,9 +78,7 @@ public static IResourceBuilder WithReference( return builder; } - public static IResourceBuilder WithReference( - this IResourceBuilder builder, - ClientDigitalBrainReference client) + public static IResourceBuilder WithReference(this IResourceBuilder builder, ClientDigitalBrainReference client) where TResource : IResourceWithEnvironment, IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); @@ -103,9 +90,7 @@ public static IResourceBuilder WithReference( return builder; } - private static void ProjectModuleManifest( - IResourceBuilder builder, - DigitalBrainBuilder brain) + private static void ProjectModuleManifest(IResourceBuilder builder, DigitalBrainBuilder brain) where TResource : IResourceWithEnvironment => builder.WithEnvironment(context => { @@ -117,9 +102,7 @@ private static void ProjectModuleManifest( } }); - private static void ProjectConfiguredFeatureManifest( - IResourceBuilder builder, - DigitalBrainBuilder brain) + private static void ProjectConfiguredFeatureManifest(IResourceBuilder builder, DigitalBrainBuilder brain) where TResource : IResourceWithEnvironment => builder.WithEnvironment(context => { diff --git a/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs b/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs index f5bc99e8..dbf0e338 100644 --- a/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs +++ b/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs @@ -44,9 +44,7 @@ public static IHostApplicationBuilder AddDigitalBrainClient( } builder.Services.AddSingleton( - services => DigitalBrainClient.Connect( - services.GetRequiredService(), - owner)); + services => DigitalBrainClient.Connect(services.GetRequiredService(), owner)); builder.Services.AddHostedService(); return builder; diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs index f66e569a..b1dce541 100644 --- a/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs +++ b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs @@ -44,8 +44,7 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) app.MapHealthChecks(HealthPath); app.MapHealthChecks(AlivePath, new HealthCheckOptions { - Predicate = static registration => - registration.Tags.Contains("live"), + Predicate = static registration => registration.Tags.Contains("live"), }); return app; @@ -55,16 +54,12 @@ private static void AddDefaultHealthChecks(TBuilder builder) where TBuilder : IHostApplicationBuilder => builder.Services .AddHealthChecks() - .AddCheck( - "self", - static () => HealthCheckResult.Healthy(), - ["live"]); + .AddCheck("self", static () => HealthCheckResult.Healthy(), ["live"]); private static void AddOpenTelemetryExporters(TBuilder builder) where TBuilder : IHostApplicationBuilder { - if (!string.IsNullOrWhiteSpace( - builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) { builder.Services.AddOpenTelemetry().UseOtlpExporter(); } @@ -73,8 +68,7 @@ private static void AddOpenTelemetryExporters(TBuilder builder) private static void ConfigureOpenTelemetry(TBuilder builder) where TBuilder : IHostApplicationBuilder { - var configuredSampleRatio = builder.Configuration.GetValue( - "Telemetry:Tracing:SampleRatio"); + var configuredSampleRatio = builder.Configuration.GetValue("Telemetry:Tracing:SampleRatio"); var sampleRatio = Math.Clamp(configuredSampleRatio ?? 1d, 0d, 1d); builder.Logging.AddOpenTelemetry(logging => @@ -96,8 +90,7 @@ private static void ConfigureOpenTelemetry(TBuilder builder) .AddMeter("Experimental.Microsoft.Extensions.AI") .AddMeter("Microsoft.Orleans")) .WithTracing(tracing => tracing - .SetSampler(new ParentBasedSampler( - new TraceIdRatioBasedSampler(sampleRatio))) + .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(sampleRatio))) .AddSource(builder.Environment.ApplicationName) .AddSource(TelemetryPrefix) .AddSource($"{TelemetryPrefix}.*") @@ -105,12 +98,8 @@ private static void ConfigureOpenTelemetry(TBuilder builder) .AddSource("Microsoft.Orleans.Application") .AddAspNetCoreInstrumentation(options => options.Filter = context => - !context.Request.Path.StartsWithSegments( - HealthPath, - StringComparison.OrdinalIgnoreCase) - && !context.Request.Path.StartsWithSegments( - AlivePath, - StringComparison.OrdinalIgnoreCase)) + !context.Request.Path.StartsWithSegments(HealthPath, StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments(AlivePath, StringComparison.OrdinalIgnoreCase)) .AddHttpClientInstrumentation()); AddOpenTelemetryExporters(builder); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs b/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs index 115dd2e8..de649837 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs @@ -6,11 +6,7 @@ namespace DigitalBrain.Behaviors; [Alias("db.behavior-execution-metadata")] public sealed record BehaviorExecutionMetadata { - public BehaviorExecutionMetadata( - OwnerId Owner, - BehaviorId Behavior, - BehaviorRevisionId Revision, - BehaviorExecutionId Execution) + public BehaviorExecutionMetadata(OwnerId Owner, BehaviorId Behavior, BehaviorRevisionId Revision, BehaviorExecutionId Execution) { ArgumentException.ThrowIfNullOrWhiteSpace(Owner.Value); Behavior.EnsureValid(); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs b/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs index c39475a1..b8a4f012 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs @@ -5,8 +5,5 @@ namespace DigitalBrain.Behaviors; public interface IBehaviorProgram where TTrigger : Synapse { - ValueTask ExecuteAsync( - TTrigger trigger, - IBehaviorContext context, - CancellationToken cancellationToken); + ValueTask ExecuteAsync(TTrigger trigger, IBehaviorContext context, CancellationToken cancellationToken); } diff --git a/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs b/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs index 35cb44c9..9fb8d45e 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs @@ -2,8 +2,5 @@ namespace DigitalBrain.Behaviors; public interface IIntentProgram { - ValueTask ExecuteAsync( - TRequest request, - IBehaviorContext context, - CancellationToken cancellationToken); + ValueTask ExecuteAsync(TRequest request, IBehaviorContext context, CancellationToken cancellationToken); } diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs index 40823de1..132c880a 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs @@ -1,6 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorCapabilityGrant( - string ContractAlias, - string MethodAlias, - string Target); +public sealed record BehaviorCapabilityGrant(string ContractAlias, string MethodAlias, string Target); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs index 965d183b..71d41699 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs @@ -1,5 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorEntryPoints( - IReadOnlyList EventAliases, - IReadOnlyList IntentSchemas); +public sealed record BehaviorEntryPoints(IReadOnlyList EventAliases, IReadOnlyList IntentSchemas); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs index b07a2c26..e87bb741 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs @@ -1,7 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorIntentSchema( - string SchemaId, - int SchemaVersion, - string RequestSchemaJson, - string ResultSchemaJson); +public sealed record BehaviorIntentSchema(string SchemaId, int SchemaVersion, string RequestSchemaJson, string ResultSchemaJson); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs index 4d95bf0b..2262b7cf 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs @@ -1,6 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorResourceLimits( - int CpuMilliseconds, - long MemoryBytes, - int WallClockMilliseconds); +public sealed record BehaviorResourceLimits(int CpuMilliseconds, long MemoryBytes, int WallClockMilliseconds); diff --git a/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs b/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs index 5b39d646..519255f3 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.journal-tally")] -public sealed record JournalTally( - [property: Id(0)] string SynapseType, - [property: Id(1)] long Recorded); +public sealed record JournalTally([property: Id(0)] string SynapseType, [property: Id(1)] long Recorded); diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs index cc5e0e4b..5783fc53 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.capability-completed")] -public sealed record CapabilityCompleted( - [property: Id(0)] SynapseId Request) : Synapse; +public sealed record CapabilityCompleted([property: Id(0)] SynapseId Request) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs index ad822cf6..fb782c76 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.capability-failed")] -public sealed record CapabilityFailed( - [property: Id(0)] SynapseId Request) : Synapse; +public sealed record CapabilityFailed([property: Id(0)] SynapseId Request) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs index 1655660c..af87d7aa 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.capability-rejected")] -public sealed record CapabilityRejected( - [property: Id(0)] SynapseId Request) : Synapse; +public sealed record CapabilityRejected([property: Id(0)] SynapseId Request) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs index 7df1aecd..66d68b70 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.digitalbrain-activated")] -public sealed record DigitalBrainActivated( - [property: Id(0)] OwnerId Owner) : Synapse; +public sealed record DigitalBrainActivated([property: Id(0)] OwnerId Owner) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs b/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs index 8d13827a..3d7146fb 100644 --- a/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs +++ b/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs @@ -43,9 +43,7 @@ public async Task SendAsync(string name, Synapse synapse) RequireDomainNeuronContract(typeof(TNeuron)); ArgumentNullException.ThrowIfNull(synapse); - await SendValidatedAsync( - new NeuronId(NeuronId.GrainTypeNameOf(typeof(TNeuron)), Owner, name), - synapse); + await SendValidatedAsync(new NeuronId(NeuronId.GrainTypeNameOf(typeof(TNeuron)), Owner, name), synapse); } public async Task SendAsync(NeuronId receiver, Synapse synapse) diff --git a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs index 68a49382..2f1863e4 100644 --- a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs +++ b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs @@ -117,9 +117,7 @@ private readonly struct ModuleModel(string name) public string Name { get; } = name; } - private readonly struct CompositionModel( - ImmutableArray modules, - bool emit) + private readonly struct CompositionModel(ImmutableArray modules, bool emit) { public ImmutableArray Modules { get; } = modules; diff --git a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs index 23e6bd11..f60abea8 100644 --- a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs +++ b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs @@ -39,9 +39,7 @@ public sealed partial class DispatchManifestGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - private static ModuleCapsuleModel? ModuleOf( - GeneratorSyntaxContext syntax, - CancellationToken cancellationToken) + private static ModuleCapsuleModel? ModuleOf(GeneratorSyntaxContext syntax, CancellationToken cancellationToken) { if (syntax.Node is not ClassDeclarationSyntax declaration || syntax.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not INamedTypeSymbol module @@ -80,37 +78,25 @@ private static void EmitModule(SourceProductionContext production, ModuleCapsule if (!module.IsPartial) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleMustBePartial, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleMustBePartial, module.Location, module.FullName)); valid = false; } if (module.IsNested) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleMustBeTopLevel, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleMustBeTopLevel, module.Location, module.FullName)); valid = false; } if (module.IsGeneric) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleMustBeNonGeneric, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleMustBeNonGeneric, module.Location, module.FullName)); valid = false; } if (!module.HasPublicParameterlessConstructor) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleNeedsPublicConstructor, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleNeedsPublicConstructor, module.Location, module.FullName)); valid = false; } @@ -119,9 +105,7 @@ private static void EmitModule(SourceProductionContext production, ModuleCapsule return; } - production.AddSource( - $"{module.FullName}.CompiledModule.g.cs", - CompiledModule(module)); + production.AddSource($"{module.FullName}.CompiledModule.g.cs", CompiledModule(module)); } private static string CompiledModule(ModuleCapsuleModel module) diff --git a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs index 18aed89e..64334c8d 100644 --- a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs +++ b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs @@ -23,9 +23,7 @@ public sealed partial class DispatchManifestGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - private static NeuronModel? NeuronOf( - GeneratorSyntaxContext syntax, - CancellationToken cancellationToken) + private static NeuronModel? NeuronOf(GeneratorSyntaxContext syntax, CancellationToken cancellationToken) { if (syntax.Node is not InterfaceDeclarationSyntax declaration || syntax.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not INamedTypeSymbol contract @@ -65,19 +63,13 @@ private static void EmitNeuron(SourceProductionContext production, NeuronModel n if (!neuron.IsPartial) { - production.ReportDiagnostic(Diagnostic.Create( - NeuronMustBePartial, - neuron.Location, - neuron.FullName)); + production.ReportDiagnostic(Diagnostic.Create(NeuronMustBePartial, neuron.Location, neuron.FullName)); valid = false; } if (neuron.HasAlias) { - production.ReportDiagnostic(Diagnostic.Create( - NeuronAliasIsGenerated, - neuron.Location, - neuron.FullName)); + production.ReportDiagnostic(Diagnostic.Create(NeuronAliasIsGenerated, neuron.Location, neuron.FullName)); valid = false; } @@ -86,9 +78,7 @@ private static void EmitNeuron(SourceProductionContext production, NeuronModel n return; } - production.AddSource( - $"{neuron.FullName}.NeuronIdentity.g.cs", - NeuronIdentity(neuron)); + production.AddSource($"{neuron.FullName}.NeuronIdentity.g.cs", NeuronIdentity(neuron)); } private static string NeuronIdentity(NeuronModel neuron) diff --git a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs index cc1b716f..35c63011 100644 --- a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs +++ b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs @@ -9,11 +9,7 @@ namespace DigitalBrain.Kernel; [Alias("db.capability-delegation")] public sealed class CapabilityDelegation { - internal CapabilityDelegation( - Guid identity, - SynapseDelivery request, - GrainId delegateSource, - OwnerId owner) + internal CapabilityDelegation(Guid identity, SynapseDelivery request, GrainId delegateSource, OwnerId owner) { Identity = identity; Request = request; @@ -51,10 +47,7 @@ internal bool Matches(CapabilityDelegation other) && expected == actual; } - internal void RequireMatches( - GrainId? actualSource, - GrainId actualTarget, - MethodInfo? actualMethod) + internal void RequireMatches(GrainId? actualSource, GrainId actualTarget, MethodInfo? actualMethod) { if (Request.Synapse is not CapabilityRequested requested || actualSource is null diff --git a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs index 94af7edf..aa79daa3 100644 --- a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs +++ b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs @@ -2,9 +2,7 @@ namespace DigitalBrain.Kernel; [GenerateSerializer] [Alias("db.kernel.capability-delegation-state")] -internal sealed class CapabilityDelegationState( - CapabilityDelegation delegation, - CapabilityDelegationStatus status) +internal sealed class CapabilityDelegationState(CapabilityDelegation delegation, CapabilityDelegationStatus status) { [Id(0)] internal CapabilityDelegation Delegation { get; } = delegation; diff --git a/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs b/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs index 94a8a4b9..24813345 100644 --- a/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs +++ b/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs @@ -19,14 +19,10 @@ internal static RedeemedCapabilityDelegation? CurrentRedeemedDelegation internal static Task InvokeAsync(SynapseDelivery delivery, Func invoke) => InvokeWithAsync(delivery, invoke); - internal static Task InvokeAsync( - CapabilityDelegation delegation, - Func> invoke) + internal static Task InvokeAsync(CapabilityDelegation delegation, Func> invoke) => InvokeWithLocalDelegationAsync(delegation, invoke); - internal static Task InvokeRedeemedAsync( - CapabilityDelegation delegation, - Func invoke) + internal static Task InvokeRedeemedAsync(CapabilityDelegation delegation, Func invoke) => InvokeWithAsync(new RedeemedCapabilityDelegation(delegation), invoke); private static async Task InvokeWithLocalDelegationAsync( diff --git a/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs b/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs index d351b5cd..9f31275b 100644 --- a/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs +++ b/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs @@ -61,10 +61,7 @@ public async Task Invoke(IIncomingGrainCallContext context) if (redeemed is not null) { - redeemed.Delegation.RequireMatches( - context.SourceId, - context.TargetId, - context.InterfaceMethod); + redeemed.Delegation.RequireMatches(context.SourceId, context.TargetId, context.InterfaceMethod); delivery = redeemed.Delegation.Request; delegatedSource = redeemed.Delegation.DelegateSource; } @@ -74,10 +71,7 @@ public async Task Invoke(IIncomingGrainCallContext context) delegatedSource = null; } - var turn = await target.BeginIncomingCapabilityRequestAsync( - delivery, - context.SourceId, - delegatedSource); + var turn = await target.BeginIncomingCapabilityRequestAsync(delivery, context.SourceId, delegatedSource); try { diff --git a/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs b/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs index 579221f5..d1ff8802 100644 --- a/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs +++ b/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs @@ -39,24 +39,18 @@ public async Task Invoke(IOutgoingGrainCallContext context) } catch (NeuronAuthorizationException) when (caller.Id.Owner != target.Owner) { - await caller.RecordCapabilityOutcomeAsync( - CapabilityOutcome.Rejected, - request); + await caller.RecordCapabilityOutcomeAsync(CapabilityOutcome.Rejected, request); throw; } catch { - await caller.RecordCapabilityOutcomeAsync( - CapabilityOutcome.Failed, - request); + await caller.RecordCapabilityOutcomeAsync(CapabilityOutcome.Failed, request); throw; } - await caller.RecordCapabilityOutcomeAsync( - CapabilityOutcome.Completed, - request); + await caller.RecordCapabilityOutcomeAsync(CapabilityOutcome.Completed, request); } [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -75,10 +69,7 @@ private static async Task InvokeDelegatedAsync(IOutgoingGrainCallContext context "The delegated call's inherited Orleans source does not match its activation source."); } - delegation.RequireMatches( - context.SourceId, - context.TargetId, - context.InterfaceMethod); + delegation.RequireMatches(context.SourceId, context.TargetId, context.InterfaceMethod); var authority = context.SourceContext!.ActivationServices .GetRequiredService() diff --git a/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs b/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs index 2f4c2ee2..2f0ac290 100644 --- a/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs +++ b/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs @@ -3,8 +3,7 @@ namespace DigitalBrain.Kernel; -internal sealed class OwnerBoundCallFilter( - IEnumerable additionalReminderSources) : +internal sealed class OwnerBoundCallFilter(IEnumerable additionalReminderSources) : IIncomingGrainCallFilter { public Task Invoke(IIncomingGrainCallContext context) @@ -64,22 +63,15 @@ private static bool IsReminderCall(IIncomingGrainCallContext context) => context.InterfaceMethod?.DeclaringType == typeof(IRemindable) && context.InterfaceMethod?.Name == nameof(IRemindable.ReceiveReminder); - private static bool IsOutboxDrainCall( - IIncomingGrainCallContext context) + private static bool IsOutboxDrainCall(IIncomingGrainCallContext context) => context.InterfaceMethod?.DeclaringType == typeof(IOutboxDrain) && context.InterfaceMethod?.Name == nameof(IOutboxDrain.Drain); - private static void RequireDedicatedWakeupForTarget( - IIncomingGrainCallContext context) + private static void RequireDedicatedWakeupForTarget(IIncomingGrainCallContext context) { if (context.SourceId is not { } source - || !string.Equals( - source.Type.ToString(), - OutboxWakeup.GrainTypeName, - StringComparison.Ordinal) - || !OutboxWakeup.TryParseTarget( - source.Key.ToString(), - out var encodedTarget) + || !string.Equals(source.Type.ToString(), OutboxWakeup.GrainTypeName, StringComparison.Ordinal) + || !OutboxWakeup.TryParseTarget(source.Key.ToString(), out var encodedTarget) || encodedTarget.ToGrainId() != context.TargetId) { throw new NeuronAuthorizationException( @@ -89,10 +81,7 @@ private static void RequireDedicatedWakeupForTarget( private bool IsTrustedReminderProvider(GrainId? source) => source is { } identified - && (string.Equals( - identified.Type.ToString(), - ReminderGrainServiceType, - StringComparison.Ordinal) + && (string.Equals(identified.Type.ToString(), ReminderGrainServiceType, StringComparison.Ordinal) || IsAdditionalTrustedSource(identified)); private bool IsAdditionalTrustedSource(GrainId source) diff --git a/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs b/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs index 59ef153e..a1623b03 100644 --- a/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs +++ b/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs @@ -59,9 +59,7 @@ public static IReadOnlySet Add( return selectedModules; } - private static HashSet SelectModules( - ISiloBuilder builder, - IReadOnlyCollection availableModules) + private static HashSet SelectModules(ISiloBuilder builder, IReadOnlyCollection availableModules) { var hostContext = builder.Services .LastOrDefault(descriptor => descriptor.ServiceType == typeof(HostBuilderContext)) diff --git a/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs b/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs index d76fd497..f69ddb86 100644 --- a/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs +++ b/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs @@ -8,10 +8,7 @@ internal sealed class PinToSiloDirector(ISiloMetadataCache metadata) : IPlacemen { internal const string SiloLabelKey = "db.silo"; - public IEnumerable Filter( - PlacementFilterStrategy strategy, - PlacementTarget target, - IEnumerable silos) + public IEnumerable Filter(PlacementFilterStrategy strategy, PlacementTarget target, IEnumerable silos) { ArgumentNullException.ThrowIfNull(strategy); diff --git a/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs b/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs index 771abe8c..8c3d90eb 100644 --- a/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs +++ b/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs @@ -13,8 +13,7 @@ internal sealed class DigitalBrainNeuron : Neuron, IDigitalBrainNeuron public DigitalBrainNeuron() { - _activationPublished = ServiceProvider.GetRequiredKeyedService>( - ActivationPublishedName); + _activationPublished = ServiceProvider.GetRequiredKeyedService>(ActivationPublishedName); } public async Task Activate() diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs index f99a1067..369741b0 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs @@ -88,12 +88,7 @@ async Task ICapabilityDelegationAuthority.FinishAsync(CapabilityDelegation deleg ? (Synapse)new CapabilityCompleted(delegation.Request.SynapseId) : new CapabilityFailed(delegation.Request.SynapseId); var sequence = _outgoing.NextSequence + _firedWhileHandling.Count; - var delivery = SynapseDelivery.Create( - fact, - Id, - sequence, - delegation.Request, - TimeProvider); + var delivery = SynapseDelivery.Create(fact, Id, sequence, delegation.Request, TimeProvider); var delegationCheckpoint = SnapshotDelegations(); var outgoingCheckpoint = _outgoing.Checkpoint(); @@ -101,9 +96,7 @@ async Task ICapabilityDelegationAuthority.FinishAsync(CapabilityDelegation deleg { FlushOutgoing(); _outgoing.Append(delivery); - _delegations[delegation.Identity] = _delegationStates.SerializeToArray(new( - state.Delegation, - terminal)); + _delegations[delegation.Identity] = _delegationStates.SerializeToArray(new(state.Delegation, terminal)); _delegationConsumed.RemoveAt(consumedIndex); _delegationTerminals.Add(delegation.Identity); await CommitAsync(CancellationToken.None); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs index f94beb43..53707289 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs @@ -62,12 +62,7 @@ internal async Task BeginIncomingCapabilityRequestAsync( _handling = delivery; _handlingDepth = DeliveryPolicy.InboundDepth(); - _turnCheckpoint = new( - _outbox.Count, - _handled.Count, - InboundCommitted: true, - _incoming.Checkpoint(), - _outgoing.Checkpoint()); + _turnCheckpoint = new(_outbox.Count, _handled.Count, InboundCommitted: true, _incoming.Checkpoint(), _outgoing.Checkpoint()); _turnRollbacks.Clear(); return turn; @@ -86,9 +81,7 @@ internal async Task CompleteIncomingCapabilityRequestAsync(CapabilityTurn turn) internal void FailIncomingCapabilityRequest(CapabilityTurn turn) { - Discard( - _outbox, - _turnCheckpoint?.CommittedOutbox ?? turn.CommittedOutbox); + Discard(_outbox, _turnCheckpoint?.CommittedOutbox ?? turn.CommittedOutbox); _outgoing.Restore(_turnCheckpoint?.Outgoing ?? turn.Outgoing); RollbackTurnState(); _firedWhileHandling.Clear(); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs index 16a8b167..839105c3 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs @@ -20,11 +20,7 @@ protected void EnlistTurnRollback(Action rollback) _turnRollbacks.Add(rollback); } - protected Task DelegateCapabilityAsync( - GrainId delegateSource, - NeuronId target, - Type contract, - string method) + protected Task DelegateCapabilityAsync(GrainId delegateSource, NeuronId target, Type contract, string method) => DelegateCapabilityAsync(_handling, delegateSource, target, contract, method); private async Task DelegateCapabilityAsync( @@ -67,11 +63,7 @@ private async Task DelegateCapabilityAsync( sequence, causation, TimeProvider); - var delegation = new CapabilityDelegation( - Guid.NewGuid(), - request, - delegateSource, - Id.Owner); + var delegation = new CapabilityDelegation(Guid.NewGuid(), request, delegateSource, Id.Owner); var delegationCheckpoint = SnapshotDelegations(); var outgoingCheckpoint = _outgoing.Checkpoint(); @@ -81,11 +73,7 @@ private async Task DelegateCapabilityAsync( StageInboundCause(); FlushOutgoing(); _outgoing.Append(request); - _delegations.Add( - delegation.Identity, - _delegationStates.SerializeToArray(new( - delegation, - CapabilityDelegationStatus.Issued))); + _delegations.Add(delegation.Identity, _delegationStates.SerializeToArray(new(delegation, CapabilityDelegationStatus.Issued))); await CommitAsync(CancellationToken.None); } catch @@ -134,10 +122,7 @@ private SynapseDelivery CurrentCapabilityRequestFrom(NeuronId expectedCaller) return delivery; } - internal async Task BeginCapabilityRequestAsync( - string contract, - string method, - NeuronId target) + internal async Task BeginCapabilityRequestAsync(string contract, string method, NeuronId target) { ArgumentException.ThrowIfNullOrWhiteSpace(contract); ArgumentException.ThrowIfNullOrWhiteSpace(method); @@ -161,9 +146,7 @@ internal async Task BeginCapabilityRequestAsync( return delivery; } - internal async Task RecordCapabilityOutcomeAsync( - CapabilityOutcome outcome, - SynapseDelivery request) + internal async Task RecordCapabilityOutcomeAsync(CapabilityOutcome outcome, SynapseDelivery request) { ArgumentNullException.ThrowIfNull(request); @@ -172,17 +155,11 @@ internal async Task RecordCapabilityOutcomeAsync( CapabilityOutcome.Completed => new CapabilityCompleted(request.SynapseId), CapabilityOutcome.Failed => new CapabilityFailed(request.SynapseId), CapabilityOutcome.Rejected => new CapabilityRejected(request.SynapseId), - _ => throw new ArgumentOutOfRangeException( - nameof(outcome)), + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), }; var sequence = _outgoing.NextSequence + _firedWhileHandling.Count; - var delivery = SynapseDelivery.Create( - fact, - Id, - sequence, - request, - TimeProvider); + var delivery = SynapseDelivery.Create(fact, Id, sequence, request, TimeProvider); FlushOutgoing(); _outgoing.Append(delivery); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs index 190359ae..8b96863f 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs @@ -2,11 +2,7 @@ namespace DigitalBrain.Kernel; public abstract partial class Neuron { - protected new IDisposable RegisterTimer( - Func callback, - object state, - TimeSpan dueTime, - TimeSpan period) + protected new IDisposable RegisterTimer(Func callback, object state, TimeSpan dueTime, TimeSpan period) => throw new InvalidOperationException( $"{nameof(RegisterTimer)} creates interleaving callbacks, but neurons require serialized turns."); } diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs index 568afeb7..9c5f6bb6 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs @@ -45,12 +45,7 @@ public async Task Deliver(SynapseDelivery delivery) _handlingDepth = DeliveryPolicy.InboundDepth(); var previousCheckpoint = _turnCheckpoint; - _turnCheckpoint = new( - _outbox.Count, - _handled.Count, - InboundCommitted: false, - _incoming.Checkpoint(), - _outgoing.Checkpoint()); + _turnCheckpoint = new(_outbox.Count, _handled.Count, InboundCommitted: false, _incoming.Checkpoint(), _outgoing.Checkpoint()); _firedWhileHandling.Clear(); _turnRollbacks.Clear(); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs index dbc2ea52..3b3b92bf 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs @@ -35,10 +35,7 @@ protected async Task EmitAsync(Synapse synapse) await FireAsync(synapse, [.. receivers], correlation); } - private Task FireAsync( - Synapse synapse, - NeuronId[] receivers, - CorrelationId? correlation = null) + private Task FireAsync(Synapse synapse, NeuronId[] receivers, CorrelationId? correlation = null) => FireAsync(synapse, receivers, _handling, correlation); private async Task FireAsync( @@ -49,13 +46,7 @@ private async Task FireAsync( { var sequence = _outgoing.NextSequence + (_handling is null ? 0 : _firedWhileHandling.Count); - var delivery = SynapseDelivery.Create( - Snapshot(synapse), - Id, - sequence, - causation, - TimeProvider, - correlation); + var delivery = SynapseDelivery.Create(Snapshot(synapse), Id, sequence, causation, TimeProvider, correlation); if (_handling is null) { @@ -68,8 +59,7 @@ private async Task FireAsync( if (receivers.Length > 0) { - _outbox.Add(_entries.SerializeToArray( - new OutboxEntry(delivery, receivers, _handlingDepth + 1, Attempts: 0))); + _outbox.Add(_entries.SerializeToArray(new OutboxEntry(delivery, receivers, _handlingDepth + 1, Attempts: 0))); } if (_handling is null) diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs index ee57a2da..60033938 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs @@ -133,16 +133,12 @@ private void MakeRoomForDelegation() { while (_delegations.Count >= MaximumRememberedDelegations) { - if (TryEvictOldest( - _delegationTerminals, - ProtectedTerminalDelegations)) + if (TryEvictOldest(_delegationTerminals, ProtectedTerminalDelegations)) { continue; } - if (TryEvictOldest( - _delegationConsumed, - ProtectedConsumedDelegations)) + if (TryEvictOldest(_delegationConsumed, ProtectedConsumedDelegations)) { continue; } @@ -157,9 +153,7 @@ private void MakeRoomForDelegation() } } - private bool TryEvictOldest( - IDurableList retentionOrder, - int protectedDelegations) + private bool TryEvictOldest(IDurableList retentionOrder, int protectedDelegations) { if (retentionOrder.Count <= protectedDelegations) { diff --git a/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs b/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs index 65bb0065..e874cc8e 100644 --- a/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs +++ b/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs @@ -16,10 +16,7 @@ internal sealed class OutboxWakeup : TimeSpan.FromMinutes(1); public async Task Arm() - => _ = await this.RegisterOrUpdateReminder( - ReminderName, - RetryCadence, - RetryCadence); + => _ = await this.RegisterOrUpdateReminder(ReminderName, RetryCadence, RetryCadence); public async Task Disarm() { @@ -29,14 +26,9 @@ public async Task Disarm() } } - async Task IRemindable.ReceiveReminder( - string reminderName, - TickStatus status) + async Task IRemindable.ReceiveReminder(string reminderName, TickStatus status) { - if (!string.Equals( - reminderName, - ReminderName, - StringComparison.Ordinal)) + if (!string.Equals(reminderName, ReminderName, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Outbox wakeup '{this.GetPrimaryKeyString()}' does not own reminder '{reminderName}'."); @@ -47,9 +39,7 @@ await GrainFactory .Drain(); } - internal static bool TryParseTarget( - string encoded, - out NeuronId target) + internal static bool TryParseTarget(string encoded, out NeuronId target) { target = default; @@ -66,9 +56,7 @@ internal static bool TryParseTarget( try { - target = NeuronId.FromGrainKey( - encoded[..separator], - encoded[(separator + 1)..]); + target = NeuronId.FromGrainKey(encoded[..separator], encoded[(separator + 1)..]); return true; } catch (ArgumentException) diff --git a/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs b/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs index 6762dd55..9f53cb9f 100644 --- a/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs +++ b/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs @@ -2,8 +2,7 @@ namespace DigitalBrain.Kernel; -internal sealed class ReminderSourceAllowlist( - IEnumerable sourceTypes) +internal sealed class ReminderSourceAllowlist(IEnumerable sourceTypes) { private readonly FrozenSet _sourceTypes = sourceTypes.ToFrozenSet(StringComparer.Ordinal); diff --git a/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs b/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs index 1abb4fb6..7ab1fc76 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs @@ -17,9 +17,7 @@ internal sealed record McpProviderHostingDefinition( internal static class McpProviderHosting { - internal static void Register( - DigitalBrainModuleBuilder module, - McpProviderHostingDefinition definition) + internal static void Register(DigitalBrainModuleBuilder module, McpProviderHostingDefinition definition) where TModule : class, IModule, new() { ArgumentNullException.ThrowIfNull(module); @@ -39,8 +37,7 @@ internal static void Register( module.ConfigureFeature(definition.Key); } - private static McpApplicationParameters GetOrAddApplicationParameters( - IDistributedApplicationBuilder builder) + private static McpApplicationParameters GetOrAddApplicationParameters(IDistributedApplicationBuilder builder) { var existing = builder.Services .LastOrDefault(descriptor => descriptor.ServiceType == typeof(McpApplicationParameters)) @@ -138,17 +135,13 @@ private sealed class McpBrainProjection : DigitalBrainModuleProjection new(StringComparer.Ordinal); private readonly IResourceBuilder _authorizationMode; - internal McpBrainProjection( - DigitalBrainBuilder brain, - IResourceBuilder authorizationMode) + internal McpBrainProjection(DigitalBrainBuilder brain, IResourceBuilder authorizationMode) { _brain = brain; _authorizationMode = authorizationMode; } - internal void Add( - McpProviderHostingDefinition definition, - McpProviderParameters parameters) + internal void Add(McpProviderHostingDefinition definition, McpProviderParameters parameters) { if (_providers.ContainsKey(definition.Key)) { @@ -163,16 +156,11 @@ public override void Apply(IResourceBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - builder.WithEnvironment( - "DigitalBrain__Integrations__Mcp__AuthorizationMode", - _authorizationMode); + builder.WithEnvironment("DigitalBrain__Integrations__Mcp__AuthorizationMode", _authorizationMode); foreach (var provider in _providers.Values) { - var root = provider.Definition.ConfigurationRoot.Replace( - ":", - "__", - StringComparison.Ordinal); + var root = provider.Definition.ConfigurationRoot.Replace(":", "__", StringComparison.Ordinal); builder .WithEnvironment($"{root}__ClientId", provider.ClientId) .WithEnvironment($"{root}__RedirectUri", provider.RedirectUri); diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs index a21366d9..7bff3408 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs @@ -46,9 +46,7 @@ await shell.Reference.Open(new OpenScene( Assert.DoesNotContain(expectedDescription, opened.Synapse.Title, StringComparison.Ordinal); } - private static async Task RunEnrichmentToCompletionAsync( - TestBrain test, - CancellationToken cancellationToken) + private static async Task RunEnrichmentToCompletionAsync(TestBrain test, CancellationToken cancellationToken) { var expectedDescription = IntegrationsFixture.SampleEnrichmentDescription; test.Mcp().Catalog( @@ -61,14 +59,11 @@ private static async Task RunEnrichmentToCompletionAsync( test.Mcp().Catalog( IntegrationsFixture.SalesforceServerKey, AdmittedMcpTools.SalesforceUpdateAccount(), - AdmittedMcpTools.SalesforceSoqlQuery( - IntegrationsFixture.SampleAccountId, - expectedDescription)); + AdmittedMcpTools.SalesforceSoqlQuery(IntegrationsFixture.SampleAccountId, expectedDescription)); var enrichment = test.Neuron("enricher"); var commandId = CommandId.New(); - var proposedWait = enrichment.Outgoing.NextAsync( - cancellationToken); + var proposedWait = enrichment.Outgoing.NextAsync(cancellationToken); await test.Client.SendAsync( "enricher", diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs index 080e5c4e..aa9684c7 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs @@ -40,9 +40,7 @@ public async Task ReadMessageFailsClosedWhenEdgeIsNotAdmitted() { var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); - test.Mcp().Catalog( - IntegrationsFixture.GmailServerKey, - AdmittedMcpTools.GmailGetMessageWithIncompatibleAnnotations()); + test.Mcp().Catalog(IntegrationsFixture.GmailServerKey, AdmittedMcpTools.GmailGetMessageWithIncompatibleAnnotations()); var driver = test.Neuron("gmail-refuse"); var failure = await Assert.ThrowsAnyAsync(() => @@ -141,9 +139,7 @@ public async Task ReadMessageRejectsToolError() { var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); - test.Mcp().Catalog( - IntegrationsFixture.GmailServerKey, - AdmittedMcpTools.GmailGetMessageWithToolError()); + test.Mcp().Catalog(IntegrationsFixture.GmailServerKey, AdmittedMcpTools.GmailGetMessageWithToolError()); var driver = test.Neuron("gmail-tool-error"); var failure = await Assert.ThrowsAsync(() => diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs index ccf2cfb7..eec225e7 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs @@ -42,9 +42,7 @@ public void ReadMessageIsUnsuffixedAliasedAndReturnsGmailMessage() Assert.All(methods, method => { Assert.DoesNotContain("Async", method.Name, StringComparison.Ordinal); - Assert.Equal( - method.Name, - method.GetCustomAttribute()?.Alias); + Assert.Equal(method.Name, method.GetCustomAttribute()?.Alias); Assert.Equal(typeof(Task), method.ReturnType); }); diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs index 0620eeb6..e5693252 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs @@ -32,19 +32,11 @@ public static NeuronId SessionOf(TestBrain test) return ISessionNeuron.ForOwner(test.Client.Owner); } - public static SalesforceMutationApproval Approval( - TestBrain test, - CommandId commandId, - string fingerprint) + public static SalesforceMutationApproval Approval(TestBrain test, CommandId commandId, string fingerprint) { ArgumentNullException.ThrowIfNull(test); ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); - return new( - Guid.NewGuid(), - commandId, - fingerprint, - SessionOf(test), - test.Clock.UtcNow); + return new(Guid.NewGuid(), commandId, fingerprint, SessionOf(test), test.Clock.UtcNow); } protected override void Configure(DigitalBrainTestBuilder brain) diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs index b72b1a38..40e05d65 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs @@ -9,10 +9,7 @@ namespace DigitalBrain.Integrations.Tests; public partial interface IIntegrationDriver : INeuron { [Alias(nameof(ReadGmailMessage))] - Task ReadGmailMessage( - string account, - string messageId, - CancellationToken cancellationToken); + Task ReadGmailMessage(string account, string messageId, CancellationToken cancellationToken); [Alias(nameof(ProposeSalesforceAccountDescription))] Task ProposeSalesforceAccountDescription( @@ -38,19 +35,14 @@ internal sealed class IntegrationDriver : IIntegrationDriver, IHandle { - public Task HandleAsync( - SalesforceMutationApproval synapse, - CancellationToken cancellationToken) + public Task HandleAsync(SalesforceMutationApproval synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } - public Task ReadGmailMessage( - string account, - string messageId, - CancellationToken cancellationToken) + public Task ReadGmailMessage(string account, string messageId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(account); ArgumentException.ThrowIfNullOrWhiteSpace(messageId); @@ -65,12 +57,7 @@ public Task ProposeSalesforceAccountDescri string accountId, string description, CancellationToken cancellationToken) - => Salesforce().ProposeAccountDescription( - commandId, - Id, - accountId, - description, - cancellationToken); + => Salesforce().ProposeAccountDescription(commandId, Id, accountId, description, cancellationToken); public async Task ApproveSalesforceWithStoredEvidence( SalesforceMutationApproval approval, @@ -78,10 +65,7 @@ public async Task ApproveSalesforceWithSto { ArgumentNullException.ThrowIfNull(approval); var evidence = await ApprovalEvidenceAsync(approval); - return await Salesforce().ApproveAccountDescription( - approval, - evidence, - cancellationToken); + return await Salesforce().ApproveAccountDescription(approval, evidence, cancellationToken); } public async Task ApproveSalesforceWithMismatchedEvidence( @@ -92,18 +76,14 @@ public async Task ApproveSalesforceWithMismatchedEvidence( ArgumentNullException.ThrowIfNull(approval); ArgumentNullException.ThrowIfNull(recordedEvidence); var evidence = await ApprovalEvidenceAsync(recordedEvidence); - _ = await Salesforce().ApproveAccountDescription( - approval, - evidence, - cancellationToken); + _ = await Salesforce().ApproveAccountDescription(approval, evidence, cancellationToken); } private ISalesforce Salesforce() => GrainFactory.GetGrain( NeuronId.For(Id.Owner, IntegrationsFixture.SalesforceServerKey).ToGrainId()); - private async Task ApprovalEvidenceAsync( - SalesforceMutationApproval approval) + private async Task ApprovalEvidenceAsync(SalesforceMutationApproval approval) { var incoming = await ReadJournal(JournalKind.Incoming, afterSequence: 0); return incoming.Delta.FirstOrDefault(delivery => diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs index 2a2bac7d..656dc7df 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs @@ -61,27 +61,17 @@ internal static class AdmittedMcpTools OpenWorldHint = false, }; - internal static McpServerTool GmailGetMessage( - string id, - string subject, - string sender, - string plaintextBody) - => Fixed( - GmailGetMessageProtocolTool(ReadOnlyAdmitted), - _ => Structured(new { id, subject, sender, plaintextBody })); + internal static McpServerTool GmailGetMessage(string id, string subject, string sender, string plaintextBody) + => Fixed(GmailGetMessageProtocolTool(ReadOnlyAdmitted), _ => Structured(new { id, subject, sender, plaintextBody })); internal static McpServerTool GmailGetMessageWithPayload(object payload) { ArgumentNullException.ThrowIfNull(payload); - return Fixed( - GmailGetMessageProtocolTool(ReadOnlyAdmitted), - _ => Structured(payload)); + return Fixed(GmailGetMessageProtocolTool(ReadOnlyAdmitted), _ => Structured(payload)); } internal static McpServerTool GmailGetMessageWithToolError() - => Fixed( - GmailGetMessageProtocolTool(ReadOnlyAdmitted), - _ => new CallToolResult { IsError = true }); + => Fixed(GmailGetMessageProtocolTool(ReadOnlyAdmitted), _ => new CallToolResult { IsError = true }); internal static McpServerTool GmailGetMessageWithIncompatibleAnnotations() => Fixed( @@ -142,23 +132,15 @@ private static Tool GmailGetMessageProtocolTool(ToolAnnotations annotations) Annotations = annotations, }; - private static JsonElement ObjectSchema( - params (string Name, object Definition)[] properties) - => ObjectSchema( - required: properties.Select(property => property.Name).ToArray(), - properties); + private static JsonElement ObjectSchema(params (string Name, object Definition)[] properties) + => ObjectSchema(required: properties.Select(property => property.Name).ToArray(), properties); - private static JsonElement ObjectSchema( - string[]? required, - params (string Name, object Definition)[] properties) + private static JsonElement ObjectSchema(string[]? required, params (string Name, object Definition)[] properties) { var shape = new Dictionary { [Type] = Object, - [Properties] = properties.ToDictionary( - property => property.Name, - property => property.Definition, - StringComparer.Ordinal), + [Properties] = properties.ToDictionary(property => property.Name, property => property.Definition, StringComparer.Ordinal), }; if (required is not null) { @@ -174,9 +156,7 @@ private static CallToolResult Structured(object payload) StructuredContent = JsonSerializer.SerializeToElement(payload), }; - private static FixedSchemaTool Fixed( - Tool protocolTool, - Func, CallToolResult> invoke) + private static FixedSchemaTool Fixed(Tool protocolTool, Func, CallToolResult> invoke) => new(protocolTool, invoke); private sealed class FixedSchemaTool( diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs index 59cb91b7..5416874f 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs @@ -13,10 +13,7 @@ internal static class McpEdgeExtensions internal static void ConfigureMcpEdge(this DigitalBrainTestBuilder builder) { var script = new McpEdgeScript(); - builder.ConfigureMcpSessionFactory( - new ScriptedMcpSessionFactory(script), - script, - static edge => edge.Reset()); + builder.ConfigureMcpSessionFactory(new ScriptedMcpSessionFactory(script), script, static edge => edge.Reset()); } internal static McpEdgeScript Mcp(this TestBrain brain) @@ -92,12 +89,8 @@ public async ValueTask OpenAsync( var tools = script.ToolsFor(server.Key); var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var serverTransport = new StreamServerTransport( - clientToServer.Reader.AsStream(), - serverToClient.Writer.AsStream()); - var clientTransport = new StreamClientTransport( - clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream()); + var serverTransport = new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()); + var clientTransport = new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()); var mcpServer = McpServer.Create( serverTransport, @@ -118,10 +111,7 @@ public async ValueTask OpenAsync( private const string ScriptedServerVersion = "test"; - private sealed class ScriptedMcpSession( - McpClient client, - McpServer server, - Task run) : IMcpClientSession + private sealed class ScriptedMcpSession(McpClient client, McpServer server, Task run) : IMcpClientSession { public McpClient Client { get; } = client; diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs index 92d029e1..487e0fd1 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs @@ -84,16 +84,12 @@ public void MissingCallbackUriIsRejected() BindingFlags.NonPublic | BindingFlags.Static)!; var exception = Assert.Throws(() => validate.Invoke( - null, - [new Uri("https://provider.example/authorize?state=expected"), RedirectUri(), null])); + null, [new Uri("https://provider.example/authorize?state=expected"), RedirectUri(), null])); Assert.IsType(exception.InnerException); } - private static Task Authorize( - Uri redirectUri, - Action launched, - CancellationToken cancellationToken) + private static Task Authorize(Uri redirectUri, Action launched, CancellationToken cancellationToken) { var core = typeof(LocalLoopbackMcpAuthorizationCallback).GetMethod( "AuthorizeAsyncCore", @@ -104,10 +100,7 @@ public void MissingCallbackUriIsRejected() [new Uri("https://provider.example/authorize?state=expected"), redirectUri, start, cancellationToken])!; } - private static async Task SendAsync( - Uri uri, - string query, - CancellationToken cancellationToken) + private static async Task SendAsync(Uri uri, string query, CancellationToken cancellationToken) { using var client = new HttpClient(); return await client.GetAsync(new Uri(uri + query), cancellationToken); diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs index 7e3549db..908fb7c9 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs @@ -50,9 +50,7 @@ public async Task ProposeRejectsCommandIdReuseWithDifferentContent() Assert.Contains("fingerprint", failure.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, test.Mcp().SessionCount); - Assert.Equal( - proposed, - await ProposeAsync(driver, commandId, description, cancellationToken)); + Assert.Equal(proposed, await ProposeAsync(driver, commandId, description, cancellationToken)); } [Fact(DisplayName = @@ -71,10 +69,7 @@ public async Task ApproveRejectsMismatchedEvidenceWithoutMcp() var mismatched = recorded with { ApprovalId = Guid.NewGuid() }; await Assert.ThrowsAsync(() => - driver.Reference.ApproveSalesforceWithMismatchedEvidence( - mismatched, - recorded, - cancellationToken)); + driver.Reference.ApproveSalesforceWithMismatchedEvidence(mismatched, recorded, cancellationToken)); Assert.Equal(0, test.Mcp().SessionCount); Assert.Equal( @@ -93,16 +88,11 @@ public async Task ApproveRejectsMismatchedFingerprintWithoutMcp() var description = IntegrationsFixture.SampleEnrichmentDescription; var proposed = await ProposeAsync(driver, commandId, description, cancellationToken); - var wrongFingerprint = IntegrationsFixture.Approval( - test, - commandId, - proposed.Fingerprint + "-tampered"); + var wrongFingerprint = IntegrationsFixture.Approval(test, commandId, proposed.Fingerprint + "-tampered"); await DeliverApprovalAsync(test, driver, wrongFingerprint, cancellationToken); var failure = await Assert.ThrowsAsync(() => - driver.Reference.ApproveSalesforceWithStoredEvidence( - wrongFingerprint, - cancellationToken)); + driver.Reference.ApproveSalesforceWithStoredEvidence(wrongFingerprint, cancellationToken)); Assert.Contains("fingerprint", failure.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, test.Mcp().SessionCount); @@ -126,9 +116,7 @@ public async Task ApproveCompletesThroughScriptedMcpEdge() var approval = IntegrationsFixture.Approval(test, commandId, proposed.Fingerprint); await DeliverApprovalAsync(test, driver, approval, cancellationToken); - var completed = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var completed = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(SalesforceMutationState.Completed, completed.State); Assert.Equal(commandId, completed.CommandId); @@ -137,9 +125,7 @@ public async Task ApproveCompletesThroughScriptedMcpEdge() Assert.Equal(proposed.Fingerprint, completed.Fingerprint); Assert.True(test.Mcp().SessionCount >= 1); - var again = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var again = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(completed, again); } @@ -157,16 +143,12 @@ public async Task ApproveReturnsOutcomeUncertainWhenReconciliationCannotProveWri test.Mcp().Catalog( IntegrationsFixture.SalesforceServerKey, AdmittedMcpTools.SalesforceUpdateAccount(success: false), - AdmittedMcpTools.SalesforceSoqlQuery( - IntegrationsFixture.SampleAccountId, - UnprovenProviderDescription)); + AdmittedMcpTools.SalesforceSoqlQuery(IntegrationsFixture.SampleAccountId, UnprovenProviderDescription)); var approval = IntegrationsFixture.Approval(test, commandId, proposed.Fingerprint); await DeliverApprovalAsync(test, driver, approval, cancellationToken); - var uncertain = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var uncertain = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(SalesforceMutationState.OutcomeUncertain, uncertain.State); Assert.Equal(commandId, uncertain.CommandId); @@ -175,9 +157,7 @@ public async Task ApproveReturnsOutcomeUncertainWhenReconciliationCannotProveWri Assert.Equal(proposed.Fingerprint, uncertain.Fingerprint); Assert.True(test.Mcp().SessionCount >= 1); - var again = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var again = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(SalesforceMutationState.OutcomeUncertain, again.State); Assert.Equal(uncertain, again); } diff --git a/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs b/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs index 4ed23f03..22fbd553 100644 --- a/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs +++ b/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs @@ -26,9 +26,7 @@ internal sealed class DurableMcpTokenCache( return ValueTask.FromResult(tokens); } - public async ValueTask StoreTokensAsync( - TokenContainer tokens, - CancellationToken cancellationToken) + public async ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(tokens); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs b/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs index 5e3259a9..f2177aad 100644 --- a/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs +++ b/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs @@ -27,11 +27,7 @@ public async ValueTask OpenAsync( ArgumentNullException.ThrowIfNull(commit); ArgumentException.ThrowIfNullOrWhiteSpace(durableIdentity); - var tokens = new DurableMcpTokenCache( - tokenState, - commit, - protector, - $"mcp/oauth/{server.Key}/{durableIdentity}"); + var tokens = new DurableMcpTokenCache(tokenState, commit, protector, $"mcp/oauth/{server.Key}/{durableIdentity}"); var authorization = McpOAuthOptions.Create(server, configuration, tokens); var httpClient = httpClients.CreateClient(McpRuntime.HttpClientName); var transport = new HttpClientTransport( @@ -44,9 +40,7 @@ public async ValueTask OpenAsync( httpClient, loggerFactory: null, ownsHttpClient: true); - var client = await McpClient.CreateAsync( - transport, - cancellationToken: cancellationToken); + var client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken); return new OwnedMcpClientSession(client); } diff --git a/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs b/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs index d8f78189..f4e5d36c 100644 --- a/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs +++ b/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs @@ -79,10 +79,7 @@ await RespondAsync( } } - private static AuthorizationResult? ValidateCallback( - Uri authorizationUri, - Uri redirectUri, - Uri? callbackUri) + private static AuthorizationResult? ValidateCallback(Uri authorizationUri, Uri redirectUri, Uri? callbackUri) { ArgumentNullException.ThrowIfNull(authorizationUri); ArgumentNullException.ThrowIfNull(redirectUri); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs b/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs index 51fe432d..a485b9c8 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs @@ -5,10 +5,7 @@ namespace DigitalBrain.Mcp; internal static class McpOAuthOptions { - internal static ClientOAuthOptions Create( - McpServerDefinition server, - IConfiguration configuration, - ITokenCache tokenCache) + internal static ClientOAuthOptions Create(McpServerDefinition server, IConfiguration configuration, ITokenCache tokenCache) { ArgumentNullException.ThrowIfNull(server); ArgumentNullException.ThrowIfNull(configuration); @@ -27,19 +24,13 @@ internal static ClientOAuthOptions Create( }; } - private static string? Optional( - IConfiguration configuration, - McpServerDefinition server, - string name) + private static string? Optional(IConfiguration configuration, McpServerDefinition server, string name) { var value = configuration[$"{server.ConfigurationRoot}:{name}"]; return !string.IsNullOrWhiteSpace(value) ? value : null; } - private static string Required( - IConfiguration configuration, - McpServerDefinition server, - string name) + private static string Required(IConfiguration configuration, McpServerDefinition server, string name) { var key = $"{server.ConfigurationRoot}:{name}"; var value = configuration[key]; @@ -50,10 +41,7 @@ private static string Required( $"{server.DisplayName} requires projected configuration '{key}'."); } - private static Uri RequiredUri( - IConfiguration configuration, - McpServerDefinition server, - string name) + private static Uri RequiredUri(IConfiguration configuration, McpServerDefinition server, string name) { var value = Required(configuration, server, name); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs b/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs index a7650a44..827ed292 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs @@ -23,19 +23,11 @@ internal async ValueTask RunAsync( ArgumentException.ThrowIfNullOrWhiteSpace(durableIdentity); ArgumentNullException.ThrowIfNull(callback); - await using var session = await sessions.OpenAsync( - server, - tokenState, - commit, - durableIdentity, - cancellationToken); + await using var session = await sessions.OpenAsync(server, tokenState, commit, durableIdentity, cancellationToken); return await callback(session.Client, cancellationToken); } - internal static JsonElement RequireStructuredContent( - CallToolResult result, - McpServerDefinition server, - string toolName) + internal static JsonElement RequireStructuredContent(CallToolResult result, McpServerDefinition server, string toolName) { ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(server); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs b/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs index 6483c15b..bb676001 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs @@ -39,9 +39,7 @@ private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element) { writer.WriteStartObject(); - foreach (var property in element.EnumerateObject().OrderBy( - property => property.Name, - StringComparer.Ordinal)) + foreach (var property in element.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal)) { writer.WritePropertyName(property.Name); WriteCanonical(writer, property.Value); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs index f10b2c0e..de43ff33 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs @@ -20,9 +20,7 @@ internal sealed class CapabilityCaller : ICapabilityCaller, IHandle { - public Task HandleAsync( - CapabilityPing synapse, - CancellationToken cancellationToken) + public Task HandleAsync(CapabilityPing synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs index e9a2c141..f47619fa 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs @@ -14,9 +14,7 @@ public async Task NeuronToNeuronCallReifiesRequestedAndCompletedFacts() var caller = test.Neuron(TestingScenario.CapabilityCaller); var target = test.Neuron(TestingScenario.CapabilityTarget); - await test.Client.SendAsync( - caller.Id.Name, - new CapabilityPing()); + await test.Client.SendAsync(caller.Id.Name, new CapabilityPing()); var stimulus = await caller.Incoming.NextAsync(cancellationToken); var requested = await caller.Outgoing.NextAsync(cancellationToken); @@ -42,9 +40,7 @@ await test.Client.SendAsync( Assert.Equal(requested.CorrelationId, completed.CorrelationId); Assert.Equal(caller.Id, completed.Caller); Assert.Equal(JournalKind.Outgoing, completed.Direction); - Assert.Empty(await caller.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await caller.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await caller.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await caller.Outgoing.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs index 383fb9fb..101cca32 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs @@ -88,9 +88,7 @@ public async Task ClockDrainsReentrantTimerWork() await probe.ScheduleAsync(1, 2, reentrantTimer: true, recurringTimer: false); await test.Clock.AdvanceAsync(TimeSpan.FromSeconds(2), cancellationToken); - Assert.Equal( - ["reminder", "timer", "reentrant-timer"], - await probe.EventsAsync()); + Assert.Equal(["reminder", "timer", "reentrant-timer"], await probe.EventsAsync()); } [Fact(DisplayName = "TestClock skips a cancelled timer while delivering a due reminder")] @@ -119,21 +117,14 @@ public async Task ClockRetainsDrainBound() var failure = await Assert.ThrowsAsync( () => test.Clock.AdvanceAsync(TimeSpan.FromSeconds(1026), cancellationToken)); - Assert.Contains( - "exceeded 1024 operations", - failure.InnerException!.Message, - StringComparison.Ordinal); + Assert.Contains("exceeded 1024 operations", failure.InnerException!.Message, StringComparison.Ordinal); } } [ClientEntryPoint] public partial interface IClockProbe : INeuron { - Task ScheduleAsync( - int reminderDueSeconds, - int timerDueSeconds, - bool reentrantTimer, - bool recurringTimer); + Task ScheduleAsync(int reminderDueSeconds, int timerDueSeconds, bool reentrantTimer, bool recurringTimer); Task CancelTimerAsync(); @@ -149,18 +140,12 @@ internal sealed class ClockProbe : Neuron, IClockProbe, IRemindable private ITimer? _timer; private bool _reentrantTimer; - public async Task ScheduleAsync( - int reminderDueSeconds, - int timerDueSeconds, - bool reentrantTimer, - bool recurringTimer) + public async Task ScheduleAsync(int reminderDueSeconds, int timerDueSeconds, bool reentrantTimer, bool recurringTimer) { _events.Clear(); _reentrantTimer = reentrantTimer; _reminder = await this.RegisterOrUpdateReminder( - ReminderName, - TimeSpan.FromSeconds(reminderDueSeconds), - TimeSpan.FromDays(1)); + ReminderName, TimeSpan.FromSeconds(reminderDueSeconds), TimeSpan.FromDays(1)); _timer = TimeProvider.CreateTimer( static state => ((ClockProbe)state!).OnTimer(), this, diff --git a/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs index 4a6ebdc8..eaf3d230 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs @@ -13,15 +13,11 @@ public async Task OutgoingJournalEvidenceCarriesDeliveryMetadata() await using var test = await fixture.CreateBrainAsync(cancellationToken); var greeter = test.Neuron(TestingScenario.WelcomeGreeter); - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var observed = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - observed.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), observed.Synapse.Message); Assert.NotEqual(default, observed.SynapseId); Assert.True(observed.Sequence > 0); Assert.NotEqual(default, observed.CorrelationId); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs index 0b393ed6..c52ef4de 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs @@ -25,19 +25,13 @@ public async Task FailNextJournalCommitFailsTheNextJournalWrite() await using (var fault = session.FailNextJournalCommit(SessionCommitFailure)) { var failure = await Assert.ThrowsAsync( - () => test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest))); + () => test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest))); Assert.Equal(SessionCommitFailure, failure.Message); } - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var greeted = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - greeted.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), greeted.Synapse.Message); } [Fact(DisplayName = "An unconsumed journal fault fails TestBrain dispose with brain.cleanup diagnostics")] @@ -48,18 +42,11 @@ public async Task UnconsumedJournalFaultFailsBrainDispose() var greeter = test.Neuron(TestingScenario.WelcomeGreeter); _ = greeter.FailNextJournalCommit(NeverFiredFault); - var failure = await Assert.ThrowsAsync( - async () => await test.DisposeAsync()); + var failure = await Assert.ThrowsAsync(async () => await test.DisposeAsync()); - Assert.Contains( - BrainCleanupOperation, - failure.Message, - StringComparison.Ordinal); + Assert.Contains(BrainCleanupOperation, failure.Message, StringComparison.Ordinal); var leak = Assert.IsType(failure.InnerException); - Assert.Contains( - UnconsumedJournalFaultsRemain, - leak.Message, - StringComparison.Ordinal); + Assert.Contains(UnconsumedJournalFaultsRemain, leak.Message, StringComparison.Ordinal); Assert.Contains(NeverFiredFault, leak.Message, StringComparison.Ordinal); Assert.Contains(greeter.Id.ToString(), leak.Message, StringComparison.Ordinal); } @@ -75,12 +62,8 @@ public async Task DisposingAnArmedJournalFaultHandleAllowsCleanBrainDispose() { } - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var greeted = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - greeted.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), greeted.Synapse.Message); } } diff --git a/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs index 542316cc..f90ee8ef 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs @@ -10,9 +10,7 @@ public sealed class LongRunningNeuronContracts [InlineData(typeof(INeuron), nameof(INeuron.Deliver))] [InlineData(typeof(INeuron), nameof(INeuron.ReadJournal))] [InlineData(typeof(ISessionNeuron), nameof(ISessionNeuron.ReadNeuronJournal))] - public void LongRunningNeuronCallsDeclareProductTimeout( - Type contract, - string methodName) + public void LongRunningNeuronCallsDeclareProductTimeout(Type contract, string methodName) { ArgumentNullException.ThrowIfNull(contract); var method = contract.GetMethod(methodName); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs index 6c2e086b..7baee8be 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs @@ -12,20 +12,14 @@ public async Task RestartHostPreservesCommittedJournalRecords() await using var test = await fixture.CreateBrainAsync(cancellationToken); var greeter = test.Neuron(TestingScenario.WelcomeGreeter); - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var first = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - first.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), first.Synapse.Message); await greeter.RestartHostAsync(cancellationToken); - var committed = await greeter.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var committed = await greeter.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Single(committed); Assert.Equal(first.SynapseId, committed[0].SynapseId); Assert.Equal(first.Sequence, committed[0].Sequence); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs index c922defe..fb892618 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs @@ -17,14 +17,10 @@ public async Task TestOwnerExposesPublicIdAndClient() Assert.Equal(other.Id, other.Client.Owner); var greeter = other.Neuron(TestingScenario.WelcomeGreeter); - await other.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await other.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var greeted = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - greeted.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), greeted.Synapse.Message); Assert.Equal(other.Id, greeter.Id.Owner); } } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs b/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs index 48016ac4..726757da 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs @@ -30,11 +30,7 @@ public override DateTimeOffset GetUtcNow() } } - public override ITimer CreateTimer( - TimerCallback callback, - object? state, - TimeSpan dueTime, - TimeSpan period) + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { ArgumentNullException.ThrowIfNull(callback); ValidateTimerValue(dueTime, nameof(dueTime)); @@ -42,11 +38,7 @@ public override ITimer CreateTimer( lock (_gate) { - var registration = new Registration( - this, - callback, - state, - _nextSequence++); + var registration = new Registration(this, callback, state, _nextSequence++); registration.ChangeUnderLock(dueTime, period); _registrations.Add(registration); return registration; @@ -136,10 +128,7 @@ internal void Reset() } } - private static DateTimeOffset Add( - DateTimeOffset value, - TimeSpan duration, - string parameterName) + private static DateTimeOffset Add(DateTimeOffset value, TimeSpan duration, string parameterName) { try { @@ -154,9 +143,7 @@ private static DateTimeOffset Add( } } - private static void ValidateTimerValue( - TimeSpan value, - string parameterName) + private static void ValidateTimerValue(TimeSpan value, string parameterName) { if (value < TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) { @@ -167,11 +154,8 @@ private static void ValidateTimerValue( } } - private sealed class Registration( - ControllableTimeProvider owner, - TimerCallback callback, - object? state, - long sequence) : ITimer + private sealed class Registration(ControllableTimeProvider owner, TimerCallback callback, object? state, long sequence) + : ITimer { private bool _disposed; diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs b/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs index afcb027b..ec618e9d 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs @@ -27,13 +27,10 @@ private FixtureCluster(TestFixtureComposition composition) ArgumentNullException.ThrowIfNull(composition); _modules = composition.Modules.ToArray(); _edges = composition.Edges; - _edges.AttachTimeProvider( - _clock, - _clock.Reset); + _edges.AttachTimeProvider(_clock, _clock.Reset); } - internal static async Task StartAsync( - TestFixtureComposition composition) + internal static async Task StartAsync(TestFixtureComposition composition) { var fixture = new FixtureCluster(composition); @@ -67,28 +64,18 @@ internal IGrainFactory Client internal TestEdgeRegistry Edges => _edges; - internal JournalFaultRegistration ArmJournalFault( - NeuronId target, - string message) + internal JournalFaultRegistration ArmJournalFault(NeuronId target, string message) => _journalStorage.ArmFault(target, message); - internal bool DisarmJournalFault( - JournalFaultRegistration registration) + internal bool DisarmJournalFault(JournalFaultRegistration registration) => _journalStorage.DisarmFault(registration); - internal async Task<(TestClock Clock, long EdgeGeneration)> PrepareMethodAsync( - string scope, - BrainTestDiagnostics diagnostics) + + internal async Task<(TestClock Clock, long EdgeGeneration)> PrepareMethodAsync(string scope, BrainTestDiagnostics diagnostics) { var edgeGeneration = _edges.ResetMethodScope(); await _reminderTable.TestOnlyClearTable(); return ( - new TestClock( - _clock, - new TestReminderDriver( - _reminderTable, - Client, - scope), - diagnostics), + new TestClock(_clock, new TestReminderDriver(_reminderTable, Client, scope), diagnostics), edgeGeneration); } @@ -107,9 +94,7 @@ internal static string LabelOf(string siloName) }; } - internal async Task RestartHostAsync( - NeuronId neuron, - CancellationToken cancellationToken) + internal async Task RestartHostAsync(NeuronId neuron, CancellationToken cancellationToken) { var cluster = _cluster ?? throw new InvalidOperationException( @@ -119,13 +104,11 @@ internal async Task RestartHostAsync( .GetDetailedGrainStatistics() .WaitAsync(cancellationToken); var hosting = statistics - .FirstOrDefault(statistic => - statistic.GrainId == neuron.ToGrainId()) + .FirstOrDefault(statistic => statistic.GrainId == neuron.ToGrainId()) ?.SiloAddress ?? throw new InvalidOperationException( $"{neuron} is not activated on any silo, so no host can be restarted."); - var host = cluster.Silos.Single(silo => - silo.SiloAddress.Equals(hosting)); + var host = cluster.Silos.Single(silo => silo.SiloAddress.Equals(hosting)); // Restart mutates assembly-scoped topology. Cancellation may prevent it // from starting, but cannot detach this method from an in-flight restart @@ -174,8 +157,7 @@ private async Task StartCoreAsync() Convert.ToBase64String(new byte[32]); DigitalBrainRuntime.Add(silo, FixtureCluster.LabelOf(options.SiloName), _modules); - silo.Services.AddSingleton(new ReminderSourceAllowlist( - [TestReminderDeliveryService.SourceType])); + silo.Services.AddSingleton(new ReminderSourceAllowlist([TestReminderDeliveryService.SourceType])); silo.Services.AddGrainService(); silo.Services.AddSingleton< ITestReminderDeliveryServiceClient, diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs index 03c47a68..6485b508 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs @@ -17,8 +17,7 @@ internal sealed class TestReminderDeliveryCaller : public TestReminderDeliveryCaller() { - _service = ServiceProvider - .GetRequiredService(); + _service = ServiceProvider.GetRequiredService(); } public Task Deliver( @@ -27,10 +26,5 @@ public Task Deliver( DateTime firstTickTime, TimeSpan period, DateTime currentTickTime) - => _service.Deliver( - target, - reminderName, - firstTickTime, - period, - currentTickTime); + => _service.Deliver(target, reminderName, firstTickTime, period, currentTickTime); } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs index 7be9a194..6de21f2d 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs @@ -18,18 +18,12 @@ internal sealed class TestReminderDeliveryService : private readonly IGrainFactory _grains; public TestReminderDeliveryService( - GrainId id, - Silo silo, - ILoggerFactory loggerFactory, - IGrainFactory grains) + GrainId id, Silo silo, ILoggerFactory loggerFactory, IGrainFactory grains) : base(id, silo, loggerFactory) { _grains = grains; - if (!string.Equals( - id.Type.ToString(), - SourceType, - StringComparison.Ordinal)) + if (!string.Equals(id.Type.ToString(), SourceType, StringComparison.Ordinal)) { throw new InvalidOperationException( $"The test reminder delivery service source changed from expected '{SourceType}' to '{id.Type}'. Update the exact fixture allowlist deliberately."); @@ -44,7 +38,5 @@ public Task Deliver( DateTime currentTickTime) => _grains .GetGrain(target.ToGrainId()) - .ReceiveReminder( - reminderName, - new TickStatus(firstTickTime, period, currentTickTime)); + .ReceiveReminder(reminderName, new TickStatus(firstTickTime, period, currentTickTime)); } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs index fb59ff75..a04b6e8a 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs @@ -20,10 +20,5 @@ public Task Deliver( TimeSpan period, DateTime currentTickTime) => GetGrainService(CurrentGrainReference.GrainId) - .Deliver( - target, - reminderName, - firstTickTime, - period, - currentTickTime); + .Deliver(target, reminderName, firstTickTime, period, currentTickTime); } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs index 5146d976..1d15eb31 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs @@ -8,27 +8,19 @@ internal sealed class TestReminderDriver private readonly string _scope; private readonly VolatileReminderTable _table; - internal TestReminderDriver( - VolatileReminderTable table, - IGrainFactory grains, - string scope) + internal TestReminderDriver(VolatileReminderTable table, IGrainFactory grains, string scope) { _table = table; _scope = scope; - var caller = NeuronId.For( - new OwnerId($"{scope}-reminder-driver"), - "caller"); - _caller = grains.GetGrain( - caller.ToGrainId()); + var caller = NeuronId.For(new OwnerId($"{scope}-reminder-driver"), "caller"); + _caller = grains.GetGrain(caller.ToGrainId()); } internal DateTimeOffset? NextDueAtOrBefore(DateTimeOffset target) => _table.NextDueAtOrBefore(target, _scope)?.Due; - internal async Task TryDeliverNextDueAsync( - DateTimeOffset now, - CancellationToken cancellationToken) + internal async Task TryDeliverNextDueAsync(DateTimeOffset now, CancellationToken cancellationToken) { var due = _table.NextDueAtOrBefore(now, _scope); if (due is null) @@ -40,16 +32,10 @@ internal async Task TryDeliverNextDueAsync( var target = NeuronId.FromGrainKey( due.GrainId.Type.ToString() - ?? throw new InvalidOperationException( - "A due reminder has no grain type."), + ?? throw new InvalidOperationException("A due reminder has no grain type."), due.GrainId.Key.ToString()); - await _caller.Deliver( - target, - due.ReminderName, - due.FirstTickTime.UtcDateTime, - due.Period, - due.Due.UtcDateTime); + await _caller.Deliver(target, due.ReminderName, due.FirstTickTime.UtcDateTime, due.Period, due.Due.UtcDateTime); _table.CompleteDelivery(due); return true; diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs index 3e5b674a..878df7e9 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs @@ -2,18 +2,13 @@ namespace DigitalBrain.Testing; -internal sealed class TestReminderRegistry( - VolatileReminderTable table, - ControllableTimeProvider clock) : IReminderRegistry +internal sealed class TestReminderRegistry(VolatileReminderTable table, ControllableTimeProvider clock) + : IReminderRegistry { - private static readonly TimeSpan MinimumPeriod = - TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan MinimumPeriod = TimeSpan.FromMilliseconds(50); public async Task RegisterOrUpdateReminder( - GrainId callingGrainId, - string reminderName, - TimeSpan dueTime, - TimeSpan period) + GrainId callingGrainId, string reminderName, TimeSpan dueTime, TimeSpan period) { ValidateName(reminderName); ValidateDueTime(dueTime); @@ -44,9 +39,7 @@ public async Task RegisterOrUpdateReminder( return new ReminderHandle(callingGrainId, reminderName, etag); } - public async Task UnregisterReminder( - GrainId callingGrainId, - IGrainReminder reminder) + public async Task UnregisterReminder(GrainId callingGrainId, IGrainReminder reminder) { ArgumentNullException.ThrowIfNull(reminder); @@ -58,10 +51,7 @@ public async Task UnregisterReminder( nameof(reminder)); } - var removal = table.RemoveRowWithStatus( - callingGrainId, - handle.ReminderName, - handle.ETag); + var removal = table.RemoveRowWithStatus(callingGrainId, handle.ReminderName, handle.ETag); if (removal == ReminderRemovalResult.ETagMismatch) { @@ -72,32 +62,23 @@ public async Task UnregisterReminder( await Task.CompletedTask; } - public async Task GetReminder( - GrainId callingGrainId, - string reminderName) + public async Task GetReminder(GrainId callingGrainId, string reminderName) { ValidateName(reminderName); var entry = await table.ReadRow(callingGrainId, reminderName); return entry is null ? null! - : new ReminderHandle( - entry.GrainId, - entry.ReminderName, - entry.ETag); + : new ReminderHandle(entry.GrainId, entry.ReminderName, entry.ETag); } - public async Task> GetReminders( - GrainId callingGrainId) + public async Task> GetReminders(GrainId callingGrainId) { var data = await table.ReadRows(callingGrainId); return [ .. data.Reminders.Select(entry => - (IGrainReminder)new ReminderHandle( - entry.GrainId, - entry.ReminderName, - entry.ETag)), + (IGrainReminder)new ReminderHandle(entry.GrainId, entry.ReminderName, entry.ETag)), ]; } @@ -122,9 +103,7 @@ private static void ValidateDueTime(TimeSpan dueTime) } } - private static void ValidatePeriod( - string reminderName, - TimeSpan period) + private static void ValidatePeriod(string reminderName, TimeSpan period) { if (period == Timeout.InfiniteTimeSpan || period < TimeSpan.Zero) { @@ -142,8 +121,5 @@ private static void ValidatePeriod( } } - private sealed record ReminderHandle( - GrainId GrainId, - string ReminderName, - string ETag) : IGrainReminder; + private sealed record ReminderHandle(GrainId GrainId, string ReminderName, string ETag) : IGrainReminder; } diff --git a/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs b/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs index d0206445..a8e54f6a 100644 --- a/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs +++ b/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs @@ -7,9 +7,7 @@ internal sealed class BrainTestDiagnostics internal BrainTestDiagnostics(string scopeId) => _scopeId = scopeId; - internal BrainTestFailureException CaptureFailure( - string operation, - Exception failure) + internal BrainTestFailureException CaptureFailure(string operation, Exception failure) { ArgumentNullException.ThrowIfNull(failure); diff --git a/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs b/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs index a2e014da..7f1735f8 100644 --- a/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs +++ b/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs @@ -16,8 +16,7 @@ public async ValueTask InitializeAsync() _cluster = await FixtureCluster.StartAsync(brain.Seal()); } - public async Task CreateBrainAsync( - CancellationToken cancellationToken = default) + public async Task CreateBrainAsync(CancellationToken cancellationToken = default) { await _methodLease.WaitAsync(cancellationToken); try @@ -25,9 +24,7 @@ public async Task CreateBrainAsync( var scope = $"test-{Guid.NewGuid():N}"; var cluster = Cluster(); var diagnostics = new BrainTestDiagnostics(scope); - var method = await cluster.PrepareMethodAsync( - scope, - diagnostics); + var method = await cluster.PrepareMethodAsync(scope, diagnostics); return new TestBrain( cluster, scope, @@ -63,6 +60,5 @@ public async ValueTask DisposeAsync() private FixtureCluster Cluster() => _cluster - ?? throw new InvalidOperationException( - "The DigitalBrain fixture has not been initialized."); + ?? throw new InvalidOperationException("The DigitalBrain fixture has not been initialized."); } diff --git a/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs b/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs index e3d62204..b4f00895 100644 --- a/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs +++ b/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs @@ -26,25 +26,15 @@ public void AddModule() [EditorBrowsable(EditorBrowsableState.Never)] public void ConfigureChatClient( - IReadOnlyCollection neuronAliases, - TService adapter, - TScript script, - Action reset) + IReadOnlyCollection neuronAliases, TService adapter, TScript script, Action reset) where TService : class where TScript : class { ThrowIfSealed(); - _edges.ConfigureChatClient( - neuronAliases, - adapter, - script, - reset); + _edges.ConfigureChatClient(neuronAliases, adapter, script, reset); } - internal void ConfigureMcpSessionFactory( - IMcpClientSessionFactory factory, - TScript script, - Action reset) + internal void ConfigureMcpSessionFactory(IMcpClientSessionFactory factory, TScript script, Action reset) where TScript : class { ThrowIfSealed(); @@ -54,9 +44,7 @@ internal void ConfigureMcpSessionFactory( internal TestFixtureComposition Seal() { _sealed = true; - return new( - _modules.Values.ToArray(), - _edges); + return new(_modules.Values.ToArray(), _edges); } private void ThrowIfSealed() @@ -69,6 +57,4 @@ private void ThrowIfSealed() } } -internal sealed record TestFixtureComposition( - IReadOnlyCollection Modules, - TestEdgeRegistry Edges); +internal sealed record TestFixtureComposition(IReadOnlyCollection Modules, TestEdgeRegistry Edges); diff --git a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs index ae3192f1..14ee6ac8 100644 --- a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs +++ b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs @@ -20,10 +20,7 @@ public static ScriptedChatClient ConfigureScriptedChat( var script = new ScriptedChatClient(); brain.ConfigureChatClient( - models, - script, - script, - static scripted => scripted.Reset()); + models, script, script, static scripted => scripted.Reset()); return script; } diff --git a/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs b/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs index 5f19610b..b0707c13 100644 --- a/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs +++ b/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs @@ -15,10 +15,7 @@ internal sealed class TestEdgeRegistry private long _methodGeneration; internal void ConfigureChatClient( - IReadOnlyCollection neuronAliases, - TService adapter, - TScript script, - Action reset) + IReadOnlyCollection neuronAliases, TService adapter, TScript script, Action reset) where TService : class where TScript : class { @@ -55,19 +52,11 @@ internal void ConfigureChatClient( "The chat-client test edge already has an assembly-configured adapter."); } - _chatClient = new( - serviceType, - aliases, - adapter, - script, - () => reset(script)); + _chatClient = new(serviceType, aliases, adapter, script, () => reset(script)); } } - internal void ConfigureMcpSessionFactory( - IMcpClientSessionFactory factory, - TScript script, - Action reset) + internal void ConfigureMcpSessionFactory(IMcpClientSessionFactory factory, TScript script, Action reset) where TScript : class { ArgumentNullException.ThrowIfNull(factory); @@ -86,9 +75,7 @@ internal void ConfigureMcpSessionFactory( } } - internal void AttachTimeProvider( - TimeProvider provider, - Action reset) + internal void AttachTimeProvider(TimeProvider provider, Action reset) { ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(reset); @@ -127,10 +114,7 @@ internal void ConfigureServices(IServiceCollection services) { foreach (var serviceKey in chatClient.ServiceKeys) { - services.AddKeyedSingleton( - chatClient.ServiceType, - serviceKey, - chatClient.Adapter); + services.AddKeyedSingleton(chatClient.ServiceType, serviceKey, chatClient.Adapter); } } @@ -140,9 +124,7 @@ internal void ConfigureServices(IServiceCollection services) services.AddSingleton(mcp.Factory); } - services.AddKeyedSingleton( - NeuronTime.ServiceKey, - timeProvider); + services.AddKeyedSingleton(NeuronTime.ServiceKey, timeProvider); } internal long ResetMethodScope() @@ -216,14 +198,7 @@ private void EnsureCurrentGeneration(long generation) } private sealed record EdgeRegistration( - Type ServiceType, - IReadOnlyList ServiceKeys, - object Adapter, - object Script, - Action Reset); - - private sealed record McpEdgeRegistration( - IMcpClientSessionFactory Factory, - object Script, - Action Reset); + Type ServiceType, IReadOnlyList ServiceKeys, object Adapter, object Script, Action Reset); + + private sealed record McpEdgeRegistration(IMcpClientSessionFactory Factory, object Script, Action Reset); } diff --git a/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs b/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs index 0b4a3bc1..62c42a74 100644 --- a/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs +++ b/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs @@ -9,9 +9,7 @@ public sealed class JournalFaultHandle : IAsyncDisposable private readonly BrainTestDiagnostics _diagnostics; internal JournalFaultHandle( - JournalFaultRegistration registration, - Func disarm, - BrainTestDiagnostics diagnostics) + JournalFaultRegistration registration, Func disarm, BrainTestDiagnostics diagnostics) { Registration = registration; _disarm = disarm; @@ -39,15 +37,10 @@ public ValueTask DisposeAsync() } catch (Exception failure) { - return ValueTask.FromException( - _diagnostics.CaptureFailure( - "fault.dispose", - failure)); + return ValueTask.FromException(_diagnostics.CaptureFailure("fault.dispose", failure)); } } internal bool Disarm() - => Interlocked.Exchange(ref _disarm, null) - ?.Invoke(this) - ?? false; + => Interlocked.Exchange(ref _disarm, null)?.Invoke(this) ?? false; } diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs b/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs index 9aa46b09..22944590 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs @@ -9,8 +9,7 @@ private AppHostExclusiveLease() { } - internal static async Task AcquireAsync( - CancellationToken cancellationToken) + internal static async Task AcquireAsync(CancellationToken cancellationToken) { await Gate.WaitAsync(cancellationToken); return new AppHostExclusiveLease(); diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs b/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs index 175a1dc4..887fce27 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs @@ -11,8 +11,7 @@ internal HostedResource(RunningAppHost host, string name) _name = name; } - public Task WaitUntilHealthyAsync( - CancellationToken cancellationToken = default) + public Task WaitUntilHealthyAsync(CancellationToken cancellationToken = default) => _host.WaitUntilHealthyAsync(_name, cancellationToken); public HttpClient CreateHttpClient() diff --git a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs index d5dba106..a8351b57 100644 --- a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs +++ b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs @@ -7,8 +7,7 @@ namespace DigitalBrain.Testing; public sealed partial class TestJournal { - private async Task> NextWrappedAsync( - CancellationToken cancellationToken) + private async Task> NextWrappedAsync(CancellationToken cancellationToken) where TSynapse : Synapse { try @@ -21,8 +20,7 @@ private async Task> NextWrappedAsync( } } - private async Task> NextCoreAsync( - CancellationToken cancellationToken) + private async Task> NextCoreAsync(CancellationToken cancellationToken) where TSynapse : Synapse { await _nextGate.WaitAsync(cancellationToken); @@ -62,9 +60,7 @@ private async Task> NextCoreAsync( } } - private SynapseDelivery? Accept( - TestJournalObserver observer, - JournalRead batch) + private SynapseDelivery? Accept(TestJournalObserver observer, JournalRead batch) where TSynapse : Synapse { if (batch.ResetSnapshot is not null) @@ -104,8 +100,7 @@ private async Task> NextCoreAsync( "Reliability", "CA2000:Dispose objects before losing scope", Justification = "Observer ownership transfers on success.")] - private async Task EnsureWatchingAsync( - CancellationToken cancellationToken) + private async Task EnsureWatchingAsync(CancellationToken cancellationToken) { await _setupGate.WaitAsync(cancellationToken); try @@ -121,11 +116,7 @@ private async Task EnsureWatchingAsync( try { reference = _cluster.Client.CreateObjectReference(observer); - await _session.WatchNeuron( - _subject, - _direction, - afterSequence: 0, - reference); + await _session.WatchNeuron(_subject, _direction, afterSequence: 0, reference); _observer = observer; _reference = reference; _watching = true; @@ -178,9 +169,7 @@ private void RetireNextTask(Task task) } } - private ObservedSynapse Observe( - TSynapse synapse, - SynapseDelivery delivery) + private ObservedSynapse Observe(TSynapse synapse, SynapseDelivery delivery) where TSynapse : Synapse => new( synapse, @@ -207,7 +196,5 @@ private ObservedSynapse Observe( } private void ThrowIfDisposed() - => ObjectDisposedException.ThrowIf( - Volatile.Read(ref _disposed) != 0, - this); + => ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); } diff --git a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs index 05865c9e..f5a9e939 100644 --- a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs +++ b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs @@ -25,11 +25,7 @@ public sealed partial class TestJournal private IJournalObserver? _reference; private bool _watching; - internal TestJournal( - FixtureCluster cluster, - NeuronId subject, - JournalKind direction, - BrainTestDiagnostics diagnostics) + internal TestJournal(FixtureCluster cluster, NeuronId subject, JournalKind direction, BrainTestDiagnostics diagnostics) { _cluster = cluster; _subject = subject; @@ -39,8 +35,7 @@ internal TestJournal( ISessionNeuron.ForOwner(subject.Owner).ToGrainId()); } - public Task> NextAsync( - CancellationToken cancellationToken = default) + public Task> NextAsync(CancellationToken cancellationToken = default) where TSynapse : Synapse { lock (_nextTasksGate) @@ -56,8 +51,7 @@ public Task> NextAsync( var task = NextWrappedAsync(cancellationToken); _outstandingNextTasks.Add(task); _ = task.ContinueWith( - static (completed, state) => - ((TestJournal)state!).RetireNextTask(completed), + static (completed, state) => ((TestJournal)state!).RetireNextTask(completed), this, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, @@ -67,8 +61,7 @@ public Task> NextAsync( } public async Task>> ReadAsync( - long afterSequence = 0, - CancellationToken cancellationToken = default) + long afterSequence = 0, CancellationToken cancellationToken = default) where TSynapse : Synapse { try @@ -89,9 +82,7 @@ public async Task>> ReadAsync( [ .. read.Delta .Where(delivery => delivery.Synapse is TSynapse) - .Select(delivery => Observe( - (TSynapse)delivery.Synapse, - delivery)), + .Select(delivery => Observe((TSynapse)delivery.Synapse, delivery)), ]; } catch (Exception failure) when (failure is not BrainTestFailureException) diff --git a/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs b/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs index d52b8778..1f72de73 100644 --- a/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs +++ b/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs @@ -13,9 +13,7 @@ internal sealed class RecordingJournalStorageProvider(IJournalStorageProvider in public IJournalStorage CreateStorage(JournalId journalId) => new RecordingJournalStorage(this, journalId, inner.CreateStorage(journalId)); - internal JournalFaultRegistration ArmFault( - NeuronId target, - string message) + internal JournalFaultRegistration ArmFault(NeuronId target, string message) { ArgumentException.ThrowIfNullOrWhiteSpace(message); @@ -81,17 +79,13 @@ private sealed class RecordingJournalStorage( { public bool IsCompactionRequested => inner.IsCompactionRequested; - public async ValueTask AppendAsync( - ReadOnlySequence value, - CancellationToken cancellationToken) + public async ValueTask AppendAsync(ReadOnlySequence value, CancellationToken cancellationToken) { recorder.BeforeWrite(journalId); await inner.AppendAsync(value, cancellationToken); } - public async ValueTask ReplaceAsync( - ReadOnlySequence value, - CancellationToken cancellationToken) + public async ValueTask ReplaceAsync(ReadOnlySequence value, CancellationToken cancellationToken) { recorder.BeforeWrite(journalId); await inner.ReplaceAsync(value, cancellationToken); @@ -108,9 +102,7 @@ public ValueTask DeleteAsync(CancellationToken cancellationToken) public ValueTask GetMetadataAsync(CancellationToken cancellationToken) => inner.GetMetadataAsync(cancellationToken); - public ValueTask ReadAsync( - IJournalStorageConsumer consumer, - CancellationToken cancellationToken) + public ValueTask ReadAsync(IJournalStorageConsumer consumer, CancellationToken cancellationToken) => inner.ReadAsync(consumer, cancellationToken); public ValueTask UpdateMetadataAsync( @@ -122,8 +114,4 @@ public ValueTask ReadAsync( } } -internal sealed record JournalFaultRegistration( - NeuronId Target, - string Message, - Task Consumed, - object Token); +internal sealed record JournalFaultRegistration(NeuronId Target, string Message, Task Consumed, object Token); diff --git a/src/core/testing/DigitalBrain.Testing/TestBrain.cs b/src/core/testing/DigitalBrain.Testing/TestBrain.cs index e0d93e85..806893c0 100644 --- a/src/core/testing/DigitalBrain.Testing/TestBrain.cs +++ b/src/core/testing/DigitalBrain.Testing/TestBrain.cs @@ -168,22 +168,15 @@ public async ValueTask DisposeAsync() } } - internal JournalFaultHandle ArmJournalFault( - NeuronId target, - string message) + internal JournalFaultHandle ArmJournalFault(NeuronId target, string message) { lock (_faultGate) { try { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); var registration = Cluster.ArmJournalFault(target, message); - var handle = new JournalFaultHandle( - registration, - RetireJournalFault, - _diagnostics); + var handle = new JournalFaultHandle(registration, RetireJournalFault, _diagnostics); _faults.Add(handle); return handle; } @@ -198,18 +191,12 @@ internal TestJournal Journal(NeuronId subject, JournalKind direction) { lock (_journalGate) { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); var key = (subject, direction); if (!_journals.TryGetValue(key, out var journal)) { - journal = new TestJournal( - Cluster, - subject, - direction, - _diagnostics); + journal = new TestJournal(Cluster, subject, direction, _diagnostics); _journals.Add(key, journal); } @@ -217,15 +204,11 @@ internal TestJournal Journal(NeuronId subject, JournalKind direction) } } - internal async Task RestartHostAsync( - NeuronId target, - CancellationToken cancellationToken) + internal async Task RestartHostAsync(NeuronId target, CancellationToken cancellationToken) { try { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); await Cluster.RestartHostAsync(target, cancellationToken); } catch (Exception failure) when (failure is not BrainTestFailureException) @@ -238,13 +221,9 @@ private TestOwner CreateOwner(string label) => new(this, new($"{_scope}-{label}")); private void ThrowIfDisposed() - => ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + => ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); - internal BrainTestFailureException CaptureFailure( - string operation, - Exception failure) + internal BrainTestFailureException CaptureFailure(string operation, Exception failure) => _diagnostics.CaptureFailure(operation, failure); private bool RetireJournalFault(JournalFaultHandle fault) @@ -278,8 +257,7 @@ private bool RetireJournalFault(JournalFaultHandle fault) continue; } - (leaks ??= []).Add( - $"neuron='{fault.Target}', message='{fault.Message}'"); + (leaks ??= []).Add($"neuron='{fault.Target}', message='{fault.Message}'"); } return leaks is null @@ -317,8 +295,7 @@ private async Task DisposeJournalsAsync() if (failures is not null) { throw new AggregateException( - "One or more DigitalBrain test journals failed to clean up.", - failures); + "One or more DigitalBrain test journals failed to clean up.", failures); } } } diff --git a/src/core/testing/DigitalBrain.Testing/TestClock.cs b/src/core/testing/DigitalBrain.Testing/TestClock.cs index 275fba4b..e50da136 100644 --- a/src/core/testing/DigitalBrain.Testing/TestClock.cs +++ b/src/core/testing/DigitalBrain.Testing/TestClock.cs @@ -16,10 +16,7 @@ public sealed class TestClock private readonly TestReminderDriver _reminders; private bool _disposed; - internal TestClock( - ControllableTimeProvider provider, - TestReminderDriver reminders, - BrainTestDiagnostics diagnostics) + internal TestClock(ControllableTimeProvider provider, TestReminderDriver reminders, BrainTestDiagnostics diagnostics) { _provider = provider; _reminders = reminders; @@ -30,36 +27,26 @@ public DateTimeOffset UtcNow { get { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _disposed), - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed), this); return _provider.GetUtcNow(); } } - public async Task AdvanceAsync( - TimeSpan duration, - CancellationToken cancellationToken = default) + public async Task AdvanceAsync(TimeSpan duration, CancellationToken cancellationToken = default) { try { - ArgumentOutOfRangeException.ThrowIfLessThan( - duration, - TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); await AdvanceCoreAsync(duration, cancellationToken); } catch (Exception failure) when (failure is not BrainTestFailureException) { - throw _diagnostics.CaptureFailure( - "clock.advance", - failure); + throw _diagnostics.CaptureFailure("clock.advance", failure); } } - private async Task AdvanceCoreAsync( - TimeSpan duration, - CancellationToken cancellationToken) + private async Task AdvanceCoreAsync(TimeSpan duration, CancellationToken cancellationToken) { await _advanceGate.WaitAsync(cancellationToken); try @@ -88,9 +75,7 @@ private async Task AdvanceCoreAsync( "A deterministic timer disappeared before it could be fired."); } } - else if (!await _reminders.TryDeliverNextDueAsync( - target, - cancellationToken)) + else if (!await _reminders.TryDeliverNextDueAsync(target, cancellationToken)) { throw new InvalidOperationException( "A deterministic reminder disappeared before it could be delivered."); @@ -124,9 +109,7 @@ private static InvalidOperationException DrainLimitFailure(DateTimeOffset target => new( $"Deterministic time drain exceeded {MaximumDrainOperations} operations while advancing to {target:O}."); - private bool TrySelectNextDue( - DateTimeOffset target, - out bool nextIsTimer) + private bool TrySelectNextDue(DateTimeOffset target, out bool nextIsTimer) { var timer = _provider.NextDueAtOrBefore(target); var reminder = _reminders.NextDueAtOrBefore(target); @@ -143,9 +126,7 @@ private bool TrySelectNextDue( return true; } - private static DateTimeOffset Add( - DateTimeOffset value, - TimeSpan duration) + private static DateTimeOffset Add(DateTimeOffset value, TimeSpan duration) { try { diff --git a/src/core/testing/DigitalBrain.Testing/TestNeuron.cs b/src/core/testing/DigitalBrain.Testing/TestNeuron.cs index 81deb6ff..098f4d30 100644 --- a/src/core/testing/DigitalBrain.Testing/TestNeuron.cs +++ b/src/core/testing/DigitalBrain.Testing/TestNeuron.cs @@ -7,12 +7,7 @@ public sealed class TestNeuron { private readonly TestBrain _brain; - internal TestNeuron( - TestBrain brain, - NeuronId id, - TNeuron reference, - TestJournal incoming, - TestJournal outgoing) + internal TestNeuron(TestBrain brain, NeuronId id, TNeuron reference, TestJournal incoming, TestJournal outgoing) { _brain = brain; Id = id; @@ -32,7 +27,6 @@ internal TestNeuron( public JournalFaultHandle FailNextJournalCommit(string message) => _brain.ArmJournalFault(Id, message); - public Task RestartHostAsync( - CancellationToken cancellationToken = default) + public Task RestartHostAsync(CancellationToken cancellationToken = default) => _brain.RestartHostAsync(Id, cancellationToken); } diff --git a/src/core/testing/DigitalBrain.Testing/TestOwner.cs b/src/core/testing/DigitalBrain.Testing/TestOwner.cs index 47e77122..51c722b6 100644 --- a/src/core/testing/DigitalBrain.Testing/TestOwner.cs +++ b/src/core/testing/DigitalBrain.Testing/TestOwner.cs @@ -40,9 +40,7 @@ public TestNeuron Neuron(string name = "default") catch (Exception failure) when (failure is not BrainTestFailureException) { - throw _brain.CaptureFailure( - "neuron.open", - failure); + throw _brain.CaptureFailure("neuron.open", failure); } } } diff --git a/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs b/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs index 1e1adf23..7ed701bb 100644 --- a/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs +++ b/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs @@ -44,10 +44,7 @@ public Task ReadRows(uint begin, uint end) { return Task.FromResult(new ReminderTableData( _entries.Values - .Where(entry => InRange( - entry.Entry.GrainId.GetUniformHashCode(), - begin, - end)) + .Where(entry => InRange(entry.Entry.GrainId.GetUniformHashCode(), begin, end)) .Select(entry => Copy(entry.Entry)))); } } @@ -61,23 +58,15 @@ public Task UpsertRow(ReminderEntry entry) var etag = Guid.NewGuid().ToString("N"); var stored = Copy(entry); stored.ETag = etag; - _entries[(stored.GrainId, stored.ReminderName)] = new( - stored, - Utc(stored.StartAt), - _nextSequence++); + _entries[(stored.GrainId, stored.ReminderName)] = new(stored, Utc(stored.StartAt), _nextSequence++); return Task.FromResult(etag); } } public Task RemoveRow(GrainId grainId, string reminderName, string eTag) - => Task.FromResult( - RemoveRowWithStatus(grainId, reminderName, eTag) - == ReminderRemovalResult.Removed); - - internal ReminderRemovalResult RemoveRowWithStatus( - GrainId grainId, - string reminderName, - string eTag) + => Task.FromResult(RemoveRowWithStatus(grainId, reminderName, eTag) == ReminderRemovalResult.Removed); + + internal ReminderRemovalResult RemoveRowWithStatus(GrainId grainId, string reminderName, string eTag) { lock (_gate) { @@ -88,10 +77,7 @@ internal ReminderRemovalResult RemoveRowWithStatus( return ReminderRemovalResult.Missing; } - if (!string.Equals( - entry.Entry.ETag, - eTag, - StringComparison.Ordinal)) + if (!string.Equals(entry.Entry.ETag, eTag, StringComparison.Ordinal)) { return ReminderRemovalResult.ETagMismatch; } @@ -111,9 +97,7 @@ public Task TestOnlyClearTable() } } - internal DueReminder? NextDueAtOrBefore( - DateTimeOffset target, - string scope) + internal DueReminder? NextDueAtOrBefore(DateTimeOffset target, string scope) { lock (_gate) { @@ -140,10 +124,7 @@ internal void CompleteDelivery(DueReminder delivered) { var key = (delivered.GrainId, delivered.ReminderName); if (!_entries.TryGetValue(key, out var current) - || !string.Equals( - current.Entry.ETag, - delivered.ETag, - StringComparison.Ordinal) + || !string.Equals(current.Entry.ETag, delivered.ETag, StringComparison.Ordinal) || current.NextDue != delivered.Due) { return; @@ -160,28 +141,18 @@ private static bool InRange(uint hash, uint begin, uint end) private static bool BelongsToScope(GrainId grainId, string scope) { - if (string.Equals( - grainId.Type.ToString(), - OutboxWakeup.GrainTypeName, - StringComparison.Ordinal)) + if (string.Equals(grainId.Type.ToString(), OutboxWakeup.GrainTypeName, StringComparison.Ordinal)) { - return OutboxWakeup.TryParseTarget( - grainId.Key.ToString(), - out var target) - && target.Owner.Value.StartsWith( - $"{scope}-", - StringComparison.Ordinal); + return OutboxWakeup.TryParseTarget(grainId.Key.ToString(), out var target) + && target.Owner.Value.StartsWith($"{scope}-", StringComparison.Ordinal); } var id = NeuronId.FromGrainKey( grainId.Type.ToString() - ?? throw new InvalidOperationException( - "A reminder grain has no grain type."), + ?? throw new InvalidOperationException("A reminder grain has no grain type."), grainId.Key.ToString()); - return id.Owner.Value.StartsWith( - $"{scope}-", - StringComparison.Ordinal); + return id.Owner.Value.StartsWith($"{scope}-", StringComparison.Ordinal); } private static DateTimeOffset Utc(DateTime value) @@ -196,10 +167,7 @@ private static DateTimeOffset Utc(DateTime value) ETag = entry.ETag, }; - private sealed class StoredReminder( - ReminderEntry entry, - DateTimeOffset nextDue, - long sequence) + private sealed class StoredReminder(ReminderEntry entry, DateTimeOffset nextDue, long sequence) { internal ReminderEntry Entry { get; } = entry; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs index cc3ff3e1..9c3f8689 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs @@ -15,15 +15,12 @@ public static class AIHostingExtensions public const string Granite41Feature = "ai.llm.granite41"; public const string Gpt56Feature = "ai.llm.gpt56"; - public static DigitalBrainModuleBuilder WithLlm( - this DigitalBrainModuleBuilder module) + public static DigitalBrainModuleBuilder WithLlm(this DigitalBrainModuleBuilder module) where TModel : class, ILLM { ArgumentNullException.ThrowIfNull(module); - var state = module.Brain.GetOrAddState( - static brain => new AIHostingState(brain), - out var added); + var state = module.Brain.GetOrAddState(static brain => new AIHostingState(brain), out var added); if (added) { module.RequireStateProtection(); @@ -87,30 +84,17 @@ public override void Apply(IResourceBuilder builder) foreach (var (model, resource) in _ollamaModels) { builder - .WithAnnotation(new WaitAnnotation( - resource.Resource, - WaitType.WaitUntilHealthy, - exitCode: 0)) - .WithEnvironment( - "DigitalBrain__AI__Ollama__Endpoint", - resource.Resource.Parent.UriExpression) - .WithEnvironment( - $"DigitalBrain__AI__Ollama__{model.Name}__Model", - resource.Resource.ModelName); + .WithAnnotation(new WaitAnnotation(resource.Resource, WaitType.WaitUntilHealthy, exitCode: 0)) + .WithEnvironment("DigitalBrain__AI__Ollama__Endpoint", resource.Resource.Parent.UriExpression) + .WithEnvironment($"DigitalBrain__AI__Ollama__{model.Name}__Model", resource.Resource.ModelName); } if (_gpt56 is not null) { builder - .WithEnvironment( - "DigitalBrain__AI__OpenAI__ApiKey", - _openAIKey!) - .WithEnvironment( - "DigitalBrain__AI__OpenAI__Endpoint", - _gpt56.Resource.Parent.UriExpression) - .WithEnvironment( - "DigitalBrain__AI__OpenAI__Gpt56__Model", - _gpt56.Resource.Model); + .WithEnvironment("DigitalBrain__AI__OpenAI__ApiKey", _openAIKey!) + .WithEnvironment("DigitalBrain__AI__OpenAI__Endpoint", _gpt56.Resource.Parent.UriExpression) + .WithEnvironment("DigitalBrain__AI__OpenAI__Gpt56__Model", _gpt56.Resource.Model); } } @@ -125,9 +109,7 @@ private void AddOllamaModel(Type model, string resourceSuffix, string tag) .WithLifetime(ContainerLifetime.Persistent) .WithOpenWebUI(uiContainer => uiContainer.WithLifetime(ContainerLifetime.Persistent)); - _ollamaModels[model] = _ollama.AddModel( - $"{brain.Name}-ai-{resourceSuffix}", - tag); + _ollamaModels[model] = _ollama.AddModel($"{brain.Name}-ai-{resourceSuffix}", tag); } private void AddGpt56() diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs index 134c99b5..065dabe0 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs @@ -4,5 +4,4 @@ namespace DigitalBrain.AI; [GenerateSerializer] [Alias("ai.capability-tool-selected")] -public sealed record CapabilityToolSelected( - [property: Id(0)] string Tool) : Synapse; +public sealed record CapabilityToolSelected([property: Id(0)] string Tool) : Synapse; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs index a74582fc..79cf8564 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs @@ -47,16 +47,12 @@ public void InvalidOllamaEndpointsFailBeforeClientContact(string? endpoint) [Theory(DisplayName = "every supported Ollama model resolves with its expected default tag")] [MemberData(nameof(SupportedOllamaModels))] - public void SupportedOllamaModelResolvesWithExpectedDefaultTag( - Type modelType, - string expectedTag, - string endpoint) + public void SupportedOllamaModelResolvesWithExpectedDefaultTag(Type modelType, string expectedTag, string endpoint) { using var provider = Compose(endpoint); var client = provider.GetRequiredKeyedService(modelType); - var ollama = Assert.IsAssignableFrom( - client.GetService(typeof(IOllamaApiClient))); + var ollama = Assert.IsAssignableFrom(client.GetService(typeof(IOllamaApiClient))); Assert.NotSame(ollama, client); Assert.Equal(new Uri(endpoint), ollama.Uri); @@ -93,9 +89,7 @@ private static ServiceProvider Compose(string? endpoint) return services.BuildServiceProvider(); } - private sealed class CompositionSiloBuilder( - IServiceCollection services, - IConfiguration configuration) : ISiloBuilder + private sealed class CompositionSiloBuilder(IServiceCollection services, IConfiguration configuration) : ISiloBuilder { public IServiceCollection Services => services; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs index 66a669d3..e9898cb9 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs @@ -18,8 +18,7 @@ public async Task TypedLlmReturnsTheScriptedResponse() await using var test = await fixture.CreateBrainAsync(cancellationToken); test.Chat().Reply(ScriptedReply); - var response = await test.Client.Get(ModelName).Respond( - [new ChatMessage(ChatRole.User, UserPrompt)]); + var response = await test.Client.Get(ModelName).Respond([new ChatMessage(ChatRole.User, UserPrompt)]); Assert.Equal(ScriptedReply, response.Text); Assert.Equal(1, test.Chat().CallCount); diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs index 62249874..5fd85e9a 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs @@ -9,9 +9,7 @@ namespace DigitalBrain.ModuleTests; [GenerateSerializer] [Alias("moduletests.account-enriched-probe")] -public sealed record ProbeAccountEnriched( - [property: Id(0)] string AccountId, - [property: Id(1)] string MessageId) : Synapse; +public sealed record ProbeAccountEnriched([property: Id(0)] string AccountId, [property: Id(1)] string MessageId) : Synapse; [Alias("DigitalBrain.ModuleTests.IEnrichmentProbe")] [ClientEntryPoint] @@ -35,8 +33,7 @@ public Task Enrich(string accountId, string messageId) } } -public sealed class ToolAgentProbe( - [FromKeyedServices(typeof(Llama32))] IChatClient chatClient) +public sealed class ToolAgentProbe([FromKeyedServices(typeof(Llama32))] IChatClient chatClient) : Agent(chatClient), IToolAgentProbe { public const string EnrichTool = "enrich_account_from_email"; @@ -56,8 +53,7 @@ public sealed class ToolAgentProbe( private async Task EnrichAsync(string accountId, string messageId) { - var enrichment = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, ProbeName).ToGrainId()); + var enrichment = GrainFactory.GetGrain(NeuronId.For(Id.Owner, ProbeName).ToGrainId()); await enrichment.Enrich(accountId, messageId); diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs index cc2e596a..60058408 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs @@ -21,8 +21,7 @@ public async Task AgentInstructionsReachModelAsFirstSystemTurn() await using var test = await fixture.CreateBrainAsync(cancellationToken); test.Chat().Reply(FinalReply); - await test.Client.Get(AgentName).Respond( - [new(Microsoft.Extensions.AI.ChatRole.User, IdentityPrompt)]); + await test.Client.Get(AgentName).Respond([new(Microsoft.Extensions.AI.ChatRole.User, IdentityPrompt)]); Assert.Collection( test.Chat().LastMessages, @@ -95,12 +94,9 @@ public async Task NoCapabilitySelectedWhenModelAnswersDirectly() var agent = test.Neuron(AgentName); test.Chat().Reply(FinalReply); - await test.Client.Get(AgentName).Respond( - [new(Microsoft.Extensions.AI.ChatRole.User, "hello")]); + await test.Client.Get(AgentName).Respond([new(Microsoft.Extensions.AI.ChatRole.User, "hello")]); - var selected = await agent.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var selected = await agent.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Empty(selected); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs index 45c16fd6..90f35ec9 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs @@ -22,9 +22,7 @@ public sealed class DurablePayloadProtectionStartup [Theory(DisplayName = "invalid durable protection keys fail during service composition")] [MemberData(nameof(InvalidKeys))] - public void InvalidKeysFailDuringServiceComposition( - string? encodedKey, - Type expectedExceptionType) + public void InvalidKeysFailDuringServiceComposition(string? encodedKey, Type expectedExceptionType) { var result = Compose(encodedKey, resolveProtector: false); @@ -79,16 +77,12 @@ private static CompositionResult Compose(string? encodedKey, bool resolveProtect return new(compositionException, resolutionException); } - private sealed class CompositionSiloBuilder( - IServiceCollection services, - IConfiguration configuration) : ISiloBuilder + private sealed class CompositionSiloBuilder(IServiceCollection services, IConfiguration configuration) : ISiloBuilder { public IServiceCollection Services => services; public IConfiguration Configuration => configuration; } - private sealed record CompositionResult( - Exception? CompositionException, - Exception? ResolutionException); + private sealed record CompositionResult(Exception? CompositionException, Exception? ResolutionException); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs index 8bc63b33..1822b193 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs @@ -32,12 +32,7 @@ public async Task GroupChatSupervisedAttemptsThrow() var workerId = test.Neuron(SupervisedTeam).Id; var taskId = test.Neuron("task-placeholder").Id; var attempt = new AttemptId(Guid.NewGuid()); - var request = new AttemptRequest( - taskId, - workerId, - attempt, - Revision: 0, - new ProbeGoal("supervised")); + var request = new AttemptRequest(taskId, workerId, attempt, Revision: 0, new ProbeGoal("supervised")); var cursor = new AttemptCursor(taskId, workerId, attempt, Revision: 0); await Assert.ThrowsAsync(() => worker.InvokeAccept(request)); @@ -77,8 +72,7 @@ public async Task ParticipantChangeOnSameIdDemandsMigrationOrReset() await orchestration.UseParticipants("left-alt", "right-alt"); - await Assert.ThrowsAsync( - () => orchestration.Respond([User()])); + await Assert.ThrowsAsync(() => orchestration.Respond([User()])); Assert.Equal(Pair, test.Chat().CallCount); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs index 164caa1c..570308f8 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs @@ -31,19 +31,13 @@ private static void AddOllamaModel(IServiceCollection services, string d where TModel : LLM => services.AddKeyedSingleton( typeof(TModel), - (provider, _) => Ollama( - provider.GetRequiredService(), - typeof(TModel).Name, - defaultTag)); + (provider, _) => Ollama(provider.GetRequiredService(), typeof(TModel).Name, defaultTag)); [SuppressMessage( "Reliability", "CA2000:Dispose objects before losing scope", Justification = "The telemetry middleware owns and disposes the inner Ollama client.")] - private static IChatClient Ollama( - IConfiguration configuration, - string modelName, - string defaultTag) + private static IChatClient Ollama(IConfiguration configuration, string modelName, string defaultTag) { var endpoint = configuration[$"{ConfigurationRoot}:Ollama:Endpoint"]; if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs index e561da53..627994fc 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Gemma4( - [Llm] IChatClient chatClient) - : LLM(chatClient), IGemma4; +public sealed class Gemma4([Llm] IChatClient chatClient) : LLM(chatClient), IGemma4; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs index 26404d86..029e814b 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Granite41( - [Llm] IChatClient chatClient) - : LLM(chatClient), IGranite41; +public sealed class Granite41([Llm] IChatClient chatClient) : LLM(chatClient), IGranite41; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs index 38df5ad4..d9b7b79b 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Llama32( - [Llm] IChatClient chatClient) - : LLM(chatClient), ILlama32; +public sealed class Llama32([Llm] IChatClient chatClient) : LLM(chatClient), ILlama32; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs index 51532115..7ba66b79 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Qwen35( - [Llm] IChatClient chatClient) - : LLM(chatClient), IQwen35; +public sealed class Qwen35([Llm] IChatClient chatClient) : LLM(chatClient), IQwen35; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs b/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs index deda30de..aac17b39 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.OpenAI; -public sealed class Gpt56( - [Llm] IChatClient chatClient) - : LLM(chatClient), IGpt56; +public sealed class Gpt56([Llm] IChatClient chatClient) : LLM(chatClient), IGpt56; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs index 4270fdee..9ddedc7a 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs @@ -15,11 +15,7 @@ public abstract class Concurrent : Neuron, IAgent protected Concurrent() { - _directSession = DirectAgentSession.Create( - ServiceProvider, - "ai.concurrent.session", - () => WriteStateAsync(), - Id); + _directSession = DirectAgentSession.Create(ServiceProvider, "ai.concurrent.session", () => WriteStateAsync(), Id); } protected abstract IReadOnlyList Participants { get; } @@ -36,10 +32,6 @@ public Task Respond(IReadOnlyList messages) var shape = DirectOrchestrationShape.CreateConcurrent(GetType(), snapshot); var agent = shape.CreateAgent(GrainFactory, TaskScheduler.Current); - return _directSession.RunAsync( - agent, - shape.Definition, - messages, - CancellationToken.None); + return _directSession.RunAsync(agent, shape.Definition, messages, CancellationToken.None); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs index 66d9dc7a..73a3720c 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs @@ -49,20 +49,12 @@ internal async Task RunAsync( var session = state.Value is { Length: > 0 } serialized ? await RestoreAsync(agent, serialized, definition, cancellationToken) : await agent.CreateSessionAsync(cancellationToken); - var response = await agent.RunAsync( - messages, - session, - cancellationToken: cancellationToken); - var serializedSession = await agent.SerializeSessionAsync( - session, - cancellationToken: cancellationToken); + var response = await agent.RunAsync(messages, session, cancellationToken: cancellationToken); + var serializedSession = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken); var protectedSession = protector.Protect( Purpose(definition.Fingerprint), Encoding.UTF8.GetBytes(serializedSession.GetRawText())); - var envelope = new DirectAgentSessionEnvelope( - CurrentEnvelopeVersion, - definition, - protectedSession); + var envelope = new DirectAgentSessionEnvelope(CurrentEnvelopeVersion, definition, protectedSession); var previous = state.Value?.ToArray(); state.Value = JsonSerializer.SerializeToUtf8Bytes(envelope); @@ -105,10 +97,7 @@ private async Task RestoreAsync( throw RecoveryRequired(); } - if (!string.Equals( - stored.Definition.Fingerprint, - definition.Fingerprint, - StringComparison.Ordinal)) + if (!string.Equals(stored.Definition.Fingerprint, definition.Fingerprint, StringComparison.Ordinal)) { throw new InvalidOperationException( "The durable direct-agent session is incompatible with the current orchestration definition; an explicit migration or reset is required."); @@ -116,9 +105,7 @@ private async Task RestoreAsync( try { - var sessionBytes = protector.Unprotect( - Purpose(definition.Fingerprint), - stored.ProtectedSession); + var sessionBytes = protector.Unprotect(Purpose(definition.Fingerprint), stored.ProtectedSession); using var sessionJson = JsonDocument.Parse(sessionBytes); return await agent.DeserializeSessionAsync( diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs index 71787945..940f1066 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs @@ -32,17 +32,12 @@ private DirectOrchestrationShape( _participants = participants; _buildWorkflow = buildWorkflow; _executionEnvironment = executionEnvironment; - Definition = OrchestrationDefinition.Describe( - orchestrationType, - participants, - identity); + Definition = OrchestrationDefinition.Describe(orchestrationType, participants, identity); } internal OrchestrationDefinition Definition { get; } - internal static Participant[] Snapshot( - NeuronId orchestration, - IReadOnlyList? participants) + internal static Participant[] Snapshot(NeuronId orchestration, IReadOnlyList? participants) { if (participants is null) { @@ -72,31 +67,19 @@ internal static Participant[] Snapshot( return snapshot; } - internal static DirectOrchestrationShape CreateConcurrent( - Type orchestrationType, - IReadOnlyList participants) + internal static DirectOrchestrationShape CreateConcurrent(Type orchestrationType, IReadOnlyList participants) => new( orchestrationType, participants, - new( - "concurrent", - "in-process-concurrent", - "concurrent-default", - static _ => "none"), + new("concurrent", "in-process-concurrent", "concurrent-default", static _ => "none"), static agents => AgentWorkflowBuilder.BuildConcurrent(agents), InProcessExecution.Concurrent); - internal static DirectOrchestrationShape CreateGroupChat( - Type orchestrationType, - IReadOnlyList participants) + internal static DirectOrchestrationShape CreateGroupChat(Type orchestrationType, IReadOnlyList participants) => new( orchestrationType, participants, - new( - "group-chat", - "in-process-lockstep", - "none", - static count => $"round-robin:{count}"), + new("group-chat", "in-process-lockstep", "none", static count => $"round-robin:{count}"), static agents => AgentWorkflowBuilder .CreateGroupChatBuilderWith(team => new RoundRobinGroupChatManager(team) { @@ -106,9 +89,7 @@ internal static DirectOrchestrationShape CreateGroupChat( .Build(), InProcessExecution.Lockstep); - internal AIAgent CreateAgent( - IGrainFactory grains, - TaskScheduler turnScheduler) + internal AIAgent CreateAgent(IGrainFactory grains, TaskScheduler turnScheduler) { ArgumentNullException.ThrowIfNull(grains); ArgumentNullException.ThrowIfNull(turnScheduler); @@ -118,8 +99,6 @@ internal AIAgent CreateAgent( .. _participants.Select(participant => participant.CreateAgent(grains, turnScheduler)), ]; - return _buildWorkflow(agents).AsAIAgent( - id: Definition.HostId, - executionEnvironment: _executionEnvironment); + return _buildWorkflow(agents).AsAIAgent(id: Definition.HostId, executionEnvironment: _executionEnvironment); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs index a0436897..23de989c 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs @@ -11,11 +11,7 @@ public abstract class GroupChat : Neuron, IGroupChat protected GroupChat() { - _directSession = DirectAgentSession.Create( - ServiceProvider, - "ai.group-chat.session", - () => WriteStateAsync(), - Id); + _directSession = DirectAgentSession.Create(ServiceProvider, "ai.group-chat.session", () => WriteStateAsync(), Id); } protected abstract IReadOnlyList Participants { get; } @@ -32,11 +28,7 @@ public Task Respond(IReadOnlyList messages) var shape = DirectOrchestrationShape.CreateGroupChat(GetType(), snapshot); var agent = shape.CreateAgent(GrainFactory, TaskScheduler.Current); - return _directSession.RunAsync( - agent, - shape.Definition, - messages, - CancellationToken.None); + return _directSession.RunAsync(agent, shape.Definition, messages, CancellationToken.None); } public Task Accept(AttemptRequest request) diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs index 78bd7779..088794c1 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs @@ -23,10 +23,7 @@ internal static OrchestrationParticipant Describe(Participant participant) "Reliability", "CA2000:Dispose objects before losing scope", Justification = "NeuronChatClient has empty disposal and is owned by the ChatClientAgent for the agent lifetime.")] - internal static AIAgent Create( - IGrainFactory grains, - NeuronId id, - TaskScheduler turnScheduler) + internal static AIAgent Create(IGrainFactory grains, NeuronId id, TaskScheduler turnScheduler) where TNeuron : INeuron { ArgumentNullException.ThrowIfNull(turnScheduler); diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs index bd01934f..25139157 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs @@ -54,8 +54,7 @@ private static void ObserveFault(Task response) TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); - private static Func, Task> InvocationFor( - INeuron participant) + private static Func, Task> InvocationFor(INeuron participant) { ArgumentNullException.ThrowIfNull(participant); @@ -69,9 +68,7 @@ private static Func, Task> InvocationFo }; } - private static IReadOnlyList Request( - IEnumerable messages, - ChatOptions? options) + private static IReadOnlyList Request(IEnumerable messages, ChatOptions? options) { var request = messages as IReadOnlyList ?? messages.ToArray(); diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs index ac1ba91f..d6022da8 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs @@ -7,9 +7,7 @@ namespace DigitalBrain.AI; -internal sealed record OrchestrationParticipant( - string Contract, - NeuronId NeuronId); +internal sealed record OrchestrationParticipant(string Contract, NeuronId NeuronId); internal sealed record OrchestrationDefinition(string Fingerprint) { @@ -36,8 +34,7 @@ internal static OrchestrationDefinition Describe( identity.ManagerName(described.Length), identity.AggregatorName, described); - var fingerprint = Convert.ToHexStringLower( - SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(source))); + var fingerprint = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(source))); return new(fingerprint); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs b/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs index 628c0381..5def2633 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs @@ -11,9 +11,7 @@ internal sealed class TurnBoundFunction(AIFunction capability, TaskScheduler tur public override JsonElement JsonSchema => capability.JsonSchema; - protected override ValueTask InvokeCoreAsync( - AIFunctionArguments arguments, - CancellationToken cancellationToken) + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => new(Task.Factory.StartNew( () => capability.InvokeAsync(arguments, cancellationToken).AsTask(), cancellationToken, diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs index 32f9b88c..e15ecced 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs @@ -4,6 +4,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.assistant-answered")] -public sealed record AssistantAnswered( - [property: Id(0)] CommandId CommandId, - [property: Id(1)] string Text) : Synapse; +public sealed record AssistantAnswered([property: Id(0)] CommandId CommandId, [property: Id(1)] string Text) : Synapse; diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs index 53e71db6..e0442348 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.transcript")] -public sealed record ChatTranscript( - [property: Id(0)] IReadOnlyList Turns); +public sealed record ChatTranscript([property: Id(0)] IReadOnlyList Turns); diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs index d091c246..d352460f 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.turn")] -public sealed record ChatTurn( - [property: Id(0)] bool FromUser, - [property: Id(1)] string Text); +public sealed record ChatTurn([property: Id(0)] bool FromUser, [property: Id(1)] string Text); diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs index 70a730a7..a0c1c2ee 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs @@ -4,6 +4,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.send-message")] -public sealed record SendMessage( - [property: Id(0)] CommandId CommandId, - [property: Id(1)] string Text); +public sealed record SendMessage([property: Id(0)] CommandId CommandId, [property: Id(1)] string Text); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs index 7c6e98db..d5f8d056 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs @@ -63,10 +63,7 @@ private static Result ResolveDesktop( $"under '{packageRoot}' or '{packageRoot}/{ShellPackageDirectoryName}'. " + "Use WithFlutterHost() for the pure-Dart host."); - return new Result( - ResolveFlutterCommand(options, configuration), - workDir, - ["run", "-d", deviceTarget]); + return new Result(ResolveFlutterCommand(options, configuration), workDir, ["run", "-d", deviceTarget]); } private static Result ResolveHeadless( @@ -74,9 +71,7 @@ private static Result ResolveHeadless( FlutterHostOptions options, Microsoft.Extensions.Configuration.IConfiguration? configuration) { - var entry = Path.Combine( - packageRoot, - FlutterHostingExtensions.HeadlessHostEntry.Replace('/', Path.DirectorySeparatorChar)); + var entry = Path.Combine(packageRoot, FlutterHostingExtensions.HeadlessHostEntry.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(entry)) { throw new InvalidOperationException( @@ -254,14 +249,12 @@ private static IEnumerable PathSearchDirectories() } // DCP / non-interactive hosts often lack a full interactive User PATH. Merge User+Machine. - foreach (var directory in SplitPath( - Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User))) + foreach (var directory in SplitPath(Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User))) { yield return directory; } - foreach (var directory in SplitPath( - Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine))) + foreach (var directory in SplitPath(Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine))) { yield return directory; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs index 650dd7b8..fc73414b 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs @@ -70,8 +70,7 @@ private static FlutterHostKind HostKindOf() $"Use {nameof(DesktopHost)} or {nameof(HeadlessHost)}."); } - private static FlutterHostingState GetOrCreateState( - DigitalBrainModuleBuilder module) + private static FlutterHostingState GetOrCreateState(DigitalBrainModuleBuilder module) { var state = module.Brain.GetOrAddState( static brain => new FlutterHostingState(brain), @@ -135,9 +134,7 @@ internal void EnsureFlutterHost(FlutterHostKind kind, FlutterHostOptions options } var appHost = brain.GetApplicationBuilder(); - var packageRoot = ResolveFlutterWorkingDirectory( - appHost.AppHostDirectory, - options.WorkingDirectory); + var packageRoot = ResolveFlutterWorkingDirectory(appHost.AppHostDirectory, options.WorkingDirectory); if (!Directory.Exists(packageRoot) || !File.Exists(Path.Combine(packageRoot, "pubspec.yaml"))) { @@ -146,11 +143,7 @@ internal void EnsureFlutterHost(FlutterHostKind kind, FlutterHostOptions options "Pass FlutterHostOptions.WorkingDirectory or place clients/digitalbrain_flutter in the repo."); } - var launch = FlutterHostLaunch.Resolve( - kind, - packageRoot, - options, - appHost.Configuration); + var launch = FlutterHostLaunch.Resolve(kind, packageRoot, options, appHost.Configuration); var resourceName = string.IsNullOrWhiteSpace(options.ResourceName) ? DefaultFlutterResourceName : options.ResourceName; @@ -176,10 +169,7 @@ public override void Apply(IResourceBuilder builder) if (_ui is not null) { - _ui.WithAnnotation(new WaitAnnotation( - builder.Resource, - WaitType.WaitUntilHealthy, - exitCode: 0)); + _ui.WithAnnotation(new WaitAnnotation(builder.Resource, WaitType.WaitUntilHealthy, exitCode: 0)); } } @@ -210,9 +200,7 @@ private static string ResolveUIProjectPath(string appHostDirectory, string? conf return Path.GetFullPath(candidates[0]); } - private static string ResolveFlutterWorkingDirectory( - string appHostDirectory, - string? configured) + private static string ResolveFlutterWorkingDirectory(string appHostDirectory, string? configured) { if (!string.IsNullOrWhiteSpace(configured)) { diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs index 0dd0a324..e1f90c01 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs @@ -19,9 +19,7 @@ public sealed class BrainCapabilityCatalogTests "all modules without configured providers claims no product capability")] public void CompleteModuleSetWithoutConfiguredFeaturesClaimsNothing() { - var capabilities = BrainCapabilityCatalog.Resolve( - ProductModules, - Configuration()); + var capabilities = BrainCapabilityCatalog.Resolve(ProductModules, Configuration()); Assert.Empty(capabilities); } @@ -34,9 +32,7 @@ public void ConfiguredFeaturesWithoutOSResponderClaimsNothing() .Where(module => module.Id != "DigitalBrain.OS.OSBehaviorsModule") .ToArray(); - var capabilities = BrainCapabilityCatalog.Resolve( - modules, - Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); + var capabilities = BrainCapabilityCatalog.Resolve(modules, Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); Assert.Empty(capabilities); } @@ -45,9 +41,7 @@ public void ConfiguredFeaturesWithoutOSResponderClaimsNothing() "configured non-Gemma model claims no product capability")] public void NonGemmaModelClaimsNothing() { - var capabilities = BrainCapabilityCatalog.Resolve( - ProductModules, - Configuration("ai.llm.llama32", "google.gmail", "salesforce")); + var capabilities = BrainCapabilityCatalog.Resolve(ProductModules, Configuration("ai.llm.llama32", "google.gmail", "salesforce")); Assert.Empty(capabilities); } @@ -56,9 +50,7 @@ public void NonGemmaModelClaimsNothing() "configured Gemma product manifest claims general assistant and account enrichment")] public void ConfiguredGemmaProductManifestClaimsExactCapabilities() { - var capabilities = BrainCapabilityCatalog.Resolve( - ProductModules, - Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); + var capabilities = BrainCapabilityCatalog.Resolve(ProductModules, Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); Assert.Equal( [ @@ -72,9 +64,7 @@ private static IConfiguration Configuration(params string[] features) { var values = features .Select((feature, index) => - new KeyValuePair( - $"DigitalBrain:ConfiguredFeatures:{index}", - feature)); + new KeyValuePair($"DigitalBrain:ConfiguredFeatures:{index}", feature)); return new ConfigurationBuilder() .AddInMemoryCollection(values) .Build(); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs index 80a21ddc..8dffe3bd 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs @@ -23,26 +23,17 @@ public async Task PostOpenSceneAndSseProjectsSceneOpened() Timeout = TimeSpan.FromSeconds(30), }; - using (var health = await http.GetAsync( - new Uri(UIEdgeContract.HealthPath, UriKind.Relative), - cancellationToken)) + using (var health = await http.GetAsync(new Uri(UIEdgeContract.HealthPath, UriKind.Relative), cancellationToken)) { Assert.True( health.IsSuccessStatusCode, $"Product {UIFixture.DefaultUIResourceName} {UIEdgeContract.HealthPath} not OK at {baseAddress}. Start: aspire start --project os/DigitalBrain.OS.AppHost. Status={(int)health.StatusCode}. Override with {UIFixture.UIBaseEnvironmentVariable}."); } - using var streamRequest = new HttpRequestMessage( - HttpMethod.Get, - UIEdgeSse.ShellEvents(shellName, afterSequence: 0)); - using var streamResponse = await http.SendAsync( - streamRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, UIEdgeSse.ShellEvents(shellName, afterSequence: 0)); + using var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); Assert.Equal(HttpStatusCode.OK, streamResponse.StatusCode); - Assert.Equal( - UIEdgeContract.EventStreamContentType, - streamResponse.Content.Headers.ContentType?.MediaType); + Assert.Equal(UIEdgeContract.EventStreamContentType, streamResponse.Content.Headers.ContentType?.MediaType); await using var body = await streamResponse.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(body); @@ -53,10 +44,7 @@ public async Task PostOpenSceneAndSseProjectsSceneOpened() cancellationToken); Assert.Equal(HttpStatusCode.Accepted, openResponse.StatusCode); - var projected = await UIEdgeSse.ReadNextSceneOpenedAsync( - reader, - cancellationToken, - timeout: TimeSpan.FromSeconds(20)); + var projected = await UIEdgeSse.ReadNextSceneOpenedAsync(reader, cancellationToken, timeout: TimeSpan.FromSeconds(20)); Assert.Equal(sceneKey, projected.SceneKey); Assert.Equal(title, projected.Title); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs index 20bbb268..8c5830c2 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs @@ -145,24 +145,14 @@ await test.Client.Get(UIFixture.DefaultShellName).Open( await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); using var http = CreateClient(app); - var topology = await http.GetFromJsonAsync( - UIEdgeContract.BrainTopologyPath, - cancellationToken); + var topology = await http.GetFromJsonAsync(UIEdgeContract.BrainTopologyPath, cancellationToken); Assert.NotNull(topology); - Assert.Contains( - topology.Modules, - module => module.Id == FlutterModule.Id.Value); - Assert.Contains( - topology.Modules, - module => module.Id == ChatModule.Id.Value); - Assert.Contains( - topology.Neurons, - neuron => neuron.Id == shell.Id.ToString()); + Assert.Contains(topology.Modules, module => module.Id == FlutterModule.Id.Value); + Assert.Contains(topology.Modules, module => module.Id == ChatModule.Id.Value); + Assert.Contains(topology.Neurons, neuron => neuron.Id == shell.Id.ToString()); Assert.Empty(topology.Capabilities); - Assert.All( - topology.Neurons, - neuron => Assert.StartsWith("cluster-", neuron.Placement, StringComparison.Ordinal)); + Assert.All(topology.Neurons, neuron => Assert.StartsWith("cluster-", neuron.Placement, StringComparison.Ordinal)); } [Fact(DisplayName = @@ -175,16 +165,11 @@ public async Task HttpChatTurnCarriesPulseIdentity() await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); using var http = CreateClient(app, streaming: true); - await using var events = await OpenChatEventStreamAsync( - http, - "pulse", - cancellationToken); + await using var events = await OpenChatEventStreamAsync(http, "pulse", cancellationToken); var command = new SendMessage(CommandId.New(), "hello"); await test.Client.Get("pulse").Send(command); - var projected = await UIEdgeSse.ReadNextChatTurnAsync( - events.Reader, - cancellationToken); + var projected = await UIEdgeSse.ReadNextChatTurnAsync(events.Reader, cancellationToken); Assert.True(projected.Sequence > 0); Assert.Equal(nameof(UserMessaged), projected.Synapse); @@ -196,28 +181,20 @@ public async Task HttpChatTurnCarriesPulseIdentity() Assert.NotEqual(default, projected.Timestamp); } - private static async Task OpenShellEventStreamAsync( - HttpClient http, - CancellationToken cancellationToken) + private static async Task OpenShellEventStreamAsync(HttpClient http, CancellationToken cancellationToken) { using var streamRequest = new HttpRequestMessage( HttpMethod.Get, UIEdgeSse.ShellEvents(UIFixture.DefaultShellName, afterSequence: 0)); - var streamResponse = await http.SendAsync( - streamRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (streamResponse.StatusCode != HttpStatusCode.OK) { var errorBody = await streamResponse.Content.ReadAsStringAsync(cancellationToken); streamResponse.Dispose(); - Assert.Fail( - $"SSE status {(int)streamResponse.StatusCode} {streamResponse.StatusCode}: {errorBody}"); + Assert.Fail($"SSE status {(int)streamResponse.StatusCode} {streamResponse.StatusCode}: {errorBody}"); } - Assert.Equal( - UIEdgeContract.EventStreamContentType, - streamResponse.Content.Headers.ContentType?.MediaType); + Assert.Equal(UIEdgeContract.EventStreamContentType, streamResponse.Content.Headers.ContentType?.MediaType); Assert.Contains( UIEdgeContract.CacheControlNoCache, streamResponse.Headers.CacheControl?.ToString() ?? string.Empty, @@ -227,18 +204,10 @@ private static async Task OpenShellEventStreamAsync( return new ShellEventStream(streamResponse, body); } - private static async Task OpenChatEventStreamAsync( - HttpClient http, - string chatName, - CancellationToken cancellationToken) + private static async Task OpenChatEventStreamAsync(HttpClient http, string chatName, CancellationToken cancellationToken) { - using var streamRequest = new HttpRequestMessage( - HttpMethod.Get, - UIEdgeSse.ChatEvents(chatName, afterSequence: 0)); - var streamResponse = await http.SendAsync( - streamRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, UIEdgeSse.ChatEvents(chatName, afterSequence: 0)); + var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); Assert.Equal(HttpStatusCode.OK, streamResponse.StatusCode); var body = await streamResponse.Content.ReadAsStreamAsync(cancellationToken); return new ShellEventStream(streamResponse, body); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs index 4826cf72..0de5e0a2 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs @@ -105,15 +105,9 @@ public static async Task ReadNextChatTurnAsync( StreamReader reader, CancellationToken cancellationToken) { - var payload = await ReadNextEventPayloadAsync( - reader, - UIEdgeContract.ChatTurnEvent, - cancellationToken); - return JsonSerializer.Deserialize( - payload, - EventJsonOptions) - ?? throw new InvalidOperationException( - "SSE chat-turn payload did not deserialize."); + var payload = await ReadNextEventPayloadAsync(reader, UIEdgeContract.ChatTurnEvent, cancellationToken); + return JsonSerializer.Deserialize(payload, EventJsonOptions) + ?? throw new InvalidOperationException("SSE chat-turn payload did not deserialize."); } private static async Task ReadNextEventPayloadAsync( @@ -121,8 +115,7 @@ private static async Task ReadNextEventPayloadAsync( string expectedEvent, CancellationToken cancellationToken) { - using var linked = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); linked.CancelAfter(TimeSpan.FromSeconds(15)); string? dataLine = null; @@ -160,7 +153,6 @@ private static async Task ReadNextEventPayloadAsync( } } - throw new TimeoutException( - $"SSE stream ended before a {expectedEvent} projection arrived."); + throw new TimeoutException($"SSE stream ended before a {expectedEvent} projection arrived."); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs index 39fa5273..0b54a613 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs @@ -33,9 +33,7 @@ public void PublicEdgeVocabularyIsUIEdgeContract() Assert.Equal("/health", UIEdgeContract.HealthPath); Assert.Equal("/shells/{shellName}/scenes", UIEdgeContract.OpenScenePath); Assert.Equal("/shells/{shellName}/events", UIEdgeContract.ShellEventsPath); - Assert.Equal( - "/scenes/{sceneKey}/controls/{controlId}/activate", - UIEdgeContract.ActivateControlPath); + Assert.Equal("/scenes/{sceneKey}/controls/{controlId}/activate", UIEdgeContract.ActivateControlPath); Assert.Equal("afterSequence", UIEdgeContract.AfterSequenceQuery); Assert.Equal("text/event-stream", UIEdgeContract.EventStreamContentType); Assert.Equal("no-cache", UIEdgeContract.CacheControlNoCache); @@ -98,9 +96,7 @@ public async Task MapUIHostServesHealth() await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); using var http = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; - using var health = await http.GetAsync( - new Uri(UIEdgeContract.HealthPath, UriKind.Relative), - cancellationToken); + using var health = await http.GetAsync(new Uri(UIEdgeContract.HealthPath, UriKind.Relative), cancellationToken); Assert.Equal(HttpStatusCode.OK, health.StatusCode); } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs index f17d6c1b..5d80daf3 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs @@ -25,9 +25,7 @@ public static Uri ResolveProductUIBaseAddress() return new Uri(configured.TrimEnd('/') + "/"); } - public static async Task StartUIEdgeAsync( - TestBrain test, - CancellationToken cancellationToken) + public static async Task StartUIEdgeAsync(TestBrain test, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(test); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs index bff2aaee..ab920cb5 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs @@ -15,9 +15,7 @@ internal static class BrainCapabilityCatalog private const string GmailFeature = "google.gmail"; private const string SalesforceFeature = "salesforce"; - internal static IReadOnlyList Resolve( - IReadOnlyList modules, - IConfiguration configuration) + internal static IReadOnlyList Resolve(IReadOnlyList modules, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(modules); ArgumentNullException.ThrowIfNull(configuration); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs index 44cb53bc..c47c4e45 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs @@ -2,8 +2,7 @@ namespace DigitalBrain.UI; internal static class BrainEndpoints { - public static IEndpointRouteBuilder MapBrain( - this IEndpointRouteBuilder endpoints) + public static IEndpointRouteBuilder MapBrain(this IEndpointRouteBuilder endpoints) { ArgumentNullException.ThrowIfNull(endpoints); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs index 92da6d16..dc023cae 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs @@ -10,8 +10,4 @@ internal sealed record BrainModule(string Id); internal sealed record BrainCapability(string Id); -internal sealed record BrainNeuron( - string Id, - string GrainType, - string Identity, - string Placement); +internal sealed record BrainNeuron(string Id, string GrainType, string Identity, string Placement); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs index 8ae317ac..39666363 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs @@ -2,13 +2,9 @@ namespace DigitalBrain.UI; -internal sealed class BrainTopologyReader( - IDigitalBrain brain, - IGrainFactory grains, - IConfiguration configuration) +internal sealed class BrainTopologyReader(IDigitalBrain brain, IGrainFactory grains, IConfiguration configuration) { - public async Task ReadAsync( - CancellationToken cancellationToken) + public async Task ReadAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -42,11 +38,7 @@ public async Task ReadAsync( { var type = statistic.GrainId.Type.ToString()!; var key = statistic.GrainId.Key.ToString()!; - return new BrainNeuron( - $"{type}:{key}", - type, - key, - placements[statistic.SiloAddress]); + return new BrainNeuron($"{type}:{key}", type, key, placements[statistic.SiloAddress]); }) .OrderBy(static neuron => neuron.GrainType, StringComparer.Ordinal) .ThenBy(static neuron => neuron.Identity, StringComparer.Ordinal) diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs index 6d019ba7..26198060 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs @@ -88,21 +88,14 @@ private static IEnumerable ProjectTurns(JournalRead batch) } } - private static Task WriteEventAsync( - Stream responseBody, - ChatTurnEvent turn, - CancellationToken cancellationToken) + private static Task WriteEventAsync(Stream responseBody, ChatTurnEvent turn, CancellationToken cancellationToken) { var payload = JsonSerializer.Serialize(turn, EventJson); - var frame = FormattableString.Invariant( - $"id: {turn.Sequence}\nevent: {UIEdgeContract.ChatTurnEvent}\ndata: {payload}\n\n"); + var frame = FormattableString.Invariant($"id: {turn.Sequence}\nevent: {UIEdgeContract.ChatTurnEvent}\ndata: {payload}\n\n"); return WriteAsync(responseBody, frame, cancellationToken); } - private static async Task WriteAsync( - Stream responseBody, - string text, - CancellationToken cancellationToken) + private static async Task WriteAsync(Stream responseBody, string text, CancellationToken cancellationToken) { var bytes = Utf8.GetBytes(text); await responseBody.WriteAsync(bytes, cancellationToken); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs index 91a049b4..b8f801f0 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs @@ -20,9 +20,7 @@ public static OwnerSessionJournal Open(IGrainFactory grains, OwnerId owner) { ArgumentNullException.ThrowIfNull(grains); - return new OwnerSessionJournal( - grains.GetGrain(ISessionNeuron.ForOwner(owner).ToGrainId()), - owner); + return new OwnerSessionJournal(grains.GetGrain(ISessionNeuron.ForOwner(owner).ToGrainId()), owner); } public Task ReadShellOutgoingAsync(string shellName, long afterSequence) @@ -30,10 +28,7 @@ public Task ReadShellOutgoingAsync(string shellName, long afterSequ ArgumentException.ThrowIfNullOrWhiteSpace(shellName); ArgumentOutOfRangeException.ThrowIfNegative(afterSequence); - return _session.ReadNeuronJournal( - NeuronId.For(_owner, shellName), - JournalKind.Outgoing, - afterSequence); + return _session.ReadNeuronJournal(NeuronId.For(_owner, shellName), JournalKind.Outgoing, afterSequence); } public Task ReadChatOutgoingAsync(string chatName, long afterSequence) @@ -41,9 +36,6 @@ public Task ReadChatOutgoingAsync(string chatName, long afterSequen ArgumentException.ThrowIfNullOrWhiteSpace(chatName); ArgumentOutOfRangeException.ThrowIfNegative(afterSequence); - return _session.ReadNeuronJournal( - NeuronId.For(_owner, chatName), - JournalKind.Outgoing, - afterSequence); + return _session.ReadNeuronJournal(NeuronId.For(_owner, chatName), JournalKind.Outgoing, afterSequence); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs index 65b9d7fd..7134e14e 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs @@ -1,8 +1,3 @@ namespace DigitalBrain.UI; -internal sealed record SceneOpenedEvent( - long Sequence, - string SceneKey, - string Title, - string CommandId, - string Shell); +internal sealed record SceneOpenedEvent(long Sequence, string SceneKey, string Title, string CommandId, string Shell); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs index 84e43618..0a49fc6e 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs @@ -46,21 +46,14 @@ public static async Task WriteSceneOpenedSseAsync( } } - private static Task WriteEventAsync( - Stream responseBody, - SceneOpenedEvent projected, - CancellationToken cancellationToken) + private static Task WriteEventAsync(Stream responseBody, SceneOpenedEvent projected, CancellationToken cancellationToken) { var payload = JsonSerializer.Serialize(projected, EventJson); - var frame = FormattableString.Invariant( - $"id: {projected.Sequence}\nevent: {UIEdgeContract.SceneOpenedEvent}\ndata: {payload}\n\n"); + var frame = FormattableString.Invariant($"id: {projected.Sequence}\nevent: {UIEdgeContract.SceneOpenedEvent}\ndata: {payload}\n\n"); return WriteAsync(responseBody, frame, cancellationToken); } - private static async Task WriteAsync( - Stream responseBody, - string text, - CancellationToken cancellationToken) + private static async Task WriteAsync(Stream responseBody, string text, CancellationToken cancellationToken) { var bytes = Utf8.GetBytes(text); await responseBody.WriteAsync(bytes, cancellationToken); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs index 455e2195..737fc9c5 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs @@ -26,10 +26,7 @@ static async Task ( cancellationToken.ThrowIfCancellationRequested(); var shell = brain.Get(shellName); - await shell.Open(new OpenScene( - CommandId.New(), - request.SceneKey, - request.Title)); + await shell.Open(new OpenScene(CommandId.New(), request.SceneKey, request.Title)); return Results.Accepted(); }); @@ -87,9 +84,7 @@ static async Task ( return Results.BadRequest(); } - await brain.SendAsync( - sceneKey, - new ControlActivated(sceneKey, controlId, request.Intent)); + await brain.SendAsync(sceneKey, new ControlActivated(sceneKey, controlId, request.Intent)); return Results.Accepted(); }); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs index f40d23c0..0b034359 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs @@ -51,16 +51,12 @@ public void ShellAndSceneMethodShapes() Assert.All(shellMethods, method => { Assert.DoesNotContain("Async", method.Name, StringComparison.Ordinal); - Assert.Equal( - method.Name, - method.GetCustomAttribute()?.Alias); + Assert.Equal(method.Name, method.GetCustomAttribute()?.Alias); Assert.Equal(typeof(Task), method.ReturnType); }); var open = shellMethods.Single(); - Assert.Equal( - [typeof(OpenScene)], - open.GetParameters().Select(parameter => parameter.ParameterType)); + Assert.Equal([typeof(OpenScene)], open.GetParameters().Select(parameter => parameter.ParameterType)); Assert.Contains(typeof(INeuron), typeof(IShell).GetInterfaces()); Assert.Contains(typeof(INeuron), typeof(IScene).GetInterfaces()); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs index f2abb517..a904919f 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs @@ -11,10 +11,7 @@ public async Task OpenJournalsSceneOpenedOnTheShell() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var shell = test.Neuron(FlutterFixture.ShellName); - var command = new OpenScene( - CommandId.New(), - FlutterFixture.HomeSceneKey, - FlutterFixture.HomeSceneTitle); + var command = new OpenScene(CommandId.New(), FlutterFixture.HomeSceneKey, FlutterFixture.HomeSceneTitle); await shell.Reference.Open(command); @@ -61,18 +58,12 @@ await shell.Reference.Open(new OpenScene( var opened = await shell.Outgoing.NextAsync(cancellationToken); Assert.Equal(FlutterFixture.HomeSceneKey, opened.Synapse.SceneKey); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Incoming.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Incoming.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = @@ -85,27 +76,14 @@ public async Task OpenRejectsBlankSceneKeyAndTitle() var commandId = CommandId.New(); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - string.Empty, - FlutterFixture.HomeSceneTitle))); + shell.Reference.Open(new OpenScene(commandId, string.Empty, FlutterFixture.HomeSceneTitle))); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - " ", - FlutterFixture.HomeSceneTitle))); + shell.Reference.Open(new OpenScene(commandId, " ", FlutterFixture.HomeSceneTitle))); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - FlutterFixture.HomeSceneKey, - string.Empty))); + shell.Reference.Open(new OpenScene(commandId, FlutterFixture.HomeSceneKey, string.Empty))); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - FlutterFixture.HomeSceneKey, - " "))); + shell.Reference.Open(new OpenScene(commandId, FlutterFixture.HomeSceneKey, " "))); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs index 01019430..fa65df5d 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs @@ -9,9 +9,7 @@ internal sealed class SceneNeuron : IScene, IHandle { - public Task HandleAsync( - ControlActivated synapse, - CancellationToken cancellationToken) + public Task HandleAsync(ControlActivated synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); ArgumentException.ThrowIfNullOrWhiteSpace(synapse.SceneKey); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs index a696d534..b61ea3b5 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs @@ -12,9 +12,7 @@ internal sealed class ShellBootNeuron : public const string HomeSceneKey = "home"; public const string HomeSceneTitle = "Home"; - public Task HandleAsync( - DigitalBrainActivated synapse, - CancellationToken cancellationToken) + public Task HandleAsync(DigitalBrainActivated synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); @@ -24,12 +22,8 @@ public Task HandleAsync( return Task.CompletedTask; } - var shell = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, DefaultShellName).ToGrainId()); + var shell = GrainFactory.GetGrain(NeuronId.For(Id.Owner, DefaultShellName).ToGrainId()); - return shell.Open(new OpenScene( - CommandId.New(), - HomeSceneKey, - HomeSceneTitle)); + return shell.Open(new OpenScene(CommandId.New(), HomeSceneKey, HomeSceneTitle)); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs index c8e6b61f..ba295e65 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs @@ -15,10 +15,6 @@ public Task Open(OpenScene command) ArgumentException.ThrowIfNullOrWhiteSpace(command.SceneKey); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - return EmitAsync(new SceneOpened( - command.CommandId, - Id, - command.SceneKey, - command.Title)); + return EmitAsync(new SceneOpened(command.CommandId, Id, command.SceneKey, command.Title)); } } diff --git a/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs b/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs index afb87f25..cd707861 100644 --- a/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs +++ b/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs @@ -14,8 +14,7 @@ public static class GoogleHostingExtensions "OAuth client secret from [Google Auth Platform](https://console.cloud.google.com/auth/clients).", "OAuth callback URI registered on the Google client. Use an HTTP loopback callback only with the explicit local development authorization mode."); - public static DigitalBrainModuleBuilder WithGmail( - this DigitalBrainModuleBuilder module) + public static DigitalBrainModuleBuilder WithGmail(this DigitalBrainModuleBuilder module) { ArgumentNullException.ThrowIfNull(module); diff --git a/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs b/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs index fe7c240d..d9f9d125 100644 --- a/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs +++ b/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs @@ -5,7 +5,5 @@ namespace DigitalBrain.Google; public partial interface IGmail : INeuron { [Alias(nameof(ReadMessage))] - Task ReadMessage( - string messageId, - CancellationToken cancellationToken); + Task ReadMessage(string messageId, CancellationToken cancellationToken); } diff --git a/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs b/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs index 13878a44..bd12f78f 100644 --- a/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs +++ b/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs @@ -27,9 +27,7 @@ public Gmail(McpRuntime runtime) _durableIdentity = Id.ToString(); } - public async Task ReadMessage( - string messageId, - CancellationToken cancellationToken) + public async Task ReadMessage(string messageId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(messageId); diff --git a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs index f53fc0af..c25c1ed8 100644 --- a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs +++ b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs @@ -91,9 +91,7 @@ public async Task ApproveAccountDescriptio return Receipt(mutation); } - private static void EnsureSameApproval( - MutationData mutation, - SalesforceMutationApproval approval) + private static void EnsureSameApproval(MutationData mutation, SalesforceMutationApproval approval) { if (mutation.Approval != approval) { @@ -118,22 +116,16 @@ private static void ValidateApprovalEvidence( } } - private void ValidateApproval( - SalesforceMutationApproval approval, - SynapseDelivery approvalEvidence) + private void ValidateApproval(SalesforceMutationApproval approval, SynapseDelivery approvalEvidence) { if (approval.CommandId == default) { - throw new ArgumentException( - "A mutation command identity is required.", - nameof(approval)); + throw new ArgumentException("A mutation command identity is required.", nameof(approval)); } if (approval.ApprovalId == Guid.Empty || approvalEvidence.SynapseId == default) { - throw new ArgumentException( - "Durable approval identity and evidence are required.", - nameof(approval)); + throw new ArgumentException("Durable approval identity and evidence are required.", nameof(approval)); } ArgumentException.ThrowIfNullOrWhiteSpace(approval.Fingerprint); diff --git a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs index f74d7ecf..70e9258f 100644 --- a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs +++ b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs @@ -7,9 +7,7 @@ namespace DigitalBrain.Salesforce; internal sealed partial class Salesforce { - private async Task InvokeUpdateAsync( - MutationData mutation, - CancellationToken cancellationToken) + private async Task InvokeUpdateAsync(MutationData mutation, CancellationToken cancellationToken) => await _runtime.RunAsync( Server, _tokenState, @@ -32,10 +30,7 @@ private async Task InvokeUpdateAsync( var result = await updateTool.Tool.CallAsync( UpdateArguments(mutation), cancellationToken: callbackCancellation); - var content = McpRuntime.RequireStructuredContent( - result, - Server, - UpdateAccountName); + var content = McpRuntime.RequireStructuredContent(result, Server, UpdateAccountName); return mutation with { @@ -72,9 +67,7 @@ private async Task ReconcileBoundedAsync(MutationData mutation) "Design", "CA1031:Do not catch general exception types", Justification = "Reconciliation is best effort; inability to prove the provider state must durably become OutcomeUncertain.")] - private async Task ReconcileAsync( - MutationData mutation, - CancellationToken cancellationToken) + private async Task ReconcileAsync(MutationData mutation, CancellationToken cancellationToken) { try { @@ -196,10 +189,7 @@ private static SelectedTool SelectQueryTool(IList tools) => idempotent: true, openWorld: false)); - private static SelectedTool SelectTool( - IList tools, - string name, - Func compatible) + private static SelectedTool SelectTool(IList tools, string name, Func compatible) { var matches = tools .Where(candidate => string.Equals(candidate.Name, name, StringComparison.Ordinal)) @@ -228,9 +218,7 @@ private static SelectedTool SelectTool( annotations?.OpenWorldHint)); } - private static bool HasExactObjectSchema( - JsonElement? schema, - params (string Name, string Type)[] expected) + private static bool HasExactObjectSchema(JsonElement? schema, params (string Name, string Type)[] expected) { if (schema is not { ValueKind: JsonValueKind.Object } value || !value.TryGetProperty("type", out var schemaType) diff --git a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs index 43cc0cef..c940a53c 100644 --- a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs +++ b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs @@ -29,8 +29,7 @@ internal sealed partial class Salesforce : Neuron, ISalesforce public Salesforce(McpRuntime runtime) { _runtime = runtime; - _mutations = ServiceProvider.GetRequiredKeyedService>( - MutationsName); + _mutations = ServiceProvider.GetRequiredKeyedService>(MutationsName); _states = ServiceProvider.GetRequiredService>(); _tokenState = ServiceProvider.GetRequiredKeyedService>(TokensName); _durableIdentity = Id.ToString(); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs index b2fdfb79..9476d76f 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs @@ -10,10 +10,7 @@ public async Task CancelMovesThroughCancellingToCancelled() { var cancellationToken = TestContext.Current.CancellationToken; await using var brain = await fixture.CreateBrainAsync(cancellationToken); - var (worker, task, started) = await StartAsync( - brain, - "cancel", - new TestGoal("cancel-me")); + var (worker, task, started) = await StartAsync(brain, "cancel", new TestGoal("cancel-me")); var running = await AcceptThenRunningAsync(task, cancellationToken); var cancel = new CancelTask(CommandId.New(), running.Revision); @@ -24,19 +21,10 @@ public async Task CancelMovesThroughCancellingToCancelled() Assert.Equal(running.ActiveAttempt, cancelling.ActiveAttempt); AssertReceipt(cancelling, await task.Reference.Cancel(cancel)); - var cancelledFact = await task.Incoming.NextAsync( - cancellationToken); - AssertAttempt( - cancelledFact, - task.Id, - worker.Id, - running.ActiveAttempt, - running.Revision); + var cancelledFact = await task.Incoming.NextAsync(cancellationToken); + AssertAttempt(cancelledFact, task.Id, worker.Id, running.ActiveAttempt, running.Revision); - var cancelled = await WaitForStateAsync( - task, - TaskState.Cancelled, - cancellationToken); + var cancelled = await WaitForStateAsync(task, TaskState.Cancelled, cancellationToken); Assert.Null(cancelled.ActiveAttempt); Assert.Null(cancelled.Blocker); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs index 47c0aef8..9b013579 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs @@ -13,28 +13,14 @@ public async Task MatchingAttemptSucceededMovesTaskToSucceededWithResultAndEvide var (worker, task, started) = await StartAsync(brain, "success", goal); var accepted = await task.Incoming.NextAsync(cancellationToken); - AssertAttempt( - accepted, - task.Id, - worker.Id, - started.ActiveAttempt, - started.Revision); - - var succeededFact = await task.Incoming.NextAsync( - cancellationToken); - AssertAttempt( - succeededFact, - task.Id, - worker.Id, - started.ActiveAttempt, - started.Revision); + AssertAttempt(accepted, task.Id, worker.Id, started.ActiveAttempt, started.Revision); + + var succeededFact = await task.Incoming.NextAsync(cancellationToken); + AssertAttempt(succeededFact, task.Id, worker.Id, started.ActiveAttempt, started.Revision); Assert.Equal(TaskFixtures.Done, succeededFact.Synapse.Result); Assert.NotEmpty(succeededFact.Synapse.Evidence); - var succeeded = await WaitForStateAsync( - task, - TaskState.Succeeded, - cancellationToken); + var succeeded = await WaitForStateAsync(task, TaskState.Succeeded, cancellationToken); Assert.Null(succeeded.ActiveAttempt); Assert.Null(succeeded.Blocker); @@ -50,10 +36,7 @@ public async Task StaleRevisionAttemptFactsAreIgnored() { var cancellationToken = TestContext.Current.CancellationToken; await using var brain = await fixture.CreateBrainAsync(cancellationToken); - var (_, task, started) = await StartAsync( - brain, - "stale", - new StaleProbeGoal("stale")); + var (_, task, started) = await StartAsync(brain, "stale", new StaleProbeGoal("stale")); var running = await AcceptThenRunningAsync(task, cancellationToken); var stale = await task.Incoming.NextAsync(cancellationToken); @@ -81,8 +64,7 @@ public async Task RetryableAttemptFailedSchedulesRetryThenNewAccept() new RetryableFailureGoal("retry-me"), TaskFixtures.TwoAttempts); - var firstAccepted = await task.Incoming.NextAsync( - cancellationToken); + var firstAccepted = await task.Incoming.NextAsync(cancellationToken); Assert.Equal(started.ActiveAttempt, firstAccepted.Synapse.Attempt); Assert.Equal(started.Revision, firstAccepted.Synapse.Revision); @@ -92,10 +74,7 @@ public async Task RetryableAttemptFailedSchedulesRetryThenNewAccept() Assert.True(failed.Synapse.Retryable); Assert.Equal(TaskFixtures.Retryable, failed.Synapse.Failure); - var waiting = await WaitForStateAsync( - task, - TaskState.Waiting, - cancellationToken); + var waiting = await WaitForStateAsync(task, TaskState.Waiting, cancellationToken); Assert.IsType(waiting.Blocker); Assert.Null(waiting.ActiveAttempt); Assert.Equal(TaskFixtures.Retryable, waiting.Failure); @@ -103,16 +82,12 @@ public async Task RetryableAttemptFailedSchedulesRetryThenNewAccept() await brain.Clock.AdvanceAsync(TaskFixtures.RetryDelay, cancellationToken); - var secondAccepted = await task.Incoming.NextAsync( - cancellationToken); + var secondAccepted = await task.Incoming.NextAsync(cancellationToken); Assert.NotEqual(started.ActiveAttempt, secondAccepted.Synapse.Attempt); Assert.Equal(started.Revision + 1, secondAccepted.Synapse.Revision); Assert.Equal(worker.Id, secondAccepted.Synapse.Worker); - var secondRunning = await WaitForStateAsync( - task, - TaskState.Running, - cancellationToken); + var secondRunning = await WaitForStateAsync(task, TaskState.Running, cancellationToken); Assert.Equal(secondAccepted.Synapse.Attempt, secondRunning.ActiveAttempt); Assert.Equal(secondAccepted.Synapse.Revision, secondRunning.Revision); Assert.Null(secondRunning.Blocker); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs index 7f950e7c..e2aca3ab 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs @@ -19,17 +19,9 @@ public async Task StartDispatchesAcceptAndAttemptAcceptedMovesTaskToRunning() Assert.NotNull(started.ActiveAttempt); var accepted = await task.Incoming.NextAsync(cancellationToken); - AssertAttempt( - accepted, - task.Id, - worker.Id, - started.ActiveAttempt, - started.Revision); + AssertAttempt(accepted, task.Id, worker.Id, started.ActiveAttempt, started.Revision); - var running = await WaitForStateAsync( - task, - TaskState.Running, - cancellationToken); + var running = await WaitForStateAsync(task, TaskState.Running, cancellationToken); Assert.Equal(started.ActiveAttempt, running.ActiveAttempt); Assert.Equal(started.Revision, running.Revision); @@ -60,8 +52,6 @@ await Assert.ThrowsAsync( new TestGoal("second-start"), worker.Id))); - Assert.Equal( - TaskState.Running, - (await task.Reference.Read()).State); + Assert.Equal(TaskState.Running, (await task.Reference.Read()).State); } } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs index 05b116c4..297d0455 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs @@ -6,15 +6,8 @@ namespace DigitalBrain.Tasks.Tests; public sealed partial class TaskLifecycle(TasksFixture fixture) { - private static StartTask StartCommand( - Goal goal, - NeuronId worker, - TaskPolicy? policy = null) - => new( - CommandId.New(), - goal, - worker, - policy ?? TaskFixtures.SingleAttempt); + private static StartTask StartCommand(Goal goal, NeuronId worker, TaskPolicy? policy = null) + => new(CommandId.New(), goal, worker, policy ?? TaskFixtures.SingleAttempt); private static async Task<( TestNeuron Worker, @@ -27,8 +20,7 @@ private static StartTask StartCommand( { var worker = brain.Neuron($"{name}-worker"); var task = brain.Neuron($"{name}-task"); - var started = await task.Reference.Start( - StartCommand(goal, worker.Id, policy)); + var started = await task.Reference.Start(StartCommand(goal, worker.Id, policy)); return (worker, task, started); } @@ -56,9 +48,7 @@ private static async Task WaitForStateAsync( $"Task '{task.Id}' stayed in {final.State} instead of becoming {expected}."); } - private static async Task AcceptThenRunningAsync( - TestNeuron task, - CancellationToken cancellationToken) + private static async Task AcceptThenRunningAsync(TestNeuron task, CancellationToken cancellationToken) { _ = await task.Incoming.NextAsync(cancellationToken); return await WaitForStateAsync(task, TaskState.Running, cancellationToken); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs index cb21d781..0ffe1c8b 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs @@ -16,11 +16,7 @@ public async Task Accept(AttemptRequest request) await SendAsync( request.Task, - new AttemptAccepted( - request.Task, - request.Worker, - request.Attempt, - request.Revision)); + new AttemptAccepted(request.Task, request.Worker, request.Attempt, request.Revision)); switch (request.Goal) { @@ -75,12 +71,6 @@ public Task Cancel(AttemptCursor cursor) { ArgumentNullException.ThrowIfNull(cursor); - return SendAsync( - cursor.Task, - new AttemptCancelled( - cursor.Task, - cursor.Worker, - cursor.Attempt, - cursor.Revision)); + return SendAsync(cursor.Task, new AttemptCancelled(cursor.Task, cursor.Worker, cursor.Attempt, cursor.Revision)); } } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs index 09d2fb5d..97716189 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs @@ -36,15 +36,9 @@ public static class TaskFixtures public static readonly TestResult StaleSuccess = new("stale-success"); public static readonly TestFailure Retryable = new("retryable"); - public static readonly TaskPolicy SingleAttempt = new( - MaximumAttempts: 1, - RetryDelay: TimeSpan.FromSeconds(1), - Deadline: null); + public static readonly TaskPolicy SingleAttempt = new(MaximumAttempts: 1, RetryDelay: TimeSpan.FromSeconds(1), Deadline: null); public static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30); - public static readonly TaskPolicy TwoAttempts = new( - MaximumAttempts: 2, - RetryDelay: RetryDelay, - Deadline: null); + public static readonly TaskPolicy TwoAttempts = new(MaximumAttempts: 2, RetryDelay: RetryDelay, Deadline: null); } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs index d4a95b56..c6fb28bb 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs @@ -130,10 +130,7 @@ public async Task HandleAsync(AttemptFailed fact, CancellationToken cancellation { data.State = TaskState.Waiting; data.Blocker = new RetryScheduled(new BlockerId(Guid.NewGuid())); - await this.RegisterOrUpdateReminder( - RetryReminderName, - data.Policy.RetryDelay, - ReminderPeriod); + await this.RegisterOrUpdateReminder(RetryReminderName, data.Policy.RetryDelay, ReminderPeriod); Stage(data); return; } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs index b571a37e..1b8aec3e 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs @@ -4,10 +4,7 @@ namespace DigitalBrain.Tasks; internal sealed partial class TaskNeuron { private Task RegisterDispatchReminderAsync() - => this.RegisterOrUpdateReminder( - DispatchReminderName, - TimeSpan.FromSeconds(1), - ReminderPeriod); + => this.RegisterOrUpdateReminder(DispatchReminderName, TimeSpan.FromSeconds(1), ReminderPeriod); private async Task UnregisterReminderAsync(string reminderName) { diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs index ed3b61f7..2e06cbc2 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs @@ -74,16 +74,12 @@ private static void Validate(StartTask command) if (command.Policy.MaximumAttempts <= 0) { - throw new ArgumentOutOfRangeException( - nameof(command), - "A task policy must allow at least one attempt."); + throw new ArgumentOutOfRangeException(nameof(command), "A task policy must allow at least one attempt."); } if (command.Policy.RetryDelay < TimeSpan.Zero) { - throw new ArgumentOutOfRangeException( - nameof(command), - "A task retry delay cannot be negative."); + throw new ArgumentOutOfRangeException(nameof(command), "A task retry delay cannot be negative."); } if (command.Worker == default) diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs index 69da5b34..5a4da34a 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs @@ -13,24 +13,15 @@ public async Task ClientEntryPointStartDoesNotJournalCapabilityFacts() await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination) = TimeFixture.Pair(test); - var started = await TimeFixture.Start( - countdown, - destination, - TimeSpan.FromHours(1)); + var started = await TimeFixture.Start(countdown, destination, TimeSpan.FromHours(1)); Assert.Equal(CountdownStatus.Scheduled, started.Status); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Incoming.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Incoming.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Incoming.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs index 9117d18f..176f1338 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs @@ -15,30 +15,19 @@ public async Task EmptyCommandIdIsRejected() var empty = default(CommandId); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - empty, - Hour, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(empty, Hour, destination.Id))); var started = await TimeFixture.Start(countdown, destination, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - empty, - started.Revision, - Hour))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(empty, started.Revision, Hour))); await Assert.ThrowsAsync( - () => countdown.Reference.Cancel(new CancelCountdown( - empty, - started.Revision))); + () => countdown.Reference.Cancel(new CancelCountdown(empty, started.Revision))); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown(CommandId.New(), started.Revision)); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - empty, - Hour))); + () => countdown.Reference.Restart(new RestartCountdown(empty, Hour))); Assert.Equal(cancelled, await countdown.Reference.Read()); } @@ -52,40 +41,23 @@ public async Task DurationMustBePositiveAndWithinRange() var (countdown, destination) = TimeFixture.Pair(test); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - TimeSpan.Zero, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), TimeSpan.Zero, destination.Id))); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - TimeSpan.FromTicks(-1), - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), TimeSpan.FromTicks(-1), destination.Id))); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - TimeSpan.MaxValue, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), TimeSpan.MaxValue, destination.Id))); var started = await TimeFixture.Start(countdown, destination, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - CommandId.New(), - started.Revision, - TimeSpan.Zero))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(CommandId.New(), started.Revision, TimeSpan.Zero))); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown(CommandId.New(), started.Revision)); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - TimeSpan.FromTicks(-1)))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TimeSpan.FromTicks(-1)))); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - TimeSpan.MaxValue))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TimeSpan.MaxValue))); Assert.Equal(cancelled, await countdown.Reference.Read()); } @@ -100,13 +72,8 @@ public async Task DestinationMustBelongToTheCountdownOwner() var foreign = test.Owner("foreign").Neuron(TimeFixture.Destination); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - Hour, - foreign.Id))); - - Assert.Equal( - CountdownStatus.Unscheduled, - (await countdown.Reference.Read()).Status); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), Hour, foreign.Id))); + + Assert.Equal(CountdownStatus.Unscheduled, (await countdown.Reference.Read()).Status); } } diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs index a524e8f9..1b4bc711 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs @@ -24,10 +24,7 @@ public async Task StartIsIdempotentAndAllowedOnlyFromUnscheduled() Assert.Equal(0, empty.Revision); Assert.Null(empty.Destination); - var command = new StartCountdown( - CommandId.New(), - Hour, - destination.Id); + var command = new StartCountdown(CommandId.New(), Hour, destination.Id); var started = await countdown.Reference.Start(command); var repeated = await countdown.Reference.Start(command); @@ -41,10 +38,7 @@ public async Task StartIsIdempotentAndAllowedOnlyFromUnscheduled() Assert.Equal(Hour, started.Duration); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - Hour, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), Hour, destination.Id))); } [Fact(DisplayName = @@ -77,12 +71,10 @@ await Assert.ThrowsAsync( Assert.Equal(test.Clock.UtcNow + Hour, rescheduled.DueAt); await test.Clock.AdvanceAsync(HalfHour, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); await test.Clock.AdvanceAsync(HalfHour, cancellationToken); - var elapsed = await destination.Incoming.NextAsync( - cancellationToken); + var elapsed = await destination.Incoming.NextAsync(cancellationToken); Assert.Equal(rescheduled.Generation, elapsed.Synapse.Generation); Assert.Equal(rescheduled.Revision, elapsed.Synapse.Revision); @@ -97,13 +89,9 @@ public async Task CancelUsesTheExactRevisionAndIsTerminal() var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Cancel(new CancelCountdown( - CommandId.New(), - ExpectedRevision: started.Revision + 1))); + () => countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), ExpectedRevision: started.Revision + 1))); - var command = new CancelCountdown( - CommandId.New(), - started.Revision); + var command = new CancelCountdown(CommandId.New(), started.Revision); var cancelled = await countdown.Reference.Cancel(command); var repeated = await countdown.Reference.Cancel(command); @@ -113,23 +101,14 @@ await Assert.ThrowsAsync( Assert.Equal(started.Revision, cancelled.Revision); await Assert.ThrowsAsync( - () => countdown.Reference.Cancel(new CancelCountdown( - CommandId.New(), - cancelled.Revision))); + () => countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), cancelled.Revision))); await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - CommandId.New(), - cancelled.Revision, - Hour))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(CommandId.New(), cancelled.Revision, Hour))); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - Hour, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), Hour, destination.Id))); await test.Clock.AdvanceAsync(TwoHours, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = @@ -141,16 +120,10 @@ public async Task RestartRetainsDestinationAndStartsANewGeneration() var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - Hour))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), Hour))); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown(CommandId.New(), started.Revision)); - var restarted = await countdown.Reference.Restart( - new RestartCountdown( - CommandId.New(), - TwoHours)); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); + var restarted = await countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TwoHours)); Assert.Equal(CountdownStatus.Scheduled, restarted.Status); Assert.Equal(cancelled.Generation + 1, restarted.Generation); @@ -169,13 +142,9 @@ public async Task RestartIsAllowedAfterElapsed() var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); await test.Clock.AdvanceAsync(Hour, cancellationToken); - _ = await destination.Incoming.NextAsync( - cancellationToken); + _ = await destination.Incoming.NextAsync(cancellationToken); - var restarted = await countdown.Reference.Restart( - new RestartCountdown( - CommandId.New(), - TwoHours)); + var restarted = await countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TwoHours)); Assert.Equal(CountdownStatus.Scheduled, restarted.Status); Assert.Equal(started.Generation + 1, restarted.Generation); @@ -190,15 +159,11 @@ public async Task CountdownEmitsExactlyOnceAtItsDueInstant() await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); - await test.Clock.AdvanceAsync( - TimeSpan.FromMinutes(59), - cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + await test.Clock.AdvanceAsync(TimeSpan.FromMinutes(59), cancellationToken); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - var elapsed = await destination.Incoming.NextAsync( - cancellationToken); + var elapsed = await destination.Incoming.NextAsync(cancellationToken); Assert.Equal(countdown.Id, elapsed.Synapse.Countdown); Assert.Equal(started.Generation, elapsed.Synapse.Generation); @@ -208,13 +173,10 @@ await test.Clock.AdvanceAsync( Assert.Equal(started.DueAt, elapsed.Synapse.DueAt); Assert.Equal(test.Clock.UtcNow, elapsed.Synapse.ObservedAt); Assert.Equal(CountdownResolution.OnTime, elapsed.Synapse.Resolution); - Assert.Equal( - CountdownStatus.Elapsed, - (await countdown.Reference.Read()).Status); + Assert.Equal(CountdownStatus.Elapsed, (await countdown.Reference.Read()).Status); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Single(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Single(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = "Read returns the committed snapshot after a hosting silo restart")] diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs index ba1a1671..6ed4ab15 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs @@ -99,8 +99,7 @@ public async Task CommittedElapsedSurvivesAnotherHostRestartWithoutDuplication() await countdown.RestartHostAsync(cancellationToken); await test.Clock.AdvanceAsync(TimeSpan.FromMinutes(2), cancellationToken); - var committed = await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken); + var committed = await destination.Incoming.ReadAsync(cancellationToken: cancellationToken); Assert.Single(committed); Assert.Equal(first.SynapseId, committed[0].SynapseId); @@ -113,8 +112,7 @@ public async Task FailedStartCommitLeavesUnscheduledWithoutElapsed() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination) = TimeFixture.Pair(test); - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.StartStateCommitFailure); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.StartStateCommitFailure); var failure = await Assert.ThrowsAsync( () => TimeFixture.Start(countdown, destination, OneHour)); @@ -126,8 +124,7 @@ public async Task FailedStartCommitLeavesUnscheduledWithoutElapsed() await test.Clock.AdvanceAsync(OneHour, cancellationToken); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = @@ -137,20 +134,15 @@ public async Task FailedRescheduleCommitKeepsCommittedSchedule() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination, started) = await TimeFixture.Schedule(test, TwoHours); - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.RescheduleStateCommitFailure); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.RescheduleStateCommitFailure); var failure = await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - CommandId.New(), - started.Revision, - OneHour))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(CommandId.New(), started.Revision, OneHour))); Assert.Equal(TimeFixture.RescheduleStateCommitFailure, failure.Message); Assert.Equal(started, await countdown.Reference.Read()); await test.Clock.AdvanceAsync(OneHour, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); await test.Clock.AdvanceAsync(OneHour, cancellationToken); await AssertElapsed(destination, started, CountdownResolution.OnTime, cancellationToken); @@ -164,25 +156,18 @@ public async Task FailedRestartCommitKeepsCancelledWithoutElapsed() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination, started) = await TimeFixture.Schedule(test, TwoHours); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown( - CommandId.New(), - started.Revision)); - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.RestartStateCommitFailure); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.RestartStateCommitFailure); var failure = await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - OneHour))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), OneHour))); Assert.Equal(TimeFixture.RestartStateCommitFailure, failure.Message); Assert.Equal(cancelled, await countdown.Reference.Read()); await test.Clock.AdvanceAsync(OneHour, cancellationToken); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); Assert.Equal(cancelled, await countdown.Reference.Read()); } @@ -192,16 +177,14 @@ private static async Task AssertElapsedCommitFails( TestNeuron destination, CancellationToken cancellationToken) { - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.OccurrenceCommitFailure); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.OccurrenceCommitFailure); var failure = await Assert.ThrowsAsync( () => test.Clock.AdvanceAsync(OneHour, cancellationToken)); Assert.Equal( TimeFixture.OccurrenceCommitFailure, failure.InnerException?.Message); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } private static async Task> AssertElapsed( @@ -210,8 +193,7 @@ private static async Task> AssertElapsed( CountdownResolution resolution, CancellationToken cancellationToken) { - var elapsed = await destination.Incoming.NextAsync( - cancellationToken); + var elapsed = await destination.Incoming.NextAsync(cancellationToken); Assert.Equal(started.Generation, elapsed.Synapse.Generation); Assert.Equal(started.Revision, elapsed.Synapse.Revision); Assert.Equal(resolution, elapsed.Synapse.Resolution); @@ -224,7 +206,6 @@ private static async Task AssertNoFurtherElapsed( CancellationToken cancellationToken) { await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Single(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Single(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs index accb405b..59bfd797 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs @@ -27,10 +27,7 @@ public static Task Start( { ArgumentNullException.ThrowIfNull(countdown); ArgumentNullException.ThrowIfNull(destination); - return countdown.Reference.Start(new StartCountdown( - CommandId.New(), - duration, - destination.Id)); + return countdown.Reference.Start(new StartCountdown(CommandId.New(), duration, destination.Id)); } public static async Task<( diff --git a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs index 36538878..4f863ffa 100644 --- a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs +++ b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs @@ -26,9 +26,7 @@ private async Task ElapseIfDue(long generation, long revision) var observedAt = TimeProvider.GetUtcNow(); if (observedAt < data.DueAt) { - await RegisterReminderAsync( - reminderName, - data.DueAt - observedAt); + await RegisterReminderAsync(reminderName, data.DueAt - observedAt); return; } @@ -72,13 +70,8 @@ await SendAsync( await RetireReminderAsync(reminderName); } - private Task RegisterReminderAsync( - string reminderName, - TimeSpan dueTime) - => this.RegisterOrUpdateReminder( - reminderName, - dueTime, - ReminderPeriod); + private Task RegisterReminderAsync(string reminderName, TimeSpan dueTime) + => this.RegisterOrUpdateReminder(reminderName, dueTime, ReminderPeriod); private async Task RetireReminderAsync(string? reminderName) { @@ -90,21 +83,14 @@ private async Task RetireReminderAsync(string? reminderName) } private static string ReminderName(long generation, long revision) - => string.Create( - CultureInfo.InvariantCulture, - $"{ReminderPrefix}{generation}.{revision}"); + => string.Create(CultureInfo.InvariantCulture, $"{ReminderPrefix}{generation}.{revision}"); - private static bool TryParseReminderName( - string reminderName, - out long generation, - out long revision) + private static bool TryParseReminderName(string reminderName, out long generation, out long revision) { generation = 0; revision = 0; - if (!reminderName.StartsWith( - ReminderPrefix, - StringComparison.Ordinal)) + if (!reminderName.StartsWith(ReminderPrefix, StringComparison.Ordinal)) { return false; } @@ -114,16 +100,8 @@ private static bool TryParseReminderName( return separator > 0 && separator == suffix.LastIndexOf('.', StringComparison.Ordinal) - && long.TryParse( - suffix.AsSpan(0, separator), - NumberStyles.None, - CultureInfo.InvariantCulture, - out generation) - && long.TryParse( - suffix.AsSpan(separator + 1), - NumberStyles.None, - CultureInfo.InvariantCulture, - out revision) + && long.TryParse(suffix.AsSpan(0, separator), NumberStyles.None, CultureInfo.InvariantCulture, out generation) + && long.TryParse(suffix.AsSpan(separator + 1), NumberStyles.None, CultureInfo.InvariantCulture, out revision) && generation > 0 && revision > 0; } diff --git a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs index ea87a54e..c5f9a9c3 100644 --- a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs +++ b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs @@ -17,9 +17,7 @@ private CountdownState Load() private void Stage(CountdownState data) => _state.Value = _states.SerializeToArray(data); - private async Task SaveAsync( - CountdownState data, - byte[] rollbackState) + private async Task SaveAsync(CountdownState data, byte[] rollbackState) { Stage(data); @@ -42,10 +40,7 @@ private byte[] SerializedState() private void RestoreState(byte[] serialized) => _state.Value = serialized; - private static bool TryReceipt( - CountdownState? data, - CommandId commandId, - out CountdownSnapshot snapshot) + private static bool TryReceipt(CountdownState? data, CommandId commandId, out CountdownSnapshot snapshot) { if (data is not null && data.Receipts.TryGetValue(commandId, out var received)) @@ -58,10 +53,7 @@ private static bool TryReceipt( return false; } - private static void Remember( - CountdownState data, - CommandId commandId, - CountdownSnapshot snapshot) + private static void Remember(CountdownState data, CommandId commandId, CountdownSnapshot snapshot) { while (data.Receipts.Count >= MaximumReceipts) { @@ -116,15 +108,11 @@ private static void Validate(CommandId commandId) { if (commandId.Value == Guid.Empty) { - throw new ArgumentException( - "A command id is required.", - nameof(commandId)); + throw new ArgumentException("A command id is required.", nameof(commandId)); } } - private static DateTimeOffset DueAt( - DateTimeOffset scheduledAt, - TimeSpan duration) + private static DateTimeOffset DueAt(DateTimeOffset scheduledAt, TimeSpan duration) { if (duration <= TimeSpan.Zero) { @@ -155,9 +143,7 @@ private void RequireScheduled(CountdownState data) } } - private void RequireRevision( - CountdownState data, - long expectedRevision) + private void RequireRevision(CountdownState data, long expectedRevision) { if (data.Revision != expectedRevision) { diff --git a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs index ef63c91c..4ba4d93b 100644 --- a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs +++ b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs @@ -196,14 +196,9 @@ public async Task Restart(RestartCountdown command) return snapshot; } - async Task IRemindable.ReceiveReminder( - string reminderName, - TickStatus status) + async Task IRemindable.ReceiveReminder(string reminderName, TickStatus status) { - if (!TryParseReminderName( - reminderName, - out var generation, - out var revision)) + if (!TryParseReminderName(reminderName, out var generation, out var revision)) { throw new InvalidOperationException( $"Countdown neuron '{Id}' does not own reminder '{reminderName}'."); diff --git a/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs b/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs index aa42f834..58c538ce 100644 --- a/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs +++ b/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs @@ -23,14 +23,11 @@ internal sealed class AccountEnrichment : public AccountEnrichment() { - _requests = ServiceProvider.GetRequiredKeyedService>( - RequestsName); + _requests = ServiceProvider.GetRequiredKeyedService>(RequestsName); _states = ServiceProvider.GetRequiredService>(); } - public async Task HandleAsync( - EnrichAccountFromEmail synapse, - CancellationToken cancellationToken) + public async Task HandleAsync(EnrichAccountFromEmail synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); Validate(synapse); @@ -41,10 +38,8 @@ public async Task HandleAsync( return; } - var gmail = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, synapse.GmailAccount).ToGrainId()); - var salesforce = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, "salesforce").ToGrainId()); + var gmail = GrainFactory.GetGrain(NeuronId.For(Id.Owner, synapse.GmailAccount).ToGrainId()); + var salesforce = GrainFactory.GetGrain(NeuronId.For(Id.Owner, "salesforce").ToGrainId()); var message = await gmail.ReadMessage(synapse.MessageId, cancellationToken); var description = @@ -74,25 +69,19 @@ await EmitAsync(new AccountEnrichmentProposed( mutation.Fingerprint)); } - public Task HandleAsync( - SalesforceMutationApproval synapse, - CancellationToken cancellationToken) + public Task HandleAsync(SalesforceMutationApproval synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); var request = Load(synapse.CommandId) - ?? throw new InvalidOperationException( - $"Account enrichment '{synapse.CommandId}' has no durable request."); + ?? throw new InvalidOperationException($"Account enrichment '{synapse.CommandId}' has no durable request."); if (request.Completed) { return Task.CompletedTask; } - if (!string.Equals( - request.MutationFingerprint, - synapse.Fingerprint, - StringComparison.Ordinal)) + if (!string.Equals(request.MutationFingerprint, synapse.Fingerprint, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Salesforce approval '{synapse.ApprovalId}' does not match the enrichment proposal."); @@ -101,37 +90,27 @@ public Task HandleAsync( return SendAsync(Id, new ExecuteApprovedAccountEnrichment(synapse)); } - public async Task HandleAsync( - ExecuteApprovedAccountEnrichment synapse, - CancellationToken cancellationToken) + public async Task HandleAsync(ExecuteApprovedAccountEnrichment synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); var approval = synapse.Approval; var request = Load(approval.CommandId) - ?? throw new InvalidOperationException( - $"Account enrichment '{approval.CommandId}' has no durable request."); + ?? throw new InvalidOperationException($"Account enrichment '{approval.CommandId}' has no durable request."); if (request.Completed) { return; } - if (!string.Equals( - request.MutationFingerprint, - approval.Fingerprint, - StringComparison.Ordinal)) + if (!string.Equals(request.MutationFingerprint, approval.Fingerprint, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Salesforce approval '{approval.ApprovalId}' does not match the enrichment proposal."); } var evidence = await ApprovalEvidenceAsync(approval); - var salesforce = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, "salesforce").ToGrainId()); - var mutation = await salesforce.ApproveAccountDescription( - approval, - evidence, - cancellationToken); + var salesforce = GrainFactory.GetGrain(NeuronId.For(Id.Owner, "salesforce").ToGrainId()); + var mutation = await salesforce.ApproveAccountDescription(approval, evidence, cancellationToken); if (mutation.State is not SalesforceMutationState.Completed) { @@ -139,9 +118,7 @@ public async Task HandleAsync( $"Salesforce could not prove completion of Account '{mutation.AccountId}' enrichment."); } - Stage( - approval.CommandId, - request with { Completed = true }); + Stage(approval.CommandId, request with { Completed = true }); await EmitAsync(new AccountEnriched( mutation.CommandId, request.MessageId, @@ -164,8 +141,7 @@ Task INeuron.Deliver(SynapseDelivery delivery) return base.Deliver(delivery); } - private async Task ApprovalEvidenceAsync( - SalesforceMutationApproval approval) + private async Task ApprovalEvidenceAsync(SalesforceMutationApproval approval) { var incoming = await ReadJournal(JournalKind.Incoming, afterSequence: 0); return incoming.Delta.FirstOrDefault(delivery => @@ -214,9 +190,7 @@ private static void Validate(EnrichAccountFromEmail request) { if (request.CommandId.Value == Guid.Empty) { - throw new ArgumentException( - "An account-enrichment command id cannot be empty.", - nameof(request)); + throw new ArgumentException("An account-enrichment command id cannot be empty.", nameof(request)); } ArgumentException.ThrowIfNullOrWhiteSpace(request.MessageId); diff --git a/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs b/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs index 019fea2b..bf3c16c6 100644 --- a/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs +++ b/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs @@ -9,10 +9,7 @@ public sealed class AccountEnrichmentSurface public const string SceneKey = "enrichment"; public const string SceneTitle = "Account enrichment"; - public async Task RunAsync( - IDigitalBrain brain, - string shellName, - CancellationToken cancellationToken) + public async Task RunAsync(IDigitalBrain brain, string shellName, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(brain); ArgumentException.ThrowIfNullOrWhiteSpace(shellName); diff --git a/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs b/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs index 83612741..46835387 100644 --- a/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs +++ b/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs @@ -27,9 +27,6 @@ public async Task RunAsync( var countdown = brain.Get(countdownName); var destination = NeuronId.For(brain.Owner, SceneKey); - return await countdown.Start(new StartCountdown( - CommandId.New(), - duration, - destination)); + return await countdown.Start(new StartCountdown(CommandId.New(), duration, destination)); } } diff --git a/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs b/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs index 1794133c..1b9b9d17 100644 --- a/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs +++ b/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs @@ -9,10 +9,7 @@ public sealed class OpenHome public const string SceneKey = "home"; public const string SceneTitle = "Home"; - public async Task RunAsync( - IDigitalBrain brain, - string shellName, - CancellationToken cancellationToken) + public async Task RunAsync(IDigitalBrain brain, string shellName, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(brain); ArgumentException.ThrowIfNullOrWhiteSpace(shellName); diff --git a/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs b/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs index 2a55a673..c1f0a977 100644 --- a/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs +++ b/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs @@ -4,10 +4,8 @@ namespace DigitalBrain.Quickstart; [GenerateSerializer] [Alias("quickstart.say-hello")] -public sealed record SayHello( - [property: Id(0)] string Name) : Synapse; +public sealed record SayHello([property: Id(0)] string Name) : Synapse; [GenerateSerializer] [Alias("quickstart.greeted")] -public sealed record Greeted( - [property: Id(0)] string Message) : Synapse; +public sealed record Greeted([property: Id(0)] string Message) : Synapse; diff --git a/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs b/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs index 363ea9a4..621069d0 100644 --- a/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs +++ b/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs @@ -20,9 +20,7 @@ public async Task GreetingIsDurableAcrossItsHostingSiloRestart() await greeter.RestartHostAsync(cancellationToken); - var committed = await greeter.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var committed = await greeter.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Single(committed); Assert.Equal(first.SynapseId, committed[0].SynapseId); } diff --git a/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs b/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs index 5d1ba283..4c196332 100644 --- a/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs +++ b/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs @@ -9,8 +9,6 @@ internal sealed class Greeter : IHandle, IEmit { - public Task HandleAsync( - SayHello request, - CancellationToken cancellationToken) + public Task HandleAsync(SayHello request, CancellationToken cancellationToken) => EmitAsync(new Greeted($"Hello, {request.Name}.")); } From 1938e6bb9369ba9a1cd80763f50974c088ed9f33 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 17:13:05 +0200 Subject: [PATCH 12/20] Update FixtureExclusivity.cs --- .../FixtureExclusivity.cs | 109 ++++++++++++++---- 1 file changed, 89 insertions(+), 20 deletions(-) diff --git a/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs b/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs index 7e232294..a2725b52 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using DigitalBrain.Testing; using Xunit; namespace DigitalBrain.HostTests; @@ -6,31 +8,98 @@ public sealed class FixtureExclusivity( TestingAppHostFixture testing, QuickstartAppHostFixture quickstart) { - [Fact(DisplayName = - "a second graph waits for the first within the same AppHost fixture")] + private const int LiveAppHostTimeoutMs = 120_000; + + [Fact( + Timeout = LiveAppHostTimeoutMs, + DisplayName = "a second graph waits for the first within the same AppHost fixture")] public async Task ASecondGraphWaitsForTheFirstWithinTheSameFixture() { - var cancellationToken = TestContext.Current.CancellationToken; - await using var first = await testing.StartAsync(cancellationToken); - var waiting = testing.StartAsync(cancellationToken); - Assert.False(waiting.IsCompleted); - - await first.DisposeAsync(); - await using var second = await waiting; - Assert.NotNull(second); + await AssertSecondGraphWaitsAsync( + testing, + testing, + TestContext.Current.CancellationToken); } - [Fact(DisplayName = - "a second graph waits for the first across silo-only AppHost fixture types")] + [Fact( + Timeout = LiveAppHostTimeoutMs, + DisplayName = + "a second graph waits for the first across silo-only AppHost fixture types")] public async Task ASecondGraphWaitsForTheFirstAcrossFixtureTypes() { - var cancellationToken = TestContext.Current.CancellationToken; - await using var first = await testing.StartAsync(cancellationToken); - var waiting = quickstart.StartAsync(cancellationToken); - Assert.False(waiting.IsCompleted); - - await first.DisposeAsync(); - await using var second = await waiting; - Assert.NotNull(second); + await AssertSecondGraphWaitsAsync( + testing, + quickstart, + TestContext.Current.CancellationToken); + } + + private static async Task AssertSecondGraphWaitsAsync( + DigitalBrainAppHostFixture firstFixture, + DigitalBrainAppHostFixture secondFixture, + CancellationToken cancellationToken) + { + RunningAppHost? first = null; + Task? waiting = null; + try + { + first = await firstFixture.StartAsync(cancellationToken); + waiting = secondFixture.StartAsync(cancellationToken); + Assert.False(waiting.IsCompleted); + + await first.DisposeAsync(); + first = null; + + await using var second = await waiting.WaitAsync(cancellationToken); + waiting = null; + Assert.NotNull(second); + } + finally + { + await DisposeQuietlyAsync(first); + await DisposeStartedAsync(waiting); + } + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Cleanup after exclusivity failures must not mask the original test exception.")] + private static async Task DisposeQuietlyAsync(RunningAppHost? host) + { + if (host is null) + { + return; + } + + try + { + await host.DisposeAsync(); + } + catch + { + // Preserve the original test failure when cleanup races or stop times out. + } + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Orphaned AppHost starts must be torn down without masking the original test exception.")] + private static async Task DisposeStartedAsync(Task? starting) + { + if (starting is null) + { + return; + } + + try + { + var host = await starting.WaitAsync(TimeSpan.FromSeconds(90)); + await DisposeQuietlyAsync(host); + } + catch + { + // Orphaned starts must not leave AppHost graphs running after the fact ends. + } } } From f7c69e548b030dfd6a9700be0afe009a72e8bc58 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 17:13:08 +0200 Subject: [PATCH 13/20] Update HostedBrain.cs --- os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs b/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs index feace423..b4254535 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs @@ -4,8 +4,10 @@ namespace DigitalBrain.HostTests; public sealed class HostedBrain(TestingAppHostFixture fixture) { - [Fact(DisplayName = - "TestingAppHost silo-only residual: silo Healthy and health path OK (not product OS surface)")] + [Fact( + Timeout = 120_000, + DisplayName = + "TestingAppHost silo-only residual: silo Healthy and health path OK (not product OS surface)")] public async Task TheSiloReachesHealthyOnTheRealHost() { var cancellationToken = TestContext.Current.CancellationToken; From f9d7d036b6f221f7a31e19e60d7dcdd0487a6464 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 17:13:12 +0200 Subject: [PATCH 14/20] Update ci.yml --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d4dae09..f5be9e94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,12 @@ env: jobs: framework: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: test - run: dotnet test DigitalBrain.slnx -c Release + run: dotnet test DigitalBrain.slnx -c Release --verbosity minimal + timeout-minutes: 12 From 33e60f40d460f6d8d7da7be26261aee7e855919b Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 17:13:13 +0200 Subject: [PATCH 15/20] Update AppHost.cs --- os/DigitalBrain.OS.AppHost/AppHost.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs index adb77792..161ca5c6 100644 --- a/os/DigitalBrain.OS.AppHost/AppHost.cs +++ b/os/DigitalBrain.OS.AppHost/AppHost.cs @@ -15,13 +15,10 @@ var brain = builder.AddDigitalBrain(ProductSurfaceResources.Brain); -brain.AddModule(ai => ai - .WithLlm()); +brain.AddModule(ai => ai.WithLlm()); brain.AddModule(); brain.AddModule(); -brain.AddModule(flutter => flutter - .WithUIEdge() - .WithFlutterHost()); +brain.AddModule(flutter => flutter.WithUIEdge().WithFlutterHost()); brain.AddModule(google => google.WithGmail()); brain.AddModule(salesforce => salesforce.WithSalesforce()); From da51f2751fd76c0b62cda76b1175548d73754c53 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 17:13:16 +0200 Subject: [PATCH 16/20] Update RunningAppHost.cs --- .../Hosting/RunningAppHost.cs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs index a08ef485..5a3d3373 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs @@ -9,7 +9,7 @@ namespace DigitalBrain.Testing; public sealed class RunningAppHost : IAsyncDisposable { private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(2); private readonly DistributedApplication _application; private readonly TimeSpan _cleanupTimeout; @@ -218,19 +218,30 @@ private async Task StopOneAsync( string resourceId, CancellationToken cancellationToken) { - var result = await _application.ResourceCommands.ExecuteCommandAsync( - resourceId, - KnownResourceCommands.StopCommand, - cancellationToken); + try + { + var result = await _application.ResourceCommands.ExecuteCommandAsync( + resourceId, + KnownResourceCommands.StopCommand, + cancellationToken); #pragma warning disable CS0618 - var errorMessage = result.ErrorMessage; + var errorMessage = result.ErrorMessage; #pragma warning restore CS0618 - if (!result.Success && !IsTerminal(resourceId)) + if (!result.Success && !IsTerminal(resourceId)) + { + throw new InvalidOperationException( + $"Stop failed for '{resourceId}': {errorMessage ?? result.Message}"); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - throw new InvalidOperationException( - $"Stop failed for '{resourceId}': {errorMessage ?? result.Message}"); + if (!IsTerminal(resourceId)) + { + throw new TimeoutException( + $"Stop timed out for '{resourceId}' within the AppHost cleanup budget."); + } } } From 92709bd002c1cda412556bf4c1856f88e839e62f Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 17:13:18 +0200 Subject: [PATCH 17/20] Update DigitalBrainAppHostFixture.cs --- .../Hosting/DigitalBrainAppHostFixture.cs | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs b/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs index 6e975273..0ff144c1 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs @@ -5,10 +5,9 @@ namespace DigitalBrain.Testing; -public abstract class DigitalBrainAppHostFixture : IAsyncLifetime - where TAppHost : class +public abstract class DigitalBrainAppHostFixture : IAsyncLifetime { - private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(2); private readonly object _sync = new(); private RunningAppHost? _active; private bool _disposed; @@ -28,23 +27,49 @@ public Task StartAsync( return StartCoreAsync(cancellationToken); } - public ValueTask DisposeAsync() + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Fixture disposal must tear down any active graph even when stop fails; original cleanup failure is rethrown after best-effort dispose.")] + public async ValueTask DisposeAsync() { + RunningAppHost? active; + var pending = 0; lock (_sync) { _disposed = true; - if (_pendingStarts != 0 || _active is not null) + active = _active; + _active = null; + pending = _pendingStarts; + _pendingStarts = 0; + } + + GC.SuppressFinalize(this); + + Exception? cleanupFailure = null; + if (active is not null) + { + try { - return ValueTask.FromException( - new InvalidOperationException( - "The AppHost fixture was disposed with a pending start or active graph handle.")); + await active.DisposeAsync(); + } + catch (Exception failure) + { + cleanupFailure = failure; } } - GC.SuppressFinalize(this); - return ValueTask.CompletedTask; + if (pending != 0 || cleanupFailure is not null) + { + throw new InvalidOperationException( + "The AppHost fixture was disposed with a pending start or active graph handle.", + cleanupFailure); + } } + protected abstract Task BuildApplicationAsync( + CancellationToken cancellationToken); + [SuppressMessage( "Design", "CA1031:Do not catch general exception types", @@ -62,18 +87,11 @@ private async Task StartCoreAsync( try { lease = await AppHostExclusiveLease.AcquireAsync(cancellationToken); - var builder = await DistributedApplicationTestingBuilder - .CreateAsync( - args: [], - configureBuilder: static (options, _) => - options.EnableResourceLogging = true, - cancellationToken); - using var startup = CancellationTokenSource .CreateLinkedTokenSource(cancellationToken); startup.CancelAfter(StartupTimeout); - application = await builder.BuildAsync(startup.Token); + application = await BuildApplicationAsync(startup.Token); await application.StartAsync(startup.Token); var running = new RunningAppHost( @@ -160,3 +178,29 @@ private void Release(RunningAppHost running) } } } + +public abstract class DigitalBrainAppHostFixture : DigitalBrainAppHostFixture + where TAppHost : class +{ + private static readonly bool ResourceLoggingEnabled = + string.Equals( + Environment.GetEnvironmentVariable("DIGITALBRAIN_APPHOST_RESOURCE_LOGS"), + "1", + StringComparison.Ordinal) + || string.Equals( + Environment.GetEnvironmentVariable("DIGITALBRAIN_APPHOST_RESOURCE_LOGS"), + "true", + StringComparison.OrdinalIgnoreCase); + + protected override async Task BuildApplicationAsync( + CancellationToken cancellationToken) + { + var builder = await DistributedApplicationTestingBuilder + .CreateAsync( + args: [], + configureBuilder: static (options, _) => + options.EnableResourceLogging = ResourceLoggingEnabled, + cancellationToken); + return await builder.BuildAsync(cancellationToken); + } +} From c3bee8178997db17b662ea898ee50644be3c3485 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 18:08:06 +0200 Subject: [PATCH 18/20] fix: make product observability truthful Stream model turns with GenAI usage telemetry and AppHost-controlled sensitive capture. Make MCP chat retries idempotent and introspection owner-scoped. Remove inferred capabilities, mirror tests, and the forbidden PowerShell verifier; replace the latter with an explicit live product proof. No-consumer check: none; every addition is consumed by the AppHost, MCP flow, or live product path. Unverified-claim check: none; Release build, the 252-test root gate, the explicit Gemma product proof, and all Dart/Flutter gates were run. Unowned-change check: preserved the user's formatting pass; unchanged generated Flutter files were not staged. --- CLAUDE.md | 13 +- DigitalBrain.slnx | 1 + README.md | 7 +- .../lib/digitalbrain_flutter.dart | 1 - .../shell/lib/brain_screen.dart | 177 ------- .../shell/test/chat_screen_test.dart | 76 ---- .../test/edge_client_test.dart | 6 - .../lib/src/ui_edge_models.dart | 19 - .../test/ui_edge_models_test.dart | 4 - os/DigitalBrain.OS.AppHost/AppHost.cs | 7 +- .../Properties/launchSettings.json | 13 + .../DigitalBrainIntrospectionTools.cs | 5 +- .../DigitalBrainMcpTools.cs | 30 +- os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs | 2 +- .../FixtureExclusivity.cs | 6 +- .../DigitalBrain.OS.Host.Tests/HostedBrain.cs | 4 +- .../ActiveNeuronTool.cs | 35 ++ .../ChatProductTool.cs | 29 +- .../DigitalBrain.OS.Product.Tests.csproj | 18 + .../LiveProduct.cs | 430 ++++++++++++++++++ scripts/verify-product.ps1 | 341 -------------- .../Hosting/HostingProjectionContracts.cs | 14 +- .../ServiceDefaultsContracts.cs | 16 - .../DigitalBrain.Mcp/DigitalBrain.Mcp.csproj | 1 + .../mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs | 8 +- .../LongRunningNeuronContracts.cs | 22 - .../Hosting/DigitalBrainAppHostFixture.cs | 4 +- .../Hosting/RunningAppHost.cs | 2 +- .../AIHostingExtensions.cs | 29 +- .../AgentResponseTimeoutContracts.cs | 18 - .../ai/DigitalBrain.Modules.AI/Agent.cs | 4 +- .../Clients/AIClients.cs | 32 +- .../StreamingUsageChatClientExtensions.cs | 46 ++ .../DigitalBrain.Modules.Chat/ChatNeuron.cs | 50 +- .../BrainCapabilityCatalogTests.cs | 72 --- .../UIEdgeRoundTrip.cs | 1 - .../BrainCapabilityCatalog.cs | 57 --- .../BrainTopologyModels.cs | 3 - .../BrainTopologyReader.cs | 1 - 39 files changed, 741 insertions(+), 863 deletions(-) create mode 100644 os/DigitalBrain.OS.AppHost/Properties/launchSettings.json create mode 100644 os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs create mode 100644 os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj create mode 100644 os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs delete mode 100644 scripts/verify-product.ps1 delete mode 100644 src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs delete mode 100644 src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs create mode 100644 src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs delete mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs delete mode 100644 src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs diff --git a/CLAUDE.md b/CLAUDE.md index a45d66a0..ad0660c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,8 @@ the live system. Tests are the regression net, not the proof. 7. Only now claim, quoting what you saw. Journals are the audit source; telemetry is a projection and never replaces them. Journals hold -causal facts only — never arguments, prompts or secrets. Telemetry tags follow the same rule. +causal facts only — never arguments, prompts or secrets. Production telemetry follows the same rule; +the product AppHost opts Development into prompt and response capture for local diagnosis. **What Aspire can actually tell you** (measured against a live AppHost after a real chat turn): @@ -80,12 +81,12 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow | `digitalbrain-mcp` journals | works, and is authoritative | | Structured logs | works — MCP invocation completion includes tool name and error state | | Application traces | works — ASP.NET, Orleans and kernel spans carry causal identifiers | -| GenAI spans, metrics | works — provider, model, duration, token usage and finish reason; no prompt or response content | +| GenAI spans, metrics | works — provider, model, duration, token usage and finish reason; prompt and response content only when the AI module explicitly enables it | -`scripts/verify-product.ps1` is the one-command live oracle. It builds Release, waits for the product -resources, checks the exact MCP catalog, drives a real Gemma4 turn, confirms the durable transcript -and correlation, confirms the configured modules and active chat neuron through the UI topology, -checks telemetry content hygiene, and stops the AppHost unless `-KeepRunning` is specified. +`dotnet test os/tests/DigitalBrain.OS.Product.Tests -c Release -- -explicit only` is the live oracle. +It starts and stops the product AppHost, drives a real Gemma4 turn and retry, confirms the durable +transcript and correlation, checks owner-scoped active-neuron discovery, and verifies GenAI usage +and Development message content in the exported span. ## Gates diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx index ca9913aa..5baffbf4 100644 --- a/DigitalBrain.slnx +++ b/DigitalBrain.slnx @@ -57,6 +57,7 @@ + diff --git a/README.md b/README.md index e8a1b92d..2f07a235 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,11 @@ git clean -fdx aspire run ``` -[scripts/verify-product.ps1](scripts/verify-product.ps1) performs the Release build and a live, -self-cleaning Aspire proof across resource health, MCP discovery, Gemma4 chat, durable journals, -live UI topology, and OpenTelemetry: +The explicit product suite performs a self-cleaning live Aspire proof across resource health, +Gemma4 chat, command retry, durable journals, owner-scoped introspection, and OpenTelemetry: ```powershell -./scripts/verify-product.ps1 +dotnet test os/tests/DigitalBrain.OS.Product.Tests -c Release -- -explicit only ``` [CLAUDE.md](CLAUDE.md) is the working discipline for every agent and contributor: the gates, the diff --git a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart index 07f0b863..13c1e5aa 100644 --- a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart +++ b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart @@ -1,7 +1,6 @@ export 'package:digitalbrain_wire/digitalbrain_wire.dart' show ActivateControlRequest, - BrainCapability, BrainModule, BrainNeuron, BrainTopologySnapshot, diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart index 2dfd7873..4ba98b4c 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart @@ -23,10 +23,6 @@ final class BrainScreen extends StatefulWidget { } final class _BrainScreenState extends State { - static const _generalAssistant = 'assistant.general'; - static const _accountEnrichment = - 'account-enrichment.gmail-salesforce-description'; - BrainTopologySelection? _selection; @override @@ -75,52 +71,6 @@ final class _BrainScreenState extends State { }; } - ({bool generalAssistant, bool accountEnrichment}) _capabilities() { - final capabilities = widget.topology?.capabilities - .map((capability) => capability.id) - .toSet(); - return ( - generalAssistant: capabilities?.contains(_generalAssistant) == true, - accountEnrichment: capabilities?.contains(_accountEnrichment) == true, - ); - } - - List _capabilityPanels() { - final capabilities = _capabilities(); - if (!capabilities.generalAssistant && !capabilities.accountEnrichment) { - return const []; - } - - return [ - const SizedBox(height: 30), - const _SectionLabel('CAPABILITIES'), - const SizedBox(height: 12), - if (capabilities.generalAssistant) - const _CapabilityCard( - icon: Icons.chat_bubble_outline_rounded, - title: 'General assistant', - body: - 'Conversation, explanation, drafting, and reasoning in the current chat.', - ), - if (capabilities.generalAssistant && capabilities.accountEnrichment) - const SizedBox(height: 12), - if (capabilities.accountEnrichment) - const _CapabilityCard( - icon: Icons.compare_arrows_rounded, - title: 'Gmail message → Salesforce Account description', - body: - 'Creates a reviewable enrichment proposal from an exact Gmail message ID and a Salesforce Account ID.', - badge: 'Approval required', - ), - if (capabilities.accountEnrichment) ...[ - const SizedBox(height: 30), - const _SectionLabel('BOUNDARIES'), - const SizedBox(height: 12), - const _BoundaryCard(), - ], - ]; - } - @override Widget build(BuildContext context) { final connected = @@ -177,7 +127,6 @@ final class _BrainScreenState extends State { onSelected: (selection) => setState(() => _selection = selection), ), - ..._capabilityPanels(), if (!connected) ...[ const SizedBox(height: 20), _ConnectionNotice(message: widget.statusMessage!), @@ -458,132 +407,6 @@ final class _MetricCard extends StatelessWidget { } } -final class _SectionLabel extends StatelessWidget { - const _SectionLabel(this.text); - - final String text; - - @override - Widget build(BuildContext context) => Text(text, style: BrainType.meta); -} - -final class _CapabilityCard extends StatelessWidget { - const _CapabilityCard({ - required this.icon, - required this.title, - required this.body, - this.badge, - }); - - final IconData icon; - final String title; - final String body; - final String? badge; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: _panelDecoration(), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: BrainPalette.signal.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(11), - ), - child: Icon(icon, color: BrainPalette.signal, size: 19), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: Text(title, style: BrainType.cardTitle)), - if (badge != null) _Badge(label: badge!), - ], - ), - const SizedBox(height: 7), - Text(body, style: BrainType.bodyMuted), - ], - ), - ), - ], - ), - ); - } -} - -final class _Badge extends StatelessWidget { - const _Badge({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), - decoration: BoxDecoration( - color: BrainPalette.owner.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: BrainPalette.owner.withValues(alpha: 0.3)), - ), - child: Text( - label, - style: BrainType.metaStrong.copyWith(color: BrainPalette.owner), - ), - ); - } -} - -final class _BoundaryCard extends StatelessWidget { - const _BoundaryCard(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: _panelDecoration(color: BrainPalette.surfaceSunken), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _BoundaryLine('No Gmail search, listing, or sending'), - SizedBox(height: 11), - _BoundaryLine('No direct Salesforce writes'), - SizedBox(height: 11), - _BoundaryLine('No account, contact, or lead creation'), - ], - ), - ); - } -} - -final class _BoundaryLine extends StatelessWidget { - const _BoundaryLine(this.text); - - final String text; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const Icon( - Icons.remove_circle_outline_rounded, - color: BrainPalette.textMuted, - size: 16, - ), - const SizedBox(width: 10), - Expanded(child: Text(text, style: BrainType.bodyMuted)), - ], - ); - } -} - final class _ConnectionNotice extends StatelessWidget { const _ConnectionNotice({required this.message}); diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart index b2cd7b3f..a3118fff 100644 --- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart +++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart @@ -31,10 +31,6 @@ BrainTopologySnapshot _topology() => BrainTopologySnapshot( BrainModule(id: 'DigitalBrain.OS.OSBehaviorsModule'), BrainModule(id: 'DigitalBrain.Salesforce.SalesforceModule'), ], - capabilities: const [ - BrainCapability(id: 'assistant.general'), - BrainCapability(id: 'account-enrichment.gmail-salesforce-description'), - ], neurons: const [ BrainNeuron( id: 'chat:owner/main', @@ -48,7 +44,6 @@ BrainTopologySnapshot _topology() => BrainTopologySnapshot( BrainTopologySnapshot _topologyWithoutNeuron() => BrainTopologySnapshot( modules: _topology().modules, - capabilities: _topology().capabilities, neurons: const [], observedAt: DateTime.utc(2026, 7, 28, 8), ); @@ -78,77 +73,6 @@ void main() { await tester.tap(find.byKey(const Key('destination_brain'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('brain_screen')), findsOneWidget); - await tester.scrollUntilVisible( - find.text('General assistant'), - 300, - scrollable: find.byType(Scrollable), - ); - expect(find.text('General assistant'), findsOneWidget); - await tester.scrollUntilVisible( - find.text('Gmail message → Salesforce Account description'), - 300, - scrollable: find.byType(Scrollable), - ); - expect( - find.text('Gmail message → Salesforce Account description'), - findsOneWidget, - ); - expect(find.text('Approval required'), findsOneWidget); - }); - - testWidgets('Brain claims only capabilities declared by the live manifest', ( - tester, - ) async { - final topology = StreamController(); - addTearDown(topology.close); - final flutterOnly = BrainTopologySnapshot( - modules: const [BrainModule(id: 'DigitalBrain.Flutter.FlutterModule')], - capabilities: const [], - neurons: const [], - observedAt: DateTime.utc(2026, 7, 28, 8), - ); - - await tester.pumpWidget( - BrainChatApp(chatName: 'main', topology: topology.stream), - ); - topology.add(flutterOnly); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('destination_brain'))); - await tester.pumpAndSettle(); - - expect(find.text('General assistant'), findsNothing); - expect( - find.text('Gmail message → Salesforce Account description'), - findsNothing, - ); - expect(find.text('Approval required'), findsNothing); - }); - - testWidgets('Brain makes no claims for installed but unconfigured modules', ( - tester, - ) async { - final topology = StreamController(); - addTearDown(topology.close); - final unconfigured = BrainTopologySnapshot( - modules: _topology().modules, - capabilities: const [], - neurons: const [], - observedAt: DateTime.utc(2026, 7, 28, 8), - ); - - await tester.pumpWidget( - BrainChatApp(chatName: 'main', topology: topology.stream), - ); - topology.add(unconfigured); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('destination_brain'))); - await tester.pumpAndSettle(); - - expect(find.text('General assistant'), findsNothing); - expect( - find.text('Gmail message → Salesforce Account description'), - findsNothing, - ); }); testWidgets('activity shows journal facts without message content', ( diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart index 042a61ac..72663b52 100644 --- a/clients/digitalbrain_flutter/test/edge_client_test.dart +++ b/clients/digitalbrain_flutter/test/edge_client_test.dart @@ -168,9 +168,6 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], - 'capabilities': [ - {'id': 'assistant.general'}, - ], 'neurons': [ if (requestCount > 1) { @@ -195,7 +192,6 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," expect(snapshots.first.neurons, isEmpty); expect(snapshots.last.neurons.single.id, 'chat:owner/main'); - expect(snapshots.last.capabilities.single.id, 'assistant.general'); }, ); @@ -215,7 +211,6 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], - 'capabilities': const [], 'neurons': const [], 'observedAt': '2026-07-28T08:00:00Z', }), @@ -281,7 +276,6 @@ final class _AbortThenSucceedClient extends http.BaseClient { 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], - 'capabilities': const [], 'neurons': const [], 'observedAt': '2026-07-28T08:00:00Z', }), diff --git a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart index d52b880d..12fd5576 100644 --- a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart +++ b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart @@ -101,16 +101,6 @@ final class BrainModule { } } -final class BrainCapability { - const BrainCapability({required this.id}); - - final String id; - - factory BrainCapability.fromJson(Map json) { - return BrainCapability(id: json['id'] as String); - } -} - final class BrainNeuron { const BrainNeuron({ required this.id, @@ -137,13 +127,11 @@ final class BrainNeuron { final class BrainTopologySnapshot { const BrainTopologySnapshot({ required this.modules, - required this.capabilities, required this.neurons, required this.observedAt, }); final List modules; - final List capabilities; final List neurons; final DateTime observedAt; @@ -155,13 +143,6 @@ final class BrainTopologySnapshot { BrainModule.fromJson(Map.from(module! as Map)), ) .toList(growable: false), - capabilities: (json['capabilities'] as List) - .map( - (capability) => BrainCapability.fromJson( - Map.from(capability! as Map), - ), - ) - .toList(growable: false), neurons: (json['neurons'] as List) .map( (neuron) => diff --git a/clients/digitalbrain_wire/test/ui_edge_models_test.dart b/clients/digitalbrain_wire/test/ui_edge_models_test.dart index dcfb5ae4..ea13bdbd 100644 --- a/clients/digitalbrain_wire/test/ui_edge_models_test.dart +++ b/clients/digitalbrain_wire/test/ui_edge_models_test.dart @@ -63,9 +63,6 @@ void main() { 'modules': [ {'id': 'DigitalBrain.Chat.ChatModule'}, ], - 'capabilities': [ - {'id': 'assistant.general'}, - ], 'neurons': [ { 'id': 'chat:owner/main', @@ -78,7 +75,6 @@ void main() { }); expect(topology.modules.single.id, 'DigitalBrain.Chat.ChatModule'); - expect(topology.capabilities.single.id, 'assistant.general'); expect(topology.neurons.single.id, 'chat:owner/main'); expect(topology.neurons.single.grainType, 'chat'); expect(topology.neurons.single.placement, 'cluster-1'); diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs index 161ca5c6..9409da67 100644 --- a/os/DigitalBrain.OS.AppHost/AppHost.cs +++ b/os/DigitalBrain.OS.AppHost/AppHost.cs @@ -10,12 +10,17 @@ using DigitalBrain.OS; using DigitalBrain.Salesforce; using DigitalBrain.Salesforce.Aspire.Hosting; +using Microsoft.Extensions.Hosting; var builder = DistributedApplication.CreateBuilder(args); var brain = builder.AddDigitalBrain(ProductSurfaceResources.Brain); -brain.AddModule(ai => ai.WithLlm()); +brain.AddModule(ai => +{ + ai.EnableSensitiveData = builder.Environment.IsDevelopment(); + ai.WithLlm(); +}); brain.AddModule(); brain.AddModule(); brain.AddModule(flutter => flutter.WithUIEdge().WithFlutterHost()); diff --git a/os/DigitalBrain.OS.AppHost/Properties/launchSettings.json b/os/DigitalBrain.OS.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..89c17333 --- /dev/null +++ b/os/DigitalBrain.OS.AppHost/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs index 9de0c10d..1a3496f3 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs @@ -25,10 +25,11 @@ public async Task> ListActiveNeuronsAsync() return [ .. statistics + .Where(statistic => statistic.GrainId.Key.ToString()! + .StartsWith($"{brain.Owner.Value}/", StringComparison.Ordinal)) .Select(statistic => new ActiveNeuron( statistic.GrainId.Type.ToString()!, - statistic.GrainId.Key.ToString()!, - statistic.SiloAddress.ToString())) + statistic.GrainId.Key.ToString()!)) .OrderBy(neuron => neuron.GrainType, StringComparer.Ordinal) .ThenBy(neuron => neuron.Identity, StringComparer.Ordinal), ]; diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs index f6419551..c815eac1 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs @@ -15,8 +15,8 @@ namespace DigitalBrain.OS.Mcp; internal sealed class DigitalBrainMcpTools(IDigitalBrain brain, IGrainFactory grains) { private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); - private const int DefaultTimeoutSeconds = 120; - private const int MaximumTimeoutSeconds = 180; + private const int DefaultTimeoutSeconds = 300; + private const int MaximumTimeoutSeconds = 300; [McpServerTool(Name = McpHost.SendChatMessageToolName)] [Description( @@ -24,29 +24,45 @@ internal sealed class DigitalBrainMcpTools(IDigitalBrain brain, IGrainFactory gr + "correlated assistant response. This is the same durable chat path used by the product UI.")] public async Task SendChatMessageAsync( [Description("Message to send to DigitalBrain")] string text, + [Description("Caller-generated command id used to resume an interrupted call")] + string commandId, [Description("Conversation name, for example 'main'")] string chatName = "main", - [Description("Maximum wait in seconds, from 1 through 180")] + [Description("Maximum wait in seconds, from 1 through 300")] int timeoutSeconds = DefaultTimeoutSeconds, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(commandId); ArgumentException.ThrowIfNullOrWhiteSpace(chatName); ArgumentOutOfRangeException.ThrowIfLessThan(timeoutSeconds, 1); ArgumentOutOfRangeException.ThrowIfGreaterThan(timeoutSeconds, MaximumTimeoutSeconds); + if (!Guid.TryParse(commandId, out var commandIdentity) + || commandIdentity == Guid.Empty) + { + throw new ArgumentException( + "The command id must be a non-empty GUID.", + nameof(commandId)); + } + var chatId = NeuronId.For(brain.Owner, chatName); var session = grains.GetGrain(ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); - var baseline = await session.ReadNeuronJournal(chatId, JournalKind.Outgoing, afterSequence: 0); - var commandId = CommandId.New(); + var command = new CommandId(commandIdentity); - await brain.Get(chatName).Send(new SendMessage(commandId, text)); + await brain.Get(chatName).Send(new SendMessage(command, text)); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); try { - return await WaitForResponseAsync(session, chatId, chatName, commandId, baseline.ResumeSequence, timeout.Token); + return await WaitForResponseAsync( + session, + chatId, + chatName, + command, + afterSequence: 0, + cancellationToken: timeout.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { diff --git a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs index e56ba82e..96dc4053 100644 --- a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs +++ b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs @@ -9,7 +9,7 @@ internal sealed record NeuronJournalPage( internal sealed record JournaledSynapse(long Sequence, string Synapse, string Caller, string Correlation, DateTimeOffset Timestamp); -internal sealed record ActiveNeuron(string GrainType, string Identity, string Silo); +internal sealed record ActiveNeuron(string GrainType, string Identity); internal sealed record ChatTranscriptPage(string Chat, IReadOnlyList Turns); diff --git a/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs b/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs index a2725b52..5829efb8 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs @@ -8,7 +8,7 @@ public sealed class FixtureExclusivity( TestingAppHostFixture testing, QuickstartAppHostFixture quickstart) { - private const int LiveAppHostTimeoutMs = 120_000; + private const int LiveAppHostTimeoutMs = 300_000; [Fact( Timeout = LiveAppHostTimeoutMs, @@ -77,7 +77,6 @@ private static async Task DisposeQuietlyAsync(RunningAppHost? host) } catch { - // Preserve the original test failure when cleanup races or stop times out. } } @@ -94,12 +93,11 @@ private static async Task DisposeStartedAsync(Task? starting) try { - var host = await starting.WaitAsync(TimeSpan.FromSeconds(90)); + var host = await starting.WaitAsync(TimeSpan.FromMinutes(5)); await DisposeQuietlyAsync(host); } catch { - // Orphaned starts must not leave AppHost graphs running after the fact ends. } } } diff --git a/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs b/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs index b4254535..f47e5bb4 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs @@ -5,7 +5,7 @@ namespace DigitalBrain.HostTests; public sealed class HostedBrain(TestingAppHostFixture fixture) { [Fact( - Timeout = 120_000, + Timeout = 300_000, DisplayName = "TestingAppHost silo-only residual: silo Healthy and health path OK (not product OS surface)")] public async Task TheSiloReachesHealthyOnTheRealHost() @@ -17,7 +17,7 @@ public async Task TheSiloReachesHealthyOnTheRealHost() await silo.WaitUntilHealthyAsync(cancellationToken); using var client = silo.CreateHttpClient(); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromMinutes(5); using var health = await client.GetAsync( new Uri(TestingAppHostFixture.HealthPath, UriKind.Relative), cancellationToken); diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs new file mode 100644 index 00000000..39551f59 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using DigitalBrain.Chat; +using DigitalBrain.Testing; +using Xunit; + +namespace DigitalBrain.OS.Mcp.Tests; + +public sealed class ActiveNeuronTool(OSMcpFixture fixture) +{ + [Fact(DisplayName = "list_active_neurons is owner scoped and exposes no silo address")] + public async Task ListActiveNeuronsIsOwnerScopedAndRedacted() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + var other = test.Owner("other"); + + await test.Client.Get("mine").Read(); + await other.Client.Get("theirs").Read(); + + var tools = new DigitalBrainIntrospectionTools(test.Client, test.Cluster.Client); + var neurons = await tools.ListActiveNeuronsAsync(); + var json = JsonSerializer.Serialize(neurons); + + Assert.Contains( + neurons, + neuron => neuron.GrainType == "chat" + && neuron.Identity == $"{test.Client.Owner.Value}/mine"); + Assert.DoesNotContain( + neurons, + neuron => neuron.Identity.StartsWith( + $"{other.Id.Value}/", + StringComparison.Ordinal)); + Assert.DoesNotContain("\"silo\"", json, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs index 7337463f..c27d4809 100644 --- a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs @@ -1,3 +1,4 @@ +using DigitalBrain.Abstractions; using DigitalBrain.Chat; using DigitalBrain.Testing; using Xunit; @@ -15,15 +16,31 @@ public async Task SendChatMessageReturnsCorrelatedAssistantResponse() await using var test = await fixture.CreateBrainAsync(cancellationToken); test.Chat().Reply("Hello from DigitalBrain."); var tools = new DigitalBrainMcpTools(test.Client, test.Cluster.Client); + var commandId = CommandId.New(); - var result = await tools.SendChatMessageAsync("hi", "main", timeoutSeconds: 10, cancellationToken); + var result = await tools.SendChatMessageAsync( + "hi", + commandId.ToString(), + "main", + timeoutSeconds: 10, + cancellationToken: cancellationToken); Assert.Equal("main", result.Chat); Assert.Equal("Hello from DigitalBrain.", result.Response); - Assert.NotEqual(Guid.Empty.ToString("N"), result.CommandId); + Assert.Equal(commandId.ToString(), result.CommandId); Assert.NotEqual(Guid.Empty.ToString("N"), result.CorrelationId); Assert.True(result.Sequence > 0); + await test.Neuron("main").RestartHostAsync(cancellationToken); + var retried = await tools.SendChatMessageAsync( + "hi", + commandId.ToString(), + "main", + timeoutSeconds: 10, + cancellationToken: cancellationToken); + + Assert.Equal(result, retried); + var transcript = await test.Client.Get("main").Read(); Assert.Collection( transcript.Turns, @@ -37,5 +54,13 @@ public async Task SendChatMessageReturnsCorrelatedAssistantResponse() Assert.False(assistant.FromUser); Assert.Equal(result.Response, assistant.Text); }); + + await Assert.ThrowsAsync( + () => tools.SendChatMessageAsync( + "different text", + commandId.ToString(), + "main", + timeoutSeconds: 10, + cancellationToken: cancellationToken)); } } diff --git a/os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj b/os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj new file mode 100644 index 00000000..d3277bd5 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj @@ -0,0 +1,18 @@ + + + Exe + $(NoWarn);CA2007;CA1515 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs b/os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs new file mode 100644 index 00000000..58e5ee09 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs @@ -0,0 +1,430 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; + +namespace DigitalBrain.ProductTests; + +public sealed class LiveProduct +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(5); + private const string AppHostPath = "os/DigitalBrain.OS.AppHost/DigitalBrain.OS.AppHost.csproj"; + private const string McpResource = "digitalbrain-mcp"; + + [Fact( + Explicit = true, + Timeout = 900_000, + DisplayName = + "LIVE product: real Gemma chat is idempotent, journaled, owner-scoped and emits content-rich GenAI telemetry in Development")] + public async Task RealChatIsObservable() + { + var cancellationToken = TestContext.Current.CancellationToken; + var repository = FindRepositoryRoot(); + var chatName = $"product-verification-{Guid.NewGuid():N}"; + var commandId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + const string prompt = "Tell me your name in one short sentence."; + var started = false; + + await RunAspireAsync( + repository, + allowFailure: true, + CancellationToken.None, + "stop", + "--apphost", + AppHostPath, + "--non-interactive", + "--nologo"); + + try + { + await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "start", + "--apphost", + AppHostPath, + "--format", + "Json", + "--non-interactive", + "--nologo"); + started = true; + + foreach (var resource in new[] { "silo", McpResource, "brain-ai-gemma4" }) + { + await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "wait", + resource, + "--apphost", + AppHostPath, + "--timeout", + "300", + "--non-interactive", + "--nologo"); + } + + var sendInput = JsonSerializer.Serialize( + new + { + text = prompt, + commandId, + chatName, + timeoutSeconds = 300, + }); + var result = await CallToolAsync( + repository, + "send_chat_message", + sendInput, + cancellationToken); + var response = RequiredString(result, "response"); + var correlationId = RequiredString(result, "correlationId"); + + Assert.False(string.IsNullOrWhiteSpace(response)); + Assert.True(Guid.TryParse(RequiredString(result, "commandId"), out _)); + Assert.True(Guid.TryParse(correlationId, out _)); + + var retried = await CallToolAsync( + repository, + "send_chat_message", + sendInput, + cancellationToken); + Assert.Equal(response, RequiredString(retried, "response")); + Assert.Equal(correlationId, RequiredString(retried, "correlationId")); + + var transcript = await CallToolAsync( + repository, + "read_chat_transcript", + JsonSerializer.Serialize(new { chatName }), + cancellationToken); + var turns = RequiredArray(transcript, "turns"); + Assert.Collection( + turns, + turn => + { + Assert.Equal("you", RequiredString(turn, "speaker")); + Assert.Equal(prompt, RequiredString(turn, "text")); + }, + turn => + { + Assert.Equal("brain", RequiredString(turn, "speaker")); + Assert.Equal(response, RequiredString(turn, "text")); + }); + + var journal = await CallToolAsync( + repository, + "read_neuron_journal", + JsonSerializer.Serialize( + new + { + grainType = "chat", + name = chatName, + kind = "outgoing", + afterSequence = 0, + }), + cancellationToken); + var entries = RequiredArray(journal, "entries"); + Assert.Collection( + entries, + entry => + { + Assert.Equal("UserMessaged", RequiredString(entry, "synapse")); + Assert.Equal(correlationId, RequiredString(entry, "correlation")); + }, + entry => + { + Assert.Equal("AssistantResponded", RequiredString(entry, "synapse")); + Assert.Equal(correlationId, RequiredString(entry, "correlation")); + }); + + var activeNeurons = await CallToolAsync( + repository, + "list_active_neurons", + "{}", + cancellationToken); + var activeChat = activeNeurons + .AsArray() + .Single(neuron => + string.Equals(RequiredString(neuron, "grainType"), "chat", StringComparison.Ordinal) + && string.Equals( + RequiredString(neuron, "identity"), + $"dev/{chatName}", + StringComparison.Ordinal)); + Assert.NotNull(activeChat); + Assert.DoesNotContain( + "\"silo\"", + activeNeurons.ToJsonString(), + StringComparison.OrdinalIgnoreCase); + + var span = await WaitForGenAiSpanAsync(repository, prompt, cancellationToken); + var attributes = RequiredObject(span, "attributes"); + + Assert.Equal("chat", RequiredString(attributes, "gen_ai.operation.name")); + Assert.Equal("ollama", RequiredString(attributes, "gen_ai.provider.name")); + Assert.Contains( + "gemma4", + RequiredString(attributes, "gen_ai.request.model"), + StringComparison.OrdinalIgnoreCase); + Assert.True(RequiredLong(attributes, "gen_ai.usage.input_tokens") > 0); + Assert.True(RequiredLong(attributes, "gen_ai.usage.output_tokens") > 0); + Assert.False( + string.IsNullOrWhiteSpace( + RequiredString(attributes, "gen_ai.response.finish_reasons"))); + Assert.Contains( + prompt, + RequiredString(attributes, "gen_ai.input.messages"), + StringComparison.Ordinal); + Assert.Contains( + response, + RequiredString(attributes, "gen_ai.output.messages"), + StringComparison.Ordinal); + } + finally + { + if (started) + { + await RunAspireAsync( + repository, + allowFailure: true, + CancellationToken.None, + "stop", + "--apphost", + AppHostPath, + "--non-interactive", + "--nologo"); + } + } + } + + private static async Task CallToolAsync( + string repository, + string tool, + string input, + CancellationToken cancellationToken) + { + var result = await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "mcp", + "call", + McpResource, + tool, + "--input", + input, + "--apphost", + AppHostPath, + "--non-interactive", + "--nologo"); + return ParseJson(result.StandardOutput); + } + + private static async Task WaitForGenAiSpanAsync( + string repository, + string prompt, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 60; attempt++) + { + var result = await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "otel", + "spans", + "--apphost", + AppHostPath, + "--format", + "Json", + "--limit", + "100", + "--search", + "gen_ai", + "--non-interactive", + "--nologo"); + var spans = ParseJson(result.StandardOutput).AsArray(); + var span = spans + .OfType() + .FirstOrDefault(candidate => + { + var attributes = candidate["attributes"] as JsonObject; + return attributes is not null + && string.Equals( + OptionalString(attributes, "gen_ai.operation.name"), + "chat", + StringComparison.Ordinal) + && string.Equals( + OptionalString(attributes, "gen_ai.provider.name"), + "ollama", + StringComparison.Ordinal) + && OptionalString(attributes, "gen_ai.input.messages") + ?.Contains(prompt, StringComparison.Ordinal) is true; + }); + + if (span is not null) + { + return span; + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + throw new Xunit.Sdk.XunitException( + "No content-rich Ollama GenAI chat span arrived within one minute."); + } + + private static async Task RunAspireAsync( + string repository, + bool allowFailure, + CancellationToken cancellationToken, + params string[] arguments) + { + using var commandTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + commandTimeout.CancelAfter(CommandTimeout); + + var start = new ProcessStartInfo("aspire") + { + WorkingDirectory = repository, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = start }; + if (!process.Start()) + { + throw new Xunit.Sdk.XunitException("The Aspire CLI process did not start."); + } + + var standardOutput = process.StandardOutput.ReadToEndAsync(commandTimeout.Token); + var standardError = process.StandardError.ReadToEndAsync(commandTimeout.Token); + + try + { + await process.WaitForExitAsync(commandTimeout.Token); + } + catch + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + throw; + } + + var result = new CommandResult( + process.ExitCode, + await standardOutput, + await standardError); + + if (!allowFailure && result.ExitCode != 0) + { + throw new Xunit.Sdk.XunitException( + $"aspire {string.Join(' ', arguments)} exited with {result.ExitCode}.{Environment.NewLine}" + + result.StandardOutput + + Environment.NewLine + + result.StandardError); + } + + return result; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "DigitalBrain.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new Xunit.Sdk.XunitException( + $"Could not find DigitalBrain.slnx above {AppContext.BaseDirectory}."); + } + + private static JsonNode ParseJson(string output) + { + var lines = output.Split( + ["\r\n", "\n"], + StringSplitOptions.None); + var firstJsonLine = Array.FindIndex( + lines, + line => + { + var trimmed = line.TrimStart(); + return trimmed.StartsWith('{', StringComparison.Ordinal) + || trimmed.StartsWith('[', StringComparison.Ordinal); + }); + + if (firstJsonLine < 0) + { + throw new Xunit.Sdk.XunitException( + $"The Aspire CLI did not return JSON.{Environment.NewLine}{output}"); + } + + return JsonNode.Parse(string.Join(Environment.NewLine, lines[firstJsonLine..])) + ?? throw new Xunit.Sdk.XunitException("The Aspire CLI returned JSON null."); + } + + private static JsonArray RequiredArray(JsonNode? node, string property) + => node?[property] as JsonArray + ?? throw new Xunit.Sdk.XunitException( + $"Expected JSON array '{property}' in {node?.ToJsonString()}."); + + private static JsonObject RequiredObject(JsonNode? node, string property) + => node?[property] as JsonObject + ?? throw new Xunit.Sdk.XunitException( + $"Expected JSON object '{property}' in {node?.ToJsonString()}."); + + private static string RequiredString(JsonNode? node, string property) + => OptionalString(node, property) + ?? throw new Xunit.Sdk.XunitException( + $"Expected JSON string '{property}' in {node?.ToJsonString()}."); + + private static string? OptionalString(JsonNode? node, string property) + { + var value = node?[property]; + if (value is null) + { + return null; + } + + return value is JsonValue jsonValue + && jsonValue.TryGetValue(out var text) + ? text + : value.ToJsonString(); + } + + private static long RequiredLong(JsonNode? node, string property) + { + var value = node?[property]; + if (value is JsonValue jsonValue + && jsonValue.TryGetValue(out var number)) + { + return number; + } + + if (long.TryParse(OptionalString(node, property), CultureInfo.InvariantCulture, out number)) + { + return number; + } + + throw new Xunit.Sdk.XunitException( + $"Expected JSON integer '{property}' in {node?.ToJsonString()}."); + } + + private sealed record CommandResult( + int ExitCode, + string StandardOutput, + string StandardError); +} diff --git a/scripts/verify-product.ps1 b/scripts/verify-product.ps1 deleted file mode 100644 index af92bd8b..00000000 --- a/scripts/verify-product.ps1 +++ /dev/null @@ -1,341 +0,0 @@ -[CmdletBinding()] -param( - [string]$Prompt = "Who are you and what can you actually do?", - [string]$ChatName = "product-verification-$([Guid]::NewGuid().ToString('N'))", - [ValidateRange(1, 180)] - [int]$TimeoutSeconds = 180, - [switch]$KeepRunning -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$repository = Split-Path -Parent $PSScriptRoot -$appHost = Join-Path $repository "os/DigitalBrain.OS.AppHost/DigitalBrain.OS.AppHost.csproj" -$started = $false - -function Invoke-Checked { - param( - [Parameter(Mandatory)] - [string]$Command, - [Parameter(Mandatory)] - [string[]]$Arguments - ) - - & $Command @Arguments - if ($LASTEXITCODE -ne 0) { - throw "'$Command $($Arguments -join ' ')' exited with code $LASTEXITCODE." - } -} - -function Invoke-AspireJson { - param( - [Parameter(Mandatory)] - [string[]]$Arguments - ) - - $output = @(& aspire @Arguments 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "'aspire $($Arguments -join ' ')' exited with code $LASTEXITCODE." - } - - $lines = @($output | ForEach-Object { $_.ToString() }) - if ($lines.Count -eq 0) { - return $null - } - - $jsonStart = 0..($lines.Count - 1) | - Where-Object { $lines[$_].TrimStart() -match "^[\[\{]" } | - Select-Object -First 1 - if ($null -eq $jsonStart) { - return $null - } - - return ($lines[$jsonStart..($lines.Count - 1)] -join "`n") | - ConvertFrom-Json -} - -function Require { - param( - [Parameter(Mandatory)] - [bool]$Condition, - [Parameter(Mandatory)] - [string]$Message - ) - - if (-not $Condition) { - throw $Message - } -} - -Push-Location $repository -try { - & aspire stop --apphost $appHost --non-interactive 2>$null - - Invoke-Checked dotnet @("build", "DigitalBrain.slnx", "-c", "Release") - Invoke-Checked aspire @( - "start", - "--apphost", $appHost, - "--format", "Json", - "--non-interactive") - $started = $true - - foreach ($resource in @( - "silo", - "digitalbrain-mcp", - "digitalbrain-ui", - "brain-ai-gemma4", - "digitalbrain-flutter")) { - Invoke-Checked aspire @( - "wait", $resource, - "--apphost", $appHost, - "--timeout", "180", - "--non-interactive") - } - - $uiDescription = Invoke-AspireJson @( - "describe", "digitalbrain-ui", - "--apphost", $appHost, - "--format", "Json", - "--non-interactive") - $uiResource = @($uiDescription.resources) | - Where-Object displayName -eq "digitalbrain-ui" | - Select-Object -First 1 - $uiUrl = @($uiResource.urls) | - Where-Object name -eq "http" | - Select-Object -ExpandProperty url -First 1 - Require ` - -Condition (-not [string]::IsNullOrWhiteSpace($uiUrl)) ` - -Message "Aspire did not expose the digitalbrain-ui HTTP endpoint." - - $tools = @() - for ($attempt = 1; $attempt -le 90 -and $tools.Count -eq 0; $attempt++) { - $tools = @( - Invoke-AspireJson @( - "mcp", "tools", - "--apphost", $appHost, - "--format", "Json", - "--non-interactive")) - if ($tools.Count -eq 0) { - Start-Sleep -Seconds 1 - } - } - $toolNames = @($tools | ForEach-Object tool) - Write-Host "Discovered MCP tools: $($toolNames -join ', ')" - $expectedTools = @( - "list_active_neurons", - "read_chat_transcript", - "read_neuron_journal", - "send_chat_message") - Require ` - -Condition ($toolNames.Count -eq $expectedTools.Count) ` - -Message "Expected $($expectedTools.Count) MCP tools but found $($toolNames.Count)." - foreach ($tool in $expectedTools) { - Require ` - -Condition ($toolNames -contains $tool) ` - -Message "MCP tool '$tool' was not discovered." - } - Require ` - -Condition ($toolNames -notcontains "ask_llama32") ` - -Message "The retired model-specific MCP tool is still exposed." - - $sendInput = @{ - text = $Prompt - chatName = $ChatName - timeoutSeconds = $TimeoutSeconds - } | ConvertTo-Json -Compress - $result = Invoke-AspireJson @( - "mcp", "call", - "digitalbrain-mcp", "send_chat_message", - "--input", $sendInput, - "--apphost", $appHost, - "--non-interactive") - - Require ` - -Condition (-not [string]::IsNullOrWhiteSpace($result.response)) ` - -Message "DigitalBrain returned an empty assistant response." - Require ` - -Condition ($result.commandId -match "^[0-9a-f]{32}$") ` - -Message "The MCP result did not return a valid command ID." - Require ` - -Condition ($result.correlationId -match "^[0-9a-f]{32}$") ` - -Message "The MCP result did not return a valid correlation ID." - - $chatInput = @{ chatName = $ChatName } | ConvertTo-Json -Compress - $transcript = Invoke-AspireJson @( - "mcp", "call", - "digitalbrain-mcp", "read_chat_transcript", - "--input", $chatInput, - "--apphost", $appHost, - "--non-interactive") - Require ` - -Condition ($transcript.turns.Count -eq 2) ` - -Message "Expected one user and one assistant transcript turn." - Require ` - -Condition ( - $transcript.turns[0].speaker -eq "you" -and - $transcript.turns[0].text -eq $Prompt) ` - -Message "The durable transcript does not contain the sent user turn." - Require ` - -Condition ( - $transcript.turns[1].speaker -eq "brain" -and - $transcript.turns[1].text -eq $result.response) ` - -Message "The durable transcript does not contain the returned assistant turn." - - $journalInput = @{ - grainType = "chat" - name = $ChatName - kind = "outgoing" - afterSequence = 0 - } | ConvertTo-Json -Compress - $journal = Invoke-AspireJson @( - "mcp", "call", - "digitalbrain-mcp", "read_neuron_journal", - "--input", $journalInput, - "--apphost", $appHost, - "--non-interactive") - Require ` - -Condition ($journal.entries.Count -eq 2) ` - -Message "Expected exactly two outgoing chat journal entries." - Require ` - -Condition ( - $journal.entries[0].synapse -eq "UserMessaged" -and - $journal.entries[1].synapse -eq "AssistantResponded") ` - -Message "Chat journal entries are missing or out of order." - Require ` - -Condition ( - $journal.entries[0].correlation -eq $result.correlationId -and - $journal.entries[1].correlation -eq $result.correlationId) ` - -Message "Chat journal entries do not share the MCP result correlation." - - $expectedModules = @( - "DigitalBrain.AI.AIModule", - "DigitalBrain.Chat.ChatModule", - "DigitalBrain.Flutter.FlutterModule", - "DigitalBrain.Google.GoogleModule", - "DigitalBrain.OS.OSBehaviorsModule", - "DigitalBrain.Salesforce.SalesforceModule") - $expectedCapabilities = @( - "assistant.general", - "account-enrichment.gmail-salesforce-description") - $expectedChatNeuron = "chat:dev/$ChatName" - $topology = $null - $moduleIds = @() - $capabilityIds = @() - $activeChat = $null - for ($attempt = 1; $attempt -le 30 -and $null -eq $activeChat; $attempt++) { - try { - $topology = Invoke-RestMethod -Uri "$uiUrl/brain/topology" - $moduleIds = @($topology.modules | ForEach-Object id) - $capabilityIds = @($topology.capabilities | ForEach-Object id) - $activeChat = @($topology.neurons) | - Where-Object id -eq $expectedChatNeuron | - Select-Object -First 1 - } - catch { - $topology = $null - } - if ($null -eq $activeChat) { - Start-Sleep -Seconds 1 - } - } - Require ` - -Condition ($null -ne $topology) ` - -Message "The UI edge did not return a live brain topology." - Require ` - -Condition ($moduleIds.Count -eq $expectedModules.Count) ` - -Message "Expected $($expectedModules.Count) live modules but found $($moduleIds.Count)." - foreach ($module in $expectedModules) { - Require ` - -Condition ($moduleIds -contains $module) ` - -Message "Live topology is missing configured module '$module'." - } - Require ` - -Condition ($capabilityIds.Count -eq $expectedCapabilities.Count) ` - -Message "Expected $($expectedCapabilities.Count) configured capabilities but found $($capabilityIds.Count)." - foreach ($capability in $expectedCapabilities) { - Require ` - -Condition ($capabilityIds -contains $capability) ` - -Message "Live topology is missing configured capability '$capability'." - } - Require ` - -Condition ($null -ne $activeChat) ` - -Message "Live topology never exposed active neuron '$expectedChatNeuron'." - - $genAi = $null - for ($attempt = 1; $attempt -le 60 -and $null -eq $genAi; $attempt++) { - $genAiSpans = @( - Invoke-AspireJson @( - "otel", "spans", - "--apphost", $appHost, - "--format", "Json", - "--limit", "50", - "--search", "gen_ai", - "--non-interactive")) - $genAi = $genAiSpans | - Where-Object { - $_.name -like "chat gemma4:*" -and - $_.attributes.'gen_ai.operation.name' -eq "chat" -and - $_.attributes.'gen_ai.provider.name' -eq "ollama" - } | - Select-Object -First 1 - if ($null -eq $genAi) { - Start-Sleep -Seconds 1 - } - } - Require ` - -Condition ($null -ne $genAi) ` - -Message "No Gemma4 OpenTelemetry chat span was exported." - Require ` - -Condition ( - -not $genAi.attributes.PSObject.Properties[ - "gen_ai.input.messages"] -and - -not $genAi.attributes.PSObject.Properties[ - "gen_ai.output.messages"]) ` - -Message "The GenAI span exported sensitive message content." - - $completion = $null - for ($attempt = 1; $attempt -le 60 -and $null -eq $completion; $attempt++) { - $toolLogs = @( - Invoke-AspireJson @( - "otel", "logs", - "--apphost", $appHost, - "--format", "Json", - "--limit", "20", - "--search", "send_chat_message", - "--non-interactive")) - $completion = $toolLogs | - Where-Object { - $_.attributes.ToolName -eq "send_chat_message" -and - $_.attributes.IsError -eq "false" - } | - Select-Object -First 1 - if ($null -eq $completion) { - Start-Sleep -Seconds 1 - } - } - Require ` - -Condition ($null -ne $completion) ` - -Message "No successful structured MCP completion log was exported." - - [pscustomobject]@{ - Chat = $result.chat - CommandId = $result.commandId - CorrelationId = $result.correlationId - Response = $result.response - GenAiTraceId = $genAi.traceId - GenAiDurationMs = $genAi.durationMs - InputTokens = $genAi.attributes.'gen_ai.usage.input_tokens' - OutputTokens = $genAi.attributes.'gen_ai.usage.output_tokens' - TopologyModules = $moduleIds.Count - ConfiguredCapabilities = $capabilityIds.Count - ActiveNeuron = $activeChat.id - } | Format-List -} -finally { - Pop-Location - - if ($started -and -not $KeepRunning) { - & aspire stop --apphost $appHost --non-interactive - } -} diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs index a22e78a9..6658a534 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs @@ -31,7 +31,11 @@ public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient { var builder = DistributedApplication.CreateBuilder(); var brain = builder.AddDigitalBrain("brain"); - brain.AddModule(ai => ai.WithLlm()); + brain.AddModule(ai => + { + ai.EnableSensitiveData = true; + ai.WithLlm(); + }); brain.AddModule(google => google.WithGmail()); brain.AddModule(salesforce => salesforce.WithSalesforce()); @@ -80,6 +84,14 @@ public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient clientEnvironment[ $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__0"] ?.ToString()); + Assert.Equal( + bool.TrueString, + (await FlutterHostingProjectionSupport.EnvironmentOf(silo.Resource).ConfigureAwait(true))[ + "DigitalBrain__AI__Telemetry__EnableSensitiveData"] + ?.ToString()); + Assert.DoesNotContain( + "DigitalBrain__AI__Telemetry__EnableSensitiveData", + clientEnvironmentKeys); Assert.Equal( "google.gmail", clientEnvironment[ diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs index 02927790..9cbefcca 100644 --- a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs +++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs @@ -3,8 +3,6 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; using Xunit; namespace DigitalBrain.ServiceDefaultsTests; @@ -34,18 +32,4 @@ public async Task ServiceDefaultsExposeHealthyReadinessAndLivenessEndpoints() Assert.Equal(HealthStatus.Healthy, health.Status); Assert.Equal(["/alive", "/health"], routes); } - - [Fact(DisplayName = "service defaults register tracing, metrics, and resilient HTTP clients")] - public async Task ServiceDefaultsRegisterTelemetryAndResilientHttpClients() - { - var builder = WebApplication.CreateBuilder(); - builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] = ""; - builder.AddServiceDefaults(); - - await using var app = builder.Build(); - - Assert.NotNull(app.Services.GetRequiredService()); - Assert.NotNull(app.Services.GetRequiredService()); - Assert.NotNull(app.Services.GetRequiredService()); - } } diff --git a/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj b/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj index decce357..bbcf6e70 100644 --- a/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj +++ b/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj @@ -7,6 +7,7 @@ + diff --git a/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs b/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs index 5346c522..992ecdc9 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs @@ -15,7 +15,13 @@ internal static void Configure(IServiceCollection services, IConfiguration confi ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); - services.AddHttpClient(); + var http = services + .AddHttpClient( + McpRuntime.HttpClientName, + static client => client.Timeout = TimeSpan.FromMinutes(5)); +#pragma warning disable EXTEXP0001 + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 DurablePayloadProtectionHosting.Configure(services, configuration); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs deleted file mode 100644 index f90ee8ef..00000000 --- a/src/core/testing/DigitalBrain.Testing.Tests/LongRunningNeuronContracts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using DigitalBrain.Abstractions; -using Xunit; - -namespace DigitalBrain.Testing.Tests; - -public sealed class LongRunningNeuronContracts -{ - [Theory] - [InlineData(typeof(INeuron), nameof(INeuron.Deliver))] - [InlineData(typeof(INeuron), nameof(INeuron.ReadJournal))] - [InlineData(typeof(ISessionNeuron), nameof(ISessionNeuron.ReadNeuronJournal))] - public void LongRunningNeuronCallsDeclareProductTimeout(Type contract, string methodName) - { - ArgumentNullException.ThrowIfNull(contract); - var method = contract.GetMethod(methodName); - var timeout = method?.GetCustomAttribute(); - - Assert.NotNull(timeout); - Assert.Equal(TimeSpan.FromMinutes(5), timeout.Timeout); - } -} diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs b/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs index 0ff144c1..8c065a5e 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs @@ -7,7 +7,7 @@ namespace DigitalBrain.Testing; public abstract class DigitalBrainAppHostFixture : IAsyncLifetime { - private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(5); private readonly object _sync = new(); private RunningAppHost? _active; private bool _disposed; @@ -113,7 +113,6 @@ private async Task StartCoreAsync( } catch { - // Preserve the startup failure. } } @@ -125,7 +124,6 @@ private async Task StartCoreAsync( } catch { - // Preserve the startup failure. } } diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs index 5a3d3373..adfec753 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs @@ -9,7 +9,7 @@ namespace DigitalBrain.Testing; public sealed class RunningAppHost : IAsyncDisposable { private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(5); private readonly DistributedApplication _application; private readonly TimeSpan _cleanupTimeout; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs index 9c3f8689..564a6f03 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs @@ -9,17 +9,35 @@ namespace DigitalBrain.AI.Aspire.Hosting; public static class AIHostingExtensions { + private const string EnableSensitiveDataEnvironmentKey = + "DigitalBrain__AI__Telemetry__EnableSensitiveData"; + public const string Llama32Feature = "ai.llm.llama32"; public const string Gemma4Feature = "ai.llm.gemma4"; public const string Qwen35Feature = "ai.llm.qwen35"; public const string Granite41Feature = "ai.llm.granite41"; public const string Gpt56Feature = "ai.llm.gpt56"; + extension(DigitalBrainModuleBuilder module) + { + public bool EnableSensitiveData + { + get => State(module).EnableSensitiveData; + set => State(module).EnableSensitiveData = value; + } + } + public static DigitalBrainModuleBuilder WithLlm(this DigitalBrainModuleBuilder module) where TModel : class, ILLM { - ArgumentNullException.ThrowIfNull(module); + var state = State(module); + module.ConfigureFeature(state.Add()); + return module; + } + private static AIHostingState State(DigitalBrainModuleBuilder module) + { + ArgumentNullException.ThrowIfNull(module); var state = module.Brain.GetOrAddState(static brain => new AIHostingState(brain), out var added); if (added) { @@ -27,8 +45,7 @@ public static DigitalBrainModuleBuilder WithLlm(this DigitalBr module.AddProjection(state); } - module.ConfigureFeature(state.Add()); - return module; + return state; } private sealed class AIHostingState(DigitalBrainBuilder brain) : DigitalBrainModuleProjection @@ -50,6 +67,8 @@ private sealed class AIHostingState(DigitalBrainBuilder brain) : DigitalBrainMod private IResourceBuilder? _gpt56; private IResourceBuilder? _openAIKey; + internal bool EnableSensitiveData { get; set; } + internal string Add() where TModel : class, ILLM { @@ -81,6 +100,10 @@ public override void Apply(IResourceBuilder builder) { ArgumentNullException.ThrowIfNull(builder); + builder.WithEnvironment( + EnableSensitiveDataEnvironmentKey, + EnableSensitiveData.ToString()); + foreach (var (model, resource) in _ollamaModels) { builder diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs deleted file mode 100644 index b19537cb..00000000 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AgentResponseTimeoutContracts.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Reflection; -using Xunit; - -namespace DigitalBrain.ModuleTests; - -public sealed class AgentResponseTimeoutContracts -{ - [Fact] - public void AgentResponsesDeclareProductTimeout() - { - var method = typeof(DigitalBrain.AI.IAgent) - .GetMethod(nameof(DigitalBrain.AI.IAgent.Respond)); - var timeout = method?.GetCustomAttribute(); - - Assert.NotNull(timeout); - Assert.Equal(TimeSpan.FromMinutes(5), timeout.Timeout); - } -} diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs index 7f23edc9..8cd5ae20 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs @@ -38,7 +38,9 @@ public async Task Respond(IReadOnlyList messages) ? messages : [new ChatMessage(ChatRole.System, instructions), .. messages]; - var response = await _toolCallingClient.GetResponseAsync(request, options); + var response = await _toolCallingClient + .GetStreamingResponseAsync(request, options) + .ToChatResponseAsync(); foreach (var selected in SelectedCapabilities(response)) { diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs index 570308f8..44074dcf 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs @@ -13,7 +13,10 @@ namespace DigitalBrain.AI; internal static class AIClients { private const string ConfigurationRoot = "DigitalBrain:AI"; + private const string EnableSensitiveDataKey = + $"{ConfigurationRoot}:Telemetry:EnableSensitiveData"; private const string TelemetrySource = "DigitalBrain.AI"; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(5); internal static void Add(IServiceCollection services) { @@ -31,13 +34,19 @@ private static void AddOllamaModel(IServiceCollection services, string d where TModel : LLM => services.AddKeyedSingleton( typeof(TModel), - (provider, _) => Ollama(provider.GetRequiredService(), typeof(TModel).Name, defaultTag)); + (provider, _) => Ollama( + provider.GetRequiredService(), + typeof(TModel).Name, + defaultTag)); [SuppressMessage( "Reliability", "CA2000:Dispose objects before losing scope", Justification = "The telemetry middleware owns and disposes the inner Ollama client.")] - private static IChatClient Ollama(IConfiguration configuration, string modelName, string defaultTag) + private static IChatClient Ollama( + IConfiguration configuration, + string modelName, + string defaultTag) { var endpoint = configuration[$"{ConfigurationRoot}:Ollama:Endpoint"]; if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) @@ -50,9 +59,18 @@ private static IChatClient Ollama(IConfiguration configuration, string modelName } var tag = configuration[$"{ConfigurationRoot}:Ollama:{modelName}:Model"] ?? defaultTag; + var enableSensitiveData = configuration.GetValue(EnableSensitiveDataKey); - return new ChatClientBuilder(new OllamaApiClient(endpointUri, tag)) - .UseOpenTelemetry(sourceName: $"{TelemetrySource}.{modelName}") + var http = new HttpClient + { + BaseAddress = endpointUri, + Timeout = RequestTimeout, + }; + + return new ChatClientBuilder(new OllamaApiClient(http, tag)) + .UseOpenTelemetry( + sourceName: $"{TelemetrySource}.{modelName}", + configure: telemetry => telemetry.EnableSensitiveData = enableSensitiveData) .Build(); } @@ -62,6 +80,7 @@ private static IChatClient OpenAI(IConfiguration configuration) ?? throw new InvalidOperationException( "Gpt56 requires DigitalBrain:AI:OpenAI:ApiKey. Configure it through AIModule.WithLlm() in AppHost."); var model = configuration[$"{ConfigurationRoot}:OpenAI:Gpt56:Model"] ?? "gpt-5.6"; + var enableSensitiveData = configuration.GetValue(EnableSensitiveDataKey); var options = new OpenAIClientOptions(); if (configuration[$"{ConfigurationRoot}:OpenAI:Endpoint"] is { } endpoint) @@ -74,7 +93,10 @@ private static IChatClient OpenAI(IConfiguration configuration) .AsIChatClient(); return new ChatClientBuilder(client) - .UseOpenTelemetry(sourceName: $"{TelemetrySource}.{nameof(Gpt56)}") + .UseStreamingUsage() + .UseOpenTelemetry( + sourceName: $"{TelemetrySource}.{nameof(Gpt56)}", + configure: telemetry => telemetry.EnableSensitiveData = enableSensitiveData) .Build(); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs new file mode 100644 index 00000000..91091275 --- /dev/null +++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.AI; +using System.Text.Json; + +namespace DigitalBrain.AI; + +internal static class StreamingUsageChatClientExtensions +{ + private const string StreamOptionsKey = "stream_options"; + private static readonly JsonDocument StreamOptionsDoc = JsonDocument.Parse("""{"include_usage":true}"""); + + extension(ChatClientBuilder builder) + { + public ChatClientBuilder UseStreamingUsage() + { + return builder.Use(async (messages, options, next, cancellationToken) => + { + options = EnsureStreamOptions(options); + await next(messages, options, cancellationToken); + }); + } + } + + private static ChatOptions EnsureStreamOptions(ChatOptions? options) + { + if (options is null) + { + return new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [StreamOptionsKey] = StreamOptionsDoc.RootElement.Clone(), + }, + }; + } + + var clonedOptions = options.Clone(); + clonedOptions.AdditionalProperties ??= []; + + if (!clonedOptions.AdditionalProperties.ContainsKey(StreamOptionsKey)) + { + clonedOptions.AdditionalProperties[StreamOptionsKey] = StreamOptionsDoc.RootElement.Clone(); + } + + return clonedOptions; + } +} diff --git a/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs b/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs index ffe81e08..cc7ab163 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs @@ -14,15 +14,24 @@ internal sealed class ChatNeuron : IEmit, IEmit { + private const string CommandOrderName = "chat.command-order"; + private const string CommandsName = "chat.commands"; private const string TranscriptName = "chat.transcript"; + private const int RememberedCommands = 64; private const int RetainedTurns = 64; + private readonly IDurableList _commandOrder; + private readonly IDurableDictionary _commands; private readonly IDurableList _transcript; + private readonly Serializer _texts; private readonly Serializer _turns; public ChatNeuron() { + _commandOrder = ServiceProvider.GetRequiredKeyedService>(CommandOrderName); + _commands = ServiceProvider.GetRequiredKeyedService>(CommandsName); _transcript = ServiceProvider.GetRequiredKeyedService>(TranscriptName); + _texts = ServiceProvider.GetRequiredService>(); _turns = ServiceProvider.GetRequiredService>(); } @@ -30,8 +39,29 @@ public async Task Send(SendMessage message) { ArgumentNullException.ThrowIfNull(message); ArgumentException.ThrowIfNullOrWhiteSpace(message.Text); + if (message.CommandId.Value == Guid.Empty) + { + throw new ArgumentException( + "The command id cannot be empty.", + nameof(message)); + } + + if (_commands.TryGetValue(message.CommandId.Value, out var serialized)) + { + if (!string.Equals( + _texts.Deserialize(serialized), + message.Text, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "A chat command id cannot be reused with different text."); + } + + return; + } - await RememberAsync(new ChatTurn(FromUser: true, message.Text)); + Remember(message.CommandId, message.Text); + Remember(new ChatTurn(FromUser: true, message.Text)); await EmitAsync(new UserMessaged(message.CommandId, Id, message.Text, Turns())); } @@ -45,7 +75,7 @@ public async Task HandleAsync(AssistantAnswered synapse, CancellationToken cance return; } - await RememberAsync(new ChatTurn(FromUser: false, synapse.Text)); + Remember(new ChatTurn(FromUser: false, synapse.Text)); await EmitAsync(new AssistantResponded(synapse.CommandId, Id, synapse.Text)); } @@ -53,7 +83,19 @@ public async Task HandleAsync(AssistantAnswered synapse, CancellationToken cance private IReadOnlyList Turns() => [.. _transcript.Select(_turns.Deserialize)]; - private async Task RememberAsync(ChatTurn turn) + private void Remember(CommandId commandId, string text) + { + _commands[commandId.Value] = _texts.SerializeToArray(text); + _commandOrder.Add(commandId.Value); + + while (_commandOrder.Count > RememberedCommands) + { + _commands.Remove(_commandOrder[0]); + _commandOrder.RemoveAt(0); + } + } + + private void Remember(ChatTurn turn) { _transcript.Add(_turns.SerializeToArray(turn)); @@ -61,7 +103,5 @@ private async Task RememberAsync(ChatTurn turn) { _transcript.RemoveAt(0); } - - await WriteStateAsync(CancellationToken.None); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs deleted file mode 100644 index e1f90c01..00000000 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/BrainCapabilityCatalogTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace DigitalBrain.UI.Tests; - -public sealed class BrainCapabilityCatalogTests -{ - private static readonly BrainModule[] ProductModules = - [ - new("DigitalBrain.AI.AIModule"), - new("DigitalBrain.Chat.ChatModule"), - new("DigitalBrain.Flutter.FlutterModule"), - new("DigitalBrain.Google.GoogleModule"), - new("DigitalBrain.OS.OSBehaviorsModule"), - new("DigitalBrain.Salesforce.SalesforceModule"), - ]; - - [Fact(DisplayName = - "all modules without configured providers claims no product capability")] - public void CompleteModuleSetWithoutConfiguredFeaturesClaimsNothing() - { - var capabilities = BrainCapabilityCatalog.Resolve(ProductModules, Configuration()); - - Assert.Empty(capabilities); - } - - [Fact(DisplayName = - "configured providers without OS responder claims no product capability")] - public void ConfiguredFeaturesWithoutOSResponderClaimsNothing() - { - var modules = ProductModules - .Where(module => module.Id != "DigitalBrain.OS.OSBehaviorsModule") - .ToArray(); - - var capabilities = BrainCapabilityCatalog.Resolve(modules, Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); - - Assert.Empty(capabilities); - } - - [Fact(DisplayName = - "configured non-Gemma model claims no product capability")] - public void NonGemmaModelClaimsNothing() - { - var capabilities = BrainCapabilityCatalog.Resolve(ProductModules, Configuration("ai.llm.llama32", "google.gmail", "salesforce")); - - Assert.Empty(capabilities); - } - - [Fact(DisplayName = - "configured Gemma product manifest claims general assistant and account enrichment")] - public void ConfiguredGemmaProductManifestClaimsExactCapabilities() - { - var capabilities = BrainCapabilityCatalog.Resolve(ProductModules, Configuration("ai.llm.gemma4", "google.gmail", "salesforce")); - - Assert.Equal( - [ - BrainCapabilityCatalog.GeneralAssistant, - BrainCapabilityCatalog.AccountEnrichment, - ], - capabilities.Select(capability => capability.Id)); - } - - private static IConfiguration Configuration(params string[] features) - { - var values = features - .Select((feature, index) => - new KeyValuePair($"DigitalBrain:ConfiguredFeatures:{index}", feature)); - return new ConfigurationBuilder() - .AddInMemoryCollection(values) - .Build(); - } -} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs index 8c5830c2..9a6d7f74 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs @@ -151,7 +151,6 @@ await test.Client.Get(UIFixture.DefaultShellName).Open( Assert.Contains(topology.Modules, module => module.Id == FlutterModule.Id.Value); Assert.Contains(topology.Modules, module => module.Id == ChatModule.Id.Value); Assert.Contains(topology.Neurons, neuron => neuron.Id == shell.Id.ToString()); - Assert.Empty(topology.Capabilities); Assert.All(topology.Neurons, neuron => Assert.StartsWith("cluster-", neuron.Placement, StringComparison.Ordinal)); } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs deleted file mode 100644 index ab920cb5..00000000 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainCapabilityCatalog.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace DigitalBrain.UI; - -internal static class BrainCapabilityCatalog -{ - internal const string GeneralAssistant = "assistant.general"; - internal const string AccountEnrichment = - "account-enrichment.gmail-salesforce-description"; - - private const string AIModule = "DigitalBrain.AI.AIModule"; - private const string ChatModule = "DigitalBrain.Chat.ChatModule"; - private const string GoogleModule = "DigitalBrain.Google.GoogleModule"; - private const string OSModule = "DigitalBrain.OS.OSBehaviorsModule"; - private const string SalesforceModule = "DigitalBrain.Salesforce.SalesforceModule"; - private const string Gemma4Feature = "ai.llm.gemma4"; - private const string GmailFeature = "google.gmail"; - private const string SalesforceFeature = "salesforce"; - - internal static IReadOnlyList Resolve(IReadOnlyList modules, IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(modules); - ArgumentNullException.ThrowIfNull(configuration); - - var moduleIds = modules - .Select(static module => module.Id) - .ToHashSet(StringComparer.Ordinal); - var features = configuration - .GetSection("DigitalBrain:ConfiguredFeatures") - .GetChildren() - .Select(static section => section.Value) - .Where(static feature => !string.IsNullOrWhiteSpace(feature)) - .ToHashSet(StringComparer.Ordinal); - var generalAssistant = - moduleIds.Contains(AIModule) && - moduleIds.Contains(ChatModule) && - moduleIds.Contains(OSModule) && - features.Contains(Gemma4Feature); - var accountEnrichment = - generalAssistant && - moduleIds.Contains(GoogleModule) && - moduleIds.Contains(SalesforceModule) && - features.Contains(GmailFeature) && - features.Contains(SalesforceFeature); - - var capabilities = new List(2); - if (generalAssistant) - { - capabilities.Add(new BrainCapability(GeneralAssistant)); - } - - if (accountEnrichment) - { - capabilities.Add(new BrainCapability(AccountEnrichment)); - } - - return capabilities; - } -} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs index dc023cae..983816a0 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs @@ -2,12 +2,9 @@ namespace DigitalBrain.UI; internal sealed record BrainTopologySnapshot( IReadOnlyList Modules, - IReadOnlyList Capabilities, IReadOnlyList Neurons, DateTimeOffset ObservedAt); internal sealed record BrainModule(string Id); -internal sealed record BrainCapability(string Id); - internal sealed record BrainNeuron(string Id, string GrainType, string Identity, string Placement); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs index 39666363..e60e80f7 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs @@ -46,7 +46,6 @@ public async Task ReadAsync(CancellationToken cancellatio return new BrainTopologySnapshot( modules, - BrainCapabilityCatalog.Resolve(modules, configuration), neurons, TimeProvider.System.GetUtcNow()); } From e0bc34476332e71d154a69caad0ce4027281d9de Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 18:19:44 +0200 Subject: [PATCH 19/20] fix: honor five-minute infrastructure boundaries Give journal watch calls the same long-running Orleans response contract as journal reads, and allow AppHost graph cleanup the five-minute resource budget used by startup and operations. No-consumer check: none; the existing SSE journal path and AppHost fixture cleanup consume these boundaries. Unverified-claim check: none; both owning suites and the exact solution-wide CI command were run. Unowned-change check: none. --- src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs | 1 + .../kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs | 1 + src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs index 1afc542c..253f6c44 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs @@ -11,6 +11,7 @@ public partial interface INeuron : IGrainWithStringKey Task ReadJournal(JournalKind kind, long afterSequence); [Alias(nameof(Watch))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task Watch(JournalKind kind, long afterSequence, IJournalObserver observer); [Alias(nameof(Unwatch))] diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs index 3c728344..884e63ce 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs @@ -24,6 +24,7 @@ static NeuronId ForOwner(OwnerId owner) Task ReadNeuronJournal(NeuronId subject, JournalKind kind, long afterSequence); [Alias(nameof(WatchNeuron))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task WatchNeuron(NeuronId subject, JournalKind kind, long afterSequence, IJournalObserver observer); [Alias(nameof(UnwatchNeuron))] diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs index adfec753..522ba269 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs @@ -8,7 +8,7 @@ namespace DigitalBrain.Testing; public sealed class RunningAppHost : IAsyncDisposable { - private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(5); private readonly DistributedApplication _application; From 93fefc8d5143dbe06c81907aa32a311df4783730 Mon Sep 17 00:00:00 2001 From: Vladyslav Horbachov <0965142838gvs@gmail.com> Date: Tue, 28 Jul 2026 18:29:02 +0200 Subject: [PATCH 20/20] fix: use Aspire graph lifecycle for cleanup Delete the redundant per-resource stop-command pass. DistributedApplication.StopAsync and DisposeAsync own graph teardown; individual stop commands are test actions and returned false failures on Linux CI even when the graph and exclusive lease were released. No-consumer check: none; this removes an unneeded layer rather than adding surface. Unverified-claim check: none; the host suite passed twice and the exact 252-test CI command passed. Unowned-change check: none. --- .../Hosting/RunningAppHost.cs | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs index 522ba269..be66273a 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs @@ -78,10 +78,6 @@ public async ValueTask DisposeAsync() using var cleanup = new CancellationTokenSource(_cleanupTimeout); var failures = new List(); - await AttemptAsync( - () => StopRuntimeResourcesAsync(cleanup.Token), - failures, - cleanup.Token); await AttemptAsync( () => _application.StopAsync(cleanup.Token), failures, @@ -182,69 +178,6 @@ private static void ReportFailures(List failures) primary); } - private async Task StopRuntimeResourcesAsync( - CancellationToken cancellationToken) - { - var stoppable = _resourceNames - .Where(id => !IsTerminal(id) && AdvertisesStop(id)) - .Order(StringComparer.Ordinal) - .ToArray(); - - await Task.WhenAll( - stoppable.Select(id => StopOneAsync(id, cancellationToken))); - } - - private bool AdvertisesStop(string resourceId) - => _application.ResourceNotifications.TryGetCurrentState( - resourceId, - out var resourceEvent) - && resourceEvent is not null - && resourceEvent.Snapshot.Commands.Any(command => - string.Equals( - command.Name, - KnownResourceCommands.StopCommand, - StringComparison.Ordinal)); - - private bool IsTerminal(string resourceId) - => _application.ResourceNotifications.TryGetCurrentState( - resourceId, - out var resourceEvent) - && resourceEvent?.Snapshot.State?.Text is { } state - && KnownResourceStates.TerminalStates.Contains( - state, - StringComparer.Ordinal); - - private async Task StopOneAsync( - string resourceId, - CancellationToken cancellationToken) - { - try - { - var result = await _application.ResourceCommands.ExecuteCommandAsync( - resourceId, - KnownResourceCommands.StopCommand, - cancellationToken); - -#pragma warning disable CS0618 - var errorMessage = result.ErrorMessage; -#pragma warning restore CS0618 - - if (!result.Success && !IsTerminal(resourceId)) - { - throw new InvalidOperationException( - $"Stop failed for '{resourceId}': {errorMessage ?? result.Message}"); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - if (!IsTerminal(resourceId)) - { - throw new TimeoutException( - $"Stop timed out for '{resourceId}' within the AppHost cleanup budget."); - } - } - } - private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf( Volatile.Read(ref _disposed) != 0,