diff --git a/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj b/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj index 13cd27b..be724dc 100644 --- a/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj +++ b/sidecar/src/Aryx.AgentHost/Aryx.AgentHost.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable en diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 7cbf269..9717122 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace Aryx.AgentHost.Contracts; @@ -66,6 +67,125 @@ public sealed class PatternDefinitionDto public string UpdatedAt { get; init; } = string.Empty; } +public sealed class WorkflowPositionDto +{ + public double X { get; init; } + public double Y { get; init; } +} + +public sealed class WorkflowNodeConfigDto +{ + public string Kind { get; init; } = string.Empty; + public string? InputType { get; init; } + public string? OutputType { get; init; } + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Instructions { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public string? ReasoningEffort { get; init; } + public PatternAgentCopilotConfigDto? Copilot { get; init; } + public string? WorkspaceAgentId { get; init; } + public string? Implementation { get; init; } + public string? FunctionRef { get; init; } + public IReadOnlyDictionary? Parameters { get; init; } + public string? WorkflowId { get; init; } + public WorkflowDefinitionDto? InlineWorkflow { get; init; } + public string? PortId { get; init; } + public string? RequestType { get; init; } + public string? ResponseType { get; init; } + public string? Prompt { get; init; } +} + +public sealed class WorkflowNodeDto +{ + public string Id { get; init; } = string.Empty; + public string Kind { get; init; } = string.Empty; + public string Label { get; init; } = string.Empty; + public WorkflowPositionDto Position { get; init; } = new(); + public int? Order { get; init; } + public WorkflowNodeConfigDto Config { get; init; } = new(); +} + +public sealed class WorkflowConditionRuleDto +{ + public string PropertyPath { get; init; } = string.Empty; + public string Operator { get; init; } = string.Empty; + public string Value { get; init; } = string.Empty; +} + +public sealed class EdgeConditionDto +{ + public string Type { get; init; } = string.Empty; + public string? TypeName { get; init; } + public string? Expression { get; init; } + public string? Combinator { get; init; } + public IReadOnlyList Rules { get; init; } = []; +} + +public sealed class FanOutConfigDto +{ + public string Strategy { get; init; } = "broadcast"; + public string? PartitionExpression { get; init; } +} + +public sealed class WorkflowEdgeDto +{ + public string Id { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string Target { get; init; } = string.Empty; + public string Kind { get; init; } = "direct"; + public EdgeConditionDto? Condition { get; init; } + public string? Label { get; init; } + public FanOutConfigDto? FanOutConfig { get; init; } +} + +public sealed class WorkflowGraphDto +{ + public IReadOnlyList Nodes { get; init; } = []; + public IReadOnlyList Edges { get; init; } = []; +} + +public sealed class WorkflowCheckpointSettingsDto +{ + public bool Enabled { get; init; } +} + +public sealed class WorkflowTelemetrySettingsDto +{ + public bool? OpenTelemetry { get; init; } + public bool? SensitiveData { get; init; } +} + +public sealed class WorkflowStateScopeDto +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public IReadOnlyDictionary? InitialValues { get; init; } +} + +public sealed class WorkflowSettingsDto +{ + public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new(); + public string ExecutionMode { get; init; } = "off-thread"; + public int? MaxIterations { get; init; } + public ApprovalPolicyDto? ApprovalPolicy { get; init; } + public IReadOnlyList StateScopes { get; init; } = []; + public WorkflowTelemetrySettingsDto? Telemetry { get; init; } +} + +public sealed class WorkflowDefinitionDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public bool? IsFavorite { get; init; } + public WorkflowGraphDto Graph { get; init; } = new(); + public WorkflowSettingsDto Settings { get; init; } = new(); + public string CreatedAt { get; init; } = string.Empty; + public string UpdatedAt { get; init; } = string.Empty; +} + public sealed class ApprovalPolicyDto { public IReadOnlyList Rules { get; init; } = []; @@ -105,6 +225,15 @@ public sealed class PatternValidationIssueDto public string Message { get; init; } = string.Empty; } +public sealed class WorkflowValidationIssueDto +{ + public string Level { get; init; } = "error"; + public string? Field { get; init; } + public string Message { get; init; } = string.Empty; + public string? NodeId { get; init; } + public string? EdgeId { get; init; } +} + public sealed class SidecarModeCapabilityDto { public bool Available { get; init; } @@ -177,6 +306,11 @@ public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope public PatternDefinitionDto Pattern { get; init; } = new(); } +public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope +{ + public WorkflowDefinitionDto Workflow { get; init; } = new(); +} + public sealed class RunTurnCommandDto : SidecarCommandEnvelope { public string SessionId { get; init; } = string.Empty; @@ -186,6 +320,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public string MessageMode { get; init; } = "enqueue"; public string? ProjectInstructions { get; init; } public PatternDefinitionDto Pattern { get; init; } = new(); + public WorkflowDefinitionDto? Workflow { get; init; } public IReadOnlyList Messages { get; init; } = []; public RunTurnPromptInvocationDto? PromptInvocation { get; init; } public RunTurnToolingConfigDto? Tooling { get; init; } @@ -330,6 +465,11 @@ public sealed class PatternValidationEventDto : SidecarEventDto public IReadOnlyList Issues { get; init; } = []; } +public sealed class WorkflowValidationEventDto : SidecarEventDto +{ + public IReadOnlyList Issues { get; init; } = []; +} + public sealed class TurnDeltaEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index 87c3e33..dc64c8e 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -12,14 +12,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { private const string HandoffFunctionPrefix = "handoff_to_"; private readonly PatternValidator _patternValidator; + private readonly WorkflowValidator _workflowValidator; + private readonly WorkflowRunner _workflowRunner = new(); private readonly CopilotApprovalCoordinator _approvalCoordinator = new(); private readonly CopilotUserInputCoordinator _userInputCoordinator = new(); private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new(); private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new(); - public CopilotWorkflowRunner(PatternValidator patternValidator) + public CopilotWorkflowRunner(PatternValidator patternValidator, WorkflowValidator? workflowValidator = null) { _patternValidator = patternValidator; + _workflowValidator = workflowValidator ?? new WorkflowValidator(); } public async Task> RunTurnAsync( @@ -32,10 +35,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner Func onExitPlanMode, CancellationToken cancellationToken) { - PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault(); + string? validationError = command.Workflow is null + ? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message + : _workflowValidator.Validate(command.Workflow).FirstOrDefault()?.Message; if (validationError is not null) { - throw new InvalidOperationException(validationError.Message); + throw new InvalidOperationException(validationError); } CopilotTurnExecutionState state = new(command); @@ -79,7 +84,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner }, runCancellation.Token); ConfigureHookLifecycleEventSuppression(state, bundle); - Workflow workflow = bundle.BuildWorkflow(command.Pattern); + Workflow workflow = command.Workflow is null + ? bundle.BuildWorkflow(command.Pattern) + : _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents); List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode); @@ -145,6 +152,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command) { ArgumentNullException.ThrowIfNull(command); + if (command.Workflow is not null) + { + return command.Workflow.Settings.Checkpointing.Enabled; + } + return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase); } diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index c90d5c1..5e3cd31 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -11,6 +11,7 @@ public sealed class SidecarProtocolHost { private const string DescribeCapabilitiesCommandType = "describe-capabilities"; private const string ValidatePatternCommandType = "validate-pattern"; + private const string ValidateWorkflowCommandType = "validate-workflow"; private const string RunTurnCommandType = "run-turn"; private const string CancelTurnCommandType = "cancel-turn"; private const string ResolveApprovalCommandType = "resolve-approval"; @@ -42,6 +43,7 @@ public sealed class SidecarProtocolHost private readonly Func> _capabilitiesProvider; private readonly PatternValidator _patternValidator; + private readonly WorkflowValidator _workflowValidator; private readonly ITurnWorkflowRunner _workflowRunner; private readonly ICopilotSessionManager _sessionManager; private readonly JsonSerializerOptions _jsonOptions; @@ -53,7 +55,7 @@ public sealed class SidecarProtocolHost new(StringComparer.Ordinal); public SidecarProtocolHost() - : this(new PatternValidator()) + : this(new PatternValidator(), new WorkflowValidator()) { } @@ -62,9 +64,20 @@ public sealed class SidecarProtocolHost ITurnWorkflowRunner? workflowRunner = null, Func>? capabilitiesProvider = null, ICopilotSessionManager? sessionManager = null) + : this(patternValidator, new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager) + { + } + + public SidecarProtocolHost( + PatternValidator patternValidator, + WorkflowValidator workflowValidator, + ITurnWorkflowRunner? workflowRunner = null, + Func>? capabilitiesProvider = null, + ICopilotSessionManager? sessionManager = null) { _patternValidator = patternValidator; - _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator); + _workflowValidator = workflowValidator; + _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator, _workflowValidator); _capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync; _sessionManager = sessionManager ?? new CopilotSessionManager(); _jsonOptions = JsonSerialization.CreateWebOptions(); @@ -74,6 +87,7 @@ public sealed class SidecarProtocolHost { [DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync, [ValidatePatternCommandType] = HandleValidatePatternAsync, + [ValidateWorkflowCommandType] = HandleValidateWorkflowAsync, [RunTurnCommandType] = HandleRunTurnAsync, [CancelTurnCommandType] = HandleCancelTurnAsync, [ResolveApprovalCommandType] = HandleResolveApprovalAsync, @@ -180,6 +194,18 @@ public sealed class SidecarProtocolHost }, context.CancellationToken).ConfigureAwait(false); } + private async Task HandleValidateWorkflowAsync(CommandContext context) + { + ValidateWorkflowCommandDto command = DeserializeCommand(context); + + await WriteAsync(context.Output, new WorkflowValidationEventDto + { + Type = "workflow-validation", + RequestId = context.Envelope.RequestId, + Issues = _workflowValidator.Validate(command.Workflow), + }, context.CancellationToken).ConfigureAwait(false); + } + private async Task HandleRunTurnAsync(CommandContext context) { RunTurnCommandDto command = DeserializeCommand(context); diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs new file mode 100644 index 0000000..a89cf67 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs @@ -0,0 +1,95 @@ +using Aryx.AgentHost.Contracts; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Specialized; + +namespace Aryx.AgentHost.Services; + +internal sealed class WorkflowRunner +{ + public Workflow BuildWorkflow( + WorkflowDefinitionDto workflowDefinition, + PatternDefinitionDto patternDefinition, + IReadOnlyList agents) + { + ArgumentNullException.ThrowIfNull(workflowDefinition); + ArgumentNullException.ThrowIfNull(patternDefinition); + ArgumentNullException.ThrowIfNull(agents); + + 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 agentMap = patternDefinition.Agents + .Zip(agents, (definition, agent) => (definition.Id, agent)) + .ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal); + + Dictionary bindings = new(StringComparer.Ordinal); + foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes) + { + bindings[node.Id] = CreateExecutorBinding(node, agentMap); + } + + WorkflowBuilder builder = new(bindings[startNode.Id]); + + foreach (WorkflowEdgeDto edge in workflowDefinition.Graph.Edges.Where(edge => + string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase))) + { + builder.AddEdge(bindings[edge.Source], bindings[edge.Target]); + } + + foreach (IGrouping fanOutGroup in workflowDefinition.Graph.Edges + .Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase)) + .GroupBy(edge => edge.Source, StringComparer.Ordinal)) + { + builder.AddFanOutEdge( + bindings[fanOutGroup.Key], + fanOutGroup.Select(edge => bindings[edge.Target]).ToArray()); + } + + foreach (IGrouping fanInGroup in workflowDefinition.Graph.Edges + .Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase)) + .GroupBy(edge => edge.Target, StringComparer.Ordinal)) + { + builder.AddFanInBarrierEdge( + fanInGroup.Select(edge => bindings[edge.Source]).ToArray(), + bindings[fanInGroup.Key]); + } + + if (!string.IsNullOrWhiteSpace(workflowDefinition.Name)) + { + builder = builder.WithName(workflowDefinition.Name); + } + + return builder.WithOutputFrom(bindings[endNode.Id]).Build(); + } + + private static ExecutorBinding CreateExecutorBinding( + WorkflowNodeDto node, + IReadOnlyDictionary agentMap) + { + if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase)) + { + return new ChatForwardingExecutor(node.Id); + } + + if (string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase)) + { + return new WorkflowOutputMessagesExecutor(); + } + + if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)) + { + string agentId = !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id; + if (!agentMap.TryGetValue(agentId, out AIAgent? agent)) + { + throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\"."); + } + + return agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()); + } + + throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet."); + } +} diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs new file mode 100644 index 0000000..4e69801 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs @@ -0,0 +1,292 @@ +using Aryx.AgentHost.Contracts; + +namespace Aryx.AgentHost.Services; + +public sealed class WorkflowValidator +{ + private static readonly HashSet ExecutableNodeKinds = new(StringComparer.OrdinalIgnoreCase) + { + "start", + "end", + "agent", + }; + + public IReadOnlyList Validate(WorkflowDefinitionDto workflow) + { + List issues = []; + + if (string.IsNullOrWhiteSpace(workflow.Name)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "name", + Message = "Workflow name is required.", + }); + } + + if (workflow.Graph.Nodes.Count == 0) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph", + Message = "Workflow graph must include nodes.", + }); + return issues; + } + + Dictionary nodesById = new(StringComparer.Ordinal); + HashSet edgeIds = new(StringComparer.Ordinal); + Dictionary incomingCounts = new(StringComparer.Ordinal); + Dictionary outgoingCounts = new(StringComparer.Ordinal); + + foreach (WorkflowNodeDto node in workflow.Graph.Nodes) + { + if (string.IsNullOrWhiteSpace(node.Id)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.id", + Message = "Workflow nodes must have an ID.", + }); + continue; + } + + if (!nodesById.TryAdd(node.Id, node)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.id", + NodeId = node.Id, + Message = $"Workflow graph contains duplicate node \"{node.Id}\".", + }); + continue; + } + + if (!ExecutableNodeKinds.Contains(node.Kind)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.kind", + NodeId = node.Id, + Message = $"Workflow node kind \"{node.Kind}\" is not executable yet.", + }); + } + + if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(node.Config.Name)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.config.name", + NodeId = node.Id, + Message = "Agent nodes require a name.", + }); + } + + if (string.IsNullOrWhiteSpace(node.Config.Model)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.config.model", + NodeId = node.Id, + Message = $"Agent node \"{node.Label}\" requires a model.", + }); + } + } + } + + foreach (WorkflowEdgeDto edge in workflow.Graph.Edges) + { + if (string.IsNullOrWhiteSpace(edge.Id)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges.id", + Message = "Workflow edges must have an ID.", + }); + continue; + } + + if (!edgeIds.Add(edge.Id)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges.id", + EdgeId = edge.Id, + Message = $"Workflow graph contains duplicate edge \"{edge.Id}\".", + }); + } + + if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges", + EdgeId = edge.Id, + Message = $"Workflow edge \"{edge.Id}\" must connect known nodes.", + }); + continue; + } + + outgoingCounts[edge.Source] = outgoingCounts.TryGetValue(edge.Source, out int outgoing) + ? outgoing + 1 + : 1; + incomingCounts[edge.Target] = incomingCounts.TryGetValue(edge.Target, out int incoming) + ? incoming + 1 + : 1; + } + + List startNodes = workflow.Graph.Nodes + .Where(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase)) + .ToList(); + List endNodes = workflow.Graph.Nodes + .Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase)) + .ToList(); + List agentNodes = workflow.Graph.Nodes + .Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (startNodes.Count != 1) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes", + Message = "Workflow graphs must contain exactly one start node.", + }); + } + + if (endNodes.Count != 1) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes", + Message = "Workflow graphs must contain exactly one end node.", + }); + } + + if (agentNodes.Count == 0) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes", + Message = "Workflow graphs must contain at least one agent node.", + }); + } + + foreach (WorkflowNodeDto startNode in startNodes) + { + if (incomingCounts.GetValueOrDefault(startNode.Id) != 0) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges", + NodeId = startNode.Id, + Message = "Start nodes cannot have incoming edges.", + }); + } + + if (outgoingCounts.GetValueOrDefault(startNode.Id) == 0) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges", + NodeId = startNode.Id, + Message = "Start nodes must connect to at least one downstream node.", + }); + } + } + + foreach (WorkflowNodeDto endNode in endNodes) + { + if (outgoingCounts.GetValueOrDefault(endNode.Id) != 0) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges", + NodeId = endNode.Id, + Message = "End nodes cannot have outgoing edges.", + }); + } + } + + foreach (IGrouping fanOutGroup in workflow.Graph.Edges + .Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase)) + .GroupBy(edge => edge.Source, StringComparer.Ordinal)) + { + if (fanOutGroup.Count() < 2) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges.kind", + NodeId = fanOutGroup.Key, + Message = "Fan-out edges require at least two outgoing fan-out connections from the same source.", + }); + } + } + + foreach (IGrouping fanInGroup in workflow.Graph.Edges + .Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase)) + .GroupBy(edge => edge.Target, StringComparer.Ordinal)) + { + if (fanInGroup.Count() < 2) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges.kind", + NodeId = fanInGroup.Key, + Message = "Fan-in edges require at least two incoming fan-in connections to the same target.", + }); + } + } + + WorkflowNodeDto? start = startNodes.FirstOrDefault(); + if (start is not null && endNodes.Count > 0 && !HasPathToAnyEnd(start.Id, workflow.Graph, endNodes.Select(node => node.Id))) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.edges", + Message = "Workflow graph must include a path from the start node to at least one end node.", + }); + } + + return issues; + } + + private static bool HasPathToAnyEnd( + string startNodeId, + WorkflowGraphDto graph, + IEnumerable endNodeIds) + { + HashSet endSet = endNodeIds.ToHashSet(StringComparer.Ordinal); + Dictionary> outgoing = graph.Edges + .GroupBy(edge => edge.Source, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.Select(edge => edge.Target).ToList(), + StringComparer.Ordinal); + + Queue queue = new([startNodeId]); + HashSet visited = new(StringComparer.Ordinal); + while (queue.Count > 0) + { + string current = queue.Dequeue(); + if (!visited.Add(current)) + { + continue; + } + + if (endSet.Contains(current)) + { + return true; + } + + foreach (string target in outgoing.GetValueOrDefault(current, [])) + { + queue.Enqueue(target); + } + } + + return false; + } +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/Aryx.AgentHost.Tests.csproj b/sidecar/tests/Aryx.AgentHost.Tests/Aryx.AgentHost.Tests.csproj index 4859b11..5223a35 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/Aryx.AgentHost.Tests.csproj +++ b/sidecar/tests/Aryx.AgentHost.Tests/Aryx.AgentHost.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index d17467c..e963229 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -108,6 +108,76 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion() + { + IReadOnlyList events = await RunHostAsync(new ValidateWorkflowCommandDto + { + Type = "validate-workflow", + RequestId = "validate-workflow-1", + Workflow = new WorkflowDefinitionDto + { + Id = "workflow-1", + Name = "", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + Edges = + [ + new WorkflowEdgeDto + { + Id = "edge-start-end", + Source = "start", + Target = "end", + Kind = "direct", + }, + ], + }, + Settings = new WorkflowSettingsDto + { + Checkpointing = new WorkflowCheckpointSettingsDto(), + }, + }, + }); + + Assert.Collection( + events, + validationEvent => + { + Assert.Equal("workflow-validation", validationEvent.GetProperty("type").GetString()); + Assert.Equal("validate-workflow-1", validationEvent.GetProperty("requestId").GetString()); + + JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray(); + Assert.Contains(issues, issue => + issue.GetProperty("field").GetString() == "name" + && 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."); + }, + completionEvent => + { + Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("validate-workflow-1", completionEvent.GetProperty("requestId").GetString()); + }); + } + [Fact] public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion() { diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index bed3c93..abcb987 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -36,6 +36,12 @@ import { type ReasoningEffort, validatePatternDefinition, } from '@shared/domain/pattern'; +import { + buildWorkflowExecutionPattern, + normalizeWorkflowDefinition, + validateWorkflowDefinition, + type WorkflowDefinition, +} from '@shared/domain/workflow'; import { normalizeWorkspaceAgentDefinition, resolvePatternAgents, @@ -623,6 +629,32 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async saveWorkflow(workflow: WorkflowDefinition): Promise { + const workspace = await this.loadWorkspace(); + const normalizedWorkflow = normalizeWorkflowDefinition(workflow); + const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error'); + if (issues.length > 0) { + throw new Error(issues[0].message); + } + + const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id); + const candidate: WorkflowDefinition = { + ...normalizedWorkflow, + isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite, + createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(), + updatedAt: nowIso(), + }; + + if (existingIndex >= 0) { + workspace.workflows[existingIndex] = candidate; + } else { + workspace.workflows.push(candidate); + } + + workspace.selectedWorkflowId = candidate.id; + return this.persistAndBroadcast(workspace); + } + async setPatternFavorite(patternId: string, isFavorite: boolean): Promise { const workspace = await this.loadWorkspace(); const pattern = this.requirePattern(workspace, patternId); @@ -726,6 +758,17 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async deleteWorkflow(workflowId: string): Promise { + const workspace = await this.loadWorkspace(); + workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId); + + if (workspace.selectedWorkflowId === workflowId) { + workspace.selectedWorkflowId = workspace.workflows[0]?.id; + } + + return this.persistAndBroadcast(workspace); + } + async saveMcpServer(server: McpServerDefinition): Promise { const workspace = await this.loadWorkspace(); const existingIndex = workspace.settings.tooling.mcpServers.findIndex( @@ -881,6 +924,41 @@ export class AryxAppService extends EventEmitter { workspace.sessions.unshift(session); workspace.selectedProjectId = project.id; workspace.selectedPatternId = pattern.id; + workspace.selectedWorkflowId = undefined; + workspace.selectedSessionId = session.id; + return this.persistAndBroadcast(workspace); + } + + async createWorkflowSession(projectId: string, workflowId: string): Promise { + const workspace = await this.loadWorkspace(); + const project = this.requireProject(workspace, projectId); + const workflow = this.requireWorkflow(workspace, workflowId); + const modelCatalog = await this.loadAvailableModelCatalog(); + const executionPattern = normalizePatternModels(buildWorkflowExecutionPattern(workflow), modelCatalog); + + const session: SessionRecord = { + id: createId('session'), + projectId: project.id, + patternId: workflow.id, + workflowId: workflow.id, + title: workflow.name, + titleSource: 'auto', + createdAt: nowIso(), + updatedAt: nowIso(), + status: 'idle', + messages: [], + sessionModelConfig: executionPattern.agents.length === 1 + ? createSessionModelConfig(executionPattern) + : undefined, + tooling: createSessionToolingSelection(), + runs: [], + }; + + await this.ensureScratchpadSessionDirectory(session); + workspace.sessions.unshift(session); + workspace.selectedProjectId = project.id; + workspace.selectedPatternId = undefined; + workspace.selectedWorkflowId = workflow.id; workspace.selectedSessionId = session.id; return this.persistAndBroadcast(workspace); } @@ -993,7 +1071,7 @@ export class AryxAppService extends EventEmitter { } const project = this.requireProject(workspace, session.projectId); - const pattern = this.requirePattern(workspace, session.patternId); + const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session); const effectivePattern = this.applyProjectCustomizationToPattern( await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []), project, @@ -1022,11 +1100,19 @@ export class AryxAppService extends EventEmitter { throw new Error('Regenerated session is missing the user message needed to replay the turn.'); } - await this.runPreparedSessionTurn(workspace, regeneratedSession, project, effectivePattern, projectInstructions, { - occurredAt, - requestId: createId('turn'), - triggerMessageId: triggerMessage.id, - }); + await this.runPreparedSessionTurn( + workspace, + regeneratedSession, + project, + effectivePattern, + projectInstructions, + { + occurredAt, + requestId: createId('turn'), + triggerMessageId: triggerMessage.id, + }, + workflow, + ); } async editAndResendSessionMessage( @@ -1052,7 +1138,7 @@ export class AryxAppService extends EventEmitter { } const project = this.requireProject(workspace, session.projectId); - const pattern = this.requirePattern(workspace, session.patternId); + const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session); const effectivePattern = this.applyProjectCustomizationToPattern( await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []), project, @@ -1084,11 +1170,19 @@ export class AryxAppService extends EventEmitter { throw new Error('Edited session is missing the user message needed to replay the turn.'); } - await this.runPreparedSessionTurn(workspace, editedSession, project, effectivePattern, projectInstructions, { - occurredAt, - requestId: createId('turn'), - triggerMessageId: triggerMessage.id, - }); + await this.runPreparedSessionTurn( + workspace, + editedSession, + project, + effectivePattern, + projectInstructions, + { + occurredAt, + requestId: createId('turn'), + triggerMessageId: triggerMessage.id, + }, + workflow, + ); } async sendSessionMessage( @@ -1106,7 +1200,7 @@ export class AryxAppService extends EventEmitter { throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.'); } const project = this.requireProject(workspace, session.projectId); - const pattern = this.requirePattern(workspace, session.patternId); + const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session); const effectivePattern = this.applyProjectCustomizationToPattern( await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []), project, @@ -1135,13 +1229,21 @@ export class AryxAppService extends EventEmitter { attachments: attachments?.length ? attachments : undefined, promptInvocation: normalizedPromptInvocation, }); - await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, { - occurredAt, - requestId, - triggerMessageId: userMessageId, - messageMode, - attachments, - }); + await this.runPreparedSessionTurn( + workspace, + session, + project, + effectivePattern, + projectInstructions, + { + occurredAt, + requestId, + triggerMessageId: userMessageId, + messageMode, + attachments, + }, + workflow, + ); } async cancelSessionTurn(sessionId: string): Promise { @@ -1328,7 +1430,7 @@ export class AryxAppService extends EventEmitter { const session = this.requireSession(workspace, sessionId); const project = this.requireProject(workspace, session.projectId); const modelCatalog = await this.loadAvailableModelCatalog(); - const pattern = this.requirePattern(workspace, session.patternId); + const { pattern } = this.resolveSessionExecutionDefinition(workspace, session); const effectivePattern = normalizePatternModels(pattern, modelCatalog); if (effectivePattern.agents.length !== 1) { @@ -1497,6 +1599,7 @@ export class AryxAppService extends EventEmitter { messageMode?: MessageMode; attachments?: ChatMessageAttachment[]; }, + effectiveWorkflow?: WorkflowDefinition, ): Promise { const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options; @@ -1529,6 +1632,7 @@ export class AryxAppService extends EventEmitter { workingDirectory: runWorkingDirectory, workspaceKind, pattern: patternForTurn, + workflow: effectiveWorkflow ? { id: effectiveWorkflow.id, name: effectiveWorkflow.name } : undefined, triggerMessageId, startedAt: occurredAt, preRunGitSnapshot, @@ -1558,6 +1662,7 @@ export class AryxAppService extends EventEmitter { messageMode, projectInstructions, pattern: patternForTurn, + workflow: effectiveWorkflow, messages: session.messages, attachments: attachments?.length ? attachments : undefined, promptInvocation, @@ -2126,6 +2231,32 @@ export class AryxAppService extends EventEmitter { return pattern; } + private requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition { + const workflow = workspace.workflows.find((current) => current.id === workflowId); + if (!workflow) { + throw new Error(`Workflow "${workflowId}" was not found.`); + } + + return workflow; + } + + private resolveSessionExecutionDefinition( + workspace: WorkspaceState, + session: SessionRecord, + ): { pattern: PatternDefinition; workflow?: WorkflowDefinition } { + if (session.workflowId) { + const workflow = this.requireWorkflow(workspace, session.workflowId); + return { + workflow, + pattern: buildWorkflowExecutionPattern(workflow), + }; + } + + return { + pattern: this.requirePattern(workspace, session.patternId), + }; + } + private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord { const session = workspace.sessions.find((current) => current.id === sessionId); if (!session) { @@ -2335,7 +2466,7 @@ export class AryxAppService extends EventEmitter { messages: ChatMessageRecord[], ): void { const session = this.requireSession(workspace, sessionId); - const pattern = this.requirePattern(workspace, session.patternId); + const { pattern } = this.resolveSessionExecutionDefinition(workspace, session); const incomingIds = new Set(messages.map((message) => message.id)); // Messages that were streamed during the turn already exist in session.messages diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 0705440..84e0f19 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -6,6 +6,7 @@ import type { BranchSessionInput, CancelSessionTurnInput, CreateSessionInput, + CreateWorkflowSessionInput, CreateProjectGitBranchInput, DismissSessionMcpAuthInput, DismissSessionPlanReviewInput, @@ -34,6 +35,7 @@ import type { SaveLspProfileInput, SaveMcpServerInput, SavePatternInput, + SaveWorkflowInput, SaveWorkspaceAgentInput, SendSessionMessageInput, SetPatternFavoriteInput, @@ -112,6 +114,8 @@ export function registerIpcHandlers( ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) => service.setPatternFavorite(input.patternId, input.isFavorite), ); + ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow)); + ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId)); ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => { const result = await service.setTheme(theme); applyTitleBarTheme(window, theme); @@ -180,6 +184,9 @@ export function registerIpcHandlers( ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) => service.createSession(input.projectId, input.patternId), ); + ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) => + service.createWorkflowSession(input.projectId, input.workflowId), + ); ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) => service.duplicateSession(input.sessionId), ); diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 624a222..d71b7e7 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -15,6 +15,7 @@ import { normalizeSessionToolingSelection, normalizeWorkspaceSettings, } from '@shared/domain/tooling'; +import { normalizeWorkflowDefinition } from '@shared/domain/workflow'; import { applyDefaultToolApprovalPolicy, normalizePendingApprovalState, @@ -120,6 +121,7 @@ export class WorkspaceRepository { approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy), graph: resolvePatternGraph(pattern), })), + workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition), projects, sessions, settings, diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 645971a..172ffc5 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -14,6 +14,7 @@ import type { McpOauthRequiredEvent, ExitPlanModeRequestedEvent, ValidatePatternCommand, + ValidateWorkflowCommand, RunTurnCommand, CopilotSessionListFilter, CopilotSessionInfo, @@ -46,6 +47,12 @@ type PendingCommand = resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void; reject: (error: Error) => void; }) + | ({ + processId: number; + kind: 'validate-workflow'; + resolve: (issues: ValidateWorkflowCommand['workflow'] extends never ? never : unknown) => void; + reject: (error: Error) => void; + }) | ({ processId: number; kind: 'resolve-approval'; @@ -127,6 +134,14 @@ export class SidecarClient { }); } + async validateWorkflow(workflow: ValidateWorkflowCommand['workflow']): Promise { + return this.dispatch({ + type: 'validate-workflow', + requestId: `validate-workflow-${Date.now()}`, + workflow, + }); + } + async runTurn( command: RunTurnCommand, onDelta: (event: TurnDeltaEvent) => void | Promise, @@ -317,6 +332,13 @@ export class SidecarClient { resolve: resolve as (issues: unknown) => void, reject, }); + } else if (command.type === 'validate-workflow') { + this.pending.set(command.requestId, { + processId: state.id, + kind: 'validate-workflow', + resolve: resolve as (issues: unknown) => void, + reject, + }); } else if (command.type === 'resolve-approval') { this.pending.set(command.requestId, { processId: state.id, @@ -413,6 +435,12 @@ export class SidecarClient { this.pending.delete(event.requestId); } return; + case 'workflow-validation': + if (pending.kind === 'validate-workflow') { + pending.resolve(event.issues); + this.pending.delete(event.requestId); + } + return; case 'turn-delta': if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event)); diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d8091a..b5d9829 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,9 @@ const api: ElectronApi = { savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input), deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId), setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input), + saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input), + deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId), + createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input), setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme), setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input), setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index a533a9b..e183e4c 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -15,6 +15,9 @@ export const ipcChannels = { savePattern: 'patterns:save', deletePattern: 'patterns:delete', setPatternFavorite: 'patterns:set-favorite', + saveWorkflow: 'workflows:save', + deleteWorkflow: 'workflows:delete', + createWorkflowSession: 'workflows:create-session', setTheme: 'settings:set-theme', setTerminalHeight: 'settings:set-terminal-height', setNotificationsEnabled: 'settings:set-notifications-enabled', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index de00d12..a90b8d7 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -1,6 +1,7 @@ import type { ApprovalDecision } from '@shared/domain/approval'; import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; +import type { WorkflowDefinition } from '@shared/domain/workflow'; import type { ProjectGitBranchSummary, ProjectGitCommitMessageSuggestion, @@ -28,10 +29,19 @@ export interface CreateSessionInput { patternId: string; } +export interface CreateWorkflowSessionInput { + projectId: string; + workflowId: string; +} + export interface SavePatternInput { pattern: PatternDefinition; } +export interface SaveWorkflowInput { + workflow: WorkflowDefinition; +} + export interface SendSessionMessageInput { sessionId: string; content: string; @@ -274,6 +284,8 @@ export interface ElectronApi { setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise; savePattern(input: SavePatternInput): Promise; deletePattern(patternId: string): Promise; + saveWorkflow(input: SaveWorkflowInput): Promise; + deleteWorkflow(workflowId: string): Promise; saveMcpServer(input: SaveMcpServerInput): Promise; deleteMcpServer(serverId: string): Promise; saveLspProfile(input: SaveLspProfileInput): Promise; @@ -283,6 +295,7 @@ export interface ElectronApi { updateSessionTooling(input: UpdateSessionToolingInput): Promise; updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise; createSession(input: CreateSessionInput): Promise; + createWorkflowSession(input: CreateWorkflowSessionInput): Promise; duplicateSession(input: DuplicateSessionInput): Promise; branchSession(input: BranchSessionInput): Promise; setSessionMessagePinned(input: SetSessionMessagePinnedInput): Promise; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index ec328ec..2e3b1f9 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -4,6 +4,7 @@ import type { ChatMessageRecord } from '@shared/domain/session'; import type { RuntimeToolDefinition } from '@shared/domain/tooling'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; +import type { WorkflowDefinition, WorkflowValidationIssue } from '@shared/domain/workflow'; export interface SidecarModeCapability { available: boolean; @@ -70,6 +71,12 @@ export interface ValidatePatternCommand { pattern: PatternDefinition; } +export interface ValidateWorkflowCommand { + type: 'validate-workflow'; + requestId: string; + workflow: WorkflowDefinition; +} + export type InteractionMode = 'interactive' | 'plan'; export type MessageMode = 'enqueue' | 'immediate'; @@ -89,6 +96,7 @@ export interface RunTurnCommand { messageMode?: MessageMode; projectInstructions?: string; pattern: PatternDefinition; + workflow?: WorkflowDefinition; messages: ChatMessageRecord[]; attachments?: ChatMessageAttachment[]; promptInvocation?: ProjectPromptInvocation; @@ -147,6 +155,7 @@ export interface CopilotSessionListFilter { export type SidecarCommand = | DescribeCapabilitiesCommand | ValidatePatternCommand + | ValidateWorkflowCommand | RunTurnCommand | CancelTurnCommand | ResolveApprovalCommand @@ -230,6 +239,12 @@ export interface PatternValidationEvent { issues: PatternValidationIssue[]; } +export interface WorkflowValidationEvent { + type: 'workflow-validation'; + requestId: string; + issues: WorkflowValidationIssue[]; +} + export interface TurnDeltaEvent { type: 'turn-delta'; requestId: string; @@ -592,6 +607,7 @@ export interface CommandCompleteEvent { export type SidecarEvent = | CapabilitiesEvent | PatternValidationEvent + | WorkflowValidationEvent | TurnDeltaEvent | TurnCompleteEvent | MessageReclassifiedEvent diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts index 406fa89..b8cbe20 100644 --- a/src/shared/domain/runTimeline.ts +++ b/src/shared/domain/runTimeline.ts @@ -17,6 +17,7 @@ import type { ProjectGitWorkingTreeSnapshot, ProjectRecord, } from '@shared/domain/project'; +import type { WorkflowDefinition } from '@shared/domain/workflow'; import { createId } from '@shared/utils/ids'; export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error'; @@ -76,6 +77,8 @@ export interface SessionRunRecord { patternId: string; patternName: string; patternMode: PatternDefinition['mode']; + workflowId?: string; + workflowName?: string; triggerMessageId: string; startedAt: string; completedAt?: string; @@ -93,6 +96,7 @@ export interface CreateSessionRunRecordInput { workingDirectory?: string; workspaceKind: SessionRunWorkspaceKind; pattern: Pick; + workflow?: Pick; triggerMessageId: string; startedAt: string; preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot; @@ -610,6 +614,8 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess patternId: input.pattern.id, patternName: input.pattern.name, patternMode: input.pattern.mode, + workflowId: normalizeOptionalString(input.workflow?.id), + workflowName: normalizeOptionalString(input.workflow?.name), triggerMessageId: input.triggerMessageId, startedAt: input.startedAt, status: 'running', @@ -655,6 +661,8 @@ export function normalizeSessionRunRecords( const workingDirectory = normalizeOptionalString(run.workingDirectory); const patternId = normalizeOptionalString(run.patternId); const patternName = normalizeOptionalString(run.patternName); + const workflowId = normalizeOptionalString(run.workflowId); + const workflowName = normalizeOptionalString(run.workflowName); const triggerMessageId = normalizeOptionalString(run.triggerMessageId); const startedAt = normalizeOptionalString(run.startedAt); if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) { @@ -672,6 +680,8 @@ export function normalizeSessionRunRecords( patternId, patternName, patternMode: run.patternMode, + workflowId, + workflowName, triggerMessageId, startedAt, completedAt: normalizeOptionalString(run.completedAt), diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 3928354..2bd8c9a 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -57,6 +57,7 @@ export interface SessionRecord { id: string; projectId: string; patternId: string; + workflowId?: string; title: string; titleSource?: SessionTitleSource; createdAt: string; diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index 12a811f..508c6eb 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -14,6 +14,7 @@ import { type SessionStatus, } from '@shared/domain/session'; import type { WorkspaceState } from '@shared/domain/workspace'; +import { buildWorkflowExecutionPattern } from '@shared/domain/workflow'; export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern'; export type SessionWorkspaceKind = 'project' | 'scratchpad'; @@ -456,6 +457,7 @@ export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[ export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] { const projectsById = new Map(workspace.projects.map((project) => [project.id, project])); const patternsById = new Map(workspace.patterns.map((pattern) => [pattern.id, pattern])); + const workflowsById = new Map(workspace.workflows.map((workflow) => [workflow.id, workflow])); const searchTokens = tokenizeSearchText(input.searchText); return workspace.sessions @@ -466,7 +468,17 @@ export function querySessions(workspace: WorkspaceState, input: QuerySessionsInp } const searchMatch = resolveSearchMatch( - buildSearchFields(session, projectsById.get(session.projectId), patternsById.get(session.patternId)), + buildSearchFields( + session, + projectsById.get(session.projectId), + patternsById.get(session.patternId) + ?? (session.workflowId + ? (() => { + const workflow = workflowsById.get(session.workflowId); + return workflow ? buildWorkflowExecutionPattern(workflow) : undefined; + })() + : undefined), + ), searchTokens, ); if (!searchMatch) { diff --git a/src/shared/domain/workflow.ts b/src/shared/domain/workflow.ts new file mode 100644 index 0000000..fec609e --- /dev/null +++ b/src/shared/domain/workflow.ts @@ -0,0 +1,606 @@ +import type { ApprovalPolicy } from '@shared/domain/approval'; +import { + syncPatternGraph, + type PatternDefinition, + type PatternGraph, + type PatternAgentDefinition, +} from '@shared/domain/pattern'; + +export type WorkflowNodeKind = + | 'start' + | 'end' + | 'agent' + | 'code-executor' + | 'function-executor' + | 'sub-workflow' + | 'request-port'; + +export type WorkflowEdgeKind = 'direct' | 'fan-out' | 'fan-in'; +export type WorkflowExecutionMode = 'off-thread' | 'lockstep'; + +export interface WorkflowPosition { + x: number; + y: number; +} + +export interface WorkflowCheckpointSettings { + enabled: boolean; +} + +export interface WorkflowTelemetrySettings { + openTelemetry?: boolean; + sensitiveData?: boolean; +} + +export interface WorkflowStateScope { + name: string; + description?: string; + initialValues?: Record; +} + +export interface WorkflowSettings { + checkpointing: WorkflowCheckpointSettings; + executionMode: WorkflowExecutionMode; + maxIterations?: number; + approvalPolicy?: ApprovalPolicy; + stateScopes?: WorkflowStateScope[]; + telemetry?: WorkflowTelemetrySettings; +} + +export interface StartNodeConfig { + kind: 'start'; + inputType?: string; +} + +export interface EndNodeConfig { + kind: 'end'; + outputType?: string; +} + +export interface AgentNodeConfig extends PatternAgentDefinition { + kind: 'agent'; +} + +export interface CodeExecutorConfig { + kind: 'code-executor'; + inputType?: string; + outputType?: string; + implementation?: string; +} + +export interface FunctionExecutorConfig { + kind: 'function-executor'; + functionRef: string; + parameters?: Record; +} + +export interface SubWorkflowConfig { + kind: 'sub-workflow'; + workflowId?: string; + inlineWorkflow?: WorkflowDefinition; +} + +export interface RequestPortConfig { + kind: 'request-port'; + portId: string; + requestType: string; + responseType: string; + prompt?: string; +} + +export type WorkflowNodeConfig = + | StartNodeConfig + | EndNodeConfig + | AgentNodeConfig + | CodeExecutorConfig + | FunctionExecutorConfig + | SubWorkflowConfig + | RequestPortConfig; + +export interface WorkflowNode { + id: string; + kind: WorkflowNodeKind; + label: string; + position: WorkflowPosition; + order?: number; + config: WorkflowNodeConfig; +} + +export interface WorkflowConditionRule { + propertyPath: string; + operator: 'equals' | 'not-equals' | 'contains' | 'gt' | 'lt' | 'regex'; + value: string; +} + +export type EdgeCondition = + | { type: 'always' } + | { type: 'message-type'; typeName: string } + | { type: 'expression'; expression: string } + | { type: 'property'; combinator?: 'and' | 'or'; rules: WorkflowConditionRule[] }; + +export interface FanOutConfig { + strategy: 'broadcast' | 'partition'; + partitionExpression?: string; +} + +export interface WorkflowEdge { + id: string; + source: string; + target: string; + kind: WorkflowEdgeKind; + condition?: EdgeCondition; + label?: string; + fanOutConfig?: FanOutConfig; +} + +export interface WorkflowGraph { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; +} + +export interface WorkflowDefinition { + id: string; + name: string; + description: string; + isFavorite?: boolean; + graph: WorkflowGraph; + settings: WorkflowSettings; + createdAt: string; + updatedAt: string; +} + +export interface WorkflowValidationIssue { + level: 'error' | 'warning'; + field?: string; + message: string; + nodeId?: string; + edgeId?: string; +} + +const executableNodeKinds = new Set(['start', 'end', 'agent']); + +function normalizeOptionalString(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizePosition(position?: Partial): WorkflowPosition { + return { + x: typeof position?.x === 'number' && Number.isFinite(position.x) ? position.x : 0, + y: typeof position?.y === 'number' && Number.isFinite(position.y) ? position.y : 0, + }; +} + +function normalizeNodeConfig(kind: WorkflowNodeKind, config?: Partial): WorkflowNodeConfig { + switch (kind) { + case 'start': + return { + kind, + inputType: normalizeOptionalString((config as Partial | undefined)?.inputType), + }; + case 'end': + return { + kind, + outputType: normalizeOptionalString((config as Partial | undefined)?.outputType), + }; + case 'agent': { + const agent = config as Partial | undefined; + return { + kind, + id: normalizeOptionalString(agent?.id) ?? '', + name: normalizeOptionalString(agent?.name) ?? '', + description: agent?.description?.trim() ?? '', + instructions: agent?.instructions?.trim() ?? '', + model: normalizeOptionalString(agent?.model) ?? '', + reasoningEffort: agent?.reasoningEffort, + copilot: agent?.copilot, + workspaceAgentId: normalizeOptionalString(agent?.workspaceAgentId), + overrides: agent?.overrides, + }; + } + case 'code-executor': { + const value = config as Partial | undefined; + return { + kind, + inputType: normalizeOptionalString(value?.inputType), + outputType: normalizeOptionalString(value?.outputType), + implementation: value?.implementation?.trim(), + }; + } + case 'function-executor': { + const value = config as Partial | undefined; + return { + kind, + functionRef: normalizeOptionalString(value?.functionRef) ?? '', + parameters: value?.parameters, + }; + } + case 'sub-workflow': { + const value = config as Partial | undefined; + return { + kind, + workflowId: normalizeOptionalString(value?.workflowId), + inlineWorkflow: value?.inlineWorkflow ? normalizeWorkflowDefinition(value.inlineWorkflow) : undefined, + }; + } + case 'request-port': { + const value = config as Partial | undefined; + return { + kind, + portId: normalizeOptionalString(value?.portId) ?? '', + requestType: normalizeOptionalString(value?.requestType) ?? '', + responseType: normalizeOptionalString(value?.responseType) ?? '', + prompt: value?.prompt?.trim(), + }; + } + } +} + +export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): WorkflowDefinition { + return { + ...workflow, + name: workflow.name.trim(), + description: workflow.description.trim(), + graph: { + nodes: (workflow.graph?.nodes ?? []).map((node) => ({ + ...node, + id: node.id.trim(), + kind: node.kind, + label: node.label.trim(), + position: normalizePosition(node.position), + config: normalizeNodeConfig(node.kind, node.config), + })), + edges: (workflow.graph?.edges ?? []).map((edge) => ({ + ...edge, + id: edge.id.trim(), + source: edge.source.trim(), + target: edge.target.trim(), + kind: edge.kind, + label: normalizeOptionalString(edge.label), + })), + }, + settings: { + checkpointing: { + enabled: workflow.settings?.checkpointing?.enabled ?? false, + }, + executionMode: workflow.settings?.executionMode === 'lockstep' ? 'lockstep' : 'off-thread', + maxIterations: + typeof workflow.settings?.maxIterations === 'number' && workflow.settings.maxIterations > 0 + ? Math.round(workflow.settings.maxIterations) + : undefined, + approvalPolicy: workflow.settings?.approvalPolicy, + stateScopes: workflow.settings?.stateScopes?.map((scope) => ({ + name: scope.name.trim(), + description: normalizeOptionalString(scope.description), + initialValues: scope.initialValues, + })), + telemetry: workflow.settings?.telemetry + ? { + openTelemetry: workflow.settings.telemetry.openTelemetry ?? false, + sensitiveData: workflow.settings.telemetry.sensitiveData ?? false, + } + : undefined, + }, + }; +} + +export function resolveWorkflowAgentNodes(workflow: WorkflowDefinition): WorkflowNode[] { + return workflow.graph.nodes + .filter((node) => node.kind === 'agent') + .slice() + .sort((left, right) => { + const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + return left.label.localeCompare(right.label); + }); +} + +export function resolveWorkflowAgents(workflow: WorkflowDefinition): PatternAgentDefinition[] { + return resolveWorkflowAgentNodes(workflow).flatMap((node) => { + if (node.config.kind !== 'agent') { + return []; + } + + return [{ + id: node.config.id || node.id, + name: node.config.name, + description: node.config.description, + instructions: node.config.instructions, + model: node.config.model, + reasoningEffort: node.config.reasoningEffort, + copilot: node.config.copilot, + workspaceAgentId: node.config.workspaceAgentId, + overrides: node.config.overrides, + }]; + }); +} + +function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefinition['mode'] { + const hasFanEdges = workflow.graph.edges.some((edge) => edge.kind !== 'direct'); + const agentCount = resolveWorkflowAgents(workflow).length; + if (hasFanEdges) { + return 'concurrent'; + } + + return agentCount <= 1 ? 'single' : 'sequential'; +} + +export function buildWorkflowExecutionPattern(workflow: WorkflowDefinition): PatternDefinition { + const agents = resolveWorkflowAgents(workflow); + const pattern: PatternDefinition = { + id: workflow.id, + name: workflow.name, + description: workflow.description, + mode: inferWorkflowPatternMode(workflow), + availability: 'available', + maxIterations: workflow.settings.maxIterations ?? 5, + approvalPolicy: workflow.settings.approvalPolicy, + agents, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }; + + return { + ...pattern, + graph: syncPatternGraph(pattern).graph as PatternGraph, + }; +} + +function addIssue( + issues: WorkflowValidationIssue[], + issue: WorkflowValidationIssue, +): void { + issues.push(issue); +} + +function hasPathToEnd(startNodeId: string, graph: WorkflowGraph, endNodeIds: Set): boolean { + const outgoing = new Map(); + for (const edge of graph.edges) { + const current = outgoing.get(edge.source); + if (current) { + current.push(edge); + } else { + outgoing.set(edge.source, [edge]); + } + } + + const queue = [startNodeId]; + const visited = new Set(); + while (queue.length > 0) { + const nodeId = queue.shift()!; + if (!visited.add(nodeId)) { + continue; + } + if (endNodeIds.has(nodeId)) { + return true; + } + for (const edge of outgoing.get(nodeId) ?? []) { + queue.push(edge.target); + } + } + + return false; +} + +export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] { + const normalized = normalizeWorkflowDefinition(workflow); + const issues: WorkflowValidationIssue[] = []; + + if (!normalized.name) { + addIssue(issues, { level: 'error', field: 'name', message: 'Workflow name is required.' }); + } + + if (normalized.graph.nodes.length === 0) { + addIssue(issues, { level: 'error', field: 'graph', message: 'Workflow graph must include nodes.' }); + return issues; + } + + const nodesById = new Map(); + const edgeIds = new Set(); + const incomingCounts = new Map(); + const outgoingCounts = new Map(); + + for (const node of normalized.graph.nodes) { + if (!node.id) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes.id', + nodeId: node.id, + message: 'Workflow nodes must have an ID.', + }); + continue; + } + + if (nodesById.has(node.id)) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes.id', + nodeId: node.id, + message: `Workflow graph contains duplicate node "${node.id}".`, + }); + continue; + } + + nodesById.set(node.id, node); + + if (!node.label) { + addIssue(issues, { + level: 'warning', + field: 'graph.nodes.label', + nodeId: node.id, + message: 'Workflow nodes should have a label.', + }); + } + + if (!executableNodeKinds.has(node.kind)) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes.kind', + nodeId: node.id, + message: `Workflow node kind "${node.kind}" is not executable yet.`, + }); + } + + if (node.kind === 'agent' && node.config.kind === 'agent') { + if (!node.config.name) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes.config.name', + nodeId: node.id, + message: 'Agent nodes require a name.', + }); + } + if (!node.config.model) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes.config.model', + nodeId: node.id, + message: `Agent node "${node.label || node.id}" requires a model.`, + }); + } + } + } + + for (const edge of normalized.graph.edges) { + if (!edge.id) { + addIssue(issues, { level: 'error', field: 'graph.edges.id', message: 'Workflow edges must have an ID.' }); + continue; + } + if (!edgeIds.add(edge.id)) { + addIssue(issues, { + level: 'error', + field: 'graph.edges.id', + edgeId: edge.id, + message: `Workflow graph contains duplicate edge "${edge.id}".`, + }); + } + if (!nodesById.has(edge.source) || !nodesById.has(edge.target)) { + addIssue(issues, { + level: 'error', + field: 'graph.edges', + edgeId: edge.id, + message: `Workflow edge "${edge.id}" must connect known nodes.`, + }); + continue; + } + + outgoingCounts.set(edge.source, (outgoingCounts.get(edge.source) ?? 0) + 1); + incomingCounts.set(edge.target, (incomingCounts.get(edge.target) ?? 0) + 1); + } + + const startNodes = normalized.graph.nodes.filter((node) => node.kind === 'start'); + const endNodes = normalized.graph.nodes.filter((node) => node.kind === 'end'); + const agentNodes = normalized.graph.nodes.filter((node) => node.kind === 'agent'); + + if (startNodes.length !== 1) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes', + message: 'Workflow graphs must contain exactly one start node.', + }); + } + + if (endNodes.length !== 1) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes', + message: 'Workflow graphs must contain exactly one end node.', + }); + } + + if (agentNodes.length === 0) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes', + message: 'Workflow graphs must contain at least one agent node.', + }); + } + + for (const startNode of startNodes) { + if ((incomingCounts.get(startNode.id) ?? 0) !== 0) { + addIssue(issues, { + level: 'error', + field: 'graph.edges', + nodeId: startNode.id, + message: 'Start nodes cannot have incoming edges.', + }); + } + if ((outgoingCounts.get(startNode.id) ?? 0) === 0) { + addIssue(issues, { + level: 'error', + field: 'graph.edges', + nodeId: startNode.id, + message: 'Start nodes must connect to at least one downstream node.', + }); + } + } + + for (const endNode of endNodes) { + if ((outgoingCounts.get(endNode.id) ?? 0) !== 0) { + addIssue(issues, { + level: 'error', + field: 'graph.edges', + nodeId: endNode.id, + message: 'End nodes cannot have outgoing edges.', + }); + } + } + + const fanOutBySource = new Map(); + const fanInByTarget = new Map(); + for (const edge of normalized.graph.edges) { + if (edge.kind === 'fan-out') { + const current = fanOutBySource.get(edge.source); + if (current) { + current.push(edge); + } else { + fanOutBySource.set(edge.source, [edge]); + } + } + if (edge.kind === 'fan-in') { + const current = fanInByTarget.get(edge.target); + if (current) { + current.push(edge); + } else { + fanInByTarget.set(edge.target, [edge]); + } + } + } + + for (const [source, edges] of fanOutBySource.entries()) { + if (edges.length < 2) { + addIssue(issues, { + level: 'error', + field: 'graph.edges.kind', + nodeId: source, + message: 'Fan-out edges require at least two outgoing fan-out connections from the same source.', + }); + } + } + + for (const [target, edges] of fanInByTarget.entries()) { + if (edges.length < 2) { + addIssue(issues, { + level: 'error', + field: 'graph.edges.kind', + nodeId: target, + message: 'Fan-in edges require at least two incoming fan-in connections to the same target.', + }); + } + } + + const startNode = startNodes[0]; + if (startNode && endNodes.length > 0 && !hasPathToEnd(startNode.id, normalized.graph, new Set(endNodes.map((node) => node.id)))) { + addIssue(issues, { + level: 'error', + field: 'graph.edges', + message: 'Workflow graph must include a path from the start node to at least one end node.', + }); + } + + return issues; +} diff --git a/src/shared/domain/workspace.ts b/src/shared/domain/workspace.ts index c290673..ffa8ce5 100644 --- a/src/shared/domain/workspace.ts +++ b/src/shared/domain/workspace.ts @@ -3,11 +3,13 @@ import { createBuiltinPatterns } from '@shared/domain/pattern'; import type { ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling'; +import type { WorkflowDefinition } from '@shared/domain/workflow'; import { nowIso } from '@shared/utils/ids'; export interface WorkspaceState { projects: ProjectRecord[]; patterns: PatternDefinition[]; + workflows: WorkflowDefinition[]; sessions: SessionRecord[]; settings: WorkspaceSettings; /** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */ @@ -16,6 +18,7 @@ export interface WorkspaceState { mcpProbingServerIds?: string[]; selectedProjectId?: string; selectedPatternId?: string; + selectedWorkflowId?: string; selectedSessionId?: string; lastUpdatedAt: string; } @@ -25,6 +28,7 @@ export function createWorkspaceSeed(): WorkspaceState { return { projects: [], patterns: createBuiltinPatterns(timestamp), + workflows: [], sessions: [], settings: createWorkspaceSettings(), lastUpdatedAt: timestamp, diff --git a/tests/main/appServiceWorkflow.test.ts b/tests/main/appServiceWorkflow.test.ts new file mode 100644 index 0000000..46c0eb0 --- /dev/null +++ b/tests/main/appServiceWorkflow.test.ts @@ -0,0 +1,115 @@ +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 { + const service = new AryxAppService(); + const internals = service as unknown as Record; + internals.loadWorkspace = async () => workspace; + internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace; + internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog(); + return service; +} + +function createWorkflow(): WorkflowDefinition { + return { + id: 'workflow-test', + name: 'Workflow Test', + description: 'Simple workflow', + 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: 'agent-primary', + kind: 'agent', + label: 'Primary', + position: { x: 200, y: 0 }, + order: 0, + config: { + kind: 'agent', + id: 'agent-primary', + name: 'Primary', + description: 'Main agent', + instructions: 'Help the user', + model: 'gpt-5.4', + }, + }, + { id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'agent-primary', kind: 'direct' }, + { id: 'e2', source: 'agent-primary', target: 'end', kind: 'direct' }, + ], + }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + }; +} + +describe('AryxAppService workflow operations', () => { + test('saves workflows into workspace state', async () => { + const workspace = createWorkspaceSeed(); + const service = createService(workspace); + + const result = await service.saveWorkflow(createWorkflow()); + + expect(result.workflows).toHaveLength(1); + expect(result.selectedWorkflowId).toBe('workflow-test'); + }); + + test('creates workflow-backed sessions', 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(createWorkflow()); + const service = createService(workspace); + + const result = await service.createWorkflowSession(project.id, 'workflow-test'); + const session = result.sessions[0]; + + expect(session?.workflowId).toBe('workflow-test'); + expect(result.selectedWorkflowId).toBe('workflow-test'); + }); +}); diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index 3361ba7..c7b0bea 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -11,6 +11,7 @@ describe('session workspace helpers', () => { return { projects: [], patterns: [], + workflows: [], settings: createWorkspaceSettings(), sessions: [ { diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts index b2d01c6..6ede92d 100644 --- a/tests/shared/sessionLibrary.test.ts +++ b/tests/shared/sessionLibrary.test.ts @@ -73,9 +73,10 @@ function createSession(overrides?: Partial): SessionRecord { } function createWorkspace(overrides?: Partial): WorkspaceState { - return { + const workspace: WorkspaceState = { projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })], patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })], + workflows: [], settings: createWorkspaceSettings(), sessions: [ createSession(), @@ -112,6 +113,11 @@ function createWorkspace(overrides?: Partial): WorkspaceState { lastUpdatedAt: '2026-03-23T00:10:00.000Z', ...overrides, }; + + return { + ...workspace, + workflows: overrides?.workflows ?? workspace.workflows, + }; } describe('session library helpers', () => { diff --git a/tests/shared/workflow.test.ts b/tests/shared/workflow.test.ts new file mode 100644 index 0000000..cb59366 --- /dev/null +++ b/tests/shared/workflow.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildWorkflowExecutionPattern, + validateWorkflowDefinition, + type WorkflowDefinition, +} from '@shared/domain/workflow'; + +const TIMESTAMP = '2026-04-05T00:00:00.000Z'; + +function createWorkflow(): WorkflowDefinition { + return { + id: 'workflow-1', + name: 'Agent Workflow', + description: 'A simple workflow.', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + graph: { + nodes: [ + { + id: 'start', + kind: 'start', + label: 'Start', + position: { x: 0, y: 0 }, + config: { kind: 'start' }, + }, + { + id: 'agent-primary', + kind: 'agent', + label: 'Primary Agent', + position: { x: 200, y: 0 }, + order: 0, + config: { + kind: 'agent', + id: 'agent-primary', + name: 'Primary Agent', + description: 'Handles the task.', + instructions: 'Do the work.', + model: 'gpt-5.4', + reasoningEffort: 'medium', + }, + }, + { + id: 'end', + kind: 'end', + label: 'End', + position: { x: 400, y: 0 }, + config: { kind: 'end' }, + }, + ], + edges: [ + { id: 'edge-start-agent', source: 'start', target: 'agent-primary', kind: 'direct' }, + { id: 'edge-agent-end', source: 'agent-primary', target: 'end', kind: 'direct' }, + ], + }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + }; +} + +describe('workflow validation', () => { + test('accepts a simple start-agent-end workflow', () => { + expect(validateWorkflowDefinition(createWorkflow())).toEqual([]); + }); + + test('rejects unsupported node kinds in phase 1 backend', () => { + const workflow = createWorkflow(); + workflow.graph.nodes.splice(1, 0, { + id: 'request-port', + kind: 'request-port', + label: 'Approval', + position: { x: 100, y: 0 }, + config: { + kind: 'request-port', + portId: 'approval', + requestType: 'Question', + responseType: 'Answer', + }, + }); + + expect(validateWorkflowDefinition(workflow).some((issue) => + issue.message.includes('not executable yet'))).toBe(true); + }); + + test('builds a synthetic execution pattern from workflow agents', () => { + const pattern = buildWorkflowExecutionPattern(createWorkflow()); + + expect(pattern.id).toBe('workflow-1'); + expect(pattern.agents).toHaveLength(1); + expect(pattern.agents[0]?.name).toBe('Primary Agent'); + expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']); + }); +});