mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +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 sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
|
||||||
{
|
{
|
||||||
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||||
|
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||||
@@ -323,6 +324,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
|||||||
public string? ProjectInstructions { get; init; }
|
public string? ProjectInstructions { get; init; }
|
||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||||
public WorkflowDefinitionDto? Workflow { get; init; }
|
public WorkflowDefinitionDto? Workflow { get; init; }
|
||||||
|
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
{
|
{
|
||||||
string? validationError = command.Workflow is null
|
string? validationError = command.Workflow is null
|
||||||
? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message
|
? _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)
|
if (validationError is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(validationError);
|
throw new InvalidOperationException(validationError);
|
||||||
@@ -86,7 +86,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||||
Workflow workflow = command.Workflow is null
|
Workflow workflow = command.Workflow is null
|
||||||
? bundle.BuildWorkflow(command.Pattern)
|
? 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();
|
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ public sealed class SidecarProtocolHost
|
|||||||
{
|
{
|
||||||
Type = "workflow-validation",
|
Type = "workflow-validation",
|
||||||
RequestId = context.Envelope.RequestId,
|
RequestId = context.Envelope.RequestId,
|
||||||
Issues = _workflowValidator.Validate(command.Workflow),
|
Issues = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary),
|
||||||
}, context.CancellationToken).ConfigureAwait(false);
|
}, context.CancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Agents.AI.Workflows;
|
using Microsoft.Agents.AI.Workflows;
|
||||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
@@ -10,12 +9,19 @@ internal sealed class WorkflowRunner
|
|||||||
public Workflow BuildWorkflow(
|
public Workflow BuildWorkflow(
|
||||||
WorkflowDefinitionDto workflowDefinition,
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
PatternDefinitionDto patternDefinition,
|
PatternDefinitionDto patternDefinition,
|
||||||
IReadOnlyList<AIAgent> agents)
|
IReadOnlyList<AIAgent> agents,
|
||||||
|
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||||
ArgumentNullException.ThrowIfNull(patternDefinition);
|
ArgumentNullException.ThrowIfNull(patternDefinition);
|
||||||
ArgumentNullException.ThrowIfNull(agents);
|
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 =>
|
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||||
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
|
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
|
||||||
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
|
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||||
@@ -25,10 +31,23 @@ internal sealed class WorkflowRunner
|
|||||||
.Zip(agents, (definition, agent) => (definition.Id, agent))
|
.Zip(agents, (definition, agent) => (definition.Id, agent))
|
||||||
.ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal);
|
.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);
|
Dictionary<string, ExecutorBinding> bindings = new(StringComparer.Ordinal);
|
||||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
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]);
|
WorkflowBuilder builder = new(bindings[startNode.Id]);
|
||||||
@@ -92,7 +111,8 @@ internal sealed class WorkflowRunner
|
|||||||
|
|
||||||
private static ExecutorBinding CreateExecutorBinding(
|
private static ExecutorBinding CreateExecutorBinding(
|
||||||
WorkflowNodeDto node,
|
WorkflowNodeDto node,
|
||||||
IReadOnlyDictionary<string, AIAgent> agentMap)
|
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||||
|
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||||
{
|
{
|
||||||
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -115,6 +135,32 @@ internal sealed class WorkflowRunner
|
|||||||
return agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions());
|
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.");
|
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;
|
using Aryx.AgentHost.Contracts;
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
namespace Aryx.AgentHost.Services;
|
||||||
@@ -9,11 +10,18 @@ public sealed class WorkflowValidator
|
|||||||
"start",
|
"start",
|
||||||
"end",
|
"end",
|
||||||
"agent",
|
"agent",
|
||||||
|
"sub-workflow",
|
||||||
};
|
};
|
||||||
|
|
||||||
public IReadOnlyList<WorkflowValidationIssueDto> Validate(WorkflowDefinitionDto workflow)
|
public IReadOnlyList<WorkflowValidationIssueDto> Validate(
|
||||||
|
WorkflowDefinitionDto workflow,
|
||||||
|
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||||
{
|
{
|
||||||
List<WorkflowValidationIssueDto> issues = [];
|
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))
|
if (string.IsNullOrWhiteSpace(workflow.Name))
|
||||||
{
|
{
|
||||||
@@ -94,6 +102,8 @@ public sealed class WorkflowValidator
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ValidateSubWorkflowNode(node, workflowLibraryById, issues);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
||||||
@@ -145,8 +155,10 @@ public sealed class WorkflowValidator
|
|||||||
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
|
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
|
||||||
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||||
.ToList();
|
.ToList();
|
||||||
List<WorkflowNodeDto> agentNodes = workflow.Graph.Nodes
|
List<WorkflowNodeDto> executableWorkNodes = workflow.Graph.Nodes
|
||||||
.Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
.Where(node =>
|
||||||
|
string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (startNodes.Count != 1)
|
if (startNodes.Count != 1)
|
||||||
@@ -167,12 +179,12 @@ public sealed class WorkflowValidator
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentNodes.Count == 0)
|
if (executableWorkNodes.Count == 0)
|
||||||
{
|
{
|
||||||
issues.Add(new WorkflowValidationIssueDto
|
issues.Add(new WorkflowValidationIssueDto
|
||||||
{
|
{
|
||||||
Field = "graph.nodes",
|
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;
|
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)
|
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
|
||||||
{
|
{
|
||||||
if (edge.Condition is null)
|
if (edge.Condition is null)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
|
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
|
||||||
Assert.Contains(issues, issue =>
|
Assert.Contains(issues, issue =>
|
||||||
issue.GetProperty("field").GetString() == "graph.nodes"
|
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 =>
|
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();
|
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]
|
[Fact]
|
||||||
public void Validate_RejectsInvalidConditionOperator()
|
public void Validate_RejectsInvalidConditionOperator()
|
||||||
{
|
{
|
||||||
@@ -122,11 +152,11 @@ public sealed class WorkflowValidatorTests
|
|||||||
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
Assert.DoesNotContain(issues, issue => issue.Level == "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorkflowDefinitionDto CreateWorkflow()
|
private static WorkflowDefinitionDto CreateWorkflow(string id = "workflow-1")
|
||||||
{
|
{
|
||||||
return new WorkflowDefinitionDto
|
return new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "workflow-1",
|
Id = id,
|
||||||
Name = "Loop Workflow",
|
Name = "Loop Workflow",
|
||||||
Graph = new WorkflowGraphDto
|
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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-2
@@ -41,6 +41,7 @@ import {
|
|||||||
normalizeWorkflowDefinition,
|
normalizeWorkflowDefinition,
|
||||||
validateWorkflowDefinition,
|
validateWorkflowDefinition,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
|
type WorkflowReference,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceAgentDefinition,
|
normalizeWorkspaceAgentDefinition,
|
||||||
@@ -644,6 +645,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
|
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
};
|
};
|
||||||
|
this.validateWorkflowReferences(workspace, candidate);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
workspace.workflows[existingIndex] = candidate;
|
workspace.workflows[existingIndex] = candidate;
|
||||||
@@ -760,6 +762,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
|
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||||
|
const references = this.listWorkflowReferencesInWorkspace(workspace, workflowId)
|
||||||
|
.filter((reference) => reference.referencingWorkflowId !== workflowId);
|
||||||
|
if (references.length > 0) {
|
||||||
|
const blockingReference = references[0];
|
||||||
|
throw new Error(
|
||||||
|
`Workflow "${workflow.name}" cannot be deleted because workflow "${blockingReference.referencingWorkflowName}" references it from node "${blockingReference.nodeLabel}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId);
|
workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId);
|
||||||
|
|
||||||
if (workspace.selectedWorkflowId === workflowId) {
|
if (workspace.selectedWorkflowId === workflowId) {
|
||||||
@@ -769,6 +781,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
this.requireWorkflow(workspace, workflowId);
|
||||||
|
return this.listWorkflowReferencesInWorkspace(workspace, workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||||
@@ -934,7 +952,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const project = this.requireProject(workspace, projectId);
|
const project = this.requireProject(workspace, projectId);
|
||||||
const workflow = this.requireWorkflow(workspace, workflowId);
|
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
const executionPattern = normalizePatternModels(buildWorkflowExecutionPattern(workflow), modelCatalog);
|
const executionPattern = normalizePatternModels(this.buildResolvedWorkflowExecutionPattern(workspace, workflow), modelCatalog);
|
||||||
|
|
||||||
const session: SessionRecord = {
|
const session: SessionRecord = {
|
||||||
id: createId('session'),
|
id: createId('session'),
|
||||||
@@ -1663,6 +1681,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
projectInstructions,
|
projectInstructions,
|
||||||
pattern: patternForTurn,
|
pattern: patternForTurn,
|
||||||
workflow: effectiveWorkflow,
|
workflow: effectiveWorkflow,
|
||||||
|
workflowLibrary: effectiveWorkflow ? workspace.workflows : undefined,
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
promptInvocation,
|
promptInvocation,
|
||||||
@@ -2248,7 +2267,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const workflow = this.requireWorkflow(workspace, session.workflowId);
|
const workflow = this.requireWorkflow(workspace, session.workflowId);
|
||||||
return {
|
return {
|
||||||
workflow,
|
workflow,
|
||||||
pattern: buildWorkflowExecutionPattern(workflow),
|
pattern: this.buildResolvedWorkflowExecutionPattern(workspace, workflow),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2257,6 +2276,105 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildResolvedWorkflowExecutionPattern(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
): PatternDefinition {
|
||||||
|
return buildWorkflowExecutionPattern(workflow, {
|
||||||
|
resolveWorkflow: (workflowId) => workspace.workflows.find((candidate) => candidate.id === workflowId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateWorkflowReferences(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
): void {
|
||||||
|
const workflowLibrary = new Map<string, WorkflowDefinition>();
|
||||||
|
for (const candidate of workspace.workflows) {
|
||||||
|
if (candidate.id !== workflow.id) {
|
||||||
|
workflowLibrary.set(candidate.id, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workflowLibrary.set(workflow.id, workflow);
|
||||||
|
|
||||||
|
const visitWorkflow = (
|
||||||
|
currentWorkflow: WorkflowDefinition,
|
||||||
|
path: string[],
|
||||||
|
visitedInlineWorkflows: Set<WorkflowDefinition>,
|
||||||
|
): void => {
|
||||||
|
for (const node of currentWorkflow.graph.nodes) {
|
||||||
|
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inlineWorkflow, workflowId } = node.config;
|
||||||
|
if (workflowId) {
|
||||||
|
const referencedWorkflow = workflowLibrary.get(workflowId);
|
||||||
|
if (!referencedWorkflow) {
|
||||||
|
throw new Error(
|
||||||
|
`Sub-workflow node "${node.label || node.id}" references unknown workflow "${workflowId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.includes(workflowId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Saving workflow "${workflow.name}" would create a circular sub-workflow reference: ${[...path, workflowId].join(' -> ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
visitWorkflow(referencedWorkflow, [...path, workflowId], visitedInlineWorkflows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||||
|
visitedInlineWorkflows.add(inlineWorkflow);
|
||||||
|
visitWorkflow(inlineWorkflow, path, visitedInlineWorkflows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
visitWorkflow(workflow, [workflow.id], new Set<WorkflowDefinition>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private listWorkflowReferencesInWorkspace(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
workflowId: string,
|
||||||
|
): WorkflowReference[] {
|
||||||
|
const references: WorkflowReference[] = [];
|
||||||
|
|
||||||
|
const visitWorkflow = (
|
||||||
|
referencingWorkflow: WorkflowDefinition,
|
||||||
|
currentWorkflow: WorkflowDefinition,
|
||||||
|
visitedInlineWorkflows: Set<WorkflowDefinition>,
|
||||||
|
): void => {
|
||||||
|
for (const node of currentWorkflow.graph.nodes) {
|
||||||
|
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inlineWorkflow, workflowId: referencedWorkflowId } = node.config;
|
||||||
|
if (referencedWorkflowId === workflowId) {
|
||||||
|
references.push({
|
||||||
|
referencingWorkflowId: referencingWorkflow.id,
|
||||||
|
referencingWorkflowName: referencingWorkflow.name,
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeLabel: node.label || node.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||||
|
visitedInlineWorkflows.add(inlineWorkflow);
|
||||||
|
visitWorkflow(referencingWorkflow, inlineWorkflow, visitedInlineWorkflows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const referencingWorkflow of workspace.workflows) {
|
||||||
|
visitWorkflow(referencingWorkflow, referencingWorkflow, new Set<WorkflowDefinition>());
|
||||||
|
}
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
||||||
const session = workspace.sessions.find((current) => current.id === sessionId);
|
const session = workspace.sessions.find((current) => current.id === sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ export function registerIpcHandlers(
|
|||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
||||||
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
|
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
|
||||||
|
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
|
||||||
|
service.listWorkflowReferences(workflowId),
|
||||||
|
);
|
||||||
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||||
const result = await service.setTheme(theme);
|
const result = await service.setTheme(theme);
|
||||||
applyTitleBarTheme(window, theme);
|
applyTitleBarTheme(window, theme);
|
||||||
|
|||||||
@@ -134,11 +134,15 @@ export class SidecarClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateWorkflow(workflow: ValidateWorkflowCommand['workflow']): Promise<unknown> {
|
async validateWorkflow(
|
||||||
|
workflow: ValidateWorkflowCommand['workflow'],
|
||||||
|
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
|
||||||
|
): Promise<unknown> {
|
||||||
return this.dispatch<unknown>({
|
return this.dispatch<unknown>({
|
||||||
type: 'validate-workflow',
|
type: 'validate-workflow',
|
||||||
requestId: `validate-workflow-${Date.now()}`,
|
requestId: `validate-workflow-${Date.now()}`,
|
||||||
workflow,
|
workflow,
|
||||||
|
workflowLibrary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const api: ElectronApi = {
|
|||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||||
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
||||||
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
||||||
|
listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, workflowId),
|
||||||
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
||||||
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
||||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const ipcChannels = {
|
|||||||
setPatternFavorite: 'patterns:set-favorite',
|
setPatternFavorite: 'patterns:set-favorite',
|
||||||
saveWorkflow: 'workflows:save',
|
saveWorkflow: 'workflows:save',
|
||||||
deleteWorkflow: 'workflows:delete',
|
deleteWorkflow: 'workflows:delete',
|
||||||
|
listWorkflowReferences: 'workflows:list-references',
|
||||||
createWorkflowSession: 'workflows:create-session',
|
createWorkflowSession: 'workflows:create-session',
|
||||||
setTheme: 'settings:set-theme',
|
setTheme: 'settings:set-theme',
|
||||||
setTerminalHeight: 'settings:set-terminal-height',
|
setTerminalHeight: 'settings:set-terminal-height',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
import type { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
|
||||||
import type {
|
import type {
|
||||||
ProjectGitBranchSummary,
|
ProjectGitBranchSummary,
|
||||||
ProjectGitCommitMessageSuggestion,
|
ProjectGitCommitMessageSuggestion,
|
||||||
@@ -286,6 +286,7 @@ export interface ElectronApi {
|
|||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||||
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||||
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||||
|
listWorkflowReferences(workflowId: string): Promise<WorkflowReference[]>;
|
||||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface ValidateWorkflowCommand {
|
|||||||
type: 'validate-workflow';
|
type: 'validate-workflow';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
|
workflowLibrary?: WorkflowDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InteractionMode = 'interactive' | 'plan';
|
export type InteractionMode = 'interactive' | 'plan';
|
||||||
@@ -97,6 +98,7 @@ export interface RunTurnCommand {
|
|||||||
projectInstructions?: string;
|
projectInstructions?: string;
|
||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
workflow?: WorkflowDefinition;
|
workflow?: WorkflowDefinition;
|
||||||
|
workflowLibrary?: WorkflowDefinition[];
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
promptInvocation?: ProjectPromptInvocation;
|
promptInvocation?: ProjectPromptInvocation;
|
||||||
|
|||||||
+145
-10
@@ -159,7 +159,18 @@ export interface WorkflowValidationIssue {
|
|||||||
edgeId?: string;
|
edgeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const executableNodeKinds = new Set<WorkflowNodeKind>(['start', 'end', 'agent']);
|
export interface WorkflowReference {
|
||||||
|
referencingWorkflowId: string;
|
||||||
|
referencingWorkflowName: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowResolutionOptions {
|
||||||
|
resolveWorkflow?: (workflowId: string) => WorkflowDefinition | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executableNodeKinds = new Set<WorkflowNodeKind>(['start', 'end', 'agent', 'sub-workflow']);
|
||||||
|
|
||||||
function normalizeOptionalString(value?: string): string | undefined {
|
function normalizeOptionalString(value?: string): string | undefined {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
@@ -362,9 +373,94 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): PatternAgen
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefinition['mode'] {
|
function hasWorkflowExecutionFanEdges(
|
||||||
const hasFanEdges = workflow.graph.edges.some((edge) => edge.kind !== 'direct');
|
workflow: WorkflowDefinition,
|
||||||
const agentCount = resolveWorkflowAgents(workflow).length;
|
options?: WorkflowResolutionOptions,
|
||||||
|
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
|
||||||
|
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
|
||||||
|
): boolean {
|
||||||
|
if (workflow.graph.edges.some((edge) => edge.kind !== 'direct')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of workflow.graph.nodes) {
|
||||||
|
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inlineWorkflow, workflowId } = node.config;
|
||||||
|
if (inlineWorkflow) {
|
||||||
|
if (!visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||||
|
visitedInlineWorkflows.add(inlineWorkflow);
|
||||||
|
if (hasWorkflowExecutionFanEdges(inlineWorkflow, options, visitedReferencedWorkflowIds, visitedInlineWorkflows)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowId && options?.resolveWorkflow && !visitedReferencedWorkflowIds.has(workflowId)) {
|
||||||
|
const referencedWorkflow = options.resolveWorkflow(workflowId);
|
||||||
|
if (referencedWorkflow) {
|
||||||
|
visitedReferencedWorkflowIds.add(workflowId);
|
||||||
|
if (hasWorkflowExecutionFanEdges(referencedWorkflow, options, visitedReferencedWorkflowIds, visitedInlineWorkflows)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkflowExecutionAgents(
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
options?: WorkflowResolutionOptions,
|
||||||
|
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
|
||||||
|
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
|
||||||
|
): PatternAgentDefinition[] {
|
||||||
|
const agents = [...resolveWorkflowAgents(workflow)];
|
||||||
|
|
||||||
|
for (const node of workflow.graph.nodes) {
|
||||||
|
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inlineWorkflow, workflowId } = node.config;
|
||||||
|
if (inlineWorkflow) {
|
||||||
|
if (!visitedInlineWorkflows.has(inlineWorkflow)) {
|
||||||
|
visitedInlineWorkflows.add(inlineWorkflow);
|
||||||
|
agents.push(...resolveWorkflowExecutionAgents(
|
||||||
|
inlineWorkflow,
|
||||||
|
options,
|
||||||
|
visitedReferencedWorkflowIds,
|
||||||
|
visitedInlineWorkflows,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowId && options?.resolveWorkflow && !visitedReferencedWorkflowIds.has(workflowId)) {
|
||||||
|
const referencedWorkflow = options.resolveWorkflow(workflowId);
|
||||||
|
if (referencedWorkflow) {
|
||||||
|
visitedReferencedWorkflowIds.add(workflowId);
|
||||||
|
agents.push(...resolveWorkflowExecutionAgents(
|
||||||
|
referencedWorkflow,
|
||||||
|
options,
|
||||||
|
visitedReferencedWorkflowIds,
|
||||||
|
visitedInlineWorkflows,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferWorkflowPatternMode(
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
options?: WorkflowResolutionOptions,
|
||||||
|
): PatternDefinition['mode'] {
|
||||||
|
const hasFanEdges = hasWorkflowExecutionFanEdges(workflow, options);
|
||||||
|
const agentCount = resolveWorkflowExecutionAgents(workflow, options).length;
|
||||||
if (hasFanEdges) {
|
if (hasFanEdges) {
|
||||||
return 'concurrent';
|
return 'concurrent';
|
||||||
}
|
}
|
||||||
@@ -372,13 +468,16 @@ function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefiniti
|
|||||||
return agentCount <= 1 ? 'single' : 'sequential';
|
return agentCount <= 1 ? 'single' : 'sequential';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkflowExecutionPattern(workflow: WorkflowDefinition): PatternDefinition {
|
export function buildWorkflowExecutionPattern(
|
||||||
const agents = resolveWorkflowAgents(workflow);
|
workflow: WorkflowDefinition,
|
||||||
|
options?: WorkflowResolutionOptions,
|
||||||
|
): PatternDefinition {
|
||||||
|
const agents = resolveWorkflowExecutionAgents(workflow, options);
|
||||||
const pattern: PatternDefinition = {
|
const pattern: PatternDefinition = {
|
||||||
id: workflow.id,
|
id: workflow.id,
|
||||||
name: workflow.name,
|
name: workflow.name,
|
||||||
description: workflow.description,
|
description: workflow.description,
|
||||||
mode: inferWorkflowPatternMode(workflow),
|
mode: inferWorkflowPatternMode(workflow, options),
|
||||||
availability: 'available',
|
availability: 'available',
|
||||||
maxIterations: workflow.settings.maxIterations ?? 5,
|
maxIterations: workflow.settings.maxIterations ?? 5,
|
||||||
approvalPolicy: workflow.settings.approvalPolicy,
|
approvalPolicy: workflow.settings.approvalPolicy,
|
||||||
@@ -608,6 +707,39 @@ function validateEdgeCondition(edge: WorkflowEdge, issues: WorkflowValidationIss
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSubWorkflowNode(node: WorkflowNode, issues: WorkflowValidationIssue[]): void {
|
||||||
|
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWorkflowId = Boolean(node.config.workflowId);
|
||||||
|
const hasInlineWorkflow = Boolean(node.config.inlineWorkflow);
|
||||||
|
if (hasWorkflowId === hasInlineWorkflow) {
|
||||||
|
addIssue(issues, {
|
||||||
|
level: 'error',
|
||||||
|
field: 'graph.nodes.config',
|
||||||
|
nodeId: node.id,
|
||||||
|
message: 'Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.config.inlineWorkflow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inlineIssue of validateWorkflowDefinition(node.config.inlineWorkflow)) {
|
||||||
|
addIssue(issues, {
|
||||||
|
...inlineIssue,
|
||||||
|
field: inlineIssue.field
|
||||||
|
? `graph.nodes.config.inlineWorkflow.${inlineIssue.field}`
|
||||||
|
: 'graph.nodes.config.inlineWorkflow',
|
||||||
|
nodeId: node.id,
|
||||||
|
message: `Inline workflow for node "${node.label || node.id}": ${inlineIssue.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] {
|
export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] {
|
||||||
const normalized = normalizeWorkflowDefinition(workflow);
|
const normalized = normalizeWorkflowDefinition(workflow);
|
||||||
const issues: WorkflowValidationIssue[] = [];
|
const issues: WorkflowValidationIssue[] = [];
|
||||||
@@ -685,6 +817,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateSubWorkflowNode(node, issues);
|
||||||
}
|
}
|
||||||
for (const edge of normalized.graph.edges) {
|
for (const edge of normalized.graph.edges) {
|
||||||
if (!edge.id) {
|
if (!edge.id) {
|
||||||
@@ -725,7 +859,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
|||||||
}
|
}
|
||||||
const startNodes = normalized.graph.nodes.filter((node) => node.kind === 'start');
|
const startNodes = normalized.graph.nodes.filter((node) => node.kind === 'start');
|
||||||
const endNodes = normalized.graph.nodes.filter((node) => node.kind === 'end');
|
const endNodes = normalized.graph.nodes.filter((node) => node.kind === 'end');
|
||||||
const agentNodes = normalized.graph.nodes.filter((node) => node.kind === 'agent');
|
const executableWorkNodes = normalized.graph.nodes.filter((node) =>
|
||||||
|
node.kind === 'agent' || node.kind === 'sub-workflow');
|
||||||
|
|
||||||
if (startNodes.length !== 1) {
|
if (startNodes.length !== 1) {
|
||||||
addIssue(issues, {
|
addIssue(issues, {
|
||||||
@@ -743,11 +878,11 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentNodes.length === 0) {
|
if (executableWorkNodes.length === 0) {
|
||||||
addIssue(issues, {
|
addIssue(issues, {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
field: 'graph.nodes',
|
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.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
||||||
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
|
mock.module('electron', () => {
|
||||||
|
const electronMock = {
|
||||||
|
app: {
|
||||||
|
isPackaged: false,
|
||||||
|
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||||
|
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
openPath: async () => '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...electronMock,
|
||||||
|
default: electronMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module('keytar', () => ({
|
||||||
|
default: {
|
||||||
|
getPassword: async () => null,
|
||||||
|
setPassword: async () => undefined,
|
||||||
|
deletePassword: async () => false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { AryxAppService } = await import('@main/AryxAppService');
|
||||||
|
|
||||||
|
function createService(workspace: WorkspaceState): InstanceType<typeof AryxAppService> {
|
||||||
|
const service = new AryxAppService();
|
||||||
|
const internals = service as unknown as Record<string, unknown>;
|
||||||
|
internals.loadWorkspace = async () => workspace;
|
||||||
|
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
||||||
|
internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog();
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgentWorkflow(id: string, name: string, agentId: string): WorkflowDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: `${name} description`,
|
||||||
|
createdAt: '2026-04-05T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-05T00:00:00.000Z',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
|
{
|
||||||
|
id: agentId,
|
||||||
|
kind: 'agent',
|
||||||
|
label: name,
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
order: 0,
|
||||||
|
config: {
|
||||||
|
kind: 'agent',
|
||||||
|
id: agentId,
|
||||||
|
name,
|
||||||
|
description: `${name} description`,
|
||||||
|
instructions: `Run ${name}`,
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'edge-start-agent', source: 'start', target: agentId, kind: 'direct' },
|
||||||
|
{ id: 'edge-agent-end', source: agentId, target: 'end', kind: 'direct' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSubWorkflow(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
config: { workflowId?: string; inlineWorkflow?: WorkflowDefinition },
|
||||||
|
): WorkflowDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: `${name} description`,
|
||||||
|
createdAt: '2026-04-05T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-05T00:00:00.000Z',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
|
{
|
||||||
|
id: 'sub-workflow',
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
label: 'Nested Workflow',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
config: {
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
workflowId: config.workflowId,
|
||||||
|
inlineWorkflow: config.inlineWorkflow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' },
|
||||||
|
{ id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AryxAppService sub-workflow operations', () => {
|
||||||
|
test('rejects saving workflows with missing referenced workflows', async () => {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
const service = createService(workspace);
|
||||||
|
|
||||||
|
await expect(service.saveWorkflow(createSubWorkflow('parent', 'Parent', { workflowId: 'missing-child' })))
|
||||||
|
.rejects.toThrow('references unknown workflow "missing-child"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects circular workflow references across saved workflows', async () => {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
workspace.workflows.push(createSubWorkflow('workflow-b', 'Workflow B', { workflowId: 'workflow-a' }));
|
||||||
|
const service = createService(workspace);
|
||||||
|
|
||||||
|
await expect(service.saveWorkflow(createSubWorkflow('workflow-a', 'Workflow A', { workflowId: 'workflow-b' })))
|
||||||
|
.rejects.toThrow('circular sub-workflow reference');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevents deleting workflows that are still referenced', async () => {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
workspace.workflows.push(
|
||||||
|
createAgentWorkflow('child', 'Child Agent', 'agent-child'),
|
||||||
|
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }),
|
||||||
|
);
|
||||||
|
const service = createService(workspace);
|
||||||
|
|
||||||
|
await expect(service.deleteWorkflow('child')).rejects.toThrow('cannot be deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists workflow references for referenced sub-workflows', async () => {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
workspace.workflows.push(
|
||||||
|
createAgentWorkflow('child', 'Child Agent', 'agent-child'),
|
||||||
|
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }),
|
||||||
|
createSubWorkflow('inline-parent', 'Inline Parent', {
|
||||||
|
inlineWorkflow: createSubWorkflow('inline-child', 'Inline Child', { workflowId: 'child' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const service = createService(workspace);
|
||||||
|
|
||||||
|
const references = await service.listWorkflowReferences('child');
|
||||||
|
|
||||||
|
expect(references).toEqual([
|
||||||
|
{
|
||||||
|
referencingWorkflowId: 'parent',
|
||||||
|
referencingWorkflowName: 'Parent Workflow',
|
||||||
|
nodeId: 'sub-workflow',
|
||||||
|
nodeLabel: 'Nested Workflow',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
referencingWorkflowId: 'inline-parent',
|
||||||
|
referencingWorkflowName: 'Inline Parent',
|
||||||
|
nodeId: 'sub-workflow',
|
||||||
|
nodeLabel: 'Nested Workflow',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates workflow-backed sessions using nested sub-workflow agents for model defaults', async () => {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
const project = {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Project',
|
||||||
|
path: 'C:\\workspace\\project-1',
|
||||||
|
addedAt: '2026-04-05T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
workspace.projects.push(project);
|
||||||
|
workspace.workflows.push(
|
||||||
|
createAgentWorkflow('child', 'Child Agent', 'agent-child'),
|
||||||
|
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }),
|
||||||
|
);
|
||||||
|
const service = createService(workspace);
|
||||||
|
|
||||||
|
const result = await service.createWorkflowSession(project.id, 'parent');
|
||||||
|
const session = result.sessions[0];
|
||||||
|
|
||||||
|
expect(session?.workflowId).toBe('parent');
|
||||||
|
expect(session?.sessionModelConfig).toEqual({
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,57 @@ function createWorkflow(): WorkflowDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createReferencedSubWorkflow(): WorkflowDefinition {
|
||||||
|
return {
|
||||||
|
id: 'child-workflow',
|
||||||
|
name: 'Child Workflow',
|
||||||
|
description: 'Nested child workflow.',
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
kind: 'start',
|
||||||
|
label: 'Start',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: { kind: 'start' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-reviewer',
|
||||||
|
kind: 'agent',
|
||||||
|
label: 'Reviewer Agent',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
order: 0,
|
||||||
|
config: {
|
||||||
|
kind: 'agent',
|
||||||
|
id: 'agent-reviewer',
|
||||||
|
name: 'Reviewer Agent',
|
||||||
|
description: 'Reviews the task.',
|
||||||
|
instructions: 'Review the task.',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'end',
|
||||||
|
kind: 'end',
|
||||||
|
label: 'End',
|
||||||
|
position: { x: 400, y: 0 },
|
||||||
|
config: { kind: 'end' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'edge-start-reviewer', source: 'start', target: 'agent-reviewer', kind: 'direct' },
|
||||||
|
{ id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('workflow validation', () => {
|
describe('workflow validation', () => {
|
||||||
test('accepts a simple start-agent-end workflow', () => {
|
test('accepts a simple start-agent-end workflow', () => {
|
||||||
expect(validateWorkflowDefinition(createWorkflow())).toEqual([]);
|
expect(validateWorkflowDefinition(createWorkflow())).toEqual([]);
|
||||||
@@ -84,6 +135,43 @@ describe('workflow validation', () => {
|
|||||||
issue.message.includes('not executable yet'))).toBe(true);
|
issue.message.includes('not executable yet'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts sub-workflow nodes with inline workflows', () => {
|
||||||
|
const inlineWorkflow = createReferencedSubWorkflow();
|
||||||
|
const workflow = createWorkflow();
|
||||||
|
workflow.graph.nodes[1] = {
|
||||||
|
id: 'sub-workflow',
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
label: 'Nested Workflow',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
config: {
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
inlineWorkflow,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
||||||
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
||||||
|
|
||||||
|
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects sub-workflow nodes without exactly one workflow source', () => {
|
||||||
|
const workflow = createWorkflow();
|
||||||
|
workflow.graph.nodes[1] = {
|
||||||
|
id: 'sub-workflow',
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
label: 'Nested Workflow',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
config: {
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
||||||
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
||||||
|
|
||||||
|
const issues = validateWorkflowDefinition(workflow);
|
||||||
|
expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('builds a synthetic execution pattern from workflow agents', () => {
|
test('builds a synthetic execution pattern from workflow agents', () => {
|
||||||
const pattern = buildWorkflowExecutionPattern(createWorkflow());
|
const pattern = buildWorkflowExecutionPattern(createWorkflow());
|
||||||
|
|
||||||
@@ -93,6 +181,30 @@ describe('workflow validation', () => {
|
|||||||
expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']);
|
expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('includes referenced sub-workflow agents when building execution patterns', () => {
|
||||||
|
const childWorkflow = createReferencedSubWorkflow();
|
||||||
|
const workflow = createWorkflow();
|
||||||
|
workflow.graph.nodes[1] = {
|
||||||
|
id: 'sub-workflow',
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
label: 'Nested Workflow',
|
||||||
|
position: { x: 200, y: 0 },
|
||||||
|
config: {
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
workflowId: childWorkflow.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
||||||
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
||||||
|
|
||||||
|
const pattern = buildWorkflowExecutionPattern(workflow, {
|
||||||
|
resolveWorkflow: (workflowId) => workflowId === childWorkflow.id ? childWorkflow : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pattern.mode).toBe('single');
|
||||||
|
expect(pattern.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts simple property and expression conditions', () => {
|
test('accepts simple property and expression conditions', () => {
|
||||||
const workflow = createWorkflow();
|
const workflow = createWorkflow();
|
||||||
workflow.graph.edges[0] = {
|
workflow.graph.edges[0] = {
|
||||||
|
|||||||
Reference in New Issue
Block a user