mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +02:00
feat(workflows): add sub-workflow backend support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -311,6 +311,7 @@ public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
|
||||
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
@@ -323,6 +324,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string? ProjectInstructions { get; init; }
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public WorkflowDefinitionDto? Workflow { get; init; }
|
||||
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
string? validationError = command.Workflow is null
|
||||
? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message
|
||||
: _workflowValidator.Validate(command.Workflow).FirstOrDefault()?.Message;
|
||||
: _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary).FirstOrDefault()?.Message;
|
||||
if (validationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException(validationError);
|
||||
@@ -86,7 +86,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||
Workflow workflow = command.Workflow is null
|
||||
? bundle.BuildWorkflow(command.Pattern)
|
||||
: _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents);
|
||||
: _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents, command.WorkflowLibrary);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ public sealed class SidecarProtocolHost
|
||||
{
|
||||
Type = "workflow-validation",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Issues = _workflowValidator.Validate(command.Workflow),
|
||||
Issues = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary),
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
@@ -10,12 +9,19 @@ internal sealed class WorkflowRunner
|
||||
public Workflow BuildWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
PatternDefinitionDto patternDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
IReadOnlyList<AIAgent> agents,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||
ArgumentNullException.ThrowIfNull(patternDefinition);
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
|
||||
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
||||
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
|
||||
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
|
||||
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
@@ -25,10 +31,23 @@ internal sealed class WorkflowRunner
|
||||
.Zip(agents, (definition, agent) => (definition.Id, agent))
|
||||
.ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal);
|
||||
|
||||
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
|
||||
}
|
||||
|
||||
private Workflow BuildWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
|
||||
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Dictionary<string, ExecutorBinding> bindings = new(StringComparer.Ordinal);
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
bindings[node.Id] = CreateExecutorBinding(node, agentMap);
|
||||
bindings[node.Id] = CreateExecutorBinding(node, agentMap, workflowLibrary);
|
||||
}
|
||||
|
||||
WorkflowBuilder builder = new(bindings[startNode.Id]);
|
||||
@@ -92,7 +111,8 @@ internal sealed class WorkflowRunner
|
||||
|
||||
private static ExecutorBinding CreateExecutorBinding(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap)
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -115,6 +135,32 @@ internal sealed class WorkflowRunner
|
||||
return agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions());
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WorkflowDefinitionDto subWorkflowDefinition = ResolveSubWorkflowDefinition(node, workflowLibrary);
|
||||
Workflow subWorkflow = new WorkflowRunner().BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary);
|
||||
return subWorkflow.BindAsExecutor(node.Id);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet.");
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
if (node.Config.InlineWorkflow is not null)
|
||||
{
|
||||
return node.Config.InlineWorkflow;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
|
||||
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
|
||||
{
|
||||
return workflow;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
@@ -9,11 +10,18 @@ public sealed class WorkflowValidator
|
||||
"start",
|
||||
"end",
|
||||
"agent",
|
||||
"sub-workflow",
|
||||
};
|
||||
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Validate(WorkflowDefinitionDto workflow)
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Validate(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
List<WorkflowValidationIssueDto> issues = [];
|
||||
Dictionary<string, WorkflowDefinitionDto>? workflowLibraryById = workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workflow.Name))
|
||||
{
|
||||
@@ -94,6 +102,8 @@ public sealed class WorkflowValidator
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ValidateSubWorkflowNode(node, workflowLibraryById, issues);
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
||||
@@ -145,8 +155,10 @@ public sealed class WorkflowValidator
|
||||
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> agentNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
List<WorkflowNodeDto> executableWorkNodes = workflow.Graph.Nodes
|
||||
.Where(node =>
|
||||
string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (startNodes.Count != 1)
|
||||
@@ -167,12 +179,12 @@ public sealed class WorkflowValidator
|
||||
});
|
||||
}
|
||||
|
||||
if (agentNodes.Count == 0)
|
||||
if (executableWorkNodes.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain at least one agent node.",
|
||||
Message = "Workflow graphs must contain at least one agent or sub-workflow node.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,6 +350,61 @@ public sealed class WorkflowValidator
|
||||
return issues;
|
||||
}
|
||||
|
||||
private void ValidateSubWorkflowNode(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibraryById,
|
||||
List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasWorkflowId = !string.IsNullOrWhiteSpace(node.Config.WorkflowId);
|
||||
bool hasInlineWorkflow = node.Config.InlineWorkflow is not null;
|
||||
if (hasWorkflowId == hasInlineWorkflow)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config",
|
||||
NodeId = node.Id,
|
||||
Message = "Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWorkflowId
|
||||
&& workflowLibraryById is not null
|
||||
&& !workflowLibraryById.ContainsKey(node.Config.WorkflowId!))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.workflowId",
|
||||
NodeId = node.Id,
|
||||
Message = $"Sub-workflow node \"{node.Label}\" references unknown workflow \"{node.Config.WorkflowId}\".",
|
||||
});
|
||||
}
|
||||
|
||||
if (node.Config.InlineWorkflow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (WorkflowValidationIssueDto inlineIssue in Validate(node.Config.InlineWorkflow, workflowLibraryById?.Values.ToList()))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Level = inlineIssue.Level,
|
||||
Field = inlineIssue.Field is null
|
||||
? "graph.nodes.config.inlineWorkflow"
|
||||
: $"graph.nodes.config.inlineWorkflow.{inlineIssue.Field}",
|
||||
NodeId = node.Id,
|
||||
EdgeId = inlineIssue.EdgeId,
|
||||
Message = $"Inline workflow for node \"{node.Label}\": {inlineIssue.Message}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (edge.Condition is null)
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class SidecarProtocolHostTests
|
||||
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
|
||||
Assert.Contains(issues, issue =>
|
||||
issue.GetProperty("field").GetString() == "graph.nodes"
|
||||
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one agent node.");
|
||||
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one agent or sub-workflow node.");
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class WorkflowRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_AcceptsInlineSubworkflows()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateSubworkflowParent(inlineWorkflow: CreateAgentWorkflow("child-inline", "agent-child")),
|
||||
CreatePattern("agent-child"),
|
||||
[CreateChatClientAgent("agent-child", "Child Agent")]);
|
||||
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||
|
||||
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildWorkflow_AcceptsReferencedSubworkflowsFromWorkflowLibrary()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
WorkflowDefinitionDto childWorkflow = CreateAgentWorkflow("child-ref", "agent-child");
|
||||
Workflow workflow = runner.BuildWorkflow(
|
||||
CreateSubworkflowParent(workflowId: childWorkflow.Id),
|
||||
CreatePattern("agent-child"),
|
||||
[CreateChatClientAgent("agent-child", "Child Agent")],
|
||||
[childWorkflow]);
|
||||
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||
|
||||
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildWorkflow_RejectsUnknownReferencedSubworkflows()
|
||||
{
|
||||
WorkflowRunner runner = new();
|
||||
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
|
||||
CreateSubworkflowParent(workflowId: "missing-child"),
|
||||
CreatePattern("agent-child"),
|
||||
[CreateChatClientAgent("agent-child", "Child Agent")],
|
||||
[]));
|
||||
|
||||
Assert.Contains("unknown workflow", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(string agentId)
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Workflow Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = agentId,
|
||||
Name = "Child Agent",
|
||||
Instructions = "Help with the request.",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateAgentWorkflow(string id, string agentId)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = "Child Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = agentId,
|
||||
Kind = "agent",
|
||||
Label = "Child Agent",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = agentId,
|
||||
Name = "Child Agent",
|
||||
Model = "gpt-5.4",
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-agent",
|
||||
Source = "start",
|
||||
Target = agentId,
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-agent-end",
|
||||
Source = agentId,
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "parent-workflow",
|
||||
Name = "Parent Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "sub-workflow",
|
||||
Kind = "sub-workflow",
|
||||
Label = "Nested Workflow",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-sub",
|
||||
Source = "start",
|
||||
Target = "sub-workflow",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-sub-end",
|
||||
Source = "sub-workflow",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateChatClientAgent(string id, string name)
|
||||
{
|
||||
return new ChatClientAgent(
|
||||
new StubChatClient(),
|
||||
id,
|
||||
name,
|
||||
"Stub agent for workflow runner tests.",
|
||||
[],
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,36 @@ public sealed class WorkflowValidatorTests
|
||||
{
|
||||
private readonly WorkflowValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsInlineSubworkflowNodes()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateSubworkflowParent(inlineWorkflow: CreateWorkflow(id: "child"));
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsSubworkflowNodesWithoutSingleSource()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateSubworkflowParent();
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
|
||||
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.nodes.config");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUnknownReferencedWorkflowIdsWhenLibraryProvided()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateSubworkflowParent(workflowId: "missing-child");
|
||||
|
||||
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow, []);
|
||||
|
||||
Assert.Contains(issues, issue => issue.Field == "graph.nodes.config.workflowId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsInvalidConditionOperator()
|
||||
{
|
||||
@@ -122,11 +152,11 @@ public sealed class WorkflowValidatorTests
|
||||
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow()
|
||||
private static WorkflowDefinitionDto CreateWorkflow(string id = "workflow-1")
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-1",
|
||||
Id = id,
|
||||
Name = "Loop Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
@@ -184,4 +214,68 @@ public sealed class WorkflowValidatorTests
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = "workflow-parent",
|
||||
Name = "Parent Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "start",
|
||||
Kind = "start",
|
||||
Label = "Start",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "start" },
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "sub-workflow",
|
||||
Kind = "sub-workflow",
|
||||
Label = "Nested Workflow",
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
},
|
||||
new WorkflowNodeDto
|
||||
{
|
||||
Id = "end",
|
||||
Kind = "end",
|
||||
Label = "End",
|
||||
Config = new WorkflowNodeConfigDto { Kind = "end" },
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-start-sub",
|
||||
Source = "start",
|
||||
Target = "sub-workflow",
|
||||
Kind = "direct",
|
||||
},
|
||||
new WorkflowEdgeDto
|
||||
{
|
||||
Id = "edge-sub-end",
|
||||
Source = "sub-workflow",
|
||||
Target = "end",
|
||||
Kind = "direct",
|
||||
},
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
Checkpointing = new WorkflowCheckpointSettingsDto(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user