From 4f5066066786faed3dba5f7410f090c1fbddcb17 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 27 Aug 2026 01:49:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?Issue=20#3542:=20[Workflow]=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=20Host=20=E6=9C=AA=E6=B3=A8=E5=86=8C=20deterministic?= =?UTF-8?q?=5Fcompute=20host=5Fcallback=20connector=EF=BC=8C#3526=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E4=B8=8A=E7=BA=BF=E4=BD=86=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented per .implement-loop/runs/implement-issue-3542.md. Closes #3542 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/canon/connector.md | 13 ++ .../v1/sources.json | 2 +- .../Hosting/MainnetHostBuilderExtensions.cs | 128 ++++++++++++++++++ .../IConnectorCatalogQueryPort.cs | 8 ++ .../ActorBackedConnectorCatalogStore.cs | 49 +++++-- .../MainnetHostCompositionTests.cs | 54 ++++++++ ...orExternalWorkflowCapabilitySourceTests.cs | 21 +++ 7 files changed, 260 insertions(+), 15 deletions(-) diff --git a/docs/canon/connector.md b/docs/canon/connector.md index 44a9e1e355..745b58d784 100644 --- a/docs/canon/connector.md +++ b/docs/canon/connector.md @@ -330,6 +330,19 @@ Actor audit facts. Recovery then revokes both protected request and completion m `host_callback.algorithm_version`,并由 `ConnectorCallModule` 复制到 `StepCompletedEvent.Annotations`; - 需要并行保留旧语义时,注册新的 algorithm id(例如后缀 `_v2`),不在运行时按版本分支。 +Mainnet Host 默认启用一个 Host-owned 确定性计算 connector: + +- connector name:`deterministic_compute` +- type:`host_callback` +- handler:`deterministic_compute` +- allowed operations:`sha256_utf8` +- allowed input keys:`text` + +该默认项由 Mainnet 组合层同时注册到运行时 `IConnectorRegistry`,并作为 Host-owned default 发布到每个 +Studio scope 的 connector catalog;因此生产镜像不依赖节点本地 `~/.aevatar/connectors.json` 才能暴露该 +内建能力。运行时注册仍通过 `HostCallbackConnectorBuilder`,若上述 handler/operation 与已注册的 +`DeterministicAlgorithmDescriptor` 不精确一致,Host 启动失败且 catalog 不会形成一个可运行的弱契约。 + ## 3.4 Host 责任边界 以下职责明确属于 host,而不是 workflow engine: diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index b0d653d553..86840a1c52 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,7 +2,7 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "d062400830041ed2c13ebe13a64ee161c6466666", + "revision": "3e0678303cfb499f98a93e02d73dd46693ec4433", "contract_files_sha256": "17f41ef766c3a125ea0d56ea44d38be3e03012c0258a73898885eb652abe7d0f", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index db3f5a1a99..0f3494d54d 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -28,6 +28,7 @@ using Aevatar.Audit.Core.DependencyInjection; using Aevatar.Audit.Hosting; using Aevatar.BackendConsole.Hosting; +using Aevatar.Bootstrap.Connectors; using Aevatar.Bootstrap.Extensions.AI; using Aevatar.Bootstrap.Hosting; using Aevatar.ChatRouting.Core; @@ -201,6 +202,12 @@ public static WebApplicationBuilder AddAevatarMainnetHost( serviceProvider.GetRequiredService()); builder.Services.AddAIWorkspace(builder.Configuration); builder.AddStudioCapability(); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IHostConnectorCatalogDefaults, + MainnetDeterministicComputeConnectorCatalogDefaults>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IHostedService, + MainnetDeterministicComputeConnectorHostedService>()); builder.Services.AddAuditTrailCore(builder.Configuration); builder.AddAuditTrailCapabilityBundle(); builder.Services.AddBackendConsoleStaticAssets(builder.Configuration); @@ -881,3 +888,124 @@ private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptionsWithDir }; } } + +internal static class MainnetDeterministicComputeConnectorDefinition +{ + internal const string ConnectorName = "deterministic_compute"; + + internal static ConnectorConfigEntry CreateRuntimeDefinition() => + new() + { + Name = ConnectorName, + Type = "host_callback", + Enabled = true, + TimeoutMs = 30_000, + Retry = 0, + HostCallback = new HostCallbackConnectorConfig + { + Handler = SHA256DeterministicComputeHandler.HandlerName, + AllowedOperations = [SHA256DeterministicComputeHandler.OperationId], + AllowedInputKeys = ["text"], + }, + }; + + internal static StoredConnectorDefinition CreateCatalogDefinition() => + new( + Name: ConnectorName, + Type: "host_callback", + Enabled: true, + TimeoutMs: 30_000, + Retry: 0, + Http: new StoredHttpConnectorConfig( + string.Empty, + [], + [], + [], + new Dictionary(StringComparer.OrdinalIgnoreCase), + EmptyAuth()), + Cli: new StoredCliConnectorConfig( + string.Empty, + [], + [], + [], + string.Empty, + new Dictionary(StringComparer.OrdinalIgnoreCase)), + Mcp: new StoredMcpConnectorConfig( + string.Empty, + string.Empty, + string.Empty, + [], + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase), + EmptyAuth(), + string.Empty, + [], + []), + HostCallback: new StoredHostCallbackConnectorConfig( + SHA256DeterministicComputeHandler.HandlerName, + [SHA256DeterministicComputeHandler.OperationId], + ["text"])); + + private static StoredConnectorAuthConfig EmptyAuth() => + new( + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty); +} + +internal sealed class MainnetDeterministicComputeConnectorCatalogDefaults : IHostConnectorCatalogDefaults +{ + public IReadOnlyList Connectors { get; } = + [MainnetDeterministicComputeConnectorDefinition.CreateCatalogDefinition()]; +} + +internal sealed class MainnetDeterministicComputeConnectorHostedService : IHostedService +{ + private readonly IConnectorRegistry _registry; + private readonly IReadOnlyList _connectorBuilders; + private readonly ILogger _logger; + + public MainnetDeterministicComputeConnectorHostedService( + IConnectorRegistry registry, + IEnumerable connectorBuilders, + ILogger logger) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _connectorBuilders = (connectorBuilders ?? throw new ArgumentNullException(nameof(connectorBuilders))) + .ToArray(); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + // Implement (issue #3542): + // Behavior: Register Mainnet's deterministic_compute connector independently of a node-local connectors.json. + // Why this shape: The existing builder remains the fail-closed authority for descriptor/config alignment. + public async Task StartAsync(CancellationToken cancellationToken) + { + var builder = _connectorBuilders.FirstOrDefault(static candidate => + string.Equals(candidate.Type, "host_callback", StringComparison.OrdinalIgnoreCase)); + if (builder is null) + throw new InvalidOperationException("Mainnet requires the host_callback connector builder."); + + var definition = MainnetDeterministicComputeConnectorDefinition.CreateRuntimeDefinition(); + if (!builder.TryBuild(definition, _logger, out var connector) || connector is null) + { + throw new InvalidOperationException( + "Mainnet deterministic_compute connector does not match the registered algorithm descriptor."); + } + + await _registry.RegisterAsync( + global::Aevatar.Foundation.Abstractions.Connectors.ConnectorRegistration.Owned(connector), + cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + return Task.CompletedTask; + } +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs index 67881adcea..8da2b21df6 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs @@ -9,3 +9,11 @@ public interface IConnectorCatalogQueryPort Task GetConnectorDraftAsync(CancellationToken cancellationToken = default); } + +/// +/// Connector definitions owned by the composed Host and published in every Studio scope catalog. +/// +public interface IHostConnectorCatalogDefaults +{ + IReadOnlyList Connectors { get; } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index 02be91c0d3..427ba28eb8 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -10,7 +10,8 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of connector catalog query and command ports. -/// Reads from the projection document store (CQRS read model). +/// Reads scope-owned definitions from the projection document store (CQRS read model), then +/// composes immutable Host-owned defaults supplied by the deployment Host. /// Writes send commands to the Write GAgent through CQRS Core dispatch. /// Local JSON is only an explicit import boundary, never a draft backup. /// Per-scope isolation: each scope gets its own connector-catalog-{scopeId} actor. @@ -28,6 +29,7 @@ internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogQueryP private readonly IStudioLocalConnectorCatalogImportReader _localImportReader; private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; + private readonly IReadOnlyList _hostConnectorDefaults; public ActorBackedConnectorCatalogStore( IStudioActorBootstrap bootstrap, @@ -35,6 +37,7 @@ public ActorBackedConnectorCatalogStore( IAppScopeResolver scopeResolver, IStudioLocalConnectorCatalogImportReader localImportReader, IProjectionDocumentReader documentReader, + IEnumerable hostConnectorDefaults, ILogger logger) { _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); @@ -42,6 +45,9 @@ public ActorBackedConnectorCatalogStore( _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _localImportReader = localImportReader ?? throw new ArgumentNullException(nameof(localImportReader)); _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + _hostConnectorDefaults = (hostConnectorDefaults ?? throw new ArgumentNullException(nameof(hostConnectorDefaults))) + .SelectMany(static defaults => defaults.Connectors) + .ToArray(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -50,20 +56,14 @@ public async Task GetConnectorCatalogAsync( { var state = await ReadProjectedStateAsync(cancellationToken); var version = state?.LastAppliedEventVersion ?? 0; - if (state is null) - { - return new StoredConnectorCatalog( - HomeDirectory: ActorHomeDirectory, - FilePath: ActorFilePath, - FileExists: false, - Connectors: [], - Version: version); - } - - var connectors = state.Connectors + var scopedConnectors = state?.Connectors .Select(ToStoredConnectorDefinition) - .ToList() - .AsReadOnly(); + .ToList() ?? []; + + // Implement (issue #3542): + // Behavior: Publish deployment-owned connector defaults in every scope while preserving scope-owned entries. + // Why this shape: Mainnet capabilities remain discoverable without query-time writes or a process-local scope registry. + var connectors = MergeHostConnectorDefaults(scopedConnectors, _hostConnectorDefaults); return new StoredConnectorCatalog( HomeDirectory: ActorHomeDirectory, @@ -73,6 +73,27 @@ public async Task GetConnectorCatalogAsync( Version: version); } + internal static IReadOnlyList MergeHostConnectorDefaults( + IReadOnlyList scopedConnectors, + IReadOnlyList hostConnectorDefaults) + { + var merged = scopedConnectors.ToList(); + foreach (var hostConnector in hostConnectorDefaults) + { + if (string.IsNullOrWhiteSpace(hostConnector.Name)) + throw new InvalidOperationException("Host connector catalog defaults require a name."); + + var existingIndex = merged.FindIndex(connector => + string.Equals(connector.Name, hostConnector.Name, StringComparison.OrdinalIgnoreCase)); + if (existingIndex >= 0) + merged[existingIndex] = hostConnector; + else + merged.Add(hostConnector); + } + + return merged.AsReadOnly(); + } + public async Task SaveConnectorCatalogAsync( StoredConnectorCatalog catalog, long? expectedVersion = null, diff --git a/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs b/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs index 76fea899f3..281f0e8535 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs @@ -29,6 +29,7 @@ using Aevatar.Authentication.Abstractions; using Aevatar.Audit.Core.Identity; using Aevatar.Audit.Core.DependencyInjection; +using Aevatar.Bootstrap.Connectors; using Aevatar.Bootstrap.Extensions.AI; using Aevatar.Bootstrap.Hosting; using Aevatar.ChatRouting.Abstractions; @@ -38,6 +39,7 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Projection.Runtime; using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Connectors; using Aevatar.Foundation.Abstractions.EventModules; using Aevatar.Foundation.Runtime.Hosting.Maintenance; using Aevatar.Foundation.VoicePresence; @@ -72,6 +74,8 @@ using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Hosting; using Aevatar.Studio.Projection.ReadModels; +using Aevatar.Workflow.Abstractions; +using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Extensions.Hosting; using Aevatar.Workflow.Core.Modules; @@ -481,6 +485,56 @@ public async Task AddAevatarMainnetHost_WithInMemoryDependencies_ShouldBuildAndS .Should() .Contain(["aevatar_core_loop", "audit_query_index"]); + var connectorRegistry = app.Services.GetRequiredService(); + connectorRegistry.TryGet( + MainnetDeterministicComputeConnectorDefinition.ConnectorName, + out var deterministicConnector).Should().BeTrue(); + var connectorResult = await deterministicConnector!.ExecuteAsync(new ConnectorRequest + { + Connector = MainnetDeterministicComputeConnectorDefinition.ConnectorName, + Operation = SHA256DeterministicComputeHandler.OperationId, + Payload = """{"text":"abc"}""", + }); + connectorResult.Success.Should().BeTrue(); + connectorResult.Output.Should().Be( + "{\"sha256\":\"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad\"}"); + connectorResult.Metadata["host_callback.algorithm_version"].Should().Be("1"); + + var connectorCatalog = await app.Services.GetRequiredService() + .GetCatalogAsync(); + var deterministicCatalogEntry = connectorCatalog.Connectors.Should() + .ContainSingle(connector => + connector.Name == MainnetDeterministicComputeConnectorDefinition.ConnectorName) + .Subject; + deterministicCatalogEntry.Type.Should().Be("host_callback"); + deterministicCatalogEntry.HostCallback!.Handler.Should() + .Be(SHA256DeterministicComputeHandler.HandlerName); + deterministicCatalogEntry.HostCallback.AllowedOperations.Should() + .Equal(SHA256DeterministicComputeHandler.OperationId); + deterministicCatalogEntry.HostCallback.AllowedInputKeys.Should().Equal("text"); + + var connectorCapabilitySource = app.Services.GetServices() + .OfType() + .Should() + .ContainSingle() + .Subject; + var access = new ExternalWorkflowCapabilityAccessContext( + "default", + "mainnet-composition-test"); + var capabilityDiscovery = await connectorCapabilitySource.ListAsync(access); + var deterministicCapability = capabilityDiscovery.Capabilities.Should() + .ContainSingle(capability => + capability.Selector.HostConnector.ConnectorCapabilityRef == + MainnetDeterministicComputeConnectorDefinition.ConnectorName) + .Subject; + deterministicCapability.Selector.HostConnector.OperationId.Should() + .Be(SHA256DeterministicComputeHandler.OperationId); + var deterministicReadiness = await connectorCapabilitySource.InspectAsync( + access, + deterministicCapability.Selector, + ExternalCapabilityExecutionMode.Interactive); + deterministicReadiness.Status.Should().Be(ExternalCapabilityReadinessStatus.Ready); + var routePatterns = ((IEndpointRouteBuilder)app).DataSources .SelectMany(x => x.Endpoints) .OfType() diff --git a/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs b/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs index f195d0184e..b13758ceff 100644 --- a/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs +++ b/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs @@ -2,6 +2,7 @@ using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Application.Studio.DependencyInjection; using Aevatar.Foundation.Abstractions.Connectors; +using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; using Aevatar.Workflow.Application.Abstractions.Runs; @@ -15,6 +16,26 @@ namespace Aevatar.Studio.Tests; public sealed class ConnectorExternalWorkflowCapabilitySourceTests { + [Fact] + public void HostConnectorDefaults_ShouldPreserveScopeConnectors_AndReplaceSameNameDrift() + { + var scopeConnector = Connector("fortune-engine", authType: string.Empty); + var driftedDeterministicConnector = DeterministicConnector( + "deterministic_compute", + allowedOperations: ["different_algorithm"]); + var hostDeterministicConnector = DeterministicConnector("deterministic_compute"); + + var merged = ActorBackedConnectorCatalogStore.MergeHostConnectorDefaults( + [scopeConnector, driftedDeterministicConnector], + [hostDeterministicConnector]); + + merged.Should().HaveCount(2); + merged.Should().ContainSingle(connector => connector.Name == "fortune-engine"); + merged.Should().ContainSingle(connector => connector.Name == "deterministic_compute") + .Which.HostCallback.AllowedOperations.Should() + .Equal(TestDeterministicComputeHandler.OperationId); + } + [Fact] public void AddStudioApplication_ShouldRegisterConnectorCapabilitySource() { From 9af09ad2958643602862c93fc3d8577893a877d8 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 27 Aug 2026 01:51:48 +0800 Subject: [PATCH 2/3] Refresh NyxID conformance source pin Pin MainnetHostBuilderExtensions.cs to the #3542 commit so architecture_guards.sh can pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/contracts/nyxid-assistant-conformance/v1/sources.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/contracts/nyxid-assistant-conformance/v1/sources.json b/docs/contracts/nyxid-assistant-conformance/v1/sources.json index 86840a1c52..908320cfe4 100644 --- a/docs/contracts/nyxid-assistant-conformance/v1/sources.json +++ b/docs/contracts/nyxid-assistant-conformance/v1/sources.json @@ -2,8 +2,8 @@ "schema_version": 1, "aevatar": { "repository": "https://github.com/AevatarAI/aevatar.git", - "revision": "3e0678303cfb499f98a93e02d73dd46693ec4433", - "contract_files_sha256": "17f41ef766c3a125ea0d56ea44d38be3e03012c0258a73898885eb652abe7d0f", + "revision": "4f5066066786faed3dba5f7410f090c1fbddcb17", + "contract_files_sha256": "616e3dc31d4a1b0dde898f8a5029d21e967c475b596dfefba33d261c354d75f6", "files": { "agents/Aevatar.GAgents.NyxidChat/NyxIdActionPostconditionPort.cs": "7791de469b567dcde70a0f8e2a88cc818972ca557617a2538294e8ccabd5bda0", "agents/Aevatar.GAgents.NyxidChat/NyxIdAssistantActionRegistry.cs": "60e6f67c94ae11b1bf0dac036ad8ac0c35901e31787b1f0c8173964f6a12d263", @@ -17,7 +17,7 @@ "src/Aevatar.AI.ToolProviders.NyxId/NyxIdAssistantToolSource.cs": "1b033df9cb55c741e9b52054cbd4a91067f03c8c3797bd076a7e3d6133eb0fcb", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyCreateTool.cs": "2c4f2cda99154f2e667c6cfd291497e697ef11df17f081f96ec70070a8af8b8c", "src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdRequestKeyRotateTool.cs": "18212bb64644cfbca401065bccce439ea5fa00316deff57d730a0d9ac2650e53", - "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "f15f082ce76f375fcead9504d9306cbdd05339f49f67c74b7a8f8c9268b81cbb" + "src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs": "2f07e4ab798bc5ed30fa7a93b735465c3c5b9fae8ba886b00bd4186d1e2049f2" } }, "nyxid": { From 5f81f721dace9f715d38b00d6eddbb8cc5d89c6f Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 27 Aug 2026 02:27:14 +0800 Subject: [PATCH 3/3] Fix review round 1 on PR #3545 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/canon/connector.md | 4 + .../IConnectorCatalogQueryPort.cs | 17 ++++ .../ServiceCollectionExtensions.cs | 1 + .../Services/ConnectorCatalogNameAuthority.cs | 81 +++++++++++++++++++ .../ActorBackedConnectorCatalogStore.cs | 43 ++++------ ...nScheduledInvocationAuthorityQueryPorts.cs | 27 +++++-- .../MainnetHostCompositionTests.cs | 7 ++ ...orExternalWorkflowCapabilitySourceTests.cs | 18 +++-- ...eduledInvocationAuthorityQueryPortTests.cs | 76 ++++++++++++++++- 9 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 src/Aevatar.Studio.Application/Studio/Services/ConnectorCatalogNameAuthority.cs diff --git a/docs/canon/connector.md b/docs/canon/connector.md index 745b58d784..012807fa82 100644 --- a/docs/canon/connector.md +++ b/docs/canon/connector.md @@ -342,6 +342,10 @@ Mainnet Host 默认启用一个 Host-owned 确定性计算 connector: Studio scope 的 connector catalog;因此生产镜像不依赖节点本地 `~/.aevatar/connectors.json` 才能暴露该 内建能力。运行时注册仍通过 `HostCallbackConnectorBuilder`,若上述 handler/operation 与已注册的 `DeterministicAlgorithmDescriptor` 不精确一致,Host 启动失败且 catalog 不会形成一个可运行的弱契约。 +Studio catalog GET、workflow capability source 与 scheduled authorization evidence 共用同一个 Host-default +connector-name authority。scope PUT 只持久化 scope-owned connector,忽略同名 Host-owned 项并返回组合后的 +catalog view;因此不依赖客户端先 GET 再 PUT 来发布默认项。catalog `Version` / ETag 只描述可写的 scope +catalog actor version,Host-owned defaults 随 Host 组合发布且不属于该并发控制边界。 ## 3.4 Host 责任边界 diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs index 8da2b21df6..514063ea27 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IConnectorCatalogQueryPort.cs @@ -17,3 +17,20 @@ public interface IHostConnectorCatalogDefaults { IReadOnlyList Connectors { get; } } + +public sealed record ConnectorCatalogNameEntry(string Name, bool Enabled); + +/// +/// Owns Host-default connector-name precedence across catalog readers and scope writes. +/// +public interface IConnectorCatalogNameAuthority +{ + IReadOnlyList ComposeDefinitions( + IReadOnlyList scopedConnectors); + + IReadOnlyList ComposeEnabledNames( + IReadOnlyList scopedConnectors); + + IReadOnlyList SelectScopeOwnedDefinitions( + IReadOnlyList requestedConnectors); +} diff --git a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs index 6ba93f20f4..6d8b352c41 100644 --- a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton< IExternalWorkflowCapabilitySource, ConnectorExternalWorkflowCapabilitySource>()); diff --git a/src/Aevatar.Studio.Application/Studio/Services/ConnectorCatalogNameAuthority.cs b/src/Aevatar.Studio.Application/Studio/Services/ConnectorCatalogNameAuthority.cs new file mode 100644 index 0000000000..2bfd244688 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Services/ConnectorCatalogNameAuthority.cs @@ -0,0 +1,81 @@ +using Aevatar.Studio.Application.Studio.Abstractions; + +namespace Aevatar.Studio.Application.Studio.Services; + +internal sealed class ConnectorCatalogNameAuthority : IConnectorCatalogNameAuthority +{ + private readonly IReadOnlyList _hostConnectorDefaults; + private readonly HashSet _hostConnectorNames; + + public ConnectorCatalogNameAuthority(IEnumerable hostConnectorDefaults) + { + ArgumentNullException.ThrowIfNull(hostConnectorDefaults); + + _hostConnectorDefaults = hostConnectorDefaults + .SelectMany(static defaults => defaults.Connectors) + .ToArray(); + _hostConnectorNames = _hostConnectorDefaults + .Select(static connector => NormalizeRequiredName(connector.Name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList ComposeDefinitions( + IReadOnlyList scopedConnectors) => + ComposeByName( + scopedConnectors, + static connector => connector.Name, + static connector => connector); + + public IReadOnlyList ComposeEnabledNames( + IReadOnlyList scopedConnectors) => + ComposeByName( + scopedConnectors, + static connector => connector.Name, + static connector => new ConnectorCatalogNameEntry(connector.Name, connector.Enabled)) + .Where(static connector => connector.Enabled && !string.IsNullOrWhiteSpace(connector.Name)) + .Select(static connector => connector.Name.Trim()) + .ToArray(); + + public IReadOnlyList SelectScopeOwnedDefinitions( + IReadOnlyList requestedConnectors) + { + ArgumentNullException.ThrowIfNull(requestedConnectors); + + return requestedConnectors + .Where(connector => !_hostConnectorNames.Contains(connector.Name.Trim())) + .ToArray(); + } + + private IReadOnlyList ComposeByName( + IReadOnlyList scopedConnectors, + Func nameSelector, + Func hostConnectorSelector) + { + ArgumentNullException.ThrowIfNull(scopedConnectors); + + var merged = scopedConnectors.ToList(); + foreach (var hostConnector in _hostConnectorDefaults) + { + var hostConnectorName = NormalizeRequiredName(hostConnector.Name); + var existingIndex = merged.FindIndex(connector => + string.Equals( + nameSelector(connector).Trim(), + hostConnectorName, + StringComparison.OrdinalIgnoreCase)); + if (existingIndex >= 0) + merged[existingIndex] = hostConnectorSelector(hostConnector); + else + merged.Add(hostConnectorSelector(hostConnector)); + } + + return merged.AsReadOnly(); + } + + private static string NormalizeRequiredName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("Host connector catalog defaults require a name."); + + return name.Trim(); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index 427ba28eb8..243ca9db77 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -29,7 +29,7 @@ internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogQueryP private readonly IStudioLocalConnectorCatalogImportReader _localImportReader; private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; - private readonly IReadOnlyList _hostConnectorDefaults; + private readonly IConnectorCatalogNameAuthority _connectorCatalogNameAuthority; public ActorBackedConnectorCatalogStore( IStudioActorBootstrap bootstrap, @@ -37,7 +37,7 @@ public ActorBackedConnectorCatalogStore( IAppScopeResolver scopeResolver, IStudioLocalConnectorCatalogImportReader localImportReader, IProjectionDocumentReader documentReader, - IEnumerable hostConnectorDefaults, + IConnectorCatalogNameAuthority connectorCatalogNameAuthority, ILogger logger) { _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); @@ -45,9 +45,8 @@ public ActorBackedConnectorCatalogStore( _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _localImportReader = localImportReader ?? throw new ArgumentNullException(nameof(localImportReader)); _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); - _hostConnectorDefaults = (hostConnectorDefaults ?? throw new ArgumentNullException(nameof(hostConnectorDefaults))) - .SelectMany(static defaults => defaults.Connectors) - .ToArray(); + _connectorCatalogNameAuthority = connectorCatalogNameAuthority ?? + throw new ArgumentNullException(nameof(connectorCatalogNameAuthority)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -63,7 +62,10 @@ public async Task GetConnectorCatalogAsync( // Implement (issue #3542): // Behavior: Publish deployment-owned connector defaults in every scope while preserving scope-owned entries. // Why this shape: Mainnet capabilities remain discoverable without query-time writes or a process-local scope registry. - var connectors = MergeHostConnectorDefaults(scopedConnectors, _hostConnectorDefaults); + // Fix (review round 1, F1): + // GET was the only reader that composed Host-owned connector names. + // Delegate composition to the catalog-name authority shared with scheduled evidence. + var connectors = _connectorCatalogNameAuthority.ComposeDefinitions(scopedConnectors); return new StoredConnectorCatalog( HomeDirectory: ActorHomeDirectory, @@ -73,35 +75,18 @@ public async Task GetConnectorCatalogAsync( Version: version); } - internal static IReadOnlyList MergeHostConnectorDefaults( - IReadOnlyList scopedConnectors, - IReadOnlyList hostConnectorDefaults) - { - var merged = scopedConnectors.ToList(); - foreach (var hostConnector in hostConnectorDefaults) - { - if (string.IsNullOrWhiteSpace(hostConnector.Name)) - throw new InvalidOperationException("Host connector catalog defaults require a name."); - - var existingIndex = merged.FindIndex(connector => - string.Equals(connector.Name, hostConnector.Name, StringComparison.OrdinalIgnoreCase)); - if (existingIndex >= 0) - merged[existingIndex] = hostConnector; - else - merged.Add(hostConnector); - } - - return merged.AsReadOnly(); - } - public async Task SaveConnectorCatalogAsync( StoredConnectorCatalog catalog, long? expectedVersion = null, CancellationToken cancellationToken = default) { + // Fix (review round 1, F2): + // GET+PUT could persist Host-owned defaults and PUT returned a different catalog view. + // Persist only scope-owned entries, then return the same composed view exposed by GET. + var scopedConnectors = _connectorCatalogNameAuthority.SelectScopeOwnedDefinitions(catalog.Connectors); var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new ConnectorCatalogSavedEvent(); - evt.Connectors.AddRange(catalog.Connectors.Select(ToProtoConnectorDefinition)); + evt.Connectors.AddRange(scopedConnectors.Select(ToProtoConnectorDefinition)); if (expectedVersion is not null) evt.ExpectedVersion = expectedVersion.Value; await _commandDispatch.DispatchAsync(actor, evt, PublisherId, cancellationToken); @@ -110,7 +95,7 @@ public async Task SaveConnectorCatalogAsync( HomeDirectory: ActorHomeDirectory, FilePath: ActorFilePath, FileExists: true, - Connectors: catalog.Connectors, + Connectors: _connectorCatalogNameAuthority.ComposeDefinitions(scopedConnectors), Version: NextDeterministicVersion(expectedVersion)); } diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionScheduledInvocationAuthorityQueryPorts.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionScheduledInvocationAuthorityQueryPorts.cs index afda0f833b..f696897aaf 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionScheduledInvocationAuthorityQueryPorts.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionScheduledInvocationAuthorityQueryPorts.cs @@ -8,6 +8,7 @@ using Aevatar.GAgentService.Abstractions.Services; using Aevatar.GAgents.ConnectorCatalog; using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Projection.ReadModels; using Aevatar.Workflow.Abstractions; @@ -127,7 +128,8 @@ private static bool HasMatchingAdmissionEvidence( } public sealed class ProjectionScheduledInvocationConnectorQueryPort( - IProjectionDocumentReader reader) + IProjectionDocumentReader reader, + IConnectorCatalogNameAuthority connectorCatalogNameAuthority) : IScheduledInvocationConnectorEvidenceQueryPort { public async Task GetAsync( @@ -135,16 +137,25 @@ public sealed class ProjectionScheduledInvocationConnectorQueryPort( CancellationToken ct = default) { var document = await reader.GetAsync($"connector-catalog-{scopeId.Trim()}", ct); - if (document?.StateRoot?.Is(ConnectorCatalogState.Descriptor) != true) + if (document is not null && + document.StateRoot?.Is(ConnectorCatalogState.Descriptor) != true) + return null; + + var state = document?.StateRoot?.Unpack(); + var scopedConnectorNames = state?.Connectors + .Select(static connector => new ConnectorCatalogNameEntry(connector.Name, connector.Enabled)) + .ToArray() ?? []; + + // Fix (review round 1, F1): + // Scheduled evidence ignored Host defaults and rejected connectors advertised by GET. + // Resolve names through the shared authority, including when no scope document exists. + var connectorNames = connectorCatalogNameAuthority.ComposeEnabledNames(scopedConnectorNames); + if (document is null && connectorNames.Count == 0) return null; - var state = document.StateRoot.Unpack(); return new ScheduledInvocationConnectorEvidence( - document.StateVersion, - state.Connectors - .Where(static connector => connector.Enabled && !string.IsNullOrWhiteSpace(connector.Name)) - .Select(static connector => connector.Name.Trim()) - .ToArray()); + document?.StateVersion ?? 0, + connectorNames); } } diff --git a/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs b/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs index 281f0e8535..ab238dff91 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetHostCompositionTests.cs @@ -535,6 +535,13 @@ public async Task AddAevatarMainnetHost_WithInMemoryDependencies_ShouldBuildAndS ExternalCapabilityExecutionMode.Interactive); deterministicReadiness.Status.Should().Be(ExternalCapabilityReadinessStatus.Ready); + var scheduledConnectorEvidence = await app.Services + .GetRequiredService() + .GetAsync("default"); + scheduledConnectorEvidence.Should().NotBeNull(); + scheduledConnectorEvidence!.ConnectorCapabilityRefs.Should() + .Contain(MainnetDeterministicComputeConnectorDefinition.ConnectorName); + var routePatterns = ((IEndpointRouteBuilder)app).DataSources .SelectMany(x => x.Endpoints) .OfType() diff --git a/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs b/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs index b13758ceff..b105db7c74 100644 --- a/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs +++ b/test/Aevatar.Studio.Tests/ConnectorExternalWorkflowCapabilitySourceTests.cs @@ -2,7 +2,6 @@ using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Application.Studio.DependencyInjection; using Aevatar.Foundation.Abstractions.Connectors; -using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Application.Abstractions.ExternalCapabilities; using Aevatar.Workflow.Application.Abstractions.Runs; @@ -17,7 +16,7 @@ namespace Aevatar.Studio.Tests; public sealed class ConnectorExternalWorkflowCapabilitySourceTests { [Fact] - public void HostConnectorDefaults_ShouldPreserveScopeConnectors_AndReplaceSameNameDrift() + public void CatalogNameAuthority_ShouldPreserveScopeConnectors_ReplaceDrift_AndExcludeHostOwnedWrites() { var scopeConnector = Connector("fortune-engine", authType: string.Empty); var driftedDeterministicConnector = DeterministicConnector( @@ -25,10 +24,13 @@ public void HostConnectorDefaults_ShouldPreserveScopeConnectors_AndReplaceSameNa allowedOperations: ["different_algorithm"]); var hostDeterministicConnector = DeterministicConnector("deterministic_compute"); - var merged = ActorBackedConnectorCatalogStore.MergeHostConnectorDefaults( - [scopeConnector, driftedDeterministicConnector], - [hostDeterministicConnector]); + var authority = new ConnectorCatalogNameAuthority( + [new StubHostConnectorCatalogDefaults([hostDeterministicConnector])]); + var scopeOwned = authority.SelectScopeOwnedDefinitions( + [scopeConnector, driftedDeterministicConnector]); + var merged = authority.ComposeDefinitions(scopeOwned); + scopeOwned.Should().Equal(scopeConnector); merged.Should().HaveCount(2); merged.Should().ContainSingle(connector => connector.Name == "fortune-engine"); merged.Should().ContainSingle(connector => connector.Name == "deterministic_compute") @@ -388,6 +390,12 @@ public Task GetConnectorDraftAsync(CancellationToken cance throw new NotSupportedException(); } + private sealed class StubHostConnectorCatalogDefaults( + IReadOnlyList connectors) : IHostConnectorCatalogDefaults + { + public IReadOnlyList Connectors { get; } = connectors; + } + private sealed class FixedTimeProvider : TimeProvider { public override DateTimeOffset GetUtcNow() => diff --git a/test/Aevatar.Studio.Tests/ProjectionScheduledInvocationAuthorityQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionScheduledInvocationAuthorityQueryPortTests.cs index e57562cdbb..7d70678e8c 100644 --- a/test/Aevatar.Studio.Tests/ProjectionScheduledInvocationAuthorityQueryPortTests.cs +++ b/test/Aevatar.Studio.Tests/ProjectionScheduledInvocationAuthorityQueryPortTests.cs @@ -6,6 +6,8 @@ using Aevatar.GAgentService.Abstractions.Schedules.Authorization; using Aevatar.GAgents.ConnectorCatalog; using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Projection.QueryPorts; using Aevatar.Studio.Projection.ReadModels; using Aevatar.Workflow.Abstractions; @@ -192,7 +194,9 @@ public async Task ConnectorPort_ShouldUseOwnedDocumentAndMapTypedEvidence() StateVersion = 7, StateRoot = Any.Pack(connectorState), }); - var connector = await new ProjectionScheduledInvocationConnectorQueryPort(connectorReader) + var connector = await new ProjectionScheduledInvocationConnectorQueryPort( + connectorReader, + new ConnectorCatalogNameAuthority([])) .GetAsync(" scope-alpha "); connectorReader.Key.Should().Be("connector-catalog-scope-alpha"); @@ -200,6 +204,32 @@ public async Task ConnectorPort_ShouldUseOwnedDocumentAndMapTypedEvidence() connector.ConnectorCapabilityRefs.Should().Equal("calendar"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConnectorPort_WithMissingOrEmptyScopeCatalog_ShouldUseHostDefaults( + bool scopeDocumentExists) + { + var connectorState = new ConnectorCatalogState(); + var connectorReader = new RecordingReader( + scopeDocumentExists + ? new ConnectorCatalogCurrentStateDocument + { + StateVersion = 9, + StateRoot = Any.Pack(connectorState), + } + : null); + var authority = new ConnectorCatalogNameAuthority( + [new StubHostConnectorCatalogDefaults([HostDefaultConnector("deterministic_compute")])]); + + var connector = await new ProjectionScheduledInvocationConnectorQueryPort(connectorReader, authority) + .GetAsync("scope-alpha"); + + connector.Should().NotBeNull(); + connector!.StateVersion.Should().Be(scopeDocumentExists ? 9 : 0); + connector.ConnectorCapabilityRefs.Should().Equal("deterministic_compute"); + } + [Fact] public async Task OwnerLlmPort_WithTypedService_ShouldMapExactProjectedIdentity() { @@ -430,7 +460,9 @@ public async Task Ports_ShouldFailClosedForMissingDocumentsOrRequiredMemberField (await new ProjectionScheduledInvocationMemberQueryPort(incompleteMember).GetAsync("s", "m")).Should().BeNull(); (await new ProjectionScheduledInvocationWorkflowQueryPort(missingWorkflow) .GetAsync("s", "svc", "rev")).Should().BeNull(); - (await new ProjectionScheduledInvocationConnectorQueryPort(missingConnector).GetAsync("s")).Should().BeNull(); + (await new ProjectionScheduledInvocationConnectorQueryPort( + missingConnector, + new ConnectorCatalogNameAuthority([])).GetAsync("s")).Should().BeNull(); } [Fact] @@ -515,6 +547,46 @@ private static NyxIdOperationExecutionPolicy ReadOnlyPolicy( ModelId = modelId, }; + private static StoredConnectorDefinition HostDefaultConnector(string name) => + new( + name, + "host_callback", + true, + 30_000, + 0, + new StoredHttpConnectorConfig( + string.Empty, + [], + [], + [], + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", "", "", "", "")), + new StoredCliConnectorConfig( + string.Empty, + [], + [], + [], + string.Empty, + new Dictionary()), + new StoredMcpConnectorConfig( + string.Empty, + string.Empty, + string.Empty, + [], + new Dictionary(), + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", "", "", "", ""), + string.Empty, + [], + []), + new StoredHostCallbackConnectorConfig("deterministic_compute", ["sha256_utf8"], ["text"])); + + private sealed class StubHostConnectorCatalogDefaults( + IReadOnlyList connectors) : IHostConnectorCatalogDefaults + { + public IReadOnlyList Connectors { get; } = connectors; + } + private sealed class RecordingRevisionCatalogReader(ServiceRevisionCatalogSnapshot? snapshot) : IServiceRevisionCatalogQueryReader {