From 1e22a7886352574c3b42adf841d3464bf2c2e641 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:17:55 +0200 Subject: [PATCH 1/7] refactor(di): split module service composition --- ...mented-dependency-injection-composition.md | 110 ++++++ docs/architecture.md | 4 + docs/architecture/dependency-injection.md | 103 ++++++ ...AgentRuntimeServiceCollectionExtensions.cs | 44 +++ ...AgentstrationServiceRegistrationOptions.cs | 60 ++++ ...ControlPlaneServiceCollectionExtensions.cs | 30 ++ .../FlowPlaneServiceCollectionExtensions.cs | 66 ++++ .../FoundationServiceCollectionExtensions.cs | 40 +++ .../PackServiceCollectionExtensions.cs | 29 ++ .../RuntimeRunServiceCollectionExtensions.cs | 38 +++ ...AndBootstrapServiceCollectionExtensions.cs | 57 ++++ .../SourceServiceCollectionExtensions.cs | 58 ++++ ...ngAndTriggerServiceCollectionExtensions.cs | 67 ++++ .../WorkPlaneServiceCollectionExtensions.cs | 42 +++ .../DependencyInjection.cs | 323 ++---------------- ...elManagementServiceCollectionExtensions.cs | 4 +- ...odelProviderServiceCollectionExtensions.cs | 6 +- ...onsoleClientServiceCollectionExtensions.cs | 112 ++++++ ...oleComponentServiceCollectionExtensions.cs | 26 ++ ...soleSecurityServiceCollectionExtensions.cs | 222 ++++++++++++ .../WebConsoleServiceCollectionExtensions.cs | 286 ++-------------- .../WebHostServiceCollectionExtensions.cs | 249 ++++++++++++++ src/Agentstration.Web/Program.cs | 160 ++------- src/Agentstration.Workplace.Web/Program.cs | 32 +- ...placeWebHostServiceCollectionExtensions.cs | 70 ++++ .../DependencyTests.cs | 55 +++ .../DependencyInjectionCompositionTests.cs | 130 +++++++ .../QuartzHostLifecycleTests.cs | 23 ++ 28 files changed, 1730 insertions(+), 716 deletions(-) create mode 100644 docs/ai-defects/0240-fragmented-dependency-injection-composition.md create mode 100644 docs/architecture/dependency-injection.md create mode 100644 src/Agentstration.Infrastructure/Composition/AgentRuntimeServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/AgentstrationServiceRegistrationOptions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/ControlPlaneServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/PackServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/SecurityAndBootstrapServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/SourceServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/ToolingAndTriggerServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Web/Configuration/WebConsoleClientServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs create mode 100644 src/Agentstration.Workplace.Web/WorkplaceWebHostServiceCollectionExtensions.cs create mode 100644 tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs diff --git a/docs/ai-defects/0240-fragmented-dependency-injection-composition.md b/docs/ai-defects/0240-fragmented-dependency-injection-composition.md new file mode 100644 index 00000000..259747ef --- /dev/null +++ b/docs/ai-defects/0240-fragmented-dependency-injection-composition.md @@ -0,0 +1,110 @@ +# CAR-0240: Fragmented dependency injection composition + +## Status + +Open — 2026-09-10 + +## References + +- Issue: #240 +- Introducing change: #30 for the duplicate Runtime execution-scope registration; broader composition growth spans multiple changes +- Corrective pull request: Pending +- Related ADRs: ADR-0001, ADR-0032 + +## Defect + +The standalone composition accumulated 154 direct service registrations in one +`AddAgentstration` method while additional registrations remained in the Web +and Workplace executable roots and in a broad Console extension. + +The default container silently accepted an exact duplicate +`IRuntimeRunExecutionScope` registration. Other exactly-one services relied on +caller order to replace fallbacks, including the Model Profile reference +validator and configured GenAI observability options. This contradicted the +repository's explicit modular-monolith boundaries and made the effective graph +hard to review. + +## Detection + +- Stage: Code review +- Detection mechanism: issue #240 audited all production + `IServiceCollection` calls and compared exact service descriptors across the + complete host composition. +- Why it was not detected earlier: functional host tests resolved only the last + descriptor selected by the default container. They did not inspect descriptor + cardinality, so duplicate exactly-one registrations remained invisible. + +## Causal analysis + +### Faulty approach + +AI-assisted feature changes appended registrations near the feature being +implemented without assigning durable registration ownership to a module-level +composition extension. Pull request #30 added a Runtime execution-scope +registration near the Runtime queue even though pull request #28 had already +registered the same contract near Runtime Run services. + +Later changes followed the same append-to-root pattern. The application +continued to start because Microsoft.Extensions.DependencyInjection resolves +the last descriptor for a single service request. + +### Contributing assumptions + +- Successful host startup was treated as evidence that each exactly-one + contract had one descriptor. +- Registration order was assumed to be a sufficient replacement mechanism for + standalone fallbacks. +- Keeping registrations in one composition root was treated as equivalent to + keeping their module ownership explicit. +- Feature-level tests were assumed to cover the structure of the complete + service graph. + +### Missed signals + +- Existing storage, identity, Model Provider, Management, MCP, and UI + registration extensions already demonstrated cohesive ownership. +- `Agentstration.Infrastructure` is documented as composition support for + explicit module boundaries, but its public method had become a multi-module + implementation body. +- The two identical `IRuntimeRunExecutionScope` statements were visible in the + same file. +- No test enumerated `ServiceDescriptor` instances for exactly-one contracts. + +### Safeguard gap + +The repository validated behavior and host lifecycle but did not validate +descriptor count, lifetime, implementation selection, or representative +provider graphs with `ValidateOnBuild` and `ValidateScopes`. The default +container's last-registration-wins behavior therefore masked duplicates and +undocumented replacements. + +## Resolution + +The platform composition is split into focused extensions for foundation, +control-plane storage, security/bootstrap, agent runtime, Packs, Sources, +tools/Triggers, Runtime Runs, Work, and Flows. The public `AddAgentstration` +method is retained as a small façade and accepts a cohesive options object, +while its existing overload remains compatible. + +Web, Console, and Workplace registrations are delegated to focused host +extensions. The duplicate Runtime execution-scope descriptor is removed. +Configured GenAI options, the Management-backed Model Profile validator, and +the server composite Flow event sink now use explicit replacement semantics. + +## Prevention + +Registration-contract tests now inspect the descriptor collection before +provider creation. They assert cardinality for exactly-one contracts, enumerate +intentional multi-bindings, verify Deterministic and Managed resolver +selection, and validate SQLite and PostgreSQL composition. + +Architecture tests keep executable roots free of direct concrete +registrations and keep `AddAgentstration` as a façade. The durable ownership map +in `docs/architecture/dependency-injection.md` documents where new +registrations belong and when `TryAdd`, `Replace`, or repeated `Add` is valid. + +## Validation + +- Static diff and whitespace validation completed. +- .NET restore, build, and MSTest validation pending in GitHub Actions because + the local execution environment does not provide the .NET SDK. diff --git a/docs/architecture.md b/docs/architecture.md index 144710d3..92b4df1c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,6 +82,10 @@ Runtime.Storage.Sqlite -> Runtime.Abstractions + EF Core SQLite Work.Storage.Sqlite -> Work storage abstractions + EF Core SQLite ``` +The executable dependency-injection ownership map and registration semantics +are documented in +[Dependency injection composition](architecture/dependency-injection.md). + Canonical Management resources and provider-neutral ports live in `Management.Abstractions`; validation and use cases live in `Management.Core`. SQLite and EF Core are confined to module-specific storage projects. Concrete `AIAgent` types are confined to `Runtime.AgentFramework`. Foundry is absent from every central project. `Agentstration.Resources` contains the neutral namespace, scope-reference, and address value types shared across boundaries. Management resources retain globally unique UIDs and use `(scope, namespace, kind, name)` as their exact logical identity. Canonical scope references are `/instance`, `/tenants/{tenantId}`, and `/workspaces/{workspaceId}`. Existing workspace callers implicitly use their current workspace and the `default` namespace. Relative references inherit their owner's namespace; explicit cross-namespace references retain the supplied namespace. See ADR-0035 and ADR-0079. diff --git a/docs/architecture/dependency-injection.md b/docs/architecture/dependency-injection.md new file mode 100644 index 00000000..7ebb6a4c --- /dev/null +++ b/docs/architecture/dependency-injection.md @@ -0,0 +1,103 @@ +# Dependency injection composition + +Agentstration uses explicit `IServiceCollection` extensions as the executable +map of its modular-monolith boundaries. Registration is intentionally not +assembly-scanned: optional providers, storage selection, security policies, +hosted services, and ordered multi-bindings must remain visible during review. + +## Composition roots + +| Root | Responsibility | +|---|---| +| `Agentstration.Web/Program.cs` | Resolve host configuration, invoke `AddAgentstrationWebHost`, map transports, and run the ordered startup lifecycle. | +| `Agentstration.Workplace.Web/Program.cs` | Resolve API endpoints, invoke `AddAgentstrationWorkplaceHost`, and map the standalone Workplace UI. | +| `Agentstration.AppHost/Program.cs` | Compose Aspire resources and pass configuration to the executable hosts and AEP extensions. | +| AEP extension `Program.cs` files | Register only the provider hosted by that autonomous extension process. | + +Endpoint mapping, database initialization, bootstrap application, extension +discovery, and application start are lifecycle operations. They remain outside +service-registration extensions. + +## Platform registration ownership + +`AddAgentstration(AgentstrationServiceRegistrationOptions)` is the public +platform façade. It validates and normalizes the host inputs once, then invokes +the following Infrastructure-owned extensions in dependency order. + +| Extension | Owned registrations | +|---|---| +| `AddAgentstrationFoundation` | Time, request context, Management events, AI defaults, and direct chat-client fallback. | +| `AddAgentstrationControlPlane` | Storage options, platform initializer, and the selected SQLite or PostgreSQL control-plane store. | +| `AddAgentstrationSecurityAndBootstrap` | Secret vaults, identity/authorization services, audit, topology bootstrap, and bootstrap resource handlers. | +| `AddAgentstrationAgentRuntime` | Agent compilation and resolution, MAF materialization, Runtime registry and queue, deployment provisioners, routing, and MCP tools. | +| `AddAgentstrationPacks` | Pack archive/artifact services, resource handlers, composition, authoring, and management. | +| `AddAgentstrationSources` | Manifest retrieval, verification index, snapshots, compatibility, catalogs, and Source management. | +| `AddAgentstrationToolingAndTriggers` | Tool resources, hooks, Trigger services, Quartz configuration, and optional scheduler hosted services. | +| `AddAgentstrationRuntimeRuns` | Selected Runtime Run store, Run lifecycle, Tool execution pipeline, event sinks, and audit reader. | +| `AddAgentstrationWorkPlane` | Selected Work store, artifact store, execution queue/gateway, Work Items, Workplace, and task projection. | +| `AddAgentstrationFlowPlane` | Selected Flow store, definitions, Entries, Run queue, execution scopes, expressions, orchestration, and retention. | + +Storage implementation projects continue to own their provider-specific +extensions. Infrastructure selects one provider once and calls those methods; +it does not reproduce EF Core registration details. + +Existing module-owned extensions remain authoritative: + +| Owning project | Extensions | +|---|---| +| Management Core | `AddAgentstrationModelManagement` | +| Model Providers | `AddAgentstrationModelProviders` | +| MCP tools | `AddAgentstrationMcpTools` | +| Identity | `AddAgentstrationLocalIdentity`, `AddAgentstrationPostgreSqlIdentity` | +| Management storage | `AddSqliteControlPlane`, `AddPostgreSqlControlPlane` | +| Runtime storage | `AddSqliteRuntimeRuns`, `AddPostgreSqlRuntimeRuns` | +| Work storage | `AddSqliteWorkPlane`, `AddPostgreSqlWorkPlane` | +| Flow storage | `AddSqliteFlowStorage`, `AddPostgreSqlFlowStorage` | +| Shared Web UI | `AddAgentstrationWebComponents`, `AddAgentstrationLocalization`, `AddAgentstrationFlowDesigner` | +| Workplace client | `AddAgentstrationWorkplaceClient` | +| AEP ASP.NET Core | `AddAgentstrationAep` and its explicit contribution extensions | + +The string-parameter `AddAgentstration` overload remains a compatibility +façade. New composition code should use +`AgentstrationServiceRegistrationOptions` so adding a host setting does not +extend an ordered parameter list. + +## Server and Console ownership + +`AddAgentstrationWebHost` composes the platform façade with focused server +registrations: + +- Model Provider and Management services; +- extension discovery and AEP enrollment; +- HTTP/Razor/SignalR/MCP transport; +- the selected ASP.NET Core Identity store; +- bootstrap host services; +- realtime projections; +- Console composition; +- optional background workers and test cleanup. + +`AddAgentstrationWebConsole` remains the convenient Console façade and delegates +to separate component, HTTP/realtime client, authentication, and authorization +registrations. `AddAgentstrationObservability` owns Web logging, tracing, and +metrics. Workplace follows the same pattern through +`AddAgentstrationWorkplaceHost` and +`AddAgentstrationWorkplaceObservability`. + +## Registration semantics + +- `TryAdd*` denotes a genuine standalone/test fallback that a fuller host may + replace. +- `Replace` denotes an intentional exactly-one production selection, including + the configured GenAI options, the Management-backed Model Profile validator, + and the server's composite Flow event sink. +- Repeated `Add*` calls for the same contract are allowed only for intentional + `IEnumerable` contributions. Current examples include secret vaults, + bootstrap and Pack handlers, agent deployment provisioners, and Tool + execution event sinks. +- Service lifetimes are part of the composition contract. Refactoring a + registration into another extension must not silently change its lifetime. +- Registration-contract tests inspect descriptor cardinality before provider + construction and build representative providers with scope/build validation. + +These rules prevent the default container's last-registration-wins behavior +from hiding accidental duplicates or undocumented caller-order dependencies. diff --git a/src/Agentstration.Infrastructure/Composition/AgentRuntimeServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/AgentRuntimeServiceCollectionExtensions.cs new file mode 100644 index 00000000..f426369d --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/AgentRuntimeServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Agentstration.Infrastructure.Agents; +using Agentstration.Infrastructure.Runtime; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.ModelProviders; +using Agentstration.Runtime.Abstractions; +using Agentstration.Runtime.AgentFramework; +using Agentstration.Runtime.Local; +using Agentstration.Tools.Mcp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Agentstration.Infrastructure; + +internal static class AgentRuntimeServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationAgentRuntime( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(); + if (!context.UseManagedProfileResolver) + services.AddSingleton(); + services.AddAgentstrationMcpTools(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new AgentRevisionRetentionOptions()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/AgentstrationServiceRegistrationOptions.cs b/src/Agentstration.Infrastructure/Composition/AgentstrationServiceRegistrationOptions.cs new file mode 100644 index 00000000..3aa11f8a --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/AgentstrationServiceRegistrationOptions.cs @@ -0,0 +1,60 @@ +using Agentstration.Infrastructure.Agents; +using Agentstration.Infrastructure.Sources; + +namespace Agentstration.Infrastructure; + +public sealed record AgentstrationServiceRegistrationOptions +{ + public required string DataDirectory { get; init; } + public AiProviderOptions? AiOptions { get; init; } + public string? ControlPlaneConnectionString { get; init; } + public string? WorkPlaneConnectionString { get; init; } + public string? FlowConnectionString { get; init; } + public string? RuntimeConnectionString { get; init; } + public AgentstrationStorageOptions? StorageOptions { get; init; } + public bool EnableHostedServices { get; init; } = true; + public SourceVerificationIndexOptions? SourceVerificationIndexOptions { get; init; } +} + +internal sealed record AgentstrationServiceRegistrationContext( + string DataDirectory, + AiProviderOptions AiOptions, + AgentstrationStorageOptions StorageOptions, + AgentstrationStorageProvider StorageProvider, + string? ControlPlaneConnectionString, + string? WorkPlaneConnectionString, + string? FlowConnectionString, + string? RuntimeConnectionString, + string SchedulerConnectionString, + bool EnableHostedServices, + SourceVerificationIndexOptions SourceVerificationIndexOptions) +{ + public bool UseManagedProfileResolver => + string.Equals(AiOptions.Provider, "Managed", StringComparison.OrdinalIgnoreCase); + + public static AgentstrationServiceRegistrationContext Create(AgentstrationServiceRegistrationOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.DataDirectory); + + var aiOptions = options.AiOptions + ?? new AiProviderOptions("Deterministic", new Uri("http://localhost/"), "deterministic", null); + var storageOptions = options.StorageOptions ?? new AgentstrationStorageOptions(); + var storageProvider = storageOptions.GetProvider(); + var schedulerConnectionString = storageProvider == AgentstrationStorageProvider.PostgreSql + ? storageOptions.ConnectionString! + : $"Data Source={Path.Combine(options.DataDirectory, "scheduler.db")};Pooling=False"; + + return new AgentstrationServiceRegistrationContext( + options.DataDirectory, + aiOptions, + storageOptions, + storageProvider, + options.ControlPlaneConnectionString, + options.WorkPlaneConnectionString, + options.FlowConnectionString, + options.RuntimeConnectionString, + schedulerConnectionString, + options.EnableHostedServices, + options.SourceVerificationIndexOptions ?? new SourceVerificationIndexOptions()); + } +} diff --git a/src/Agentstration.Infrastructure/Composition/ControlPlaneServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/ControlPlaneServiceCollectionExtensions.cs new file mode 100644 index 00000000..a4638af1 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/ControlPlaneServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Agentstration.Management.Abstractions; +using Agentstration.Management.Storage.PostgreSql; +using Agentstration.Management.Storage.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Infrastructure; + +internal static class ControlPlaneServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationControlPlane( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + services.AddSingleton(context.StorageOptions); + if (context.StorageProvider == AgentstrationStorageProvider.PostgreSql) + { + services.AddSingleton(); + services.AddPostgreSqlControlPlane(context.StorageOptions.ConnectionString!); + } + else + { + services.AddSingleton(); + services.AddSqliteControlPlane( + context.ControlPlaneConnectionString + ?? $"Data Source={Path.Combine(context.DataDirectory, "control-plane.db")}"); + } + + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs new file mode 100644 index 00000000..d6de5c21 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs @@ -0,0 +1,66 @@ +using Agentstration.Flow.Application; +using Agentstration.Flow.Storage.PostgreSql; +using Agentstration.Flow.Storage.Sqlite; +using Agentstration.Infrastructure.Flows; +using Agentstration.Infrastructure.Work; +using Agentstration.Management.Abstractions; +using Agentstration.Runtime.Abstractions; +using Agentstration.Work; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Agentstration.Infrastructure; + +internal static class FlowPlaneServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationFlowPlane( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + if (context.StorageProvider == AgentstrationStorageProvider.PostgreSql) + { + services.AddPostgreSqlFlowStorage(context.StorageOptions.ConnectionString!); + } + else + { + services.AddSqliteFlowStorage( + context.FlowConnectionString + ?? $"Data Source={Path.Combine(context.DataDirectory, "flow-plane.db")}"); + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs new file mode 100644 index 00000000..cb3d75d3 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Agentstration.Infrastructure.Agents; +using Agentstration.Infrastructure.Events; +using Agentstration.Management.Abstractions; +using Agentstration.ModelProviders; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Agentstration.Infrastructure; + +internal static class FoundationServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationFoundation( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(provider => provider.GetRequiredService()); + services.TryAddSingleton(provider => provider.GetRequiredService()); + services.TryAddSingleton(new GenAiObservabilityOptions()); + services.TryAddTransient(); + services.AddSingleton(); + services.AddSingleton(context.AiOptions); + + if (string.Equals(context.AiOptions.Provider, "Deterministic", StringComparison.OrdinalIgnoreCase)) + { + services.AddSingleton(); + } + else if (!context.UseManagedProfileResolver) + { + services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(90)) + .AddHttpMessageHandler(); + services.AddSingleton(provider => provider.GetRequiredService()); + } + + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/PackServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/PackServiceCollectionExtensions.cs new file mode 100644 index 00000000..98ab9205 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/PackServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Agentstration.Infrastructure.Packs; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Infrastructure; + +internal static class PackServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationPacks( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + services.AddSingleton(); + services.AddSingleton(_ => + new FileSystemPackArtifactStore(Path.Combine(context.DataDirectory, "pack-artifacts"))); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs new file mode 100644 index 00000000..b9013e94 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using Agentstration.Infrastructure.Runtime; +using Agentstration.Runtime.Abstractions; +using Agentstration.Runtime.Core; +using Agentstration.Runtime.Storage.PostgreSql; +using Agentstration.Runtime.Storage.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Agentstration.Infrastructure; + +internal static class RuntimeRunServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationRuntimeRuns( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + if (context.StorageProvider == AgentstrationStorageProvider.PostgreSql) + { + services.AddPostgreSqlRuntimeRuns(context.StorageOptions.ConnectionString!); + } + else + { + services.AddSqliteRuntimeRuns( + context.RuntimeConnectionString + ?? $"Data Source={Path.Combine(context.DataDirectory, "runtime-plane.db")}"); + } + + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(new ToolExecutionCaptureOptions()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/SecurityAndBootstrapServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/SecurityAndBootstrapServiceCollectionExtensions.cs new file mode 100644 index 00000000..898cdf6a --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/SecurityAndBootstrapServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using Agentstration.Infrastructure.Bootstrap; +using Agentstration.Infrastructure.Packs; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.Secrets.Abstractions; +using Agentstration.Secrets.Local; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Infrastructure; + +internal static class SecurityAndBootstrapServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationSecurityAndBootstrap( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + var secretPath = Path.Combine(context.DataDirectory, "secrets"); + services.AddSingleton(_ => new EnvironmentMasterKeyProvider(Path.Combine(secretPath, "master.key"))); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => new LocalSecretVaultProvider( + secretPath, + provider.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/SourceServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/SourceServiceCollectionExtensions.cs new file mode 100644 index 00000000..77b598c5 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/SourceServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +using Agentstration.Infrastructure.Sources; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Contracts; +using Agentstration.Management.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Infrastructure; + +internal static class SourceServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationSources( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + var verificationOptions = context.SourceVerificationIndexOptions; + if (verificationOptions.TimeoutSeconds is < 1 or > 60) + throw new InvalidOperationException("Source verification index timeout must be between 1 and 60 seconds."); + if (verificationOptions.MaximumBytes is < 1024 or > SourceVerificationIndexReader.MaximumIndexBytes) + throw new InvalidOperationException( + $"Source verification index maximum bytes must be between 1024 and {SourceVerificationIndexReader.MaximumIndexBytes}."); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Agentstration-Source-Importer/1.0"); + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 5 + }); + services.AddSingleton(verificationOptions); + services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(verificationOptions.TimeoutSeconds); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Agentstration-Source-Verification/1.0"); + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 5 + }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => + new FileSystemSourceSnapshotArtifactStore(Path.Combine(context.DataDirectory, "source-snapshots"))); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new SourceMaterializationLimits()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/ToolingAndTriggerServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/ToolingAndTriggerServiceCollectionExtensions.cs new file mode 100644 index 00000000..2fbb7103 --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/ToolingAndTriggerServiceCollectionExtensions.cs @@ -0,0 +1,67 @@ +using Agentstration.Infrastructure.Triggers; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Quartz; + +namespace Agentstration.Infrastructure; + +internal static class ToolingAndTriggerServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationToolingAndTriggers( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddQuartz(configuration => + { + configuration.SchedulerId = "AUTO"; + configuration.SchedulerName = "Agentstration.TriggerScheduler"; + configuration.UsePersistentStore(options => + { + options.UseProperties = true; + if (context.StorageProvider == AgentstrationStorageProvider.PostgreSql) + { + options.UsePostgres(postgres => + { + postgres.ConnectionString = context.SchedulerConnectionString; + postgres.TablePrefix = "scheduler.qrtz_"; + }); + } + else + { + // Quartz owns a short-lived local database. Pooling is disabled so + // shutdown releases the scheduler file handles deterministically. + options.UseMicrosoftSQLite(sqlite => + sqlite.ConnectionString = context.SchedulerConnectionString); + } + options.UseSystemTextJsonSerializer(); + }); + }); + + if (context.EnableHostedServices) + { + services.AddSingleton(); + if (context.StorageProvider == AgentstrationStorageProvider.Sqlite) + { + services.AddSingleton(_ => + new QuartzSqliteSchemaInitializer(context.SchedulerConnectionString)); + } + services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + services.AddHostedService(); + } + + return services; + } +} diff --git a/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs new file mode 100644 index 00000000..093334bc --- /dev/null +++ b/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using Agentstration.Application.Work; +using Agentstration.Infrastructure.Artifacts; +using Agentstration.Infrastructure.Work; +using Agentstration.Work; +using Agentstration.Work.Storage.Abstractions; +using Agentstration.Work.Storage.PostgreSql; +using Agentstration.Work.Storage.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Infrastructure; + +internal static class WorkPlaneServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationWorkPlane( + this IServiceCollection services, + AgentstrationServiceRegistrationContext context) + { + if (context.StorageProvider == AgentstrationStorageProvider.PostgreSql) + { + services.AddPostgreSqlWorkPlane(context.StorageOptions.ConnectionString!); + } + else + { + services.AddSqliteWorkPlane( + context.WorkPlaneConnectionString + ?? $"Data Source={Path.Combine(context.DataDirectory, "work-plane.db")}"); + } + + services.AddSingleton(_ => + new FileSystemArtifactStore(Path.Combine(context.DataDirectory, "artifacts"))); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Agentstration.Infrastructure/DependencyInjection.cs b/src/Agentstration.Infrastructure/DependencyInjection.cs index afc9d9bb..542db74f 100644 --- a/src/Agentstration.Infrastructure/DependencyInjection.cs +++ b/src/Agentstration.Infrastructure/DependencyInjection.cs @@ -1,45 +1,32 @@ -using Agentstration.Application.Work; -using Agentstration.Flow.Application; -using Agentstration.Flow.Storage.PostgreSql; -using Agentstration.Flow.Storage.Sqlite; using Agentstration.Infrastructure.Agents; -using Agentstration.Infrastructure.Artifacts; -using Agentstration.Infrastructure.Bootstrap; -using Agentstration.Infrastructure.Events; -using Agentstration.Infrastructure.Flows; -using Agentstration.Infrastructure.Packs; -using Agentstration.Infrastructure.Runtime; using Agentstration.Infrastructure.Sources; -using Agentstration.Infrastructure.Triggers; -using Agentstration.Infrastructure.Work; -using Agentstration.Management.Abstractions; -using Agentstration.Management.Core; -using Agentstration.Management.Storage.PostgreSql; -using Agentstration.Management.Storage.Sqlite; -using Agentstration.ModelProviders; -using Agentstration.Runtime.Abstractions; -using Agentstration.Runtime.AgentFramework; -using Agentstration.Runtime.Core; -using Agentstration.Runtime.Local; -using Agentstration.Runtime.Storage.PostgreSql; -using Agentstration.Runtime.Storage.Sqlite; -using Agentstration.Secrets.Abstractions; -using Agentstration.Secrets.Local; -using Agentstration.Tools.Mcp; -using Agentstration.Work; -using Agentstration.Work.Storage.Abstractions; -using Agentstration.Work.Storage.PostgreSql; -using Agentstration.Work.Storage.Sqlite; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; -using Quartz; namespace Agentstration.Infrastructure; public static class DependencyInjection { + public static IServiceCollection AddAgentstration( + this IServiceCollection services, + AgentstrationServiceRegistrationOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(options); + + var composition = AgentstrationServiceRegistrationContext.Create(options); + return services + .AddAgentstrationFoundation(composition) + .AddAgentstrationControlPlane(composition) + .AddAgentstrationSecurityAndBootstrap(composition) + .AddAgentstrationAgentRuntime(composition) + .AddAgentstrationPacks(composition) + .AddAgentstrationSources(composition) + .AddAgentstrationToolingAndTriggers(composition) + .AddAgentstrationRuntimeRuns(composition) + .AddAgentstrationWorkPlane(composition) + .AddAgentstrationFlowPlane(composition); + } + public static IServiceCollection AddAgentstration( this IServiceCollection services, string dataDirectory, @@ -50,261 +37,17 @@ public static IServiceCollection AddAgentstration( string? runtimeConnectionString = null, AgentstrationStorageOptions? storageOptions = null, bool enableHostedServices = true, - SourceVerificationIndexOptions? sourceVerificationIndexOptions = null) - { - services.AddSingleton(TimeProvider.System); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(provider => provider.GetRequiredService()); - services.TryAddSingleton(provider => provider.GetRequiredService()); - services.TryAddSingleton(new GenAiObservabilityOptions()); - services.TryAddTransient(); - services.AddSingleton(); - aiOptions ??= new AiProviderOptions("Deterministic", new Uri("http://localhost/"), "deterministic", null); - services.AddSingleton(aiOptions); - var useManagedProfileResolver = string.Equals(aiOptions.Provider, "Managed", StringComparison.OrdinalIgnoreCase); - if (string.Equals(aiOptions.Provider, "Deterministic", StringComparison.OrdinalIgnoreCase)) - { - services.AddSingleton(); - } - else if (!useManagedProfileResolver) - { - services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(90)) - .AddHttpMessageHandler(); - services.AddSingleton(provider => provider.GetRequiredService()); - } - storageOptions ??= new AgentstrationStorageOptions(); - var storageProvider = storageOptions.GetProvider(); - services.AddSingleton(storageOptions); - if (storageProvider == AgentstrationStorageProvider.PostgreSql) - services.AddSingleton(); - else - services.AddSingleton(); - if (storageProvider == AgentstrationStorageProvider.PostgreSql) - services.AddPostgreSqlControlPlane(storageOptions.ConnectionString!); - else - { - controlPlaneConnectionString ??= $"Data Source={Path.Combine(dataDirectory, "control-plane.db")}"; - services.AddSqliteControlPlane(controlPlaneConnectionString); - } - var secretPath = Path.Combine(dataDirectory, "secrets"); - services.AddSingleton(_ => new EnvironmentMasterKeyProvider(Path.Combine(secretPath, "master.key"))); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => new LocalSecretVaultProvider( - secretPath, - provider.GetRequiredService())); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - if (!useManagedProfileResolver) - services.AddSingleton(); - services.AddAgentstrationMcpTools(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(services => - services.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(new AgentRevisionRetentionOptions()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(_ => new FileSystemPackArtifactStore(Path.Combine(dataDirectory, "pack-artifacts"))); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddHttpClient(client => - { - client.Timeout = TimeSpan.FromSeconds(15); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Agentstration-Source-Importer/1.0"); - }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AllowAutoRedirect = true, - MaxAutomaticRedirections = 5 - }); - sourceVerificationIndexOptions ??= new SourceVerificationIndexOptions(); - if (sourceVerificationIndexOptions.TimeoutSeconds is < 1 or > 60) - throw new InvalidOperationException("Source verification index timeout must be between 1 and 60 seconds."); - if (sourceVerificationIndexOptions.MaximumBytes is < 1024 or > Agentstration.Management.Contracts.SourceVerificationIndexReader.MaximumIndexBytes) - throw new InvalidOperationException($"Source verification index maximum bytes must be between 1024 and {Agentstration.Management.Contracts.SourceVerificationIndexReader.MaximumIndexBytes}."); - services.AddSingleton(sourceVerificationIndexOptions); - services.AddHttpClient(client => - { - client.Timeout = TimeSpan.FromSeconds(sourceVerificationIndexOptions.TimeoutSeconds); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Agentstration-Source-Verification/1.0"); - }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AllowAutoRedirect = true, - MaxAutomaticRedirections = 5 - }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(_ => new FileSystemSourceSnapshotArtifactStore(Path.Combine(dataDirectory, "source-snapshots"))); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(new SourceMaterializationLimits()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - // Quartz owns a short-lived, local scheduler database. Disabling ADO.NET pooling - // ensures its file handles are released when the hosted scheduler shuts down. - var schedulerConnectionString = storageProvider == AgentstrationStorageProvider.PostgreSql - ? storageOptions.ConnectionString! - : $"Data Source={Path.Combine(dataDirectory, "scheduler.db")};Pooling=False"; - services.AddQuartz(configuration => - { - configuration.SchedulerId = "AUTO"; - configuration.SchedulerName = "Agentstration.TriggerScheduler"; - configuration.UsePersistentStore(options => - { - options.UseProperties = true; - if (storageProvider == AgentstrationStorageProvider.PostgreSql) - { - options.UsePostgres(postgres => - { - postgres.ConnectionString = schedulerConnectionString; - postgres.TablePrefix = "scheduler.qrtz_"; - }); - } - else - options.UseMicrosoftSQLite(sqlite => sqlite.ConnectionString = schedulerConnectionString); - options.UseSystemTextJsonSerializer(); - }); + SourceVerificationIndexOptions? sourceVerificationIndexOptions = null) => + services.AddAgentstration(new AgentstrationServiceRegistrationOptions + { + DataDirectory = dataDirectory, + AiOptions = aiOptions, + ControlPlaneConnectionString = controlPlaneConnectionString, + WorkPlaneConnectionString = workPlaneConnectionString, + FlowConnectionString = flowConnectionString, + RuntimeConnectionString = runtimeConnectionString, + StorageOptions = storageOptions, + EnableHostedServices = enableHostedServices, + SourceVerificationIndexOptions = sourceVerificationIndexOptions }); - if (enableHostedServices) - { - services.AddSingleton(); - if (storageProvider == AgentstrationStorageProvider.Sqlite) - services.AddSingleton(_ => new QuartzSqliteSchemaInitializer(schedulerConnectionString)); - services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); - services.AddHostedService(); - } - if (storageProvider == AgentstrationStorageProvider.PostgreSql) - services.AddPostgreSqlRuntimeRuns(storageOptions.ConnectionString!); - else - { - runtimeConnectionString ??= $"Data Source={Path.Combine(dataDirectory, "runtime-plane.db")}"; - services.AddSqliteRuntimeRuns(runtimeConnectionString); - } - services.AddSingleton(); - services.AddSingleton(); - services.TryAddSingleton(new ToolExecutionCaptureOptions()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - if (storageProvider == AgentstrationStorageProvider.PostgreSql) - services.AddPostgreSqlWorkPlane(storageOptions.ConnectionString!); - else - { - workPlaneConnectionString ??= $"Data Source={Path.Combine(dataDirectory, "work-plane.db")}"; - services.AddSqliteWorkPlane(workPlaneConnectionString); - } - services.AddSingleton(_ => new FileSystemArtifactStore(Path.Combine(dataDirectory, "artifacts"))); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - if (storageProvider == AgentstrationStorageProvider.PostgreSql) - services.AddPostgreSqlFlowStorage(storageOptions.ConnectionString!); - else - { - flowConnectionString ??= $"Data Source={Path.Combine(dataDirectory, "flow-plane.db")}"; - services.AddSqliteFlowStorage(flowConnectionString); - } - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.TryAddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return services; - } } diff --git a/src/Agentstration.Management.Core/ModelManagementServiceCollectionExtensions.cs b/src/Agentstration.Management.Core/ModelManagementServiceCollectionExtensions.cs index d9bc5478..978feedd 100644 --- a/src/Agentstration.Management.Core/ModelManagementServiceCollectionExtensions.cs +++ b/src/Agentstration.Management.Core/ModelManagementServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Agentstration.Resources; using Agentstration.Runtime.Abstractions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Agentstration.Management.Core; @@ -17,7 +18,8 @@ public static IServiceCollection AddAgentstrationModelManagement(this IServiceCo services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); + services.Replace(ServiceDescriptor.Singleton( + provider => provider.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Agentstration.ModelProviders/ModelProviderServiceCollectionExtensions.cs b/src/Agentstration.ModelProviders/ModelProviderServiceCollectionExtensions.cs index 7c09b5df..88629f88 100644 --- a/src/Agentstration.ModelProviders/ModelProviderServiceCollectionExtensions.cs +++ b/src/Agentstration.ModelProviders/ModelProviderServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Agentstration.Management.Abstractions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Agentstration.ModelProviders; @@ -16,8 +17,9 @@ public static IServiceCollection AddAgentstrationModelProviders( var transportOptions = configuration.GetSection(AepTransportSecurityOptions.SectionName).Get() ?? new(); transportOptions.Validate(); services.AddSingleton(transportOptions); - services.AddSingleton(configuration.GetSection(GenAiObservabilityOptions.SectionName).Get() ?? new()); - services.AddTransient(); + services.Replace(ServiceDescriptor.Singleton( + configuration.GetSection(GenAiObservabilityOptions.SectionName).Get() ?? new())); + services.TryAddTransient(); services.AddHttpClient("agentstration-aep", client => client.Timeout = TimeSpan.FromSeconds(90)) .ConfigurePrimaryHttpMessageHandler(services => AepSecureHttpMessageHandler.Create(services.GetRequiredService())) diff --git a/src/Agentstration.Web/Configuration/WebConsoleClientServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleClientServiceCollectionExtensions.cs new file mode 100644 index 00000000..afc11a59 --- /dev/null +++ b/src/Agentstration.Web/Configuration/WebConsoleClientServiceCollectionExtensions.cs @@ -0,0 +1,112 @@ +using Agentstration.Management.Abstractions; +using Agentstration.Web.Components.State; +using Agentstration.Web.Console; +using Agentstration.Web.Hosting; +using Agentstration.Web.Security; + +namespace Agentstration.Web.Configuration; + +internal static class WebConsoleClientServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationConsoleClients( + this IServiceCollection services, + AgentstrationWebOptions options) + { + AddClient(services, options.RuntimeApi); + AddClient(services, CleanupApiClient.RuntimeClient, options.RuntimeApi); + AddClient(services, CleanupApiClient.FlowClient, options.FlowApi); + AddClient(services, CleanupApiClient.ManagementClient, options.ManagementApi); + AddClient(services, CleanupApiClient.WorkClient, options.WorkApi); + services.AddScoped(); + services.AddScoped(); + + AddClient(services, options.WorkApi); + AddClient(services, options.WorkApi); + AddClient(services, EntryAdministrationApiClient.AgentResourceCatalogClient, options.ManagementApi); + AddClient(services, EntryAdministrationApiClient.FlowResourceCatalogClient, options.FlowApi); + AddClient(services, options.FlowApi); + AddClient(services, options.RuntimeApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddSensitiveClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.ManagementApi); + AddClient(services, options.RuntimeApi); + return services; + } + + internal static IServiceCollection AddAgentstrationConsoleRealtimeClient( + this IServiceCollection services, + AgentstrationWebOptions options) + { + services.AddSingleton(); + services.AddScoped(provider => new WorkOperationsRealtimeClient( + new Uri(new Uri(options.WorkApi.BaseAddress, UriKind.Absolute), "hubs/workplace"), + provider.GetRequiredService(), + provider.GetRequiredService>())); + return services; + } + + private static void AddClient( + IServiceCollection services, + ApiEndpointOptions options) + where TImplementation : class, TContract + where TContract : class => + Configure(services.AddHttpClient(), options); + + private static void AddClient( + IServiceCollection services, + string name, + ApiEndpointOptions options) => + Configure(services.AddHttpClient(name), options); + + private static void AddSensitiveClient( + IServiceCollection services, + ApiEndpointOptions options) + where TImplementation : class, TContract + where TContract : class => + ConfigureClient(services.AddHttpClient(), options); + + private static void Configure(IHttpClientBuilder builder, ApiEndpointOptions options) + { + ConfigureClient(builder, options).AddStandardResilienceHandler(resilience => + { + resilience.AttemptTimeout.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + resilience.TotalRequestTimeout.Timeout = + TimeSpan.FromSeconds(Math.Min(120, options.TimeoutSeconds * 3)); + resilience.Retry.MaxRetryAttempts = 2; + }); + } + + private static IHttpClientBuilder ConfigureClient( + IHttpClientBuilder builder, + ApiEndpointOptions options) + { + var baseAddress = new Uri(options.BaseAddress, UriKind.Absolute); + builder.ConfigureHttpClient(client => + { + client.BaseAddress = baseAddress; + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.Add("X-Agentstration-Client", "Agentstration.Web"); + }); + if (options.ForwardSessionCookie) + { + builder.ConfigurePrimaryHttpMessageHandler(() => + new HttpClientHandler { AllowAutoRedirect = false }); + builder.AddHttpMessageHandler(provider => new ConsoleApiSessionHandler( + provider.GetRequiredService(), + provider.GetRequiredService(), + baseAddress, + AgentstrationAuthenticationDefaults.ApplicationCookie)); + } + return builder; + } +} diff --git a/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs new file mode 100644 index 00000000..1c6bd6c9 --- /dev/null +++ b/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Agentstration.Web.Components; +using Agentstration.Web.Components.State; +using Agentstration.Web.Features.Flows.Designer; +using Agentstration.Web.FlowDesigner.Backend; +using Agentstration.Web.FlowDesigner.DependencyInjection; +using Agentstration.Web.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Agentstration.Web.Configuration; + +internal static class WebConsoleComponentServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationConsoleComponents(this IServiceCollection services) + { + services.TryAddSingleton(TimeProvider.System); + services.AddAgentstrationWebComponents(); + services.AddScoped(); + services.AddScoped(); + services.AddAgentstrationFlowDesigner(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs new file mode 100644 index 00000000..44efe98f --- /dev/null +++ b/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs @@ -0,0 +1,222 @@ +using Agentstration.Management.Core; +using Agentstration.Web.Security; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; + +namespace Agentstration.Web.Configuration; + +internal static class WebConsoleSecurityServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationConsoleAuthentication( + this IServiceCollection services, + AuthenticationOptions options, + IHostEnvironment environment) + { + services.AddHttpContextAccessor(); + var local = string.Equals(options.Mode, AuthenticationOptions.Local, StringComparison.OrdinalIgnoreCase); + var oidc = string.Equals(options.Mode, AuthenticationOptions.Oidc, StringComparison.OrdinalIgnoreCase); + var hybrid = string.Equals(options.Mode, AuthenticationOptions.Hybrid, StringComparison.OrdinalIgnoreCase); + if (local || oidc || hybrid) + { + if ((oidc || hybrid) + && (string.IsNullOrWhiteSpace(options.Authority) + || string.IsNullOrWhiteSpace(options.Audience) + || string.IsNullOrWhiteSpace(options.ClientId))) + { + throw new InvalidOperationException( + "OIDC authentication requires Authority, Audience, and ClientId."); + } + + var authentication = services.AddAuthentication(authenticationOptions => + { + authenticationOptions.DefaultScheme = + AgentstrationAuthenticationDefaults.PolicyScheme; + authenticationOptions.DefaultChallengeScheme = + AgentstrationAuthenticationDefaults.PolicyScheme; + }) + .AddPolicyScheme( + AgentstrationAuthenticationDefaults.PolicyScheme, + "Agentstration authentication", + policy => + { + policy.ForwardDefaultSelector = context => + { + var authorization = context.Request.Headers.Authorization.ToString(); + var bearer = authorization.StartsWith( + "Bearer ", + StringComparison.OrdinalIgnoreCase); + var personalAccessToken = authorization.StartsWith( + $"Bearer {PersonalAccessTokenService.TokenPrefix}", + StringComparison.Ordinal); + var apiWithoutWebSession = oidc + && (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) + && !context.Request.Cookies.ContainsKey( + AgentstrationAuthenticationDefaults.ApplicationCookie); + if (personalAccessToken) + return PersonalAccessTokenAuthenticationDefaults.Scheme; + return (oidc || hybrid) && (bearer || apiWithoutWebSession) + ? JwtBearerDefaults.AuthenticationScheme + : IdentityConstants.ApplicationScheme; + }; + }) + .AddCookie(IdentityConstants.ApplicationScheme, cookie => + { + cookie.Cookie.Name = AgentstrationAuthenticationDefaults.ApplicationCookie; + cookie.LoginPath = "/login"; + cookie.AccessDeniedPath = "/access-denied"; + cookie.SlidingExpiration = true; + if (oidc) + cookie.ForwardChallenge = OpenIdConnectDefaults.AuthenticationScheme; + cookie.Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync; + cookie.Events.OnRedirectToLogin = context => + ApiStatusOrRedirect(context, StatusCodes.Status401Unauthorized); + cookie.Events.OnRedirectToAccessDenied = context => + ApiStatusOrRedirect(context, StatusCodes.Status403Forbidden); + }) + .AddCookie(IdentityConstants.ExternalScheme) + .AddCookie(IdentityConstants.TwoFactorRememberMeScheme) + .AddCookie(IdentityConstants.TwoFactorUserIdScheme) + .AddScheme( + PersonalAccessTokenAuthenticationDefaults.Scheme, + _ => { }); + + if (oidc || hybrid) + { + authentication.AddJwtBearer(jwt => + { + jwt.Authority = options.Authority; + jwt.Audience = options.Audience; + jwt.RequireHttpsMetadata = options.RequireHttpsMetadata; + jwt.MapInboundClaims = false; + }).AddOpenIdConnect(oidcOptions => + { + oidcOptions.Authority = options.Authority; + oidcOptions.ClientId = options.ClientId; + oidcOptions.ClientSecret = options.ClientSecret; + oidcOptions.RequireHttpsMetadata = options.RequireHttpsMetadata; + oidcOptions.ResponseType = "code"; + oidcOptions.UsePkce = true; + oidcOptions.SaveTokens = true; + oidcOptions.MapInboundClaims = false; + oidcOptions.SignInScheme = IdentityConstants.ApplicationScheme; + oidcOptions.Scope.Clear(); + oidcOptions.Scope.Add("openid"); + oidcOptions.Scope.Add("profile"); + oidcOptions.Scope.Add("email"); + }); + } + } + else + { + if (!string.Equals( + options.Mode, + AuthenticationOptions.Development, + StringComparison.OrdinalIgnoreCase) + && !string.Equals( + options.Mode, + AuthenticationOptions.Disabled, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Unsupported authentication mode '{options.Mode}'."); + } + if (!environment.IsDevelopment() && !environment.IsEnvironment("Testing")) + { + throw new InvalidOperationException( + $"Authentication mode '{options.Mode}' is permitted only in Development or Testing."); + } + services.AddAuthentication(DevelopmentAuthenticationHandler.SchemeName) + .AddScheme( + DevelopmentAuthenticationHandler.SchemeName, + _ => { }); + } + + return services; + } + + internal static IServiceCollection AddAgentstrationConsoleAuthorization( + this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddAuthorizationBuilder() + .SetFallbackPolicy(new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()) + .AddPolicy( + AgentstrationPolicies.Authenticated, + policy => policy.RequireAuthenticatedUser()) + .AddPolicy(AgentstrationPolicies.PlatformAdmin, policy => + { + policy.RequireAuthenticatedUser(); + policy.AddRequirements(new InteractiveUserRequirement()); + policy.AddRequirements(new PlatformAdministratorRequirement()); + }) + .AddPolicy(AgentstrationPolicies.InteractiveUser, policy => + { + policy.RequireAuthenticatedUser(); + policy.AddRequirements(new InteractiveUserRequirement()); + }) + .AddPolicy( + AgentstrationPolicies.WorkspaceReader, + policy => WorkspacePolicy(policy, AuthorizationPermissions.WorkspacesRead)) + .AddPolicy( + AgentstrationPolicies.WorkspaceAdmin, + policy => WorkspacePolicy(policy, AuthorizationPermissions.WorkspacesWrite)) + .AddPolicy( + AgentstrationPolicies.AuthorizationReader, + policy => WorkspacePolicy(policy, AuthorizationPermissions.AuthorizationRead)) + .AddPolicy( + AgentstrationPolicies.AuthorizationAdmin, + policy => WorkspacePolicy(policy, AuthorizationPermissions.AuthorizationWrite)) + .AddPolicy( + AgentstrationPolicies.CanReadResources, + policy => WorkspacePolicy(policy, AuthorizationPermissions.ResourcesRead)) + .AddPolicy( + AgentstrationPolicies.CanWriteResources, + policy => WorkspacePolicy(policy, AuthorizationPermissions.ResourcesWrite)) + .AddPolicy( + AgentstrationPolicies.CanDeleteResources, + policy => WorkspacePolicy(policy, AuthorizationPermissions.ResourcesDelete)) + .AddPolicy( + AgentstrationPolicies.CanReadRuns, + policy => WorkspacePolicy(policy, AuthorizationPermissions.RunsRead)) + .AddPolicy( + AgentstrationPolicies.CanExecuteRuns, + policy => WorkspacePolicy(policy, AuthorizationPermissions.RunsExecute)) + .AddPolicy( + AgentstrationPolicies.CanDeleteRuns, + policy => WorkspacePolicy(policy, AuthorizationPermissions.RunsDelete)); + return services; + } + + private static void WorkspacePolicy(AuthorizationPolicyBuilder policy, string permission) + { + policy.RequireAuthenticatedUser(); + policy.AddRequirements(new WorkspacePermissionRequirement(permission)); + } + + private static Task ApiStatusOrRedirect( + RedirectContext context, + int statusCode) + { + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/hubs") + || context.Request.Path.StartsWithSegments("/mcp")) + { + context.Response.StatusCode = statusCode; + } + else + { + context.Response.Redirect(context.RedirectUri); + } + return Task.CompletedTask; + } +} diff --git a/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs index c30d656f..b4d11c9f 100644 --- a/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs +++ b/src/Agentstration.Web/Configuration/WebConsoleServiceCollectionExtensions.cs @@ -1,274 +1,42 @@ -using Agentstration.Management.Abstractions; -using Agentstration.Management.Core; -using Agentstration.Web.Components; -using Agentstration.Web.Components.State; -using Agentstration.Web.Console; -using Agentstration.Web.Features.Flows.Designer; -using Agentstration.Web.FlowDesigner.Backend; -using Agentstration.Web.FlowDesigner.DependencyInjection; -using Agentstration.Web.Hosting; -using Agentstration.Web.Security; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; - namespace Agentstration.Web.Configuration; public static class WebConsoleServiceCollectionExtensions { - public static IServiceCollection AddAgentstrationWebConsole(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) + public static IServiceCollection AddAgentstrationWebConsole( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(environment); + services.AddOptions() .Bind(configuration.GetSection(AgentstrationWebOptions.SectionName)) .Validate(Validate, "API base addresses must be absolute HTTP(S) URIs and timeouts must be between 1 and 120 seconds.") .ValidateOnStart(); - services.AddSingleton(TimeProvider.System); - services.AddAgentstrationWebComponents(); - services.AddScoped(); - services.AddScoped(); - services.AddAgentstrationFlowDesigner(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - var configured = configuration.GetSection(AgentstrationWebOptions.SectionName).Get() ?? new(); - AddClient(services, configured.RuntimeApi); - AddClient(services, CleanupApiClient.RuntimeClient, configured.RuntimeApi); - AddClient(services, CleanupApiClient.FlowClient, configured.FlowApi); - AddClient(services, CleanupApiClient.ManagementClient, configured.ManagementApi); - AddClient(services, CleanupApiClient.WorkClient, configured.WorkApi); - services.AddScoped(); - services.AddScoped(); - - // Tasks are always real Work API resources, even when unrelated Console - // projections still use deterministic demonstration data. - AddClient(services, configured.WorkApi); - AddClient(services, configured.WorkApi); - AddClient(services, EntryAdministrationApiClient.AgentResourceCatalogClient, configured.ManagementApi); - AddClient(services, EntryAdministrationApiClient.FlowResourceCatalogClient, configured.FlowApi); - services.AddScoped(provider => new WorkOperationsRealtimeClient( - new Uri(new Uri(configured.WorkApi.BaseAddress, UriKind.Absolute), "hubs/workplace"), - provider.GetRequiredService(), - provider.GetRequiredService>())); - - AddClient(services, configured.FlowApi); - AddClient(services, configured.RuntimeApi); - - // Agent and model management use the canonical HTTP APIs so edits, - // deployments, and Runtime activation observe the same persisted state. - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddSensitiveClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.ManagementApi); - AddClient(services, configured.RuntimeApi); - - AddSecurity(services, configured.Authentication, environment); - return services; - } - private static void AddSecurity(IServiceCollection services, AuthenticationOptions options, IHostEnvironment environment) - { - services.AddHttpContextAccessor(); - var local = string.Equals(options.Mode, AuthenticationOptions.Local, StringComparison.OrdinalIgnoreCase); - var oidc = string.Equals(options.Mode, AuthenticationOptions.Oidc, StringComparison.OrdinalIgnoreCase); - var hybrid = string.Equals(options.Mode, AuthenticationOptions.Hybrid, StringComparison.OrdinalIgnoreCase); - if (local || oidc || hybrid) - { - if ((oidc || hybrid) && (string.IsNullOrWhiteSpace(options.Authority) || string.IsNullOrWhiteSpace(options.Audience) - || string.IsNullOrWhiteSpace(options.ClientId))) - throw new InvalidOperationException("OIDC authentication requires Authority, Audience, and ClientId."); - - var authentication = services.AddAuthentication(authenticationOptions => - { - authenticationOptions.DefaultScheme = AgentstrationAuthenticationDefaults.PolicyScheme; - authenticationOptions.DefaultChallengeScheme = AgentstrationAuthenticationDefaults.PolicyScheme; - }) - .AddPolicyScheme(AgentstrationAuthenticationDefaults.PolicyScheme, "Agentstration authentication", policy => - { - policy.ForwardDefaultSelector = context => - { - var bearer = context.Request.Headers.Authorization.ToString() - .StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase); - var personalAccessToken = context.Request.Headers.Authorization.ToString() - .StartsWith($"Bearer {PersonalAccessTokenService.TokenPrefix}", StringComparison.Ordinal); - var apiWithoutWebSession = oidc - && (context.Request.Path.StartsWithSegments("/api") || context.Request.Path.StartsWithSegments("/mcp")) - && !context.Request.Cookies.ContainsKey(AgentstrationAuthenticationDefaults.ApplicationCookie); - if (personalAccessToken) return PersonalAccessTokenAuthenticationDefaults.Scheme; - return (oidc || hybrid) && (bearer || apiWithoutWebSession) - ? JwtBearerDefaults.AuthenticationScheme - : IdentityConstants.ApplicationScheme; - }; - }) - .AddCookie(IdentityConstants.ApplicationScheme, cookie => - { - cookie.Cookie.Name = AgentstrationAuthenticationDefaults.ApplicationCookie; - cookie.LoginPath = "/login"; - cookie.AccessDeniedPath = "/access-denied"; - cookie.SlidingExpiration = true; - if (oidc) cookie.ForwardChallenge = OpenIdConnectDefaults.AuthenticationScheme; - cookie.Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync; - cookie.Events.OnRedirectToLogin = context => ApiStatusOrRedirect(context, StatusCodes.Status401Unauthorized); - cookie.Events.OnRedirectToAccessDenied = context => ApiStatusOrRedirect(context, StatusCodes.Status403Forbidden); - }) - .AddCookie(IdentityConstants.ExternalScheme) - .AddCookie(IdentityConstants.TwoFactorRememberMeScheme) - .AddCookie(IdentityConstants.TwoFactorUserIdScheme) - .AddScheme( - PersonalAccessTokenAuthenticationDefaults.Scheme, - _ => { }); - - if (oidc || hybrid) - { - authentication.AddJwtBearer(jwt => - { - jwt.Authority = options.Authority; - jwt.Audience = options.Audience; - jwt.RequireHttpsMetadata = options.RequireHttpsMetadata; - jwt.MapInboundClaims = false; - }).AddOpenIdConnect(oidcOptions => - { - oidcOptions.Authority = options.Authority; - oidcOptions.ClientId = options.ClientId; - oidcOptions.ClientSecret = options.ClientSecret; - oidcOptions.RequireHttpsMetadata = options.RequireHttpsMetadata; - oidcOptions.ResponseType = "code"; - oidcOptions.UsePkce = true; - oidcOptions.SaveTokens = true; - oidcOptions.MapInboundClaims = false; - oidcOptions.SignInScheme = IdentityConstants.ApplicationScheme; - oidcOptions.Scope.Clear(); - oidcOptions.Scope.Add("openid"); - oidcOptions.Scope.Add("profile"); - oidcOptions.Scope.Add("email"); - }); - } - } - else - { - if (!string.Equals(options.Mode, AuthenticationOptions.Development, StringComparison.OrdinalIgnoreCase) - && !string.Equals(options.Mode, AuthenticationOptions.Disabled, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Unsupported authentication mode '{options.Mode}'."); - if (!environment.IsDevelopment() && !environment.IsEnvironment("Testing")) - throw new InvalidOperationException($"Authentication mode '{options.Mode}' is permitted only in Development or Testing."); - services.AddAuthentication(DevelopmentAuthenticationHandler.SchemeName) - .AddScheme(DevelopmentAuthenticationHandler.SchemeName, _ => { }); - } - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddAuthorizationBuilder() - .SetFallbackPolicy(new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build()) - .AddPolicy(AgentstrationPolicies.Authenticated, policy => policy.RequireAuthenticatedUser()) - .AddPolicy(AgentstrationPolicies.PlatformAdmin, policy => - { - policy.RequireAuthenticatedUser(); - policy.AddRequirements(new InteractiveUserRequirement()); - policy.AddRequirements(new PlatformAdministratorRequirement()); - }) - .AddPolicy(AgentstrationPolicies.InteractiveUser, policy => - { - policy.RequireAuthenticatedUser(); - policy.AddRequirements(new InteractiveUserRequirement()); - }) - .AddPolicy(AgentstrationPolicies.WorkspaceReader, policy => WorkspacePolicy(policy, AuthorizationPermissions.WorkspacesRead)) - .AddPolicy(AgentstrationPolicies.WorkspaceAdmin, policy => WorkspacePolicy(policy, AuthorizationPermissions.WorkspacesWrite)) - .AddPolicy(AgentstrationPolicies.AuthorizationReader, policy => WorkspacePolicy(policy, AuthorizationPermissions.AuthorizationRead)) - .AddPolicy(AgentstrationPolicies.AuthorizationAdmin, policy => WorkspacePolicy(policy, AuthorizationPermissions.AuthorizationWrite)) - .AddPolicy(AgentstrationPolicies.CanReadResources, policy => WorkspacePolicy(policy, AuthorizationPermissions.ResourcesRead)) - .AddPolicy(AgentstrationPolicies.CanWriteResources, policy => WorkspacePolicy(policy, AuthorizationPermissions.ResourcesWrite)) - .AddPolicy(AgentstrationPolicies.CanDeleteResources, policy => WorkspacePolicy(policy, AuthorizationPermissions.ResourcesDelete)) - .AddPolicy(AgentstrationPolicies.CanReadRuns, policy => WorkspacePolicy(policy, AuthorizationPermissions.RunsRead)) - .AddPolicy(AgentstrationPolicies.CanExecuteRuns, policy => WorkspacePolicy(policy, AuthorizationPermissions.RunsExecute)) - .AddPolicy(AgentstrationPolicies.CanDeleteRuns, policy => WorkspacePolicy(policy, AuthorizationPermissions.RunsDelete)); - } - - private static void WorkspacePolicy(AuthorizationPolicyBuilder policy, string permission) - { - policy.RequireAuthenticatedUser(); - policy.AddRequirements(new WorkspacePermissionRequirement(permission)); - } - - private static Task ApiStatusOrRedirect(RedirectContext context, int statusCode) - { - if (context.Request.Path.StartsWithSegments("/api") - || context.Request.Path.StartsWithSegments("/hubs") - || context.Request.Path.StartsWithSegments("/mcp")) - context.Response.StatusCode = statusCode; - else context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - } - - private static void AddClient(IServiceCollection services, ApiEndpointOptions options) - where TImplementation : class, TContract - where TContract : class - { - Configure(services.AddHttpClient(), options); - } - - private static void AddClient(IServiceCollection services, string name, ApiEndpointOptions options) => - Configure(services.AddHttpClient(name), options); - - private static void AddSensitiveClient(IServiceCollection services, ApiEndpointOptions options) - where TImplementation : class, TContract where TContract : class => - ConfigureClient(services.AddHttpClient(), options); - - private static void Configure(IHttpClientBuilder builder, ApiEndpointOptions options) - { - ConfigureClient(builder, options).AddStandardResilienceHandler(resilience => - { - resilience.AttemptTimeout.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); - resilience.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(Math.Min(120, options.TimeoutSeconds * 3)); - resilience.Retry.MaxRetryAttempts = 2; - }); - } - - private static IHttpClientBuilder ConfigureClient(IHttpClientBuilder builder, ApiEndpointOptions options) - { - var baseAddress = new Uri(options.BaseAddress, UriKind.Absolute); - builder.ConfigureHttpClient(client => - { - client.BaseAddress = baseAddress; - client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); - client.DefaultRequestHeaders.Add("X-Agentstration-Client", "Agentstration.Web"); - }); - if (options.ForwardSessionCookie) - { - builder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); - builder.AddHttpMessageHandler(provider => new ConsoleApiSessionHandler( - provider.GetRequiredService(), - provider.GetRequiredService(), - baseAddress, - AgentstrationAuthenticationDefaults.ApplicationCookie)); - } - return builder; + var configured = configuration.GetSection(AgentstrationWebOptions.SectionName) + .Get() ?? new(); + return services + .AddAgentstrationConsoleComponents() + .AddAgentstrationConsoleClients(configured) + .AddAgentstrationConsoleRealtimeClient(configured) + .AddAgentstrationConsoleAuthentication(configured.Authentication, environment) + .AddAgentstrationConsoleAuthorization(); } - private static bool Validate(AgentstrationWebOptions options) => ValidateEndpoint(options.WorkApi) && - ValidateEndpoint(options.ManagementApi) && ValidateEndpoint(options.RuntimeApi) && ValidateEndpoint(options.FlowApi) && - (string.IsNullOrWhiteSpace(options.WorkplaceBaseUrl) || Uri.TryCreate(options.WorkplaceBaseUrl, UriKind.Absolute, out var workplace) && workplace.Scheme is "http" or "https"); + private static bool Validate(AgentstrationWebOptions options) => + ValidateEndpoint(options.WorkApi) + && ValidateEndpoint(options.ManagementApi) + && ValidateEndpoint(options.RuntimeApi) + && ValidateEndpoint(options.FlowApi) + && (string.IsNullOrWhiteSpace(options.WorkplaceBaseUrl) + || Uri.TryCreate(options.WorkplaceBaseUrl, UriKind.Absolute, out var workplace) + && workplace.Scheme is "http" or "https"); private static bool ValidateEndpoint(ApiEndpointOptions options) => - options.TimeoutSeconds is >= 1 and <= 120 && - Uri.TryCreate(options.BaseAddress, UriKind.Absolute, out var uri) && - (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + options.TimeoutSeconds is >= 1 and <= 120 + && Uri.TryCreate(options.BaseAddress, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); } diff --git a/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs new file mode 100644 index 00000000..59148fb1 --- /dev/null +++ b/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs @@ -0,0 +1,249 @@ +using System.Threading.RateLimiting; +using Agentstration.Aep.Abstractions; +using Agentstration.Application.Work; +using Agentstration.Flow.Application; +using Agentstration.Infrastructure; +using Agentstration.Infrastructure.Agents; +using Agentstration.Infrastructure.Flows; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.ModelProviders; +using Agentstration.Runtime.Abstractions; +using Agentstration.Runtime.AgentFramework; +using Agentstration.Runtime.Core; +using Agentstration.Security.AspNetCoreIdentity; +using Agentstration.Security.AspNetCoreIdentity.PostgreSql; +using Agentstration.Web.Components.Localization; +using Agentstration.Web.Hosting; +using Agentstration.Work; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.AspNetCore; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Agentstration.Web.Configuration; + +internal sealed record WebHostServiceRegistrationOptions( + LocalBootstrapOptions BootstrapOptions, + ToolExecutionCaptureOptions ToolExecutionCaptureOptions, + AgentstrationServiceRegistrationOptions PlatformOptions, + AgentstrationStorageProvider StorageProvider, + string IdentityConnectionString, + string DataProtectionKeysPath, + string? TestingStorageDirectory, + IReadOnlyList TestingSqliteConnectionStrings, + bool UseManagedProfileResolver); + +internal static class WebHostServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationWebHost( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment, + WebHostServiceRegistrationOptions options) + { + services.AddSingleton(options.BootstrapOptions); + services.AddSingleton(options.ToolExecutionCaptureOptions); + services.AddAgentstration(options.PlatformOptions); + services.AddAgentstrationModelProviders(configuration, options.UseManagedProfileResolver); + services.AddSingleton(configuration + .GetSection(AepEnrollmentPolicyOptions.SectionName) + .Get() ?? new()); + services.AddAgentstrationModelManagement(); + services.AddAgentstrationExtensionDiscovery(); + services.AddAgentstrationServerTransport(configuration); + services.AddAgentstrationIdentity( + options.StorageProvider, + options.IdentityConnectionString, + options.DataProtectionKeysPath, + environment); + services.AddAgentstrationBootstrapHosting(); + services.AddAgentstrationRealtimeEvents(); + services.AddAgentstrationWebConsole(configuration, environment); + services.AddAgentstrationBackgroundWorkers(options.PlatformOptions.EnableHostedServices); + services.AddAgentstrationTestingCleanup( + options.TestingStorageDirectory, + options.TestingSqliteConnectionStrings); + return services; + } + + internal static WebApplicationBuilder AddAgentstrationObservability( + this WebApplicationBuilder builder, + bool enabled) + { + if (!enabled) + return builder; + + var otlpEnabled = !string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + builder.Logging.AddOpenTelemetry(logging => + { + logging.SetResourceBuilder( + ResourceBuilder.CreateDefault().AddService("Agentstration.Web")); + logging.IncludeScopes = true; + logging.IncludeFormattedMessage = true; + if (otlpEnabled) + logging.AddOtlpExporter(); + }); + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("Agentstration.Web")) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSource( + WorkItemService.ActivitySource.Name, + RuntimeRunService.ActivitySource.Name, + FlowRunService.ActivitySource.Name, + AgentFrameworkRuntimeFactory.TelemetrySourceName, + GenAiObservabilityOptions.ChatClientSourceName, + GenAiHttpPayloadCaptureHandler.TelemetrySourceName); + if (otlpEnabled) + tracing.AddOtlpExporter(); + }) + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddMeter( + WorkItemService.Meter.Name, + FlowRunService.Meter.Name, + AgentFrameworkRuntimeFactory.TelemetrySourceName, + GenAiObservabilityOptions.ChatClientSourceName); + if (otlpEnabled) + metrics.AddOtlpExporter(); + }); + return builder; + } + + private static IServiceCollection AddAgentstrationExtensionDiscovery( + this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + return services; + } + + private static IServiceCollection AddAgentstrationServerTransport( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddProblemDetails(); + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = static async (context, token) => + await context.HttpContext.Response.WriteAsJsonAsync( + new + { + error = new AepEnrollmentError( + "rate_limited", + "Too many enrollment requests; retry later.") + }, + token); + options.AddPolicy("aep-enrollment-public", context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + }); + services.AddAgentstrationOpenApi(); + services.AddRazorPages(); + services.AddRazorComponents().AddInteractiveServerComponents(); + services.AddAgentstrationLocalization(configuration); + services.AddSignalR(); + services.AddMcpServer().WithHttpTransport().WithToolsFromAssembly(); + return services; + } + + private static IServiceCollection AddAgentstrationIdentity( + this IServiceCollection services, + AgentstrationStorageProvider storageProvider, + string connectionString, + string dataProtectionKeysPath, + IHostEnvironment environment) + { + if (storageProvider == AgentstrationStorageProvider.PostgreSql) + { + services.AddAgentstrationPostgreSqlIdentity( + connectionString, + dataProtectionKeysPath, + useDevelopmentPasswordPolicy: environment.IsDevelopment()); + } + else + { + services.AddAgentstrationLocalIdentity( + connectionString, + dataProtectionKeysPath, + useDevelopmentPasswordPolicy: environment.IsDevelopment()); + } + return services; + } + + private static IServiceCollection AddAgentstrationBootstrapHosting( + this IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + return services; + } + + private static IServiceCollection AddAgentstrationRealtimeEvents( + this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.Replace(ServiceDescriptor.Singleton(provider => + new CompositeFlowRunEventSink( + [ + provider.GetRequiredService(), + provider.GetRequiredService() + ]))); + services.AddSingleton(); + return services; + } + + private static IServiceCollection AddAgentstrationBackgroundWorkers( + this IServiceCollection services, + bool enabled) + { + if (!enabled) + return services; + + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + return services; + } + + private static IServiceCollection AddAgentstrationTestingCleanup( + this IServiceCollection services, + string? testingStorageDirectory, + IReadOnlyList sqliteConnectionStrings) + { + if (testingStorageDirectory is null) + return services; + + services.AddSingleton(provider => new TestingDataDirectoryCleanup( + testingStorageDirectory, + sqliteConnectionStrings, + provider.GetRequiredService>())); + return services; + } +} diff --git a/src/Agentstration.Web/Program.cs b/src/Agentstration.Web/Program.cs index ff2714de..74628b78 100644 --- a/src/Agentstration.Web/Program.cs +++ b/src/Agentstration.Web/Program.cs @@ -1,18 +1,13 @@ -using System.Threading.RateLimiting; -using Agentstration.Aep.Abstractions; using Agentstration.Application.Work; using Agentstration.Flow.Application; using Agentstration.Infrastructure; using Agentstration.Infrastructure.Agents; -using Agentstration.Infrastructure.Flows; using Agentstration.Management.Abstractions; using Agentstration.Management.Core; using Agentstration.ModelProviders; using Agentstration.Runtime.Abstractions; -using Agentstration.Runtime.AgentFramework; using Agentstration.Runtime.Core; using Agentstration.Security.AspNetCoreIdentity; -using Agentstration.Security.AspNetCoreIdentity.PostgreSql; using Agentstration.Web; using Agentstration.Web.Api; using Agentstration.Web.Components; @@ -21,13 +16,7 @@ using Agentstration.Web.Features.Flows; using Agentstration.Web.Features.Workplace; using Agentstration.Web.Hosting; -using Agentstration.Work; -using Microsoft.AspNetCore.RateLimiting; using ModelContextProtocol.AspNetCore; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); var bootstrapOptions = new LocalBootstrapOptions(); @@ -38,12 +27,10 @@ bootstrapOptions.ExternalIdentitySubject = configuredAuthentication.DevelopmentSubject; bootstrapOptions.PrincipalDisplayName = configuredAuthentication.DevelopmentDisplayName; } -builder.Services.AddSingleton(bootstrapOptions); var genAiObservability = builder.Configuration.GetSection(GenAiObservabilityOptions.SectionName).Get() ?? new(); genAiObservability.Validate(builder.Environment.IsDevelopment()); var toolExecutionCapture = builder.Configuration.GetSection("Agentstration:ToolExecution").Get() ?? new(); toolExecutionCapture.Validate(); -builder.Services.AddSingleton(toolExecutionCapture); var isTesting = builder.Environment.IsEnvironment("Testing"); var hostedServicesEnabled = !isTesting || builder.Configuration.GetValue("Agentstration:Testing:HostedServicesEnabled", false); @@ -98,84 +85,20 @@ var sourceVerificationIndexOptions = builder.Configuration .GetSection(Agentstration.Infrastructure.Sources.SourceVerificationIndexOptions.SectionName) .Get() ?? new(); -builder.Services.AddAgentstration( - dataDirectory, - aiOptions, - controlPlaneConnectionString, - workPlaneConnectionString, - flowConnectionString, - runtimeConnectionString, - storageOptions, - enableHostedServices: hostedServicesEnabled, - sourceVerificationIndexOptions: sourceVerificationIndexOptions); -builder.Services.AddAgentstrationModelProviders( - builder.Configuration, - useManagedProfileResolver); -builder.Services.AddSingleton(builder.Configuration - .GetSection(AepEnrollmentPolicyOptions.SectionName) - .Get() ?? new()); -builder.Services.AddAgentstrationModelManagement(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(provider => provider.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddProblemDetails(); -builder.Services.AddRateLimiter(options => -{ - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.OnRejected = static async (context, token) => - await context.HttpContext.Response.WriteAsJsonAsync( - new { error = new AepEnrollmentError("rate_limited", "Too many enrollment requests; retry later.") }, token); - options.AddPolicy("aep-enrollment-public", context => - RateLimitPartition.GetFixedWindowLimiter( - context.Connection.RemoteIpAddress?.ToString() ?? "unknown", - _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 30, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0 - })); -}); -builder.Services.AddAgentstrationOpenApi(); -builder.Services.AddRazorPages(); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -builder.Services.AddAgentstrationLocalization(builder.Configuration); -builder.Services.AddSignalR(); -if (storageProvider == AgentstrationStorageProvider.PostgreSql) - builder.Services.AddAgentstrationPostgreSqlIdentity( - identityConnectionString, - dataProtectionKeysPath, - useDevelopmentPasswordPolicy: builder.Environment.IsDevelopment()); -else - builder.Services.AddAgentstrationLocalIdentity( - identityConnectionString, - dataProtectionKeysPath, - useDevelopmentPasswordPolicy: builder.Environment.IsDevelopment()); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(provider => new CompositeFlowRunEventSink( -[ - provider.GetRequiredService(), - provider.GetRequiredService() -])); -builder.Services.AddSingleton(); -builder.Services.AddAgentstrationWebConsole(builder.Configuration, builder.Environment); -builder.Services.AddMcpServer().WithHttpTransport().WithToolsFromAssembly(); -if (hostedServicesEnabled) -{ - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); -} -if (testingStorageDirectory is not null) +var platformRegistrationOptions = new AgentstrationServiceRegistrationOptions { - var sqliteConnectionStrings = storageProvider == AgentstrationStorageProvider.Sqlite + DataDirectory = dataDirectory, + AiOptions = aiOptions, + ControlPlaneConnectionString = controlPlaneConnectionString, + WorkPlaneConnectionString = workPlaneConnectionString, + FlowConnectionString = flowConnectionString, + RuntimeConnectionString = runtimeConnectionString, + StorageOptions = storageOptions, + EnableHostedServices = hostedServicesEnabled, + SourceVerificationIndexOptions = sourceVerificationIndexOptions +}; +var testingSqliteConnectionStrings = testingStorageDirectory is not null + && storageProvider == AgentstrationStorageProvider.Sqlite ? new[] { identityConnectionString, @@ -185,51 +108,20 @@ await context.HttpContext.Response.WriteAsJsonAsync( runtimeConnectionString! } : []; - builder.Services.AddSingleton(provider => new TestingDataDirectoryCleanup( +builder.Services.AddAgentstrationWebHost( + builder.Configuration, + builder.Environment, + new WebHostServiceRegistrationOptions( + bootstrapOptions, + toolExecutionCapture, + platformRegistrationOptions, + storageProvider, + identityConnectionString, + dataProtectionKeysPath, testingStorageDirectory, - sqliteConnectionStrings, - provider.GetRequiredService>())); -} - -if (openTelemetryEnabled) -{ - var otlpEnabled = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - builder.Logging.AddOpenTelemetry(logging => - { - logging.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("Agentstration.Web")); - logging.IncludeScopes = true; - logging.IncludeFormattedMessage = true; - if (otlpEnabled) logging.AddOtlpExporter(); - }); - builder.Services.AddOpenTelemetry() - .ConfigureResource(resource => resource.AddService("Agentstration.Web")) - .WithTracing(tracing => - { - tracing - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddSource( - WorkItemService.ActivitySource.Name, - RuntimeRunService.ActivitySource.Name, - FlowRunService.ActivitySource.Name, - AgentFrameworkRuntimeFactory.TelemetrySourceName, - GenAiObservabilityOptions.ChatClientSourceName, - GenAiHttpPayloadCaptureHandler.TelemetrySourceName); - if (otlpEnabled) tracing.AddOtlpExporter(); - }) - .WithMetrics(metrics => - { - metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddMeter( - WorkItemService.Meter.Name, - FlowRunService.Meter.Name, - AgentFrameworkRuntimeFactory.TelemetrySourceName, - GenAiObservabilityOptions.ChatClientSourceName); - if (otlpEnabled) metrics.AddOtlpExporter(); - }); -} + testingSqliteConnectionStrings, + useManagedProfileResolver)); +builder.AddAgentstrationObservability(openTelemetryEnabled); var app = builder.Build(); var testingDataDirectoryCleanup = testingStorageDirectory is not null diff --git a/src/Agentstration.Workplace.Web/Program.cs b/src/Agentstration.Workplace.Web/Program.cs index 75898955..8e99be07 100644 --- a/src/Agentstration.Workplace.Web/Program.cs +++ b/src/Agentstration.Workplace.Web/Program.cs @@ -1,12 +1,6 @@ -using Agentstration.Web.Components; using Agentstration.Web.Components.Localization; -using Agentstration.Web.Components.State; -using Agentstration.Workplace.Client; using Agentstration.Workplace.Web; using Agentstration.Workplace.Web.Components; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); var apiValue = builder.Configuration["Agentstration:ApiBaseUrl"] ?? throw new InvalidOperationException("Agentstration:ApiBaseUrl is required."); @@ -14,29 +8,7 @@ var hubValue = builder.Configuration["Agentstration:WorkplaceHubUrl"]; hubValue = string.IsNullOrWhiteSpace(hubValue) ? new Uri(apiUrl, "hubs/workplace").ToString() : hubValue; if (!Uri.TryCreate(hubValue, UriKind.Absolute, out var hubUrl) || hubUrl.Scheme is not ("http" or "https")) throw new InvalidOperationException("Agentstration:WorkplaceHubUrl must be an absolute HTTP(S) URL."); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -builder.Services.AddAgentstrationWebComponents(); -builder.Services.AddScoped(); -builder.Services.AddAgentstrationLocalization(builder.Configuration); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddTransient(provider => new WorkplaceApiSessionHandler( - provider.GetRequiredService(), - apiUrl, - ".Agentstration.Identity.Application", - "agentstration.workspace")); -builder.Services.AddScoped(provider => new WorkplaceRealtimeSession( - provider.GetRequiredService(), - hubUrl, - ".Agentstration.Identity.Application", - "agentstration.workspace")); -builder.Services.AddAgentstrationWorkplaceClient(apiUrl, hubUrl) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) - .AddHttpMessageHandler(); -builder.Services.AddHttpClient(client => client.BaseAddress = apiUrl) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) - .AddHttpMessageHandler(); -builder.Services.AddProblemDetails(); builder.Services.AddHealthChecks(); -var otlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); -builder.Services.AddOpenTelemetry().ConfigureResource(value => value.AddService("Agentstration.Workplace.Web")).WithTracing(value => { value.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation(); if (otlp) value.AddOtlpExporter(); }).WithMetrics(value => { value.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation(); if (otlp) value.AddOtlpExporter(); }); +builder.Services.AddAgentstrationWorkplaceHost(builder.Configuration, apiUrl, hubUrl); +builder.AddAgentstrationWorkplaceObservability(); var app = builder.Build(); app.UseExceptionHandler(); app.UseStatusCodePages(); app.UseRequestLocalization(); app.UseAntiforgery(); app.MapHealthChecks("/health"); app.MapAgentstrationCultureEndpoint(); app.MapStaticAssets(); app.MapRazorComponents().AddInteractiveServerRenderMode(); await app.RunAsync(); public partial class Program; diff --git a/src/Agentstration.Workplace.Web/WorkplaceWebHostServiceCollectionExtensions.cs b/src/Agentstration.Workplace.Web/WorkplaceWebHostServiceCollectionExtensions.cs new file mode 100644 index 00000000..47654a9f --- /dev/null +++ b/src/Agentstration.Workplace.Web/WorkplaceWebHostServiceCollectionExtensions.cs @@ -0,0 +1,70 @@ +using Agentstration.Web.Components; +using Agentstration.Web.Components.Localization; +using Agentstration.Web.Components.State; +using Agentstration.Workplace.Client; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Agentstration.Workplace.Web; + +internal static class WorkplaceWebHostServiceCollectionExtensions +{ + internal static IServiceCollection AddAgentstrationWorkplaceHost( + this IServiceCollection services, + IConfiguration configuration, + Uri apiUrl, + Uri hubUrl) + { + services.AddRazorComponents().AddInteractiveServerComponents(); + services.AddAgentstrationWebComponents(); + services.AddScoped(); + services.AddAgentstrationLocalization(configuration); + services.AddHttpContextAccessor(); + services.AddTransient(provider => new WorkplaceApiSessionHandler( + provider.GetRequiredService(), + apiUrl, + ".Agentstration.Identity.Application", + "agentstration.workspace")); + services.AddScoped(provider => + new WorkplaceRealtimeSession( + provider.GetRequiredService(), + hubUrl, + ".Agentstration.Identity.Application", + "agentstration.workspace")); + services.AddAgentstrationWorkplaceClient(apiUrl, hubUrl) + .ConfigurePrimaryHttpMessageHandler(() => + new HttpClientHandler { AllowAutoRedirect = false }) + .AddHttpMessageHandler(); + services.AddHttpClient(client => + client.BaseAddress = apiUrl) + .ConfigurePrimaryHttpMessageHandler(() => + new HttpClientHandler { AllowAutoRedirect = false }) + .AddHttpMessageHandler(); + services.AddProblemDetails(); + services.AddHealthChecks(); + return services; + } + + internal static WebApplicationBuilder AddAgentstrationWorkplaceObservability( + this WebApplicationBuilder builder) + { + var otlp = !string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + builder.Services.AddOpenTelemetry() + .ConfigureResource(value => value.AddService("Agentstration.Workplace.Web")) + .WithTracing(value => + { + value.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation(); + if (otlp) + value.AddOtlpExporter(); + }) + .WithMetrics(value => + { + value.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation(); + if (otlp) + value.AddOtlpExporter(); + }); + return builder; + } +} diff --git a/tests/Agentstration.ArchitectureTests/DependencyTests.cs b/tests/Agentstration.ArchitectureTests/DependencyTests.cs index 1093e4ef..b203b2ea 100644 --- a/tests/Agentstration.ArchitectureTests/DependencyTests.cs +++ b/tests/Agentstration.ArchitectureTests/DependencyTests.cs @@ -34,6 +34,61 @@ namespace Agentstration.ArchitectureTests; [TestClass] public sealed class DependencyTests { + [TestMethod] + public void ExecutableCompositionRootsDelegateServiceRegistration() + { + var root = FindRepositoryRoot(); + var programs = new[] + { + Path.Combine(root, "src", "Agentstration.Web", "Program.cs"), + Path.Combine(root, "src", "Agentstration.Workplace.Web", "Program.cs") + }; + + var directRegistrations = new[] + { + "builder.Services.AddSingleton", + "builder.Services.AddScoped", + "builder.Services.AddTransient", + "builder.Services.AddHostedService", + "builder.Services.AddHttpClient" + }; + var violations = programs + .Where(path => + { + var source = File.ReadAllText(path); + return directRegistrations.Any(value => source.Contains(value, StringComparison.Ordinal)); + }) + .Select(path => Path.GetRelativePath(root, path)) + .ToArray(); + + Assert.IsEmpty( + violations, + $"Executable composition roots must delegate service registration: {string.Join(", ", violations)}"); + } + + [TestMethod] + public void InfrastructureCompositionFacadeContainsNoConcreteRegistrations() + { + var path = Path.Combine( + FindRepositoryRoot(), + "src", + "Agentstration.Infrastructure", + "DependencyInjection.cs"); + var source = File.ReadAllText(path); + var registrationCalls = new[] + { + ".AddSingleton", + ".AddScoped", + ".AddTransient", + ".AddHostedService", + ".AddHttpClient" + }; + + Assert.IsFalse( + registrationCalls.Any(value => source.Contains(value, StringComparison.Ordinal)), + "AddAgentstration must remain a composition facade over focused registration extensions."); + } + [TestMethod] public void WorkplaceRealtimeClientIsScopedPerBlazorCircuit() { diff --git a/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs b/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs new file mode 100644 index 00000000..3439b157 --- /dev/null +++ b/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs @@ -0,0 +1,130 @@ +using Agentstration.Flow.Storage.Abstractions; +using Agentstration.Infrastructure; +using Agentstration.Infrastructure.Agents; +using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; +using Agentstration.ModelProviders; +using Agentstration.Runtime.Abstractions; +using Agentstration.Secrets.Abstractions; +using Agentstration.Work.Storage.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Agentstration.Management.Tests; + +[TestClass] +public sealed class DependencyInjectionCompositionTests +{ + [TestMethod] + public void DeterministicSqliteCompositionHasExplicitSingleServiceBindings() + { + var services = Compose(AgentstrationStorageProvider.Sqlite, "Deterministic"); + + AssertSingleServiceContracts(services); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(2, Count(services)); + Assert.AreEqual(2, Count(services)); + Assert.AreEqual(10, Count(services)); + Assert.AreEqual(6, Count(services)); + Assert.AreEqual(2, Count(services)); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + Assert.IsInstanceOfType( + provider.GetRequiredService()); + Assert.AreSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + } + + [TestMethod] + public void ManagedSqliteCompositionSelectsManagedResolverWithoutDuplicateFallbacks() + { + var services = Compose(AgentstrationStorageProvider.Sqlite, "Managed"); + + AssertSingleServiceContracts(services); + Assert.AreEqual(1, Count(services)); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + Assert.IsInstanceOfType( + provider.GetRequiredService()); + Assert.AreSame( + provider.GetRequiredService(), + provider.GetRequiredService()); + } + + [TestMethod] + public void PostgreSqlCompositionSelectsOneStoreForEachPlane() + { + var services = Compose(AgentstrationStorageProvider.PostgreSql, "Deterministic"); + + AssertSingleServiceContracts(services); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + } + + private static ServiceCollection Compose( + AgentstrationStorageProvider storageProvider, + string aiProvider) + { + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder().Build(); + var storageOptions = new AgentstrationStorageOptions + { + Provider = storageProvider.ToString(), + ConnectionString = storageProvider == AgentstrationStorageProvider.PostgreSql + ? "Host=localhost;Database=agentstration;Username=test;Password=test" + : null + }; + services.AddAgentstration(new AgentstrationServiceRegistrationOptions + { + DataDirectory = Path.Combine(Path.GetTempPath(), "agentstration-di-contracts"), + AiOptions = new AiProviderOptions( + aiProvider, + new Uri("http://localhost/"), + "deterministic", + null), + StorageOptions = storageOptions, + EnableHostedServices = false + }); + services.AddAgentstrationModelProviders( + configuration, + string.Equals(aiProvider, "Managed", StringComparison.Ordinal)); + services.AddAgentstrationModelManagement(); + return services; + } + + private static void AssertSingleServiceContracts(IServiceCollection services) + { + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + Assert.AreEqual(1, Count(services)); + } + + private static int Count(IServiceCollection services) => + services.Count(descriptor => descriptor.ServiceType == typeof(TService)); +} diff --git a/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs b/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs index 0a9bfa89..5023b61d 100644 --- a/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs +++ b/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs @@ -4,12 +4,35 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Agentstration.Flow.Application; +using Agentstration.Management.Abstractions; +using Agentstration.ModelProviders; +using Agentstration.Runtime.Abstractions; namespace Agentstration.Web.Tests; [TestClass] public sealed class QuartzHostLifecycleTests { + [TestMethod] + public void StandardHostResolvesExactlyOneOfEachSingleServiceContract() + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + builder.UseSetting("Logging:LogLevel:Default", "Warning"); + }); + + _ = factory.CreateClient(); + + Assert.AreEqual(1, factory.Services.GetServices().Count()); + Assert.AreEqual(1, factory.Services.GetServices().Count()); + Assert.AreEqual(1, factory.Services.GetServices().Count()); + Assert.AreEqual(1, factory.Services.GetServices().Count()); + Assert.AreEqual(1, factory.Services.GetServices().Count()); + Assert.AreEqual(1, factory.Services.GetServices().Count()); + } + [TestMethod] public async Task DefaultTestingDataDirectoryIsRemovedAfterHostShutdown() { From 54eae1fb8ff0c8bfd9cfdd8d55e05271dd64c853 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:24:39 +0200 Subject: [PATCH 2/7] fix(di): restore composition namespace imports --- .../Composition/FlowPlaneServiceCollectionExtensions.cs | 2 ++ .../Composition/FoundationServiceCollectionExtensions.cs | 1 + .../Composition/RuntimeRunServiceCollectionExtensions.cs | 1 + .../Composition/WorkPlaneServiceCollectionExtensions.cs | 1 + .../WebConsoleSecurityServiceCollectionExtensions.cs | 1 + 5 files changed, 6 insertions(+) diff --git a/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs index d6de5c21..f96f557e 100644 --- a/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs +++ b/src/Agentstration.Infrastructure/Composition/FlowPlaneServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Agentstration.Application.Work; using Agentstration.Flow.Application; using Agentstration.Flow.Storage.PostgreSql; using Agentstration.Flow.Storage.Sqlite; @@ -5,6 +6,7 @@ using Agentstration.Infrastructure.Work; using Agentstration.Management.Abstractions; using Agentstration.Runtime.Abstractions; +using Agentstration.Runtime.AgentFramework; using Agentstration.Work; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs index cb3d75d3..6e3497cb 100644 --- a/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs +++ b/src/Agentstration.Infrastructure/Composition/FoundationServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Agentstration.Infrastructure.Agents; using Agentstration.Infrastructure.Events; using Agentstration.Management.Abstractions; +using Agentstration.Management.Core; using Agentstration.ModelProviders; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs index b9013e94..60bc4fdc 100644 --- a/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs +++ b/src/Agentstration.Infrastructure/Composition/RuntimeRunServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Agentstration.Infrastructure.Flows; using Agentstration.Infrastructure.Runtime; using Agentstration.Runtime.Abstractions; using Agentstration.Runtime.Core; diff --git a/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs b/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs index 093334bc..298dde4c 100644 --- a/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs +++ b/src/Agentstration.Infrastructure/Composition/WorkPlaneServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Agentstration.Application.Work; using Agentstration.Infrastructure.Artifacts; using Agentstration.Infrastructure.Work; +using Agentstration.Runtime.Local; using Agentstration.Work; using Agentstration.Work.Storage.Abstractions; using Agentstration.Work.Storage.PostgreSql; diff --git a/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs index 44efe98f..4d3b71a9 100644 --- a/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs +++ b/src/Agentstration.Web/Configuration/WebConsoleSecurityServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Agentstration.Management.Abstractions; using Agentstration.Management.Core; using Agentstration.Web.Security; using Microsoft.AspNetCore.Authentication; From fba4747517384b21351c98551dcd78225c30005b Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:28:44 +0200 Subject: [PATCH 3/7] fix(web): restore host composition imports --- .../WebConsoleComponentServiceCollectionExtensions.cs | 1 + .../Configuration/WebHostServiceCollectionExtensions.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs index 1c6bd6c9..369865fa 100644 --- a/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs +++ b/src/Agentstration.Web/Configuration/WebConsoleComponentServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Agentstration.Web.Components; using Agentstration.Web.Components.State; +using Agentstration.Web.Console; using Agentstration.Web.Features.Flows.Designer; using Agentstration.Web.FlowDesigner.Backend; using Agentstration.Web.FlowDesigner.DependencyInjection; diff --git a/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs b/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs index 59148fb1..11214d55 100644 --- a/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs +++ b/src/Agentstration.Web/Configuration/WebHostServiceCollectionExtensions.cs @@ -14,6 +14,8 @@ using Agentstration.Security.AspNetCoreIdentity; using Agentstration.Security.AspNetCoreIdentity.PostgreSql; using Agentstration.Web.Components.Localization; +using Agentstration.Web.Features.Flows; +using Agentstration.Web.Features.Workplace; using Agentstration.Web.Hosting; using Agentstration.Work; using Microsoft.AspNetCore.RateLimiting; From e865f4b0e2e5a4eaa54044e37feebe5bbf1a51d3 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:33:28 +0200 Subject: [PATCH 4/7] style(tests): order host lifecycle imports --- tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs b/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs index 5023b61d..87136f60 100644 --- a/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs +++ b/tests/Agentstration.Web.Tests/QuartzHostLifecycleTests.cs @@ -1,13 +1,13 @@ using System.Net; +using Agentstration.Flow.Application; +using Agentstration.Management.Abstractions; +using Agentstration.ModelProviders; +using Agentstration.Runtime.Abstractions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Agentstration.Flow.Application; -using Agentstration.Management.Abstractions; -using Agentstration.ModelProviders; -using Agentstration.Runtime.Abstractions; namespace Agentstration.Web.Tests; From 05deef987229fc8c96c006c23f6ec8cc46fff8dc Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:38:19 +0200 Subject: [PATCH 5/7] fix(tests): import local runtime composition types --- .../DependencyInjectionCompositionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs b/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs index 3439b157..ce3ea763 100644 --- a/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs +++ b/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs @@ -5,6 +5,7 @@ using Agentstration.Management.Core; using Agentstration.ModelProviders; using Agentstration.Runtime.Abstractions; +using Agentstration.Runtime.Local; using Agentstration.Secrets.Abstractions; using Agentstration.Work.Storage.Abstractions; using Microsoft.Extensions.Configuration; From 6e35f7129eee5466d8e712a8de21c5c595842eb9 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:46:38 +0200 Subject: [PATCH 6/7] test(di): supply host principal resolver boundary --- .../DependencyInjectionCompositionTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs b/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs index ce3ea763..0f5bacc0 100644 --- a/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs +++ b/tests/Agentstration.Management.Tests/DependencyInjectionCompositionTests.cs @@ -109,6 +109,7 @@ private static ServiceCollection Compose( configuration, string.Equals(aiProvider, "Managed", StringComparison.Ordinal)); services.AddAgentstrationModelManagement(); + services.AddScoped(); return services; } @@ -128,4 +129,12 @@ private static void AssertSingleServiceContracts(IServiceCollection services) private static int Count(IServiceCollection services) => services.Count(descriptor => descriptor.ServiceType == typeof(TService)); + + private sealed class StubLocalAccountPrincipalResolver : ILocalAccountPrincipalResolver + { + public Task ResolveByUserNameAsync( + string userName, + CancellationToken cancellationToken) => + Task.FromResult(null); + } } From 01eb26927ce2db674d1862bf0181861daff6ce52 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Thu, 10 Sep 2026 03:55:32 +0200 Subject: [PATCH 7/7] docs(ai-defect): close CAR-0240 --- ...-fragmented-dependency-injection-composition.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/ai-defects/0240-fragmented-dependency-injection-composition.md b/docs/ai-defects/0240-fragmented-dependency-injection-composition.md index 259747ef..e85f37c6 100644 --- a/docs/ai-defects/0240-fragmented-dependency-injection-composition.md +++ b/docs/ai-defects/0240-fragmented-dependency-injection-composition.md @@ -2,13 +2,13 @@ ## Status -Open — 2026-09-10 +Prevented — 2026-09-10 ## References - Issue: #240 - Introducing change: #30 for the duplicate Runtime execution-scope registration; broader composition growth spans multiple changes -- Corrective pull request: Pending +- Corrective pull request: #241 - Related ADRs: ADR-0001, ADR-0032 ## Defect @@ -106,5 +106,11 @@ registrations belong and when `TryAdd`, `Replace`, or repeated `Add` is valid. ## Validation - Static diff and whitespace validation completed. -- .NET restore, build, and MSTest validation pending in GitHub Actions because - the local execution environment does not provide the .NET SDK. +- GitHub Actions CI run 506 passed formatting verification, the complete + Agentstration build, the MSTest suite, the Source Registry package smoke + test, PostgreSQL migration/restart and concurrency validation, the container + build, and Windows host lifecycle validation. +- Dependency Review run 380, Documentation run 336, and CodeQL run 502 passed. +- The local execution environment did not provide the .NET SDK; all .NET + validation was therefore executed by GitHub Actions against pull request + #241.