mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
feat(workflows): Phase 1 backend — WorkflowDefinition model, persistence, IPC, sidecar execution
Introduce WorkflowDefinition as a first-class entity alongside PatternDefinition. Shared domain: - src/shared/domain/workflow.ts: new WorkflowDefinition, WorkflowGraph, WorkflowNode/Edge/Config types, normalization, TS-side validation, and buildWorkflowExecutionPattern (synthetic pattern bridge for session plumbing) - workspace.ts: add workflows[] and selectedWorkflowId - session.ts: add optional workflowId to SessionRecord - runTimeline.ts: add optional workflowId/workflowName to run records - sessionLibrary.ts: workflow-aware session search IPC / main process: - contracts/channels.ts, contracts/ipc.ts: workflow CRUD channels - preload/index.ts: expose saveWorkflow, deleteWorkflow, createWorkflowSession - ipc/registerIpcHandlers.ts: register workflow handlers - persistence/workspaceRepository.ts: load/save/normalize workflows - AryxAppService.ts: saveWorkflow, deleteWorkflow, createWorkflowSession, resolveSessionExecutionDefinition, workflow-aware turn/run plumbing Sidecar protocol: - contracts/sidecar.ts: ValidateWorkflowCommand, WorkflowValidationEvent, optional workflow on RunTurnCommand - sidecarProcess.ts: validate-workflow command dispatch and event handling - Contracts/ProtocolModels.cs: workflow DTOs and validate-workflow DTOs - Services/SidecarProtocolHost.cs: validate-workflow command handler - Services/CopilotWorkflowRunner.cs: workflow-aware build and checkpoint path - Services/WorkflowValidator.cs: graph validation (connectivity, start/end, fan-out/in arity, path reachability) - Services/WorkflowRunner.cs: WorkflowDefinitionDto -> Agent Framework Workflow builder (direct, fan-out, fan-in edges; agent executor binding) Project: bump both csproj from net9.0 to net10.0 Tests: - tests/shared/workflow.test.ts: TS workflow validation and pattern synthesis - tests/main/appServiceWorkflow.test.ts: saveWorkflow / createWorkflowSession - tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs: ValidateWorkflowCommand - Fix two stale workspace fixtures missing workflows field Validation: tsc clean, bun test 367/367 pass, dotnet test 244/244 pass, bun run build succeeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
|
||||
@@ -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<string, JsonElement>? 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<WorkflowConditionRuleDto> 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<WorkflowNodeDto> Nodes { get; init; } = [];
|
||||
public IReadOnlyList<WorkflowEdgeDto> 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<string, JsonElement>? 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<WorkflowStateScopeDto> 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<ApprovalCheckpointRuleDto> 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<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
@@ -330,6 +465,11 @@ public sealed class PatternValidationEventDto : SidecarEventDto
|
||||
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class WorkflowValidationEventDto : SidecarEventDto
|
||||
{
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class TurnDeltaEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
|
||||
@@ -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<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
@@ -32,10 +35,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
Func<ExitPlanModeRequestedEventDto, Task> 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<ChatMessage> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CancellationToken, Task<SidecarCapabilitiesDto>> _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<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
ICopilotSessionManager? sessionManager = null)
|
||||
: this(patternValidator, new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
|
||||
{
|
||||
}
|
||||
|
||||
public SidecarProtocolHost(
|
||||
PatternValidator patternValidator,
|
||||
WorkflowValidator workflowValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? 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<ValidateWorkflowCommandDto>(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<RunTurnCommandDto>(context);
|
||||
|
||||
@@ -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<AIAgent> 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<string, AIAgent> agentMap = patternDefinition.Agents
|
||||
.Zip(agents, (definition, agent) => (definition.Id, agent))
|
||||
.ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal);
|
||||
|
||||
Dictionary<string, ExecutorBinding> 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<string, WorkflowEdgeDto> 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<string, WorkflowEdgeDto> 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<string, AIAgent> 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class WorkflowValidator
|
||||
{
|
||||
private static readonly HashSet<string> ExecutableNodeKinds = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"start",
|
||||
"end",
|
||||
"agent",
|
||||
};
|
||||
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Validate(WorkflowDefinitionDto workflow)
|
||||
{
|
||||
List<WorkflowValidationIssueDto> 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<string, WorkflowNodeDto> nodesById = new(StringComparer.Ordinal);
|
||||
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> incomingCounts = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> 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<WorkflowNodeDto> startNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> agentNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
.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<string, WorkflowEdgeDto> 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<string, WorkflowEdgeDto> 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<string> endNodeIds)
|
||||
{
|
||||
HashSet<string> endSet = endNodeIds.ToHashSet(StringComparer.Ordinal);
|
||||
Dictionary<string, List<string>> outgoing = graph.Edges
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(edge => edge.Target).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Queue<string> queue = new([startNodeId]);
|
||||
HashSet<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -108,6 +108,76 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
|
||||
{
|
||||
IReadOnlyList<JsonElement> 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()
|
||||
{
|
||||
|
||||
+153
-22
@@ -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<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async saveWorkflow(workflow: WorkflowDefinition): Promise<WorkspaceState> {
|
||||
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<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const pattern = this.requirePattern(workspace, patternId);
|
||||
@@ -726,6 +758,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
||||
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<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||
@@ -881,6 +924,41 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<WorkspaceState> {
|
||||
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<AppServiceEvents> {
|
||||
}
|
||||
|
||||
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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
}
|
||||
|
||||
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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
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<void> {
|
||||
@@ -1328,7 +1430,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
messageMode?: MessageMode;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
},
|
||||
effectiveWorkflow?: WorkflowDefinition,
|
||||
): Promise<void> {
|
||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||
@@ -1529,6 +1632,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: patternForTurn,
|
||||
workflow: effectiveWorkflow,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
promptInvocation,
|
||||
@@ -2126,6 +2231,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown> {
|
||||
return this.dispatch<unknown>({
|
||||
type: 'validate-workflow',
|
||||
requestId: `validate-workflow-${Date.now()}`,
|
||||
workflow,
|
||||
});
|
||||
}
|
||||
|
||||
async runTurn(
|
||||
command: RunTurnCommand,
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<WorkspaceState>;
|
||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||
@@ -283,6 +295,7 @@ export interface ElectronApi {
|
||||
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||
createWorkflowSession(input: CreateWorkflowSessionInput): Promise<WorkspaceState>;
|
||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||
setSessionMessagePinned(input: SetSessionMessagePinnedInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
||||
workflow?: Pick<WorkflowDefinition, 'id' | 'name'>;
|
||||
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),
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface SessionRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
patternId: string;
|
||||
workflowId?: string;
|
||||
title: string;
|
||||
titleSource?: SessionTitleSource;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
||||
const patternsById = new Map<string, PatternDefinition>(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) {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<WorkflowNodeKind>(['start', 'end', 'agent']);
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizePosition(position?: Partial<WorkflowPosition>): 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>): WorkflowNodeConfig {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return {
|
||||
kind,
|
||||
inputType: normalizeOptionalString((config as Partial<StartNodeConfig> | undefined)?.inputType),
|
||||
};
|
||||
case 'end':
|
||||
return {
|
||||
kind,
|
||||
outputType: normalizeOptionalString((config as Partial<EndNodeConfig> | undefined)?.outputType),
|
||||
};
|
||||
case 'agent': {
|
||||
const agent = config as Partial<AgentNodeConfig> | 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<CodeExecutorConfig> | undefined;
|
||||
return {
|
||||
kind,
|
||||
inputType: normalizeOptionalString(value?.inputType),
|
||||
outputType: normalizeOptionalString(value?.outputType),
|
||||
implementation: value?.implementation?.trim(),
|
||||
};
|
||||
}
|
||||
case 'function-executor': {
|
||||
const value = config as Partial<FunctionExecutorConfig> | undefined;
|
||||
return {
|
||||
kind,
|
||||
functionRef: normalizeOptionalString(value?.functionRef) ?? '',
|
||||
parameters: value?.parameters,
|
||||
};
|
||||
}
|
||||
case 'sub-workflow': {
|
||||
const value = config as Partial<SubWorkflowConfig> | undefined;
|
||||
return {
|
||||
kind,
|
||||
workflowId: normalizeOptionalString(value?.workflowId),
|
||||
inlineWorkflow: value?.inlineWorkflow ? normalizeWorkflowDefinition(value.inlineWorkflow) : undefined,
|
||||
};
|
||||
}
|
||||
case 'request-port': {
|
||||
const value = config as Partial<RequestPortConfig> | 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<string>): boolean {
|
||||
const outgoing = new Map<string, WorkflowEdge[]>();
|
||||
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<string>();
|
||||
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<string, WorkflowNode>();
|
||||
const edgeIds = new Set<string>();
|
||||
const incomingCounts = new Map<string, number>();
|
||||
const outgoingCounts = new Map<string, number>();
|
||||
|
||||
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<string, WorkflowEdge[]>();
|
||||
const fanInByTarget = new Map<string, WorkflowEdge[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 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');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ describe('session workspace helpers', () => {
|
||||
return {
|
||||
projects: [],
|
||||
patterns: [],
|
||||
workflows: [],
|
||||
settings: createWorkspaceSettings(),
|
||||
sessions: [
|
||||
{
|
||||
|
||||
@@ -73,9 +73,10 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
}
|
||||
|
||||
function createWorkspace(overrides?: Partial<WorkspaceState>): 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>): WorkspaceState {
|
||||
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
...workspace,
|
||||
workflows: overrides?.workflows ?? workspace.workflows,
|
||||
};
|
||||
}
|
||||
|
||||
describe('session library helpers', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user