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
@@ -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.");
}
}