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:
David Kaya
2026-04-05 17:29:03 +02:00
co-authored by Copilot
parent 4c3198a550
commit 19a764d297
25 changed files with 1720 additions and 32 deletions
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Aryx.AgentHost.Contracts;
@@ -66,6 +67,125 @@ public sealed class PatternDefinitionDto
public string UpdatedAt { get; init; } = string.Empty;
}
public sealed class WorkflowPositionDto
{
public double X { get; init; }
public double Y { get; init; }
}
public sealed class WorkflowNodeConfigDto
{
public string Kind { get; init; } = string.Empty;
public string? InputType { get; init; }
public string? OutputType { get; init; }
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Instructions { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string? ReasoningEffort { get; init; }
public PatternAgentCopilotConfigDto? Copilot { get; init; }
public string? WorkspaceAgentId { get; init; }
public string? Implementation { get; init; }
public string? FunctionRef { get; init; }
public IReadOnlyDictionary<string, JsonElement>? Parameters { get; init; }
public string? WorkflowId { get; init; }
public WorkflowDefinitionDto? InlineWorkflow { get; init; }
public string? PortId { get; init; }
public string? RequestType { get; init; }
public string? ResponseType { get; init; }
public string? Prompt { get; init; }
}
public sealed class WorkflowNodeDto
{
public string Id { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public WorkflowPositionDto Position { get; init; } = new();
public int? Order { get; init; }
public WorkflowNodeConfigDto Config { get; init; } = new();
}
public sealed class WorkflowConditionRuleDto
{
public string PropertyPath { get; init; } = string.Empty;
public string Operator { get; init; } = string.Empty;
public string Value { get; init; } = string.Empty;
}
public sealed class EdgeConditionDto
{
public string Type { get; init; } = string.Empty;
public string? TypeName { get; init; }
public string? Expression { get; init; }
public string? Combinator { get; init; }
public IReadOnlyList<WorkflowConditionRuleDto> Rules { get; init; } = [];
}
public sealed class FanOutConfigDto
{
public string Strategy { get; init; } = "broadcast";
public string? PartitionExpression { get; init; }
}
public sealed class WorkflowEdgeDto
{
public string Id { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public string Target { get; init; } = string.Empty;
public string Kind { get; init; } = "direct";
public EdgeConditionDto? Condition { get; init; }
public string? Label { get; init; }
public FanOutConfigDto? FanOutConfig { get; init; }
}
public sealed class WorkflowGraphDto
{
public IReadOnlyList<WorkflowNodeDto> Nodes { get; init; } = [];
public IReadOnlyList<WorkflowEdgeDto> Edges { get; init; } = [];
}
public sealed class WorkflowCheckpointSettingsDto
{
public bool Enabled { get; init; }
}
public sealed class WorkflowTelemetrySettingsDto
{
public bool? OpenTelemetry { get; init; }
public bool? SensitiveData { get; init; }
}
public sealed class WorkflowStateScopeDto
{
public string Name { get; init; } = string.Empty;
public string? Description { get; init; }
public IReadOnlyDictionary<string, JsonElement>? InitialValues { get; init; }
}
public sealed class WorkflowSettingsDto
{
public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new();
public string ExecutionMode { get; init; } = "off-thread";
public int? MaxIterations { get; init; }
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
public IReadOnlyList<WorkflowStateScopeDto> StateScopes { get; init; } = [];
public WorkflowTelemetrySettingsDto? Telemetry { get; init; }
}
public sealed class WorkflowDefinitionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public bool? IsFavorite { get; init; }
public WorkflowGraphDto Graph { get; init; } = new();
public WorkflowSettingsDto Settings { get; init; } = new();
public string CreatedAt { get; init; } = string.Empty;
public string UpdatedAt { get; init; } = string.Empty;
}
public sealed class ApprovalPolicyDto
{
public IReadOnlyList<ApprovalCheckpointRuleDto> Rules { get; init; } = [];
@@ -105,6 +225,15 @@ public sealed class PatternValidationIssueDto
public string Message { get; init; } = string.Empty;
}
public sealed class WorkflowValidationIssueDto
{
public string Level { get; init; } = "error";
public string? Field { get; init; }
public string Message { get; init; } = string.Empty;
public string? NodeId { get; init; }
public string? EdgeId { get; init; }
}
public sealed class SidecarModeCapabilityDto
{
public bool Available { get; init; }
@@ -177,6 +306,11 @@ public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
public PatternDefinitionDto Pattern { get; init; } = new();
}
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
{
public WorkflowDefinitionDto Workflow { get; init; } = new();
}
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
{
public string SessionId { get; init; } = string.Empty;
@@ -186,6 +320,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
public string MessageMode { get; init; } = "enqueue";
public string? ProjectInstructions { get; init; }
public PatternDefinitionDto Pattern { get; init; } = new();
public WorkflowDefinitionDto? Workflow { get; init; }
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
public RunTurnToolingConfigDto? Tooling { get; init; }
@@ -330,6 +465,11 @@ public sealed class PatternValidationEventDto : SidecarEventDto
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
}
public sealed class WorkflowValidationEventDto : SidecarEventDto
{
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
}
public sealed class TurnDeltaEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
@@ -12,14 +12,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
private const string HandoffFunctionPrefix = "handoff_to_";
private readonly PatternValidator _patternValidator;
private readonly WorkflowValidator _workflowValidator;
private readonly WorkflowRunner _workflowRunner = new();
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
public CopilotWorkflowRunner(PatternValidator patternValidator)
public CopilotWorkflowRunner(PatternValidator patternValidator, WorkflowValidator? workflowValidator = null)
{
_patternValidator = patternValidator;
_workflowValidator = workflowValidator ?? new WorkflowValidator();
}
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
@@ -32,10 +35,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
string? validationError = command.Workflow is null
? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message
: _workflowValidator.Validate(command.Workflow).FirstOrDefault()?.Message;
if (validationError is not null)
{
throw new InvalidOperationException(validationError.Message);
throw new InvalidOperationException(validationError);
}
CopilotTurnExecutionState state = new(command);
@@ -79,7 +84,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
},
runCancellation.Token);
ConfigureHookLifecycleEventSuppression(state, bundle);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
Workflow workflow = command.Workflow is null
? bundle.BuildWorkflow(command.Pattern)
: _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
@@ -145,6 +152,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
{
ArgumentNullException.ThrowIfNull(command);
if (command.Workflow is not null)
{
return command.Workflow.Settings.Checkpointing.Enabled;
}
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase);
}
@@ -11,6 +11,7 @@ public sealed class SidecarProtocolHost
{
private const string DescribeCapabilitiesCommandType = "describe-capabilities";
private const string ValidatePatternCommandType = "validate-pattern";
private const string ValidateWorkflowCommandType = "validate-workflow";
private const string RunTurnCommandType = "run-turn";
private const string CancelTurnCommandType = "cancel-turn";
private const string ResolveApprovalCommandType = "resolve-approval";
@@ -42,6 +43,7 @@ public sealed class SidecarProtocolHost
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly PatternValidator _patternValidator;
private readonly WorkflowValidator _workflowValidator;
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions;
@@ -53,7 +55,7 @@ public sealed class SidecarProtocolHost
new(StringComparer.Ordinal);
public SidecarProtocolHost()
: this(new PatternValidator())
: this(new PatternValidator(), new WorkflowValidator())
{
}
@@ -62,9 +64,20 @@ public sealed class SidecarProtocolHost
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
: this(patternValidator, new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
{
}
public SidecarProtocolHost(
PatternValidator patternValidator,
WorkflowValidator workflowValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
{
_patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_workflowValidator = workflowValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator, _workflowValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_jsonOptions = JsonSerialization.CreateWebOptions();
@@ -74,6 +87,7 @@ public sealed class SidecarProtocolHost
{
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
[ValidatePatternCommandType] = HandleValidatePatternAsync,
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
[RunTurnCommandType] = HandleRunTurnAsync,
[CancelTurnCommandType] = HandleCancelTurnAsync,
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
@@ -180,6 +194,18 @@ public sealed class SidecarProtocolHost
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleValidateWorkflowAsync(CommandContext context)
{
ValidateWorkflowCommandDto command = DeserializeCommand<ValidateWorkflowCommandDto>(context);
await WriteAsync(context.Output, new WorkflowValidationEventDto
{
Type = "workflow-validation",
RequestId = context.Envelope.RequestId,
Issues = _workflowValidator.Validate(command.Workflow),
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleRunTurnAsync(CommandContext context)
{
RunTurnCommandDto command = DeserializeCommand<RunTurnCommandDto>(context);
@@ -0,0 +1,95 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized;
namespace Aryx.AgentHost.Services;
internal sealed class WorkflowRunner
{
public Workflow BuildWorkflow(
WorkflowDefinitionDto workflowDefinition,
PatternDefinitionDto patternDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(patternDefinition);
ArgumentNullException.ThrowIfNull(agents);
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase));
Dictionary<string, AIAgent> agentMap = patternDefinition.Agents
.Zip(agents, (definition, agent) => (definition.Id, agent))
.ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal);
Dictionary<string, ExecutorBinding> bindings = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
bindings[node.Id] = CreateExecutorBinding(node, agentMap);
}
WorkflowBuilder builder = new(bindings[startNode.Id]);
foreach (WorkflowEdgeDto edge in workflowDefinition.Graph.Edges.Where(edge =>
string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase)))
{
builder.AddEdge(bindings[edge.Source], bindings[edge.Target]);
}
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflowDefinition.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
{
builder.AddFanOutEdge(
bindings[fanOutGroup.Key],
fanOutGroup.Select(edge => bindings[edge.Target]).ToArray());
}
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflowDefinition.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
{
builder.AddFanInBarrierEdge(
fanInGroup.Select(edge => bindings[edge.Source]).ToArray(),
bindings[fanInGroup.Key]);
}
if (!string.IsNullOrWhiteSpace(workflowDefinition.Name))
{
builder = builder.WithName(workflowDefinition.Name);
}
return builder.WithOutputFrom(bindings[endNode.Id]).Build();
}
private static ExecutorBinding CreateExecutorBinding(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentMap)
{
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
{
return new ChatForwardingExecutor(node.Id);
}
if (string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
{
return new WorkflowOutputMessagesExecutor();
}
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
{
string agentId = !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
if (!agentMap.TryGetValue(agentId, out AIAgent? agent))
{
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
}
return agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions());
}
throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet.");
}
}
@@ -0,0 +1,292 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public sealed class WorkflowValidator
{
private static readonly HashSet<string> ExecutableNodeKinds = new(StringComparer.OrdinalIgnoreCase)
{
"start",
"end",
"agent",
};
public IReadOnlyList<WorkflowValidationIssueDto> Validate(WorkflowDefinitionDto workflow)
{
List<WorkflowValidationIssueDto> issues = [];
if (string.IsNullOrWhiteSpace(workflow.Name))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "name",
Message = "Workflow name is required.",
});
}
if (workflow.Graph.Nodes.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph",
Message = "Workflow graph must include nodes.",
});
return issues;
}
Dictionary<string, WorkflowNodeDto> nodesById = new(StringComparer.Ordinal);
HashSet<string> edgeIds = new(StringComparer.Ordinal);
Dictionary<string, int> incomingCounts = new(StringComparer.Ordinal);
Dictionary<string, int> outgoingCounts = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto node in workflow.Graph.Nodes)
{
if (string.IsNullOrWhiteSpace(node.Id))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.id",
Message = "Workflow nodes must have an ID.",
});
continue;
}
if (!nodesById.TryAdd(node.Id, node))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.id",
NodeId = node.Id,
Message = $"Workflow graph contains duplicate node \"{node.Id}\".",
});
continue;
}
if (!ExecutableNodeKinds.Contains(node.Kind))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.kind",
NodeId = node.Id,
Message = $"Workflow node kind \"{node.Kind}\" is not executable yet.",
});
}
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(node.Config.Name))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.name",
NodeId = node.Id,
Message = "Agent nodes require a name.",
});
}
if (string.IsNullOrWhiteSpace(node.Config.Model))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.model",
NodeId = node.Id,
Message = $"Agent node \"{node.Label}\" requires a model.",
});
}
}
}
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
{
if (string.IsNullOrWhiteSpace(edge.Id))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.id",
Message = "Workflow edges must have an ID.",
});
continue;
}
if (!edgeIds.Add(edge.Id))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.id",
EdgeId = edge.Id,
Message = $"Workflow graph contains duplicate edge \"{edge.Id}\".",
});
}
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
EdgeId = edge.Id,
Message = $"Workflow edge \"{edge.Id}\" must connect known nodes.",
});
continue;
}
outgoingCounts[edge.Source] = outgoingCounts.TryGetValue(edge.Source, out int outgoing)
? outgoing + 1
: 1;
incomingCounts[edge.Target] = incomingCounts.TryGetValue(edge.Target, out int incoming)
? incoming + 1
: 1;
}
List<WorkflowNodeDto> startNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
.ToList();
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
.ToList();
List<WorkflowNodeDto> agentNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
.ToList();
if (startNodes.Count != 1)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain exactly one start node.",
});
}
if (endNodes.Count != 1)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain exactly one end node.",
});
}
if (agentNodes.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain at least one agent node.",
});
}
foreach (WorkflowNodeDto startNode in startNodes)
{
if (incomingCounts.GetValueOrDefault(startNode.Id) != 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
NodeId = startNode.Id,
Message = "Start nodes cannot have incoming edges.",
});
}
if (outgoingCounts.GetValueOrDefault(startNode.Id) == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
NodeId = startNode.Id,
Message = "Start nodes must connect to at least one downstream node.",
});
}
}
foreach (WorkflowNodeDto endNode in endNodes)
{
if (outgoingCounts.GetValueOrDefault(endNode.Id) != 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
NodeId = endNode.Id,
Message = "End nodes cannot have outgoing edges.",
});
}
}
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflow.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
{
if (fanOutGroup.Count() < 2)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.kind",
NodeId = fanOutGroup.Key,
Message = "Fan-out edges require at least two outgoing fan-out connections from the same source.",
});
}
}
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflow.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
{
if (fanInGroup.Count() < 2)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.kind",
NodeId = fanInGroup.Key,
Message = "Fan-in edges require at least two incoming fan-in connections to the same target.",
});
}
}
WorkflowNodeDto? start = startNodes.FirstOrDefault();
if (start is not null && endNodes.Count > 0 && !HasPathToAnyEnd(start.Id, workflow.Graph, endNodes.Select(node => node.Id)))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
Message = "Workflow graph must include a path from the start node to at least one end node.",
});
}
return issues;
}
private static bool HasPathToAnyEnd(
string startNodeId,
WorkflowGraphDto graph,
IEnumerable<string> endNodeIds)
{
HashSet<string> endSet = endNodeIds.ToHashSet(StringComparer.Ordinal);
Dictionary<string, List<string>> outgoing = graph.Edges
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.Select(edge => edge.Target).ToList(),
StringComparer.Ordinal);
Queue<string> queue = new([startNodeId]);
HashSet<string> visited = new(StringComparer.Ordinal);
while (queue.Count > 0)
{
string current = queue.Dequeue();
if (!visited.Add(current))
{
continue;
}
if (endSet.Contains(current))
{
return true;
}
foreach (string target in outgoing.GetValueOrDefault(current, []))
{
queue.Enqueue(target);
}
}
return false;
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -108,6 +108,76 @@ public sealed class SidecarProtocolHostTests
});
}
[Fact]
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
{
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidateWorkflowCommandDto
{
Type = "validate-workflow",
RequestId = "validate-workflow-1",
Workflow = new WorkflowDefinitionDto
{
Id = "workflow-1",
Name = "",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-end",
Source = "start",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
},
});
Assert.Collection(
events,
validationEvent =>
{
Assert.Equal("workflow-validation", validationEvent.GetProperty("type").GetString());
Assert.Equal("validate-workflow-1", validationEvent.GetProperty("requestId").GetString());
JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray();
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "name"
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "graph.nodes"
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one agent node.");
},
completionEvent =>
{
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
Assert.Equal("validate-workflow-1", completionEvent.GetProperty("requestId").GetString());
});
}
[Fact]
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
{
+153 -22
View File
@@ -36,6 +36,12 @@ import {
type ReasoningEffort,
validatePatternDefinition,
} from '@shared/domain/pattern';
import {
buildWorkflowExecutionPattern,
normalizeWorkflowDefinition,
validateWorkflowDefinition,
type WorkflowDefinition,
} from '@shared/domain/workflow';
import {
normalizeWorkspaceAgentDefinition,
resolvePatternAgents,
@@ -623,6 +629,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async saveWorkflow(workflow: WorkflowDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error');
if (issues.length > 0) {
throw new Error(issues[0].message);
}
const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id);
const candidate: WorkflowDefinition = {
...normalizedWorkflow,
isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite,
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
updatedAt: nowIso(),
};
if (existingIndex >= 0) {
workspace.workflows[existingIndex] = candidate;
} else {
workspace.workflows.push(candidate);
}
workspace.selectedWorkflowId = candidate.id;
return this.persistAndBroadcast(workspace);
}
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const pattern = this.requirePattern(workspace, patternId);
@@ -726,6 +758,17 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId);
if (workspace.selectedWorkflowId === workflowId) {
workspace.selectedWorkflowId = workspace.workflows[0]?.id;
}
return this.persistAndBroadcast(workspace);
}
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
@@ -881,6 +924,41 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workspace.sessions.unshift(session);
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = undefined;
workspace.selectedSessionId = session.id;
return this.persistAndBroadcast(workspace);
}
async createWorkflowSession(projectId: string, workflowId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
const workflow = this.requireWorkflow(workspace, workflowId);
const modelCatalog = await this.loadAvailableModelCatalog();
const executionPattern = normalizePatternModels(buildWorkflowExecutionPattern(workflow), modelCatalog);
const session: SessionRecord = {
id: createId('session'),
projectId: project.id,
patternId: workflow.id,
workflowId: workflow.id,
title: workflow.name,
titleSource: 'auto',
createdAt: nowIso(),
updatedAt: nowIso(),
status: 'idle',
messages: [],
sessionModelConfig: executionPattern.agents.length === 1
? createSessionModelConfig(executionPattern)
: undefined,
tooling: createSessionToolingSelection(),
runs: [],
};
await this.ensureScratchpadSessionDirectory(session);
workspace.sessions.unshift(session);
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = undefined;
workspace.selectedWorkflowId = workflow.id;
workspace.selectedSessionId = session.id;
return this.persistAndBroadcast(workspace);
}
@@ -993,7 +1071,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
project,
@@ -1022,11 +1100,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
throw new Error('Regenerated session is missing the user message needed to replay the turn.');
}
await this.runPreparedSessionTurn(workspace, regeneratedSession, project, effectivePattern, projectInstructions, {
occurredAt,
requestId: createId('turn'),
triggerMessageId: triggerMessage.id,
});
await this.runPreparedSessionTurn(
workspace,
regeneratedSession,
project,
effectivePattern,
projectInstructions,
{
occurredAt,
requestId: createId('turn'),
triggerMessageId: triggerMessage.id,
},
workflow,
);
}
async editAndResendSessionMessage(
@@ -1052,7 +1138,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
project,
@@ -1084,11 +1170,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
throw new Error('Edited session is missing the user message needed to replay the turn.');
}
await this.runPreparedSessionTurn(workspace, editedSession, project, effectivePattern, projectInstructions, {
occurredAt,
requestId: createId('turn'),
triggerMessageId: triggerMessage.id,
});
await this.runPreparedSessionTurn(
workspace,
editedSession,
project,
effectivePattern,
projectInstructions,
{
occurredAt,
requestId: createId('turn'),
triggerMessageId: triggerMessage.id,
},
workflow,
);
}
async sendSessionMessage(
@@ -1106,7 +1200,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
}
const project = this.requireProject(workspace, session.projectId);
const pattern = this.requirePattern(workspace, session.patternId);
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = this.applyProjectCustomizationToPattern(
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
project,
@@ -1135,13 +1229,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
attachments: attachments?.length ? attachments : undefined,
promptInvocation: normalizedPromptInvocation,
});
await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, {
occurredAt,
requestId,
triggerMessageId: userMessageId,
messageMode,
attachments,
});
await this.runPreparedSessionTurn(
workspace,
session,
project,
effectivePattern,
projectInstructions,
{
occurredAt,
requestId,
triggerMessageId: userMessageId,
messageMode,
attachments,
},
workflow,
);
}
async cancelSessionTurn(sessionId: string): Promise<void> {
@@ -1328,7 +1430,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
const modelCatalog = await this.loadAvailableModelCatalog();
const pattern = this.requirePattern(workspace, session.patternId);
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
const effectivePattern = normalizePatternModels(pattern, modelCatalog);
if (effectivePattern.agents.length !== 1) {
@@ -1497,6 +1599,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
messageMode?: MessageMode;
attachments?: ChatMessageAttachment[];
},
effectiveWorkflow?: WorkflowDefinition,
): Promise<void> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
@@ -1529,6 +1632,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
workingDirectory: runWorkingDirectory,
workspaceKind,
pattern: patternForTurn,
workflow: effectiveWorkflow ? { id: effectiveWorkflow.id, name: effectiveWorkflow.name } : undefined,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
@@ -1558,6 +1662,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
messageMode,
projectInstructions,
pattern: patternForTurn,
workflow: effectiveWorkflow,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
@@ -2126,6 +2231,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return pattern;
}
private requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition {
const workflow = workspace.workflows.find((current) => current.id === workflowId);
if (!workflow) {
throw new Error(`Workflow "${workflowId}" was not found.`);
}
return workflow;
}
private resolveSessionExecutionDefinition(
workspace: WorkspaceState,
session: SessionRecord,
): { pattern: PatternDefinition; workflow?: WorkflowDefinition } {
if (session.workflowId) {
const workflow = this.requireWorkflow(workspace, session.workflowId);
return {
workflow,
pattern: buildWorkflowExecutionPattern(workflow),
};
}
return {
pattern: this.requirePattern(workspace, session.patternId),
};
}
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
const session = workspace.sessions.find((current) => current.id === sessionId);
if (!session) {
@@ -2335,7 +2466,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
messages: ChatMessageRecord[],
): void {
const session = this.requireSession(workspace, sessionId);
const pattern = this.requirePattern(workspace, session.patternId);
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
const incomingIds = new Set(messages.map((message) => message.id));
// Messages that were streamed during the turn already exist in session.messages
+7
View File
@@ -6,6 +6,7 @@ import type {
BranchSessionInput,
CancelSessionTurnInput,
CreateSessionInput,
CreateWorkflowSessionInput,
CreateProjectGitBranchInput,
DismissSessionMcpAuthInput,
DismissSessionPlanReviewInput,
@@ -34,6 +35,7 @@ import type {
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SaveWorkflowInput,
SaveWorkspaceAgentInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
@@ -112,6 +114,8 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
service.setPatternFavorite(input.patternId, input.isFavorite),
);
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
const result = await service.setTheme(theme);
applyTitleBarTheme(window, theme);
@@ -180,6 +184,9 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
service.createSession(input.projectId, input.patternId),
);
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
service.createWorkflowSession(input.projectId, input.workflowId),
);
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
service.duplicateSession(input.sessionId),
);
@@ -15,6 +15,7 @@ import {
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
} from '@shared/domain/tooling';
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
import {
applyDefaultToolApprovalPolicy,
normalizePendingApprovalState,
@@ -120,6 +121,7 @@ export class WorkspaceRepository {
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
graph: resolvePatternGraph(pattern),
})),
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
projects,
sessions,
settings,
+28
View File
@@ -14,6 +14,7 @@ import type {
McpOauthRequiredEvent,
ExitPlanModeRequestedEvent,
ValidatePatternCommand,
ValidateWorkflowCommand,
RunTurnCommand,
CopilotSessionListFilter,
CopilotSessionInfo,
@@ -46,6 +47,12 @@ type PendingCommand =
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'validate-workflow';
resolve: (issues: ValidateWorkflowCommand['workflow'] extends never ? never : unknown) => void;
reject: (error: Error) => void;
})
| ({
processId: number;
kind: 'resolve-approval';
@@ -127,6 +134,14 @@ export class SidecarClient {
});
}
async validateWorkflow(workflow: ValidateWorkflowCommand['workflow']): Promise<unknown> {
return this.dispatch<unknown>({
type: 'validate-workflow',
requestId: `validate-workflow-${Date.now()}`,
workflow,
});
}
async runTurn(
command: RunTurnCommand,
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
@@ -317,6 +332,13 @@ export class SidecarClient {
resolve: resolve as (issues: unknown) => void,
reject,
});
} else if (command.type === 'validate-workflow') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'validate-workflow',
resolve: resolve as (issues: unknown) => void,
reject,
});
} else if (command.type === 'resolve-approval') {
this.pending.set(command.requestId, {
processId: state.id,
@@ -413,6 +435,12 @@ export class SidecarClient {
this.pending.delete(event.requestId);
}
return;
case 'workflow-validation':
if (pending.kind === 'validate-workflow') {
pending.resolve(event.issues);
this.pending.delete(event.requestId);
}
return;
case 'turn-delta':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event));
+3
View File
@@ -26,6 +26,9 @@ const api: ElectronApi = {
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
+3
View File
@@ -15,6 +15,9 @@ export const ipcChannels = {
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
saveWorkflow: 'workflows:save',
deleteWorkflow: 'workflows:delete',
createWorkflowSession: 'workflows:create-session',
setTheme: 'settings:set-theme',
setTerminalHeight: 'settings:set-terminal-height',
setNotificationsEnabled: 'settings:set-notifications-enabled',
+13
View File
@@ -1,6 +1,7 @@
import type { ApprovalDecision } from '@shared/domain/approval';
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type {
ProjectGitBranchSummary,
ProjectGitCommitMessageSuggestion,
@@ -28,10 +29,19 @@ export interface CreateSessionInput {
patternId: string;
}
export interface CreateWorkflowSessionInput {
projectId: string;
workflowId: string;
}
export interface SavePatternInput {
pattern: PatternDefinition;
}
export interface SaveWorkflowInput {
workflow: WorkflowDefinition;
}
export interface SendSessionMessageInput {
sessionId: string;
content: string;
@@ -274,6 +284,8 @@ export interface ElectronApi {
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
@@ -283,6 +295,7 @@ export interface ElectronApi {
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
createWorkflowSession(input: CreateWorkflowSessionInput): Promise<WorkspaceState>;
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
setSessionMessagePinned(input: SetSessionMessagePinnedInput): Promise<WorkspaceState>;
+16
View File
@@ -4,6 +4,7 @@ import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import type { WorkflowDefinition, WorkflowValidationIssue } from '@shared/domain/workflow';
export interface SidecarModeCapability {
available: boolean;
@@ -70,6 +71,12 @@ export interface ValidatePatternCommand {
pattern: PatternDefinition;
}
export interface ValidateWorkflowCommand {
type: 'validate-workflow';
requestId: string;
workflow: WorkflowDefinition;
}
export type InteractionMode = 'interactive' | 'plan';
export type MessageMode = 'enqueue' | 'immediate';
@@ -89,6 +96,7 @@ export interface RunTurnCommand {
messageMode?: MessageMode;
projectInstructions?: string;
pattern: PatternDefinition;
workflow?: WorkflowDefinition;
messages: ChatMessageRecord[];
attachments?: ChatMessageAttachment[];
promptInvocation?: ProjectPromptInvocation;
@@ -147,6 +155,7 @@ export interface CopilotSessionListFilter {
export type SidecarCommand =
| DescribeCapabilitiesCommand
| ValidatePatternCommand
| ValidateWorkflowCommand
| RunTurnCommand
| CancelTurnCommand
| ResolveApprovalCommand
@@ -230,6 +239,12 @@ export interface PatternValidationEvent {
issues: PatternValidationIssue[];
}
export interface WorkflowValidationEvent {
type: 'workflow-validation';
requestId: string;
issues: WorkflowValidationIssue[];
}
export interface TurnDeltaEvent {
type: 'turn-delta';
requestId: string;
@@ -592,6 +607,7 @@ export interface CommandCompleteEvent {
export type SidecarEvent =
| CapabilitiesEvent
| PatternValidationEvent
| WorkflowValidationEvent
| TurnDeltaEvent
| TurnCompleteEvent
| MessageReclassifiedEvent
+10
View File
@@ -17,6 +17,7 @@ import type {
ProjectGitWorkingTreeSnapshot,
ProjectRecord,
} from '@shared/domain/project';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
@@ -76,6 +77,8 @@ export interface SessionRunRecord {
patternId: string;
patternName: string;
patternMode: PatternDefinition['mode'];
workflowId?: string;
workflowName?: string;
triggerMessageId: string;
startedAt: string;
completedAt?: string;
@@ -93,6 +96,7 @@ export interface CreateSessionRunRecordInput {
workingDirectory?: string;
workspaceKind: SessionRunWorkspaceKind;
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
workflow?: Pick<WorkflowDefinition, 'id' | 'name'>;
triggerMessageId: string;
startedAt: string;
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
@@ -610,6 +614,8 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
patternId: input.pattern.id,
patternName: input.pattern.name,
patternMode: input.pattern.mode,
workflowId: normalizeOptionalString(input.workflow?.id),
workflowName: normalizeOptionalString(input.workflow?.name),
triggerMessageId: input.triggerMessageId,
startedAt: input.startedAt,
status: 'running',
@@ -655,6 +661,8 @@ export function normalizeSessionRunRecords(
const workingDirectory = normalizeOptionalString(run.workingDirectory);
const patternId = normalizeOptionalString(run.patternId);
const patternName = normalizeOptionalString(run.patternName);
const workflowId = normalizeOptionalString(run.workflowId);
const workflowName = normalizeOptionalString(run.workflowName);
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
const startedAt = normalizeOptionalString(run.startedAt);
if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) {
@@ -672,6 +680,8 @@ export function normalizeSessionRunRecords(
patternId,
patternName,
patternMode: run.patternMode,
workflowId,
workflowName,
triggerMessageId,
startedAt,
completedAt: normalizeOptionalString(run.completedAt),
+1
View File
@@ -57,6 +57,7 @@ export interface SessionRecord {
id: string;
projectId: string;
patternId: string;
workflowId?: string;
title: string;
titleSource?: SessionTitleSource;
createdAt: string;
+13 -1
View File
@@ -14,6 +14,7 @@ import {
type SessionStatus,
} from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { buildWorkflowExecutionPattern } from '@shared/domain/workflow';
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
export type SessionWorkspaceKind = 'project' | 'scratchpad';
@@ -456,6 +457,7 @@ export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
const workflowsById = new Map(workspace.workflows.map((workflow) => [workflow.id, workflow]));
const searchTokens = tokenizeSearchText(input.searchText);
return workspace.sessions
@@ -466,7 +468,17 @@ export function querySessions(workspace: WorkspaceState, input: QuerySessionsInp
}
const searchMatch = resolveSearchMatch(
buildSearchFields(session, projectsById.get(session.projectId), patternsById.get(session.patternId)),
buildSearchFields(
session,
projectsById.get(session.projectId),
patternsById.get(session.patternId)
?? (session.workflowId
? (() => {
const workflow = workflowsById.get(session.workflowId);
return workflow ? buildWorkflowExecutionPattern(workflow) : undefined;
})()
: undefined),
),
searchTokens,
);
if (!searchMatch) {
+606
View File
@@ -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;
}
+4
View File
@@ -3,11 +3,13 @@ import { createBuiltinPatterns } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { nowIso } from '@shared/utils/ids';
export interface WorkspaceState {
projects: ProjectRecord[];
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
sessions: SessionRecord[];
settings: WorkspaceSettings;
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
@@ -16,6 +18,7 @@ export interface WorkspaceState {
mcpProbingServerIds?: string[];
selectedProjectId?: string;
selectedPatternId?: string;
selectedWorkflowId?: string;
selectedSessionId?: string;
lastUpdatedAt: string;
}
@@ -25,6 +28,7 @@ export function createWorkspaceSeed(): WorkspaceState {
return {
projects: [],
patterns: createBuiltinPatterns(timestamp),
workflows: [],
sessions: [],
settings: createWorkspaceSettings(),
lastUpdatedAt: timestamp,
+115
View File
@@ -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');
});
});
+1
View File
@@ -11,6 +11,7 @@ describe('session workspace helpers', () => {
return {
projects: [],
patterns: [],
workflows: [],
settings: createWorkspaceSettings(),
sessions: [
{
+7 -1
View File
@@ -73,9 +73,10 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
}
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
return {
const workspace: WorkspaceState = {
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
workflows: [],
settings: createWorkspaceSettings(),
sessions: [
createSession(),
@@ -112,6 +113,11 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
...overrides,
};
return {
...workspace,
workflows: overrides?.workflows ?? workspace.workflows,
};
}
describe('session library helpers', () => {
+95
View File
@@ -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']);
});
});