diff --git a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs index 5f91e3274..9bec34540 100644 --- a/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs +++ b/agents/Aevatar.GAgents.WorkflowDelivery/WorkflowDeliveryGAgent.cs @@ -947,6 +947,12 @@ WorkflowDeliveryAcceptanceMode.AutomaticPreview or string.IsNullOrWhiteSpace(package.AcceptancePolicy.Limitation)) throw new InvalidOperationException("manual workflow delivery acceptance policy requires a limitation."); WorkflowDeliveryConventions.ValidateAcceptanceInput(package.AcceptancePolicy.Input); + foreach (var slot in package.ConnectionSlots) + { + WorkflowDeliveryConventions.NormalizeRequired(slot.Key, "connection_slot.key"); + WorkflowDeliveryConventions.NormalizeRequired(slot.ServiceSlug, "connection_slot.service_slug"); + WorkflowDeliveryConventions.NormalizeRequired(slot.YamlPointer, "connection_slot.yaml_pointer"); + } var expectedPackageHash = WorkflowDeliveryConventions.ComputePackageHash(package); if (!string.Equals(package.PackageHash, expectedPackageHash, StringComparison.Ordinal)) throw new InvalidOperationException("workflow delivery package hash does not match its immutable content."); @@ -960,6 +966,11 @@ WorkflowDeliveryAcceptanceMode.AutomaticPreview or throw new InvalidOperationException("workflow delivery variable keys must be unique."); if (package.ConnectionSlots.Select(x => x.Key).Distinct(StringComparer.Ordinal).Count() != package.ConnectionSlots.Count) throw new InvalidOperationException("workflow delivery connection slot keys must be unique."); + if (package.ConnectionSlots.Select(x => x.YamlPointer).Distinct(StringComparer.Ordinal).Count() != package.ConnectionSlots.Count) + throw new InvalidOperationException("workflow delivery connection slot yaml pointers must be unique."); + var variableYamlPointers = package.VariableSchema.Select(x => x.YamlPointer).ToHashSet(StringComparer.Ordinal); + if (package.ConnectionSlots.Any(x => variableYamlPointers.Contains(x.YamlPointer))) + throw new InvalidOperationException("workflow delivery connection slot yaml pointers must not overlap variable yaml pointers."); } private void ValidateInstallation(StartWorkflowInstallationCommand command) diff --git a/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto b/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto index c26069c18..8c0c99af0 100644 --- a/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto +++ b/agents/Aevatar.GAgents.WorkflowDelivery/workflow_delivery_messages.proto @@ -98,6 +98,7 @@ message WorkflowDeliveryConnectionSlotDefinition { string label = 2; string service_slug = 3; bool required = 4; + string yaml_pointer = 5; } enum WorkflowDeliveryAcceptanceDateProjection { diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs index 23ac0ed35..4e7b0ecd9 100644 --- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs +++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/WorkflowDeliveryContracts.cs @@ -101,7 +101,8 @@ public sealed record WorkflowDeliveryConnectionSlotDefinition( string Key, string Label, string ServiceSlug, - bool Required); + bool Required, + string YamlPointer); public enum WorkflowDeliveryAcceptanceDateProjection { diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs index 2d0aa6c58..7907ad5fa 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryConfigurationRenderer.cs @@ -109,17 +109,13 @@ public WorkflowDeliveryRenderResult Render( } var resolvedConnections = ResolveConnections(package, connectionReferences); - if (resolvedConnections.Count != 0) + foreach (var slot in package.ConnectionSlots) { - var replacements = ReplaceUserServiceIds( - stream.Documents[0].RootNode, - resolvedConnections.Values.Single()); - if (replacements == 0) - { - throw new WorkflowDeliveryConfigurationException( - "CONNECTION_BINDING_NOT_FOUND", - "The workflow package has no structured user_service_id fields for its connection slot."); - } + var yamlNode = ResolveYamlPointer(stream.Documents[0].RootNode, slot.YamlPointer); + if (yamlNode is not YamlScalarNode scalar) + throw InvalidPointer(slot.Key); + scalar.Value = resolvedConnections.GetValueOrDefault(slot.Key) ?? string.Empty; + scalar.Style = ScalarStyle.Plain; } string resolvedYaml; @@ -156,8 +152,6 @@ private static IReadOnlyDictionary ResolveConnections( } resolved.Add(slot.Key, raw.Trim()); } - if (resolved.Count > 1) - throw new WorkflowDeliveryConfigurationException("MULTIPLE_CONNECTIONS_UNSUPPORTED", "This MVP supports one connection slot per workflow package."); return resolved; } @@ -260,36 +254,6 @@ JsonArray array when int.TryParse(segments[i], NumberStyles.None, CultureInfo.In } } - private static int ReplaceUserServiceIds(YamlNode node, string userServiceId) - { - var count = 0; - switch (node) - { - case YamlMappingNode mapping: - foreach (var pair in mapping.Children) - { - if (pair.Key is YamlScalarNode key && - string.Equals(key.Value, "user_service_id", StringComparison.Ordinal) && - pair.Value is YamlScalarNode value) - { - value.Value = userServiceId; - value.Style = ScalarStyle.Plain; - count++; - } - else - { - count += ReplaceUserServiceIds(pair.Value, userServiceId); - } - } - break; - case YamlSequenceNode sequence: - foreach (var child in sequence.Children) - count += ReplaceUserServiceIds(child, userServiceId); - break; - } - return count; - } - private static string DecodePointerSegment(string value) => value.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal); diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs index 1ff05a537..93dda46fb 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryOptions.cs @@ -84,6 +84,8 @@ public sealed class WorkflowDeliveryConnectionSlotOptions public string ServiceSlug { get; set; } = string.Empty; + public string YamlPointer { get; set; } = string.Empty; + public bool Required { get; set; } } diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs index 3d79dd5eb..1ace13051 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryPackageCatalog.cs @@ -174,6 +174,7 @@ private static WorkflowDeliveryConnectionSlotDefinition ToProto(ConnectionSlotDe Label = value.Label, ServiceSlug = value.ServiceSlug, Required = value.Required, + YamlPointer = value.YamlPointer, }; private static PackageDefinition ToDefinition(WorkflowDeliveryPackageOptions options) @@ -195,9 +196,15 @@ private static PackageDefinition ToDefinition(WorkflowDeliveryPackageOptions opt NormalizeRequired(value.Key, "delivery connection slot key"), NormalizeRequired(value.Label, "delivery connection slot label"), NormalizeRequired(value.ServiceSlug, "delivery connection service slug"), - value.Required)).ToArray(); + value.Required, + NormalizeRequired(value.YamlPointer, "delivery connection yaml pointer"))).ToArray(); if (connectionSlots.Select(static value => value.Key).Distinct(StringComparer.Ordinal).Count() != connectionSlots.Length) throw new InvalidOperationException("Delivery package connection slot keys must be unique."); + if (connectionSlots.Select(static value => value.YamlPointer).Distinct(StringComparer.Ordinal).Count() != connectionSlots.Length) + throw new InvalidOperationException("Delivery package connection slot yaml pointers must be unique."); + var variableYamlPointers = variables.Select(static value => value.YamlPointer).ToHashSet(StringComparer.Ordinal); + if (connectionSlots.Any(value => variableYamlPointers.Contains(value.YamlPointer))) + throw new InvalidOperationException("Delivery package connection slot yaml pointers must not overlap variable yaml pointers."); var acceptance = options.Acceptance ?? throw new InvalidOperationException( "Delivery package acceptance policy is required."); @@ -413,7 +420,8 @@ private sealed record ConnectionSlotDefinition( string Key, string Label, string ServiceSlug, - bool Required); + bool Required, + string YamlPointer); } public sealed class WorkflowDeliveryPackageNotAllowedException(string workflowName) diff --git a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs index 676b75725..f4ab972c8 100644 --- a/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs +++ b/src/Aevatar.Studio.Application/Delivery/WorkflowDeliveryService.cs @@ -1099,7 +1099,8 @@ private static PackageSnapshot ToContract(WorkflowPackageVersionSnapshot package item.Key, item.Label, item.ServiceSlug, - item.Required)).ToArray(), + item.Required, + item.YamlPointer)).ToArray(), package.Capabilities.ToArray(), package.RiskSummary, package.ParserDiagnostics.ToArray(), @@ -1142,6 +1143,7 @@ private static WorkflowPackageVersionSnapshot ToActorPackage(PackageSnapshot pac Label = item.Label, ServiceSlug = item.ServiceSlug, Required = item.Required, + YamlPointer = item.YamlPointer, })); result.Capabilities.Add(package.Capabilities); result.ParserDiagnostics.Add(package.ParserDiagnostics); diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs index da494b2d1..4811c8d3f 100644 --- a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchWorkflowDeliveryCommandService.cs @@ -520,6 +520,7 @@ private static WorkflowDeliveryConnectionSlotDefinition MapConnectionSlot( Label = value.Label, ServiceSlug = value.ServiceSlug, Required = value.Required, + YamlPointer = value.YamlPointer, }; private static WorkflowDeliveryTriggerIntent MapTrigger(DeliveryApplication.WorkflowDeliveryTriggerIntent value) diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs index a2bc4334d..bdcaee59f 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionWorkflowDeliveryQueryPort.cs @@ -315,7 +315,7 @@ private static ApplicationVariableDefinition MapVariable( private static ApplicationConnectionSlotDefinition MapConnectionSlot( Aevatar.GAgents.WorkflowDelivery.WorkflowDeliveryConnectionSlotDefinition value) => - new(value.Key, value.Label, value.ServiceSlug, value.Required); + new(value.Key, value.Label, value.ServiceSlug, value.Required, value.YamlPointer); private static WorkflowDeliveryConnectionSnapshot MapConnection( WorkflowDeliveryConnectionState value) => diff --git a/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs index 187bc9e65..6a7ef058c 100644 --- a/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ActorDispatchWorkflowDeliveryCommandServiceTests.cs @@ -354,7 +354,12 @@ private static WorkflowDeliveryPackageSnapshot Package(bool inputDeclared = true null, "10"), ], - [new ApplicationConnectionSlotDefinition("mail", "Mail", "lark", true)], + [new ApplicationConnectionSlotDefinition( + "mail", + "Mail", + "lark", + true, + "/steps/0/capability/nyxid_request/user_service_id")], ["network.write"], "Writes a notification", [], diff --git a/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs index 4089783fd..e54bfba60 100644 --- a/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs +++ b/test/Aevatar.Studio.Tests/ProjectionWorkflowDeliveryQueryPortTests.cs @@ -42,6 +42,8 @@ public async Task GetForScopeAsync_ShouldFilterBeforeMappingAndReturnTypedImmuta -2)); snapshot.Package.AcceptancePolicy.Input.Bindings[1].Source.Should().BeOfType< DeliveryApplication.WorkflowDeliveryAuthenticatedOwnerExternalUserIdInput>(); + snapshot.Package.ConnectionSlots.Should().ContainSingle().Which.YamlPointer + .Should().Be("/steps/0/capability/nyxid_request/user_service_id"); snapshot.LifecycleStatus.Should().Be(DeliveryApplication.WorkflowDeliveryLifecycleStatus.Active); snapshot.Installation!.Status.Should().Be(DeliveryApplication.WorkflowInstallationStatus.Ready); snapshot.Installation.AcceptanceInput.Should().NotBeNull(); @@ -300,6 +302,17 @@ private static WorkflowDeliveryCurrentStateDocument ValidDocument() => }, CreatedBy = "admin-alpha", CreatedAtUtc = At(0), + ConnectionSlots = + { + new WorkflowDeliveryConnectionSlotDefinition + { + Key = "calendar", + Label = "Calendar", + ServiceSlug = "service-calendar", + Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", + }, + }, }, TargetScopeId = "scope-alpha", ExpiresAtUtc = At(8), diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs index af44933e1..d5332b9ee 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryConfigurationRendererTests.cs @@ -131,6 +131,97 @@ public void Render_WhenOptionalConfigurationFieldIsMissing_ShouldKeepPackageDefa result.ResolvedYaml.Should().Contain("\"keep\":\"yes\""); } + [Fact] + public void Render_WhenOptionalConnectionSlotIsMissing_ShouldClearPackageDefault() + { + var package = Package(); + package.ConnectionSlots[0].Required = false; + var renderer = new WorkflowDeliveryConfigurationRenderer(); + + var result = renderer.Render( + package, + new Dictionary + { + ["threshold"] = Json("25"), + }, + null); + + result.ConnectionReferences.Should().BeEmpty(); + var root = ParseRoot(result.ResolvedYaml); + var steps = (YamlSequenceNode)Child(root, "steps"); + var call = (YamlMappingNode)steps.Children[1]; + var capability = (YamlMappingNode)Child(call, "capability"); + var request = (YamlMappingNode)Child(capability, "nyxid_request"); + Scalar(request, "user_service_id").Value.Should().BeEmpty(); + } + + [Fact] + public void Render_ShouldResolveMultipleConnectionSlotsByDeclaredYamlPointers() + { + const string sourceYaml = """ + name: merchant-intake-workflow + description: 'user_service_id: decoy' + steps: + - id: calendar + type: tool_call + capability: + nyxid_request: + user_service_id: calendar-placeholder + - id: document + type: tool_call + capability: + nyxid_request: + user_service_id: document-placeholder + """; + var package = new WorkflowPackageVersionSnapshot + { + PackageId = "merchant-intake-workflow", + PackageVersionId = "merchant-intake-workflow@source-alpha", + WorkflowName = "merchant-intake-workflow", + Version = "1", + DisplayName = "Merchant Intake Workflow", + SourceYaml = sourceYaml, + SourceHash = Hash(sourceYaml), + CreatedBy = "admin-alpha", + }; + package.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotDefinition + { + Key = "calendar", + Label = "Calendar", + ServiceSlug = "service-calendar", + Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", + }); + package.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotDefinition + { + Key = "document", + Label = "Document", + ServiceSlug = "service-document", + Required = true, + YamlPointer = "/steps/1/capability/nyxid_request/user_service_id", + }); + var renderer = new WorkflowDeliveryConfigurationRenderer(); + + var result = renderer.Render( + package, + null, + new Dictionary + { + ["calendar"] = "user-service-calendar", + ["document"] = "user-service-document", + }); + + result.ConnectionReferences.Should().Contain("calendar", "user-service-calendar"); + result.ConnectionReferences.Should().Contain("document", "user-service-document"); + var root = ParseRoot(result.ResolvedYaml); + Scalar(root, "description").Value.Should().Be("user_service_id: decoy"); + var steps = (YamlSequenceNode)Child(root, "steps"); + var calendar = (YamlMappingNode)Child((YamlMappingNode)Child((YamlMappingNode)steps.Children[0], "capability"), "nyxid_request"); + var document = (YamlMappingNode)Child((YamlMappingNode)Child((YamlMappingNode)steps.Children[1], "capability"), "nyxid_request"); + Scalar(calendar, "user_service_id").Value.Should().Be("user-service-calendar"); + Scalar(document, "user_service_id").Value.Should().Be("user-service-document"); + } + private static WorkflowPackageVersionSnapshot Package() { var package = new WorkflowPackageVersionSnapshot @@ -160,6 +251,7 @@ private static WorkflowPackageVersionSnapshot Package() Label = "Mail", ServiceSlug = "api-lark-bot", Required = true, + YamlPointer = "/steps/1/capability/nyxid_request/user_service_id", }); return package; } diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs index 706b580d6..6a9c341eb 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryGAgentTests.cs @@ -110,6 +110,52 @@ await action.Should().ThrowAsync() agent.EventSourcing!.CurrentVersion.Should().Be(0); } + [Fact] + public async Task Create_WhenConnectionSlotYamlPointersAreDuplicated_ShouldRejectBeforeCommit() + { + var agent = await CreateAgentAsync("delivery-alpha"); + var command = CreateCommandWithConnectionSlot(); + command.Package.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotDefinition + { + Key = "calendar-secondary", + Label = "Calendar Secondary", + ServiceSlug = "api-calendar-secondary", + Required = true, + YamlPointer = command.Package.ConnectionSlots[0].YamlPointer, + }); + ResealPackage(command.Package); + + var action = () => agent.HandleCreateAsync(command); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must be unique*"); + agent.EventSourcing!.CurrentVersion.Should().Be(0); + } + + [Fact] + public async Task Create_WhenConnectionSlotYamlPointerOverlapsVariable_ShouldRejectBeforeCommit() + { + var agent = await CreateAgentAsync("delivery-alpha"); + var command = CreateCommandWithConnectionSlot(); + command.Package.VariableSchema.Add(new WorkflowDeliveryVariableDefinition + { + Key = "threshold", + Label = "Threshold", + Description = "Approval threshold", + Kind = WorkflowDeliveryVariableKind.Integer, + Required = true, + YamlPointer = "/steps/0/parameters/value", + }); + command.Package.ConnectionSlots[0].YamlPointer = command.Package.VariableSchema[0].YamlPointer; + ResealPackage(command.Package); + + var action = () => agent.HandleCreateAsync(command); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must not overlap variable yaml pointers*"); + agent.EventSourcing!.CurrentVersion.Should().Be(0); + } + [Fact] public async Task DuplicateCreate_WithDefaultExpiryClockDrift_ShouldKeepFirstExpiryWhileExplicitDriftConflicts() { @@ -1392,6 +1438,7 @@ private static CreateWorkflowDeliveryCommand CreateCommandWithConnectionSlot() Label = "Lark", ServiceSlug = "api-lark", Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", }); command.Package.PackageHash = WorkflowDeliveryConventions.ComputePackageHash(command.Package); command.Package.Version = command.Package.PackageHash[..16]; diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs index 0a4d6e02a..dca0a242e 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryPackageCatalogTests.cs @@ -56,6 +56,7 @@ public async Task ListAsync_ShouldExposeExactlyTheExternallyConfiguredParseableP packages.Should().OnlyContain(static package => package.PackageId == package.WorkflowName && package.SourceYaml.Length > 0 && + package.ConnectionSlots.All(slot => slot.YamlPointer == "/steps/0/capability/nyxid_request/user_service_id") && package.AcceptancePolicy.Input.Literals.Fields.ContainsKey("dry_run")); var parser = new WorkflowParser(); @@ -119,6 +120,7 @@ public async Task GetAsync_PackageIdentity_ShouldCoverAllImmutableDeliverySemant { Mutate(package, value => value.VariableSchema[0].Label += " changed"), Mutate(package, value => value.ConnectionSlots[0].ServiceSlug += "-changed"), + Mutate(package, value => value.ConnectionSlots[0].YamlPointer += "-changed"), Mutate(package, value => value.Capabilities.Add("new.capability")), Mutate(package, value => value.RiskSummary += " changed"), Mutate(package, value => value.ParserDiagnostics.Add("new diagnostic")), @@ -214,6 +216,52 @@ await action.Should().ThrowAsync() .WithMessage("*delivery display name is required*"); } + [Fact] + public async Task StartupProbe_WhenConnectionSlotDoesNotDeclareYamlPointer_ShouldFailHostStartup() + { + var definition = Package("workflow-alpha"); + definition.ConnectionSlots[0].YamlPointer = " "; + var probe = new WorkflowDeliveryPackageCatalogStartupProbe(CreateCatalog([definition])); + + var action = () => probe.StartAsync(CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*delivery connection yaml pointer is required*"); + } + + [Fact] + public async Task StartupProbe_WhenConnectionSlotYamlPointersAreDuplicated_ShouldFailHostStartup() + { + var definition = Package("workflow-alpha"); + definition.ConnectionSlots.Add(new WorkflowDeliveryConnectionSlotOptions + { + Key = "provider-secondary", + Label = "Provider Secondary", + ServiceSlug = "provider-beta", + Required = true, + YamlPointer = definition.ConnectionSlots[0].YamlPointer, + }); + var probe = new WorkflowDeliveryPackageCatalogStartupProbe(CreateCatalog([definition])); + + var action = () => probe.StartAsync(CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must be unique*"); + } + + [Fact] + public async Task StartupProbe_WhenConnectionSlotYamlPointerOverlapsVariable_ShouldFailHostStartup() + { + var definition = Package("workflow-alpha"); + definition.ConnectionSlots[0].YamlPointer = definition.Variables[0].YamlPointer; + var probe = new WorkflowDeliveryPackageCatalogStartupProbe(CreateCatalog([definition])); + + var action = () => probe.StartAsync(CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*connection slot yaml pointers must not overlap variable yaml pointers*"); + } + [Fact] public async Task StartupProbe_WhenConfiguredPackageSourceIsMissing_ShouldFailHostStartup() { @@ -536,6 +584,7 @@ private static WorkflowDeliveryPackageOptions Package(string workflowName) => Label = "Provider", ServiceSlug = "provider-alpha", Required = true, + YamlPointer = "/steps/0/capability/nyxid_request/user_service_id", }, ], Acceptance = new WorkflowDeliveryAcceptanceOptions diff --git a/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs b/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs index 7bee3dfb1..62b3555e0 100644 --- a/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowDeliveryServiceTests.cs @@ -118,6 +118,30 @@ public async Task PublishAsync_WhenRepeatedWithSameIdempotencyKey_ShouldPersistA context.Provisioning.Requests.Should().BeEmpty(); } + [Fact] + public async Task PublishAsync_ShouldRenderMultipleCompletedConnectionsIntoProvisionRequest() + { + var context = new TestContext( + DeliveryWithMultipleConnectionSlots(), + renderer: new WorkflowDeliveryConfigurationRenderer()); + + await context.Service.PublishAsync( + "delivery-alpha", + "scope-alpha", + PublishRequest("digest-alpha", "write"), + Caller()); + + var workflowYaml = context.Provisioning.PreparationRequests.Should().ContainSingle().Subject.WorkflowYaml; + workflowYaml.Should().Contain("user_service_id: user-service-calendar"); + workflowYaml.Should().Contain("user_service_id: user-service-document"); + workflowYaml.Should().NotContain("calendar-placeholder"); + workflowYaml.Should().NotContain("document-placeholder"); + + var start = context.Commands.Started.Should().ContainSingle().Subject; + start.ConnectionReferences.Should().Contain("calendar", "user-service-calendar"); + start.ConnectionReferences.Should().Contain("document", "user-service-document"); + } + [Fact] public async Task PublishAsync_WhenInstallationAlreadyExistsWithDifferentRequest_ShouldConflictBeforePreparation() { @@ -1069,12 +1093,73 @@ private static WorkflowDeliverySnapshot DeliveryWithLarkSlot() "lark", "Lark", "api-lark", - true), + true, + "/steps/0/capability/nyxid_request/user_service_id"), ], }, }; } + private static WorkflowDeliverySnapshot DeliveryWithMultipleConnectionSlots() + { + const string sourceYaml = """ + name: workflow-alpha + steps: + - id: calendar + type: tool_call + capability: + nyxid_request: + user_service_id: calendar-placeholder + - id: document + type: tool_call + capability: + nyxid_request: + user_service_id: document-placeholder + """; + var snapshot = DeliverySnapshot(); + var now = DateTimeOffset.Parse("2026-08-16T01:30:00Z"); + return snapshot with + { + Package = snapshot.Package with + { + SourceYaml = sourceYaml, + SourceHash = WorkflowDeliveryPackageCatalog.ComputeHash(sourceYaml), + ConnectionSlots = + [ + new WorkflowDeliveryConnectionSlotDefinition( + "calendar", + "Calendar", + "service-calendar", + true, + "/steps/0/capability/nyxid_request/user_service_id"), + new WorkflowDeliveryConnectionSlotDefinition( + "document", + "Document", + "service-document", + true, + "/steps/1/capability/nyxid_request/user_service_id"), + ], + }, + Connections = + [ + new WorkflowDeliveryConnectionSnapshot( + "calendar", + "service-calendar", + string.Empty, + WorkflowDeliveryConnectionStatus.Completed, + "user-service-calendar", + now), + new WorkflowDeliveryConnectionSnapshot( + "document", + "service-document", + string.Empty, + WorkflowDeliveryConnectionStatus.Completed, + "user-service-document", + now), + ], + }; + } + private static NyxIdUserServiceInventoryItem UserService( string userServiceId, string instanceSlug = "customer-lark", @@ -1112,7 +1197,8 @@ public TestContext( Action? beginProjection = null, Action? attachProjection = null, Action? startProjection = null, - IWorkflowDeliveryPackageCatalog? packageCatalog = null) + IWorkflowDeliveryPackageCatalog? packageCatalog = null, + IWorkflowDeliveryConfigurationRenderer? renderer = null) { Queries = new StubQueryPort(snapshot ?? DeliverySnapshot()); var projector = beginProjection ?? @@ -1129,7 +1215,7 @@ public TestContext( Provisioning = new RecordingProvisioningService(); Service = new WorkflowDeliveryService( packageCatalog ?? new UnusedPackageCatalog(), - new StubRenderer(), + renderer ?? new StubRenderer(), Commands, Queries, connectLinks ?? new UnusedConnectLinkPort(),