mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 21:33:58 +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>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Contracts;
|
namespace Aryx.AgentHost.Contracts;
|
||||||
@@ -66,6 +67,125 @@ public sealed class PatternDefinitionDto
|
|||||||
public string UpdatedAt { get; init; } = string.Empty;
|
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 sealed class ApprovalPolicyDto
|
||||||
{
|
{
|
||||||
public IReadOnlyList<ApprovalCheckpointRuleDto> Rules { get; init; } = [];
|
public IReadOnlyList<ApprovalCheckpointRuleDto> Rules { get; init; } = [];
|
||||||
@@ -105,6 +225,15 @@ public sealed class PatternValidationIssueDto
|
|||||||
public string Message { get; init; } = string.Empty;
|
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 sealed class SidecarModeCapabilityDto
|
||||||
{
|
{
|
||||||
public bool Available { get; init; }
|
public bool Available { get; init; }
|
||||||
@@ -177,6 +306,11 @@ public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
|
|||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
|
||||||
|
{
|
||||||
|
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||||
{
|
{
|
||||||
public string SessionId { get; init; } = string.Empty;
|
public string SessionId { get; init; } = string.Empty;
|
||||||
@@ -186,6 +320,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
|||||||
public string MessageMode { get; init; } = "enqueue";
|
public string MessageMode { get; init; } = "enqueue";
|
||||||
public string? ProjectInstructions { get; init; }
|
public string? ProjectInstructions { get; init; }
|
||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||||
|
public WorkflowDefinitionDto? Workflow { get; init; }
|
||||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||||
@@ -330,6 +465,11 @@ public sealed class PatternValidationEventDto : SidecarEventDto
|
|||||||
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
|
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class WorkflowValidationEventDto : SidecarEventDto
|
||||||
|
{
|
||||||
|
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TurnDeltaEventDto : SidecarEventDto
|
public sealed class TurnDeltaEventDto : SidecarEventDto
|
||||||
{
|
{
|
||||||
public string SessionId { get; init; } = string.Empty;
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
{
|
{
|
||||||
private const string HandoffFunctionPrefix = "handoff_to_";
|
private const string HandoffFunctionPrefix = "handoff_to_";
|
||||||
private readonly PatternValidator _patternValidator;
|
private readonly PatternValidator _patternValidator;
|
||||||
|
private readonly WorkflowValidator _workflowValidator;
|
||||||
|
private readonly WorkflowRunner _workflowRunner = new();
|
||||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||||
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||||
|
|
||||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
public CopilotWorkflowRunner(PatternValidator patternValidator, WorkflowValidator? workflowValidator = null)
|
||||||
{
|
{
|
||||||
_patternValidator = patternValidator;
|
_patternValidator = patternValidator;
|
||||||
|
_workflowValidator = workflowValidator ?? new WorkflowValidator();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||||
@@ -32,10 +35,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||||
CancellationToken cancellationToken)
|
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)
|
if (validationError is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(validationError.Message);
|
throw new InvalidOperationException(validationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
@@ -79,7 +84,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
},
|
},
|
||||||
runCancellation.Token);
|
runCancellation.Token);
|
||||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
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();
|
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||||
|
|
||||||
@@ -145,6 +152,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(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);
|
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 DescribeCapabilitiesCommandType = "describe-capabilities";
|
||||||
private const string ValidatePatternCommandType = "validate-pattern";
|
private const string ValidatePatternCommandType = "validate-pattern";
|
||||||
|
private const string ValidateWorkflowCommandType = "validate-workflow";
|
||||||
private const string RunTurnCommandType = "run-turn";
|
private const string RunTurnCommandType = "run-turn";
|
||||||
private const string CancelTurnCommandType = "cancel-turn";
|
private const string CancelTurnCommandType = "cancel-turn";
|
||||||
private const string ResolveApprovalCommandType = "resolve-approval";
|
private const string ResolveApprovalCommandType = "resolve-approval";
|
||||||
@@ -42,6 +43,7 @@ public sealed class SidecarProtocolHost
|
|||||||
|
|
||||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||||
private readonly PatternValidator _patternValidator;
|
private readonly PatternValidator _patternValidator;
|
||||||
|
private readonly WorkflowValidator _workflowValidator;
|
||||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||||
private readonly ICopilotSessionManager _sessionManager;
|
private readonly ICopilotSessionManager _sessionManager;
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
@@ -53,7 +55,7 @@ public sealed class SidecarProtocolHost
|
|||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
public SidecarProtocolHost()
|
public SidecarProtocolHost()
|
||||||
: this(new PatternValidator())
|
: this(new PatternValidator(), new WorkflowValidator())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +64,20 @@ public sealed class SidecarProtocolHost
|
|||||||
ITurnWorkflowRunner? workflowRunner = null,
|
ITurnWorkflowRunner? workflowRunner = null,
|
||||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||||
ICopilotSessionManager? sessionManager = 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;
|
_patternValidator = patternValidator;
|
||||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
_workflowValidator = workflowValidator;
|
||||||
|
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator, _workflowValidator);
|
||||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||||
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
||||||
_jsonOptions = JsonSerialization.CreateWebOptions();
|
_jsonOptions = JsonSerialization.CreateWebOptions();
|
||||||
@@ -74,6 +87,7 @@ public sealed class SidecarProtocolHost
|
|||||||
{
|
{
|
||||||
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
||||||
[ValidatePatternCommandType] = HandleValidatePatternAsync,
|
[ValidatePatternCommandType] = HandleValidatePatternAsync,
|
||||||
|
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
|
||||||
[RunTurnCommandType] = HandleRunTurnAsync,
|
[RunTurnCommandType] = HandleRunTurnAsync,
|
||||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||||
@@ -180,6 +194,18 @@ public sealed class SidecarProtocolHost
|
|||||||
}, context.CancellationToken).ConfigureAwait(false);
|
}, 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)
|
private async Task HandleRunTurnAsync(CommandContext context)
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = DeserializeCommand<RunTurnCommandDto>(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">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<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]
|
[Fact]
|
||||||
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
|
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
|
||||||
{
|
{
|
||||||
|
|||||||
+153
-22
@@ -36,6 +36,12 @@ import {
|
|||||||
type ReasoningEffort,
|
type ReasoningEffort,
|
||||||
validatePatternDefinition,
|
validatePatternDefinition,
|
||||||
} from '@shared/domain/pattern';
|
} from '@shared/domain/pattern';
|
||||||
|
import {
|
||||||
|
buildWorkflowExecutionPattern,
|
||||||
|
normalizeWorkflowDefinition,
|
||||||
|
validateWorkflowDefinition,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceAgentDefinition,
|
normalizeWorkspaceAgentDefinition,
|
||||||
resolvePatternAgents,
|
resolvePatternAgents,
|
||||||
@@ -623,6 +629,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
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> {
|
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const pattern = this.requirePattern(workspace, patternId);
|
const pattern = this.requirePattern(workspace, patternId);
|
||||||
@@ -726,6 +758,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
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> {
|
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||||
@@ -881,6 +924,41 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace.sessions.unshift(session);
|
workspace.sessions.unshift(session);
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.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;
|
workspace.selectedSessionId = session.id;
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
@@ -993,7 +1071,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
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(
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||||
project,
|
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.');
|
throw new Error('Regenerated session is missing the user message needed to replay the turn.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.runPreparedSessionTurn(workspace, regeneratedSession, project, effectivePattern, projectInstructions, {
|
await this.runPreparedSessionTurn(
|
||||||
occurredAt,
|
workspace,
|
||||||
requestId: createId('turn'),
|
regeneratedSession,
|
||||||
triggerMessageId: triggerMessage.id,
|
project,
|
||||||
});
|
effectivePattern,
|
||||||
|
projectInstructions,
|
||||||
|
{
|
||||||
|
occurredAt,
|
||||||
|
requestId: createId('turn'),
|
||||||
|
triggerMessageId: triggerMessage.id,
|
||||||
|
},
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async editAndResendSessionMessage(
|
async editAndResendSessionMessage(
|
||||||
@@ -1052,7 +1138,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
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(
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||||
project,
|
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.');
|
throw new Error('Edited session is missing the user message needed to replay the turn.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.runPreparedSessionTurn(workspace, editedSession, project, effectivePattern, projectInstructions, {
|
await this.runPreparedSessionTurn(
|
||||||
occurredAt,
|
workspace,
|
||||||
requestId: createId('turn'),
|
editedSession,
|
||||||
triggerMessageId: triggerMessage.id,
|
project,
|
||||||
});
|
effectivePattern,
|
||||||
|
projectInstructions,
|
||||||
|
{
|
||||||
|
occurredAt,
|
||||||
|
requestId: createId('turn'),
|
||||||
|
triggerMessageId: triggerMessage.id,
|
||||||
|
},
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSessionMessage(
|
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.');
|
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 project = this.requireProject(workspace, session.projectId);
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
@@ -1135,13 +1229,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
promptInvocation: normalizedPromptInvocation,
|
promptInvocation: normalizedPromptInvocation,
|
||||||
});
|
});
|
||||||
await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, {
|
await this.runPreparedSessionTurn(
|
||||||
occurredAt,
|
workspace,
|
||||||
requestId,
|
session,
|
||||||
triggerMessageId: userMessageId,
|
project,
|
||||||
messageMode,
|
effectivePattern,
|
||||||
attachments,
|
projectInstructions,
|
||||||
});
|
{
|
||||||
|
occurredAt,
|
||||||
|
requestId,
|
||||||
|
triggerMessageId: userMessageId,
|
||||||
|
messageMode,
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelSessionTurn(sessionId: string): Promise<void> {
|
async cancelSessionTurn(sessionId: string): Promise<void> {
|
||||||
@@ -1328,7 +1430,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
|
||||||
const effectivePattern = normalizePatternModels(pattern, modelCatalog);
|
const effectivePattern = normalizePatternModels(pattern, modelCatalog);
|
||||||
|
|
||||||
if (effectivePattern.agents.length !== 1) {
|
if (effectivePattern.agents.length !== 1) {
|
||||||
@@ -1497,6 +1599,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messageMode?: MessageMode;
|
messageMode?: MessageMode;
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
},
|
},
|
||||||
|
effectiveWorkflow?: WorkflowDefinition,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||||
@@ -1529,6 +1632,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workingDirectory: runWorkingDirectory,
|
workingDirectory: runWorkingDirectory,
|
||||||
workspaceKind,
|
workspaceKind,
|
||||||
pattern: patternForTurn,
|
pattern: patternForTurn,
|
||||||
|
workflow: effectiveWorkflow ? { id: effectiveWorkflow.id, name: effectiveWorkflow.name } : undefined,
|
||||||
triggerMessageId,
|
triggerMessageId,
|
||||||
startedAt: occurredAt,
|
startedAt: occurredAt,
|
||||||
preRunGitSnapshot,
|
preRunGitSnapshot,
|
||||||
@@ -1558,6 +1662,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messageMode,
|
messageMode,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
pattern: patternForTurn,
|
pattern: patternForTurn,
|
||||||
|
workflow: effectiveWorkflow,
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
promptInvocation,
|
promptInvocation,
|
||||||
@@ -2126,6 +2231,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return pattern;
|
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 {
|
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
||||||
const session = workspace.sessions.find((current) => current.id === sessionId);
|
const session = workspace.sessions.find((current) => current.id === sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -2335,7 +2466,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messages: ChatMessageRecord[],
|
messages: ChatMessageRecord[],
|
||||||
): void {
|
): void {
|
||||||
const session = this.requireSession(workspace, sessionId);
|
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));
|
const incomingIds = new Set(messages.map((message) => message.id));
|
||||||
|
|
||||||
// Messages that were streamed during the turn already exist in session.messages
|
// Messages that were streamed during the turn already exist in session.messages
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BranchSessionInput,
|
BranchSessionInput,
|
||||||
CancelSessionTurnInput,
|
CancelSessionTurnInput,
|
||||||
CreateSessionInput,
|
CreateSessionInput,
|
||||||
|
CreateWorkflowSessionInput,
|
||||||
CreateProjectGitBranchInput,
|
CreateProjectGitBranchInput,
|
||||||
DismissSessionMcpAuthInput,
|
DismissSessionMcpAuthInput,
|
||||||
DismissSessionPlanReviewInput,
|
DismissSessionPlanReviewInput,
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
SaveLspProfileInput,
|
SaveLspProfileInput,
|
||||||
SaveMcpServerInput,
|
SaveMcpServerInput,
|
||||||
SavePatternInput,
|
SavePatternInput,
|
||||||
|
SaveWorkflowInput,
|
||||||
SaveWorkspaceAgentInput,
|
SaveWorkspaceAgentInput,
|
||||||
SendSessionMessageInput,
|
SendSessionMessageInput,
|
||||||
SetPatternFavoriteInput,
|
SetPatternFavoriteInput,
|
||||||
@@ -112,6 +114,8 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
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) => {
|
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||||
const result = await service.setTheme(theme);
|
const result = await service.setTheme(theme);
|
||||||
applyTitleBarTheme(window, theme);
|
applyTitleBarTheme(window, theme);
|
||||||
@@ -180,6 +184,9 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||||
service.createSession(input.projectId, input.patternId),
|
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) =>
|
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
||||||
service.duplicateSession(input.sessionId),
|
service.duplicateSession(input.sessionId),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
normalizeSessionToolingSelection,
|
normalizeSessionToolingSelection,
|
||||||
normalizeWorkspaceSettings,
|
normalizeWorkspaceSettings,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
|
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
applyDefaultToolApprovalPolicy,
|
applyDefaultToolApprovalPolicy,
|
||||||
normalizePendingApprovalState,
|
normalizePendingApprovalState,
|
||||||
@@ -120,6 +121,7 @@ export class WorkspaceRepository {
|
|||||||
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
|
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
|
||||||
graph: resolvePatternGraph(pattern),
|
graph: resolvePatternGraph(pattern),
|
||||||
})),
|
})),
|
||||||
|
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
|
||||||
projects,
|
projects,
|
||||||
sessions,
|
sessions,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
McpOauthRequiredEvent,
|
McpOauthRequiredEvent,
|
||||||
ExitPlanModeRequestedEvent,
|
ExitPlanModeRequestedEvent,
|
||||||
ValidatePatternCommand,
|
ValidatePatternCommand,
|
||||||
|
ValidateWorkflowCommand,
|
||||||
RunTurnCommand,
|
RunTurnCommand,
|
||||||
CopilotSessionListFilter,
|
CopilotSessionListFilter,
|
||||||
CopilotSessionInfo,
|
CopilotSessionInfo,
|
||||||
@@ -46,6 +47,12 @@ type PendingCommand =
|
|||||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||||
reject: (error: Error) => 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;
|
processId: number;
|
||||||
kind: 'resolve-approval';
|
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(
|
async runTurn(
|
||||||
command: RunTurnCommand,
|
command: RunTurnCommand,
|
||||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||||
@@ -317,6 +332,13 @@ export class SidecarClient {
|
|||||||
resolve: resolve as (issues: unknown) => void,
|
resolve: resolve as (issues: unknown) => void,
|
||||||
reject,
|
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') {
|
} else if (command.type === 'resolve-approval') {
|
||||||
this.pending.set(command.requestId, {
|
this.pending.set(command.requestId, {
|
||||||
processId: state.id,
|
processId: state.id,
|
||||||
@@ -413,6 +435,12 @@ export class SidecarClient {
|
|||||||
this.pending.delete(event.requestId);
|
this.pending.delete(event.requestId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
case 'workflow-validation':
|
||||||
|
if (pending.kind === 'validate-workflow') {
|
||||||
|
pending.resolve(event.issues);
|
||||||
|
this.pending.delete(event.requestId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
case 'turn-delta':
|
case 'turn-delta':
|
||||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event));
|
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event));
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const api: ElectronApi = {
|
|||||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
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),
|
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
||||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||||
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const ipcChannels = {
|
|||||||
savePattern: 'patterns:save',
|
savePattern: 'patterns:save',
|
||||||
deletePattern: 'patterns:delete',
|
deletePattern: 'patterns:delete',
|
||||||
setPatternFavorite: 'patterns:set-favorite',
|
setPatternFavorite: 'patterns:set-favorite',
|
||||||
|
saveWorkflow: 'workflows:save',
|
||||||
|
deleteWorkflow: 'workflows:delete',
|
||||||
|
createWorkflowSession: 'workflows:create-session',
|
||||||
setTheme: 'settings:set-theme',
|
setTheme: 'settings:set-theme',
|
||||||
setTerminalHeight: 'settings:set-terminal-height',
|
setTerminalHeight: 'settings:set-terminal-height',
|
||||||
setNotificationsEnabled: 'settings:set-notifications-enabled',
|
setNotificationsEnabled: 'settings:set-notifications-enabled',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||||
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type {
|
import type {
|
||||||
ProjectGitBranchSummary,
|
ProjectGitBranchSummary,
|
||||||
ProjectGitCommitMessageSuggestion,
|
ProjectGitCommitMessageSuggestion,
|
||||||
@@ -28,10 +29,19 @@ export interface CreateSessionInput {
|
|||||||
patternId: string;
|
patternId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateWorkflowSessionInput {
|
||||||
|
projectId: string;
|
||||||
|
workflowId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SavePatternInput {
|
export interface SavePatternInput {
|
||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveWorkflowInput {
|
||||||
|
workflow: WorkflowDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendSessionMessageInput {
|
export interface SendSessionMessageInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -274,6 +284,8 @@ export interface ElectronApi {
|
|||||||
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
|
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
|
||||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||||
|
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||||
|
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||||
@@ -283,6 +295,7 @@ export interface ElectronApi {
|
|||||||
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
||||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||||
|
createWorkflowSession(input: CreateWorkflowSessionInput): Promise<WorkspaceState>;
|
||||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||||
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||||
setSessionMessagePinned(input: SetSessionMessagePinnedInput): 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 { RuntimeToolDefinition } from '@shared/domain/tooling';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||||
|
import type { WorkflowDefinition, WorkflowValidationIssue } from '@shared/domain/workflow';
|
||||||
|
|
||||||
export interface SidecarModeCapability {
|
export interface SidecarModeCapability {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -70,6 +71,12 @@ export interface ValidatePatternCommand {
|
|||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidateWorkflowCommand {
|
||||||
|
type: 'validate-workflow';
|
||||||
|
requestId: string;
|
||||||
|
workflow: WorkflowDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
export type InteractionMode = 'interactive' | 'plan';
|
export type InteractionMode = 'interactive' | 'plan';
|
||||||
export type MessageMode = 'enqueue' | 'immediate';
|
export type MessageMode = 'enqueue' | 'immediate';
|
||||||
|
|
||||||
@@ -89,6 +96,7 @@ export interface RunTurnCommand {
|
|||||||
messageMode?: MessageMode;
|
messageMode?: MessageMode;
|
||||||
projectInstructions?: string;
|
projectInstructions?: string;
|
||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
|
workflow?: WorkflowDefinition;
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
promptInvocation?: ProjectPromptInvocation;
|
promptInvocation?: ProjectPromptInvocation;
|
||||||
@@ -147,6 +155,7 @@ export interface CopilotSessionListFilter {
|
|||||||
export type SidecarCommand =
|
export type SidecarCommand =
|
||||||
| DescribeCapabilitiesCommand
|
| DescribeCapabilitiesCommand
|
||||||
| ValidatePatternCommand
|
| ValidatePatternCommand
|
||||||
|
| ValidateWorkflowCommand
|
||||||
| RunTurnCommand
|
| RunTurnCommand
|
||||||
| CancelTurnCommand
|
| CancelTurnCommand
|
||||||
| ResolveApprovalCommand
|
| ResolveApprovalCommand
|
||||||
@@ -230,6 +239,12 @@ export interface PatternValidationEvent {
|
|||||||
issues: PatternValidationIssue[];
|
issues: PatternValidationIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkflowValidationEvent {
|
||||||
|
type: 'workflow-validation';
|
||||||
|
requestId: string;
|
||||||
|
issues: WorkflowValidationIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnDeltaEvent {
|
export interface TurnDeltaEvent {
|
||||||
type: 'turn-delta';
|
type: 'turn-delta';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -592,6 +607,7 @@ export interface CommandCompleteEvent {
|
|||||||
export type SidecarEvent =
|
export type SidecarEvent =
|
||||||
| CapabilitiesEvent
|
| CapabilitiesEvent
|
||||||
| PatternValidationEvent
|
| PatternValidationEvent
|
||||||
|
| WorkflowValidationEvent
|
||||||
| TurnDeltaEvent
|
| TurnDeltaEvent
|
||||||
| TurnCompleteEvent
|
| TurnCompleteEvent
|
||||||
| MessageReclassifiedEvent
|
| MessageReclassifiedEvent
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
ProjectGitWorkingTreeSnapshot,
|
ProjectGitWorkingTreeSnapshot,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
} from '@shared/domain/project';
|
} from '@shared/domain/project';
|
||||||
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { createId } from '@shared/utils/ids';
|
import { createId } from '@shared/utils/ids';
|
||||||
|
|
||||||
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
|
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
|
||||||
@@ -76,6 +77,8 @@ export interface SessionRunRecord {
|
|||||||
patternId: string;
|
patternId: string;
|
||||||
patternName: string;
|
patternName: string;
|
||||||
patternMode: PatternDefinition['mode'];
|
patternMode: PatternDefinition['mode'];
|
||||||
|
workflowId?: string;
|
||||||
|
workflowName?: string;
|
||||||
triggerMessageId: string;
|
triggerMessageId: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
@@ -93,6 +96,7 @@ export interface CreateSessionRunRecordInput {
|
|||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
workspaceKind: SessionRunWorkspaceKind;
|
workspaceKind: SessionRunWorkspaceKind;
|
||||||
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
||||||
|
workflow?: Pick<WorkflowDefinition, 'id' | 'name'>;
|
||||||
triggerMessageId: string;
|
triggerMessageId: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||||
@@ -610,6 +614,8 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
|||||||
patternId: input.pattern.id,
|
patternId: input.pattern.id,
|
||||||
patternName: input.pattern.name,
|
patternName: input.pattern.name,
|
||||||
patternMode: input.pattern.mode,
|
patternMode: input.pattern.mode,
|
||||||
|
workflowId: normalizeOptionalString(input.workflow?.id),
|
||||||
|
workflowName: normalizeOptionalString(input.workflow?.name),
|
||||||
triggerMessageId: input.triggerMessageId,
|
triggerMessageId: input.triggerMessageId,
|
||||||
startedAt: input.startedAt,
|
startedAt: input.startedAt,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
@@ -655,6 +661,8 @@ export function normalizeSessionRunRecords(
|
|||||||
const workingDirectory = normalizeOptionalString(run.workingDirectory);
|
const workingDirectory = normalizeOptionalString(run.workingDirectory);
|
||||||
const patternId = normalizeOptionalString(run.patternId);
|
const patternId = normalizeOptionalString(run.patternId);
|
||||||
const patternName = normalizeOptionalString(run.patternName);
|
const patternName = normalizeOptionalString(run.patternName);
|
||||||
|
const workflowId = normalizeOptionalString(run.workflowId);
|
||||||
|
const workflowName = normalizeOptionalString(run.workflowName);
|
||||||
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
||||||
const startedAt = normalizeOptionalString(run.startedAt);
|
const startedAt = normalizeOptionalString(run.startedAt);
|
||||||
if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) {
|
if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) {
|
||||||
@@ -672,6 +680,8 @@ export function normalizeSessionRunRecords(
|
|||||||
patternId,
|
patternId,
|
||||||
patternName,
|
patternName,
|
||||||
patternMode: run.patternMode,
|
patternMode: run.patternMode,
|
||||||
|
workflowId,
|
||||||
|
workflowName,
|
||||||
triggerMessageId,
|
triggerMessageId,
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt: normalizeOptionalString(run.completedAt),
|
completedAt: normalizeOptionalString(run.completedAt),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface SessionRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
patternId: string;
|
patternId: string;
|
||||||
|
workflowId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
titleSource?: SessionTitleSource;
|
titleSource?: SessionTitleSource;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SessionStatus,
|
type SessionStatus,
|
||||||
} from '@shared/domain/session';
|
} from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import { buildWorkflowExecutionPattern } from '@shared/domain/workflow';
|
||||||
|
|
||||||
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
|
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
|
||||||
export type SessionWorkspaceKind = 'project' | 'scratchpad';
|
export type SessionWorkspaceKind = 'project' | 'scratchpad';
|
||||||
@@ -456,6 +457,7 @@ export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[
|
|||||||
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
||||||
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
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 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);
|
const searchTokens = tokenizeSearchText(input.searchText);
|
||||||
|
|
||||||
return workspace.sessions
|
return workspace.sessions
|
||||||
@@ -466,7 +468,17 @@ export function querySessions(workspace: WorkspaceState, input: QuerySessionsInp
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchMatch = resolveSearchMatch(
|
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,
|
searchTokens,
|
||||||
);
|
);
|
||||||
if (!searchMatch) {
|
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 { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
||||||
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
export interface WorkspaceState {
|
export interface WorkspaceState {
|
||||||
projects: ProjectRecord[];
|
projects: ProjectRecord[];
|
||||||
patterns: PatternDefinition[];
|
patterns: PatternDefinition[];
|
||||||
|
workflows: WorkflowDefinition[];
|
||||||
sessions: SessionRecord[];
|
sessions: SessionRecord[];
|
||||||
settings: WorkspaceSettings;
|
settings: WorkspaceSettings;
|
||||||
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
|
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
|
||||||
@@ -16,6 +18,7 @@ export interface WorkspaceState {
|
|||||||
mcpProbingServerIds?: string[];
|
mcpProbingServerIds?: string[];
|
||||||
selectedProjectId?: string;
|
selectedProjectId?: string;
|
||||||
selectedPatternId?: string;
|
selectedPatternId?: string;
|
||||||
|
selectedWorkflowId?: string;
|
||||||
selectedSessionId?: string;
|
selectedSessionId?: string;
|
||||||
lastUpdatedAt: string;
|
lastUpdatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -25,6 +28,7 @@ export function createWorkspaceSeed(): WorkspaceState {
|
|||||||
return {
|
return {
|
||||||
projects: [],
|
projects: [],
|
||||||
patterns: createBuiltinPatterns(timestamp),
|
patterns: createBuiltinPatterns(timestamp),
|
||||||
|
workflows: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
lastUpdatedAt: timestamp,
|
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 {
|
return {
|
||||||
projects: [],
|
projects: [],
|
||||||
patterns: [],
|
patterns: [],
|
||||||
|
workflows: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
||||||
return {
|
const workspace: WorkspaceState = {
|
||||||
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
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' })],
|
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
|
||||||
|
workflows: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
createSession(),
|
createSession(),
|
||||||
@@ -112,6 +113,11 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
|||||||
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
|
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
workflows: overrides?.workflows ?? workspace.workflows,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('session library helpers', () => {
|
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