From db3d947a134bd49c2889f1d8b091d5b7cee17fea Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 28 Apr 2026 21:16:56 -0500 Subject: [PATCH 1/4] chore(tests): switch Ollama model to qwen2.5:1.5b, enforce serial E2E execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace qwen3:30b-instruct with qwen2.5:1.5b (1.5B params, <=2B constraint) everywhere the model is configured: OllamaAgentProvider default, OllamaFixture, SemanticKernelE2ETests, AiDsl sample, TaskStream sample extension - Reduce OllamaFixture warmup timeout from 300s to 120s; remove /no_think from warmup prompt (qwen3-specific; harmless on qwen2.5 but unnecessary) - Add xunit.runner.json to Tests.E2E with parallelizeTestCollections:false and maxParallelThreads:1 – combined with the existing [Collection("Ollama")] attribute this guarantees all Ollama-backed tests run one at a time - Update OllamaFactAttribute skip message to reference new model - Update OllamaOptionsTests default model assertion to match new default - Update prerequisite XML doc comment in AgentLoopOllamaE2ETests To run the Ollama-backed E2E tests locally: ollama pull qwen2.5:1.5b dotnet test tests/WorkflowFramework.Tests.E2E/WorkflowFramework.Tests.E2E.csproj \ --filter Category=E2E Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 227 ++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../OllamaAgentProvider.cs | 2 +- .../AgentLoopOllamaE2ETests.cs | 76 ++++++ .../OllamaFixture.cs | 36 ++- .../SemanticKernelE2ETests.cs | 2 +- .../WorkflowFramework.Tests.E2E.csproj | 3 + .../xunit.runner.json | 5 + .../Extensions/AI/OllamaAgentProviderTests.cs | 2 +- 9 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 samples/WorkflowFramework.Samples.AiDsl/Program.cs create mode 100644 tests/WorkflowFramework.Tests.E2E/xunit.runner.json diff --git a/samples/WorkflowFramework.Samples.AiDsl/Program.cs b/samples/WorkflowFramework.Samples.AiDsl/Program.cs new file mode 100644 index 0000000..2155345 --- /dev/null +++ b/samples/WorkflowFramework.Samples.AiDsl/Program.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using WorkflowFramework; +using WorkflowFramework.Extensions.AI; +using WorkflowFramework.Serialization; + +// ───────────────────────────────────────────────────────────────────────────── +// DSL-Emitter Pattern Demo +// +// This sample demonstrates how an LLM can dynamically emit workflow steps as +// JSON, which are then reviewed by a human and executed at runtime. +// +// Flow: +// 1. AgentProviderSelectorStep – picks echo or ollama from the registry and +// stores the resolved IAgentProvider in context +// 2. DslEmitterStep – reads the provider from context (set by step 1) +// and iteratively asks the LLM for step definitions +// 3. HumanApprovalGate – prints emitted steps and asks for approval +// 4. BridgeStep – copies Emitter.EmittedSteps → Executor.Steps +// 5. WorkflowDslExecutorStep – materialises and runs each emitted step +// ───────────────────────────────────────────────────────────────────────────── + +// ── 1. Parse arguments ─────────────────────────────────────────────────────── + +var providerKey = "echo"; +foreach (var arg in args) +{ + if (arg.StartsWith("--provider=", StringComparison.OrdinalIgnoreCase)) + providerKey = arg["--provider=".Length..].Trim().ToLowerInvariant(); +} + +Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ WorkflowFramework – DSL-Emitter Demo ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); +Console.WriteLine(); +Console.WriteLine($" Provider : {providerKey}"); +Console.WriteLine(); + +// ── 2. Build provider registry ─────────────────────────────────────────────── + +// Echo provider – two pre-queued responses: +// • First response : the DSL steps JSON array +// • Second response : [] (done signal, ends the iteration loop) +const string dslStepsJson = + """[{"name":"AnalyseInput","type":"action","config":{"message":"Analysing input data..."}},{"name":"GenerateSummary","type":"action","config":{"message":"Generating summary report..."}}]"""; + +var echoProvider = new EchoAgentProvider(new[] { dslStepsJson, "[]" }); + +var ollamaBaseUrl = Environment.GetEnvironmentVariable("OLLAMA_BASE_URL") + ?? "http://localhost:11434"; +var ollamaProvider = new OllamaAgentProvider(new OllamaOptions +{ + BaseUrl = ollamaBaseUrl, + DefaultModel = "qwen2.5:1.5b", + DisableThinking = true +}); + +var registry = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + ["echo"] = echoProvider, + ["ollama"] = ollamaProvider +}; + +if (!registry.TryGetValue(providerKey, out var selectedProvider)) +{ + Console.Error.WriteLine($"Unknown provider '{providerKey}'. Use --provider=echo or --provider=ollama."); + return 1; +} + +// ── 3. Build the DSL-emitter step ──────────────────────────────────────────── + +const string systemPrompt = + """ + You are a workflow planning assistant. + Respond ONLY with a valid JSON array of step definitions – no markdown, no prose. + Each step must have "name" (string) and "type" (string, e.g. "action") fields. + You may include an optional "config" object with arbitrary string key/value pairs. + + Example: + [ + {"name":"FetchData","type":"action","config":{"source":"api"}}, + {"name":"Transform","type":"action","config":{"mode":"map"}} + ] + + When you have emitted all desired steps, respond with exactly: [] + """; + +var emitterStep = new DslEmitterStep(selectedProvider, new DslEmitterOptions +{ + StepName = "Emitter", + MaxIterations = 5, + SystemPromptTemplate = systemPrompt, + IncludeSchemaInPrompt = false, + DoneSignal = "[]" +}); + +// ── 4. Build the workflow ───────────────────────────────────────────────────── + +var workflow = Workflow.Create("DslEmitterDemo") + // Step 1 – resolve the chosen provider and store it in context so DslEmitterStep picks it up + .Step(new AgentProviderSelectorStep(registry, providerKey)) + + // Step 2 – ask the LLM to emit DSL step definitions + .Step(emitterStep) + + // Step 3 – human-in-the-loop approval gate + .Step("HumanApprovalGate", ctx => + { + Console.WriteLine("──────────────────────────────────────────────────────────"); + Console.WriteLine(" HUMAN APPROVAL GATE"); + Console.WriteLine("──────────────────────────────────────────────────────────"); + + if (!ctx.Properties.TryGetValue("Emitter.EmittedSteps", out var stepsObj) + || stepsObj is not List emitted + || emitted.Count == 0) + { + Console.WriteLine(" No steps were emitted. Nothing to approve."); + ctx.Properties["HumanApproval.Approved"] = false; + ctx.IsAborted = true; + return Task.CompletedTask; + } + + Console.WriteLine($" The LLM emitted {emitted.Count} step(s):"); + Console.WriteLine(); + for (var i = 0; i < emitted.Count; i++) + { + var s = emitted[i]; + Console.Write($" [{i + 1}] {s.Name} (type={s.Type})"); + if (s.Config?.Count > 0) + { + var cfg = string.Join(", ", s.Config.Select(kv => $"{kv.Key}={kv.Value}")); + Console.Write($" config={{ {cfg} }}"); + } + Console.WriteLine(); + } + Console.WriteLine(); + Console.Write(" Approve and execute these steps? [Y/n]: "); + + var answer = Console.ReadLine()?.Trim() ?? string.Empty; + var approved = answer.Length == 0 + || answer.Equals("y", StringComparison.OrdinalIgnoreCase) + || answer.Equals("yes", StringComparison.OrdinalIgnoreCase); + + ctx.Properties["HumanApproval.Approved"] = approved; + + if (!approved) + { + Console.WriteLine(); + Console.WriteLine(" ✗ Execution aborted by user."); + ctx.IsAborted = true; + } + else + { + Console.WriteLine(); + Console.WriteLine(" ✓ Approved – proceeding to execution."); + } + + Console.WriteLine("──────────────────────────────────────────────────────────"); + Console.WriteLine(); + return Task.CompletedTask; + }) + + // Step 4 – bridge: copy Emitter.EmittedSteps → WorkflowDslExecutor.Steps + .Step("BridgeStep", ctx => + { + if (ctx.Properties.TryGetValue("Emitter.EmittedSteps", out var steps)) + ctx.Properties["WorkflowDslExecutor.Steps"] = steps; + return Task.CompletedTask; + }) + + // Step 5 – execute the emitted steps + .Step(new WorkflowDslExecutorStep()) + + .Build(); + +// ── 5. Seed the context and run ─────────────────────────────────────────────── + +var context = new WorkflowContext(); +context.Properties["provider"] = providerKey; +context.Properties["DslEmitter.UserMessage"] = + "Generate a simple two-step data processing workflow."; + +Console.WriteLine(" Running workflow…"); +Console.WriteLine(); + +WorkflowResult result; +try +{ + result = await workflow.ExecuteAsync(context); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Workflow threw an exception: {ex.Message}"); + return 1; +} + +// ── 6. Print results ────────────────────────────────────────────────────────── + +Console.WriteLine("══════════════════════════════════════════════════════════"); +Console.WriteLine(" RESULTS"); +Console.WriteLine("══════════════════════════════════════════════════════════"); +Console.WriteLine($" Workflow status : {result.Status}"); + +if (context.Properties.TryGetValue("Emitter.Iterations", out var iterObj)) + Console.WriteLine($" Emitter iterations: {iterObj}"); + +if (context.Properties.TryGetValue("WorkflowDslExecutor.ExecutedCount", out var countObj)) + Console.WriteLine($" Executed steps : {countObj}"); + +if (context.Properties.TryGetValue("WorkflowDslExecutor.Results", out var resultsObj) + && resultsObj is string resultsStr && !string.IsNullOrEmpty(resultsStr)) + Console.WriteLine($" Execution order : {resultsStr}"); + +if (context.Properties.TryGetValue("HumanApproval.Approved", out var approvedObj)) + Console.WriteLine($" Human approved : {approvedObj}"); + +if (result.Errors.Count > 0) +{ + Console.WriteLine(); + Console.WriteLine(" Errors:"); + foreach (var err in result.Errors) + Console.WriteLine($" • {err.StepName}: {err.Exception.Message}"); +} + +Console.WriteLine("══════════════════════════════════════════════════════════"); +Console.WriteLine(); + +return 0; diff --git a/samples/WorkflowFramework.Samples.TaskStream/Extensions/ServiceCollectionExtensions.cs b/samples/WorkflowFramework.Samples.TaskStream/Extensions/ServiceCollectionExtensions.cs index 0d09f4e..b123228 100644 --- a/samples/WorkflowFramework.Samples.TaskStream/Extensions/ServiceCollectionExtensions.cs +++ b/samples/WorkflowFramework.Samples.TaskStream/Extensions/ServiceCollectionExtensions.cs @@ -65,7 +65,7 @@ public static IServiceCollection AddTaskStream( var plugin = new TaskStreamPlugin(tools); var builder = Kernel.CreateBuilder(); - builder.AddOllamaChatCompletion("qwen3:30b-instruct", new Uri("http://localhost:11434")); + builder.AddOllamaChatCompletion("qwen2.5:1.5b", new Uri("http://localhost:11434")); builder.Plugins.AddFromObject(plugin, "TaskStream"); var kernel = builder.Build(); diff --git a/src/WorkflowFramework.Extensions.AI/OllamaAgentProvider.cs b/src/WorkflowFramework.Extensions.AI/OllamaAgentProvider.cs index 588863c..9f2428e 100644 --- a/src/WorkflowFramework.Extensions.AI/OllamaAgentProvider.cs +++ b/src/WorkflowFramework.Extensions.AI/OllamaAgentProvider.cs @@ -12,7 +12,7 @@ public sealed class OllamaOptions public string BaseUrl { get; set; } = "http://localhost:11434"; /// Gets or sets the default model. - public string DefaultModel { get; set; } = "qwen3:30b-instruct"; + public string DefaultModel { get; set; } = "qwen2.5:1.5b"; /// Gets or sets the request timeout. public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(120); diff --git a/tests/WorkflowFramework.Tests.E2E/AgentLoopOllamaE2ETests.cs b/tests/WorkflowFramework.Tests.E2E/AgentLoopOllamaE2ETests.cs index ddd5dfc..c226351 100644 --- a/tests/WorkflowFramework.Tests.E2E/AgentLoopOllamaE2ETests.cs +++ b/tests/WorkflowFramework.Tests.E2E/AgentLoopOllamaE2ETests.cs @@ -235,3 +235,79 @@ public Task> ListToolsAsync(CancellationToken ct = public Task InvokeToolAsync(string toolName, string argumentsJson, CancellationToken ct = default) => _handler(toolName, argumentsJson); } + +// ─── Goals 2-6 (E2E with real Ollama) ───────────────────────────────────── + +/// +/// End-to-end tests for the DSL-emitter pipeline against a live local Ollama instance. +/// These tests are automatically skipped when Ollama is not reachable (via ). +/// +/// Prerequisite: ollama pull qwen2.5:1.5b +/// +/// Goals covered: 2 (agent emits DSL), 3 (framework executes DSL), 5 (E2E with Ollama), +/// 6 (realistic "diagnose compilation" prompt → workflow steps, agent never calls tools). +/// +[Collection("Ollama")] +[Trait("Category", "E2E")] +public class DslEmitterOllamaTests(OllamaFixture fixture, ITestOutputHelper output) +{ + /// + /// Goal 5 + 6: a realistic "diagnose compilation errors" task should cause the model to + /// emit at least one Workflow Framework DSL step describing an inspection/build action. + /// The agent must NEVER call tools; it only emits DSL pipeline instructions. + /// + [OllamaFact(Timeout = 180_000)] // ← OllamaFact – skips automatically if Ollama absent + public async Task DslEmitter_EmitsAtLeastOneDslStep_ForCodebaseDiagnosisTask_AgenticDemo() + { + // Arrange – use the shared Ollama provider from the fixture + var options = new DslEmitterOptions + { + StepName = "Diagnoser", + MaxIterations = 3 + }; + var step = new DslEmitterStep(fixture.Provider, options); + var context = new WorkflowContext(); + context.Properties["task"] = "Diagnose the errors in this codebase compilation. " + + "Do NOT run anything yourself — emit only a JSON array of WorkflowFramework DSL steps."; + + // Act + await step.ExecuteAsync(context); + + // Assert – the real model must emit at least one DSL step (goal 6) + var emittedSteps = context.Properties["Diagnoser.EmittedSteps"] as System.Collections.IList; + output.WriteLine($"Emitted step count: {emittedSteps?.Count ?? 0}"); + emittedSteps.Should().NotBeNull("provider should emit at least one DSL step"); + emittedSteps!.Count.Should().BeGreaterThan(0, + "a diagnosis task should produce at least one inspection step"); + } + + /// + /// Goal 6 (agent never calls tools directly): the emitter response must never contain + /// raw tool invocations — only DSL instructions for the framework. + /// + [OllamaFact(Timeout = 180_000)] // ← OllamaFact – skips automatically if Ollama absent + public async Task DslEmitter_NeverSurfacesToolCalls_AgentOnlyEmitsDsl_AgenticDemo() + { + // Arrange + var options = new DslEmitterOptions + { + StepName = "CodeDiagAgent", + MaxIterations = 2 + }; + var step = new DslEmitterStep(fixture.Provider, options); + var context = new WorkflowContext(); + context.Properties["task"] = "List the workflow steps needed to detect and report " + + "build errors in a .NET project. Respond ONLY with a JSON array of DSL steps."; + + // Act + await step.ExecuteAsync(context); + + // Assert – requirement 6: the framework must never proxy raw tool calls through the emitter + context.Properties.Should().NotContainKey("CodeDiagAgent.ToolCalls", + "the DslEmitterStep must absorb/ignore tool calls — only DSL pipeline steps are allowed"); + + context.Properties.TryGetValue("CodeDiagAgent.Iterations", out var iterations); + output.WriteLine($"Iterations: {iterations}"); + iterations.Should().NotBeNull("iteration count must be recorded"); + } +} diff --git a/tests/WorkflowFramework.Tests.E2E/OllamaFixture.cs b/tests/WorkflowFramework.Tests.E2E/OllamaFixture.cs index bdd7096..057b733 100644 --- a/tests/WorkflowFramework.Tests.E2E/OllamaFixture.cs +++ b/tests/WorkflowFramework.Tests.E2E/OllamaFixture.cs @@ -29,15 +29,15 @@ public async Task InitializeAsync() { Provider = new OllamaAgentProvider(new OllamaOptions { - DefaultModel = "qwen3:30b-instruct", - Timeout = TimeSpan.FromSeconds(300), + DefaultModel = "qwen2.5:1.5b", + Timeout = TimeSpan.FromSeconds(120), DisableThinking = true }); // Warm up: force model load so first test doesn't eat the load time try { - await Provider.CompleteAsync(new LlmRequest { Prompt = "Hi /no_think" }); + await Provider.CompleteAsync(new LlmRequest { Prompt = "Hi" }); } catch { /* best effort warmup */ } } @@ -58,3 +58,33 @@ public void Dispose() [CollectionDefinition("Ollama")] public class OllamaCollection : ICollectionFixture; + +/// +/// A fact attribute that automatically skips the test when Ollama is not reachable +/// at http://localhost:11434. No Ollama dependency means no flaky CI failures. +/// +public sealed class OllamaFactAttribute : FactAttribute +{ + private const string OllamaUrl = "http://localhost:11434/api/tags"; + private const int ProbeTimeoutSeconds = 3; + + public OllamaFactAttribute() + { + if (!IsOllamaReachable()) + Skip = "SKIP: Ollama is not reachable at http://localhost:11434 — install Ollama and run 'ollama pull qwen2.5:1.5b' to run this test."; + } + + private static bool IsOllamaReachable() + { + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds) }; + using var response = http.GetAsync(OllamaUrl).GetAwaiter().GetResult(); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } +} diff --git a/tests/WorkflowFramework.Tests.E2E/SemanticKernelE2ETests.cs b/tests/WorkflowFramework.Tests.E2E/SemanticKernelE2ETests.cs index 08c650c..eb0010f 100644 --- a/tests/WorkflowFramework.Tests.E2E/SemanticKernelE2ETests.cs +++ b/tests/WorkflowFramework.Tests.E2E/SemanticKernelE2ETests.cs @@ -25,7 +25,7 @@ private void SkipIfUnavailable() private static Kernel BuildKernel(IEnumerable? tools = null) { var builder = Kernel.CreateBuilder(); - builder.AddOllamaChatCompletion("qwen3:30b-instruct", new Uri("http://localhost:11434")); + builder.AddOllamaChatCompletion("qwen2.5:1.5b", new Uri("http://localhost:11434")); if (tools is not null) { diff --git a/tests/WorkflowFramework.Tests.E2E/WorkflowFramework.Tests.E2E.csproj b/tests/WorkflowFramework.Tests.E2E/WorkflowFramework.Tests.E2E.csproj index ab48d7f..2cccdbc 100644 --- a/tests/WorkflowFramework.Tests.E2E/WorkflowFramework.Tests.E2E.csproj +++ b/tests/WorkflowFramework.Tests.E2E/WorkflowFramework.Tests.E2E.csproj @@ -13,6 +13,9 @@ + + + diff --git a/tests/WorkflowFramework.Tests.E2E/xunit.runner.json b/tests/WorkflowFramework.Tests.E2E/xunit.runner.json new file mode 100644 index 0000000..598f456 --- /dev/null +++ b/tests/WorkflowFramework.Tests.E2E/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} diff --git a/tests/WorkflowFramework.Tests/Extensions/AI/OllamaAgentProviderTests.cs b/tests/WorkflowFramework.Tests/Extensions/AI/OllamaAgentProviderTests.cs index e920ede..40a7523 100644 --- a/tests/WorkflowFramework.Tests/Extensions/AI/OllamaAgentProviderTests.cs +++ b/tests/WorkflowFramework.Tests/Extensions/AI/OllamaAgentProviderTests.cs @@ -14,7 +14,7 @@ public void Defaults_AreCorrect() { var opts = new OllamaOptions(); opts.BaseUrl.Should().Be("http://localhost:11434"); - opts.DefaultModel.Should().Be("qwen3:30b-instruct"); + opts.DefaultModel.Should().Be("qwen2.5:1.5b"); opts.Timeout.Should().Be(TimeSpan.FromSeconds(120)); opts.DisableThinking.Should().BeTrue(); } From 4c087fc1a4ba73da7ebe93d636af8f0446c3295b Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 28 Apr 2026 21:40:09 -0500 Subject: [PATCH 2/4] feat(dashboard): surface AI DSL Emitter demo as featured template and seeded workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the AI DSL Emitter demo to the dashboard in two complementary ways: 1. Template ("New from Template" browser): id=ai-dsl-emitter, name="AI DSL Emitter", category="AI & Agents", marked IsFeatured=true with preview SVG. 2. Seeded workflow (open-existing list): id=sample-ai-dsl-emitter, name="AI DSL Emitter", immediately visible in the workflow list on startup. Step types used match the Extensions.AI implementation: SelectProvider (Action) → EmitSteps (DslEmitterStep) → ApprovePlan (ApprovalStep) → BridgeContext (Action) → ExecuteEmittedSteps (WorkflowDslExecutorStep) Also registers DslEmitterStep and WorkflowDslExecutorStep in StepTypeRegistry so the designer UI can render their config schemas. Tests: 43/43 passed (WorkflowTemplateLibraryTests + SampleWorkflowSeederTests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InMemoryWorkflowTemplateLibrary.cs | 2083 +++++++++-------- .../Services/SampleWorkflowSeeder.cs | 868 +++---- .../Services/StepTypeRegistry.cs | 907 +++---- .../templates/ai-dsl-emitter-preview.svg | 78 + .../SampleWorkflowSeederTests.cs | 87 +- .../WorkflowTemplateLibraryTests.cs | 506 ++-- 6 files changed, 2394 insertions(+), 2135 deletions(-) create mode 100644 src/WorkflowFramework.Dashboard.Web/wwwroot/images/templates/ai-dsl-emitter-preview.svg diff --git a/src/WorkflowFramework.Dashboard.Api/Services/InMemoryWorkflowTemplateLibrary.cs b/src/WorkflowFramework.Dashboard.Api/Services/InMemoryWorkflowTemplateLibrary.cs index 5645918..a7dbae3 100644 --- a/src/WorkflowFramework.Dashboard.Api/Services/InMemoryWorkflowTemplateLibrary.cs +++ b/src/WorkflowFramework.Dashboard.Api/Services/InMemoryWorkflowTemplateLibrary.cs @@ -1,1008 +1,1075 @@ -using WorkflowFramework.Dashboard.Api.Models; -using WorkflowFramework.Serialization; - -namespace WorkflowFramework.Dashboard.Api.Services; - -/// -/// In-memory template library pre-loaded with all workflow templates. -/// -public sealed class InMemoryWorkflowTemplateLibrary : IWorkflowTemplateLibrary -{ - private readonly List _templates; - - public InMemoryWorkflowTemplateLibrary() - { - _templates = BuildAllTemplates(); - } - - public Task> GetTemplatesAsync(string? category = null, string? tag = null, CancellationToken ct = default) - { - var query = _templates.AsEnumerable(); - - if (!string.IsNullOrEmpty(category)) - query = query.Where(t => string.Equals(t.Category, category, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrEmpty(tag)) - query = query.Where(t => t.Tags.Any(tg => string.Equals(tg, tag, StringComparison.OrdinalIgnoreCase))); - - var result = query.Select(t => new WorkflowTemplateSummary - { - Id = t.Id, - Name = t.Name, - Description = t.Description, - Category = t.Category, - Tags = t.Tags, - Difficulty = t.Difficulty, - StepCount = t.StepCount, - PreviewImageUrl = t.PreviewImageUrl, - IsFeatured = t.IsFeatured, - FeaturedReason = t.FeaturedReason - }).ToList(); - - return Task.FromResult>(result); - } - - public Task GetTemplateAsync(string id, CancellationToken ct = default) - { - var template = _templates.FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase)); - return Task.FromResult(template); - } - - public Task> GetCategoriesAsync(CancellationToken ct = default) - { - var categories = _templates.Select(t => t.Category).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(c => c).ToList(); - return Task.FromResult>(categories); - } - - private static List BuildAllTemplates() - { - var templates = new List(); - - // === Getting Started === - templates.Add(HelloWorld()); - templates.Add(SequentialPipeline()); - templates.Add(ConditionalBranching()); - templates.Add(ParallelExecution()); - templates.Add(ErrorHandling()); - templates.Add(RetryWithBackoff()); - templates.Add(LoopProcessing()); - - // === Data Processing === - templates.Add(CsvEtlPipeline()); - templates.Add(DataMappingTransform()); - templates.Add(SchemaValidation()); - - // === Order Management === - templates.Add(OrderProcessingSaga()); - templates.Add(ExpressOrderFlow()); - templates.Add(OrderWithApproval()); - - // === AI & Agents === - templates.Add(TaskExtractionPipeline()); - templates.Add(AgentTriageWorkflow()); - templates.Add(MultimodalLocalRouter()); - - // === Voice & Audio === - templates.Add(QuickTranscript()); - templates.Add(MeetingNotes()); - templates.Add(BlogFromInterview()); - templates.Add(BrainDumpSynthesis()); - templates.Add(PodcastTranscript()); - - // === Integration Patterns === - templates.Add(ContentBasedRouter()); - templates.Add(ScatterGather()); - templates.Add(PublishSubscribe()); - templates.Add(HttpApiOrchestration()); - templates.Add(WebhookHandler()); - - return templates; - } - - // ── Getting Started ───────────────────────────────────────── - - private static WorkflowTemplate HelloWorld() => new() - { - Id = "hello-world", - Name = "Hello World", - Description = "The simplest possible workflow — a single action step that greets the user.", - Category = "Getting Started", - Tags = ["beginner", "simple", "action"], - Difficulty = TemplateDifficulty.Beginner, - StepCount = 1, - Definition = new WorkflowDefinitionDto - { - Name = "HelloWorkflow", - Steps = [new StepDefinitionDto { Name = "Greet", Type = "Action" }] - } - }; - - private static WorkflowTemplate SequentialPipeline() => new() - { - Id = "sequential-pipeline", - Name = "Sequential Pipeline", - Description = "A 3-step linear flow demonstrating sequential step execution.", - Category = "Getting Started", - Tags = ["beginner", "sequential", "pipeline"], - Difficulty = TemplateDifficulty.Beginner, - StepCount = 3, - Definition = new WorkflowDefinitionDto - { - Name = "SequentialPipeline", - Steps = - [ - new StepDefinitionDto { Name = "Step1_Prepare", Type = "Action" }, - new StepDefinitionDto { Name = "Step2_Process", Type = "Action" }, - new StepDefinitionDto { Name = "Step3_Finalize", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate ConditionalBranching() => new() - { - Id = "conditional-branching", - Name = "Conditional Branching", - Description = "If/else branching with different execution paths based on a condition.", - Category = "Getting Started", - Tags = ["beginner", "conditional", "branching"], - Difficulty = TemplateDifficulty.Beginner, - StepCount = 4, - Definition = new WorkflowDefinitionDto - { - Name = "ConditionalBranching", - Steps = - [ - new StepDefinitionDto { Name = "ValidateInput", Type = "Action" }, - new StepDefinitionDto - { - Name = "CheckCondition", - Type = "Conditional", - Then = new StepDefinitionDto { Name = "ProcessValid", Type = "Action" }, - Else = new StepDefinitionDto { Name = "HandleInvalid", Type = "Action" } - }, - new StepDefinitionDto { Name = "Summary", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate ParallelExecution() => new() - { - Id = "parallel-execution", - Name = "Parallel Execution", - Description = "Run multiple branches in parallel and wait for all to complete before continuing.", - Category = "Getting Started", - Tags = ["beginner", "parallel", "concurrency"], - Difficulty = TemplateDifficulty.Beginner, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "ParallelExecution", - Steps = - [ - new StepDefinitionDto { Name = "PrepareData", Type = "Action" }, - new StepDefinitionDto - { - Name = "ParallelBranches", - Type = "Parallel", - Steps = - [ - new StepDefinitionDto { Name = "BranchA_FetchExternal", Type = "Action" }, - new StepDefinitionDto { Name = "BranchB_ComputeLocal", Type = "Action" }, - new StepDefinitionDto { Name = "BranchC_ValidateRules", Type = "Action" } - ] - }, - new StepDefinitionDto { Name = "MergeResults", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate ErrorHandling() => new() - { - Id = "error-handling", - Name = "Error Handling", - Description = "Try/catch/finally pattern for robust error handling in workflows.", - Category = "Getting Started", - Tags = ["intermediate", "error-handling", "try-catch"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "ErrorHandling", - Steps = - [ - new StepDefinitionDto - { - Name = "SafeOperation", - Type = "TryCatch", - TryBody = - [ - new StepDefinitionDto { Name = "RiskyStep", Type = "Action" }, - new StepDefinitionDto { Name = "DependentStep", Type = "Action" } - ], - CatchTypes = ["System.InvalidOperationException", "System.TimeoutException"], - FinallyBody = - [ - new StepDefinitionDto { Name = "CleanupResources", Type = "Action" } - ] - }, - new StepDefinitionDto { Name = "Continue", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate RetryWithBackoff() => new() - { - Id = "retry-with-backoff", - Name = "Retry with Backoff", - Description = "Wrap a flaky operation in a retry step with configurable max attempts.", - Category = "Getting Started", - Tags = ["intermediate", "retry", "resilience"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 3, - Definition = new WorkflowDefinitionDto - { - Name = "RetryWithBackoff", - Steps = - [ - new StepDefinitionDto { Name = "SetupConnection", Type = "Action" }, - new StepDefinitionDto - { - Name = "RetryableCall", - Type = "Retry", - MaxAttempts = 3, - Steps = [new StepDefinitionDto { Name = "CallExternalApi", Type = "Action" }] - }, - new StepDefinitionDto { Name = "ProcessResponse", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate LoopProcessing() => new() - { - Id = "loop-processing", - Name = "Loop Processing", - Description = "ForEach and While loop patterns for iterative data processing.", - Category = "Getting Started", - Tags = ["intermediate", "loop", "foreach", "while"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 4, - Definition = new WorkflowDefinitionDto - { - Name = "LoopProcessing", - Steps = - [ - new StepDefinitionDto { Name = "LoadItems", Type = "Action" }, - new StepDefinitionDto - { - Name = "ProcessEachItem", - Type = "ForEach", - Steps = - [ - new StepDefinitionDto { Name = "TransformItem", Type = "Action" }, - new StepDefinitionDto { Name = "ValidateItem", Type = "Action" } - ] - }, - new StepDefinitionDto - { - Name = "PollUntilComplete", - Type = "While", - Steps = [new StepDefinitionDto { Name = "CheckStatus", Type = "Action" }] - }, - new StepDefinitionDto { Name = "Summarize", Type = "Action" } - ] - } - }; - - // ── Data Processing ───────────────────────────────────────── - - private static WorkflowTemplate CsvEtlPipeline() => new() - { - Id = "csv-etl-pipeline", - Name = "CSV ETL Pipeline", - Description = "Extract CSV data, transform and filter records, validate, and write output.", - Category = "Data Processing", - Tags = ["etl", "csv", "transform", "pipeline"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 4, - Definition = new WorkflowDefinitionDto - { - Name = "CsvEtlPipeline", - Steps = - [ - new StepDefinitionDto { Name = "Extract", Type = "Action" }, - new StepDefinitionDto { Name = "Transform", Type = "DataMapStep" }, - new StepDefinitionDto { Name = "Validate", Type = "Action" }, - new StepDefinitionDto { Name = "Load", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate DataMappingTransform() => new() - { - Id = "data-mapping-transform", - Name = "Data Mapping & Transform", - Description = "Use DataMapStep to map and transform fields between data formats.", - Category = "Data Processing", - Tags = ["data-mapping", "transform", "fields"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 3, - Definition = new WorkflowDefinitionDto - { - Name = "DataMappingTransform", - Steps = - [ - new StepDefinitionDto { Name = "ReadSource", Type = "Action" }, - new StepDefinitionDto { Name = "MapFields", Type = "DataMapStep" }, - new StepDefinitionDto { Name = "WriteTarget", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate SchemaValidation() => new() - { - Id = "schema-validation", - Name = "Schema Validation", - Description = "Validate data against a JSON schema with conditional error handling.", - Category = "Data Processing", - Tags = ["validation", "schema", "json"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 4, - Definition = new WorkflowDefinitionDto - { - Name = "SchemaValidation", - Steps = - [ - new StepDefinitionDto { Name = "LoadData", Type = "Action" }, - new StepDefinitionDto { Name = "ValidateSchema", Type = "Action" }, - new StepDefinitionDto - { - Name = "CheckValid", - Type = "Conditional", - Then = new StepDefinitionDto { Name = "ProcessData", Type = "Action" }, - Else = new StepDefinitionDto { Name = "ReportErrors", Type = "Action" } - } - ] - } - }; - - // ── Order Management ──────────────────────────────────────── - - private static WorkflowTemplate OrderProcessingSaga() => new() - { - Id = "order-processing-saga", - Name = "Order Processing Saga", - Description = "Multi-step order saga with inventory reservation, payment charging, and compensation on failure.", - Category = "Order Management", - Tags = ["saga", "compensation", "order", "transaction"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 6, - Definition = new WorkflowDefinitionDto - { - Name = "OrderProcessingSaga", - Steps = - [ - new StepDefinitionDto { Name = "ValidateOrder", Type = "Action" }, - new StepDefinitionDto - { - Name = "OrderSaga", - Type = "Saga", - Steps = - [ - new StepDefinitionDto { Name = "CheckInventory", Type = "Action" }, - new StepDefinitionDto { Name = "ChargePayment", Type = "Action" }, - new StepDefinitionDto { Name = "ShipOrder", Type = "Action" } - ] - }, - new StepDefinitionDto { Name = "SendConfirmation", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate ExpressOrderFlow() => new() - { - Id = "express-order-flow", - Name = "Express Order Flow", - Description = "Fast-path conditional routing — express orders get prioritized, standard orders follow normal processing.", - Category = "Order Management", - Tags = ["conditional", "routing", "order", "express"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "ExpressOrderFlow", - Steps = - [ - new StepDefinitionDto { Name = "ValidateOrder", Type = "Action" }, - new StepDefinitionDto { Name = "CheckInventory", Type = "Action" }, - new StepDefinitionDto - { - Name = "ShippingRoute", - Type = "Conditional", - Then = new StepDefinitionDto { Name = "PrioritizeOrder", Type = "Action" }, - Else = new StepDefinitionDto { Name = "StandardProcessing", Type = "Action" } - }, - new StepDefinitionDto { Name = "ChargePayment", Type = "Action" }, - new StepDefinitionDto { Name = "SendConfirmation", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate OrderWithApproval() => new() - { - Id = "order-with-approval", - Name = "Order with Approval", - Description = "Order workflow with a human approval gate for high-value orders.", - Category = "Order Management", - Tags = ["approval", "human-task", "order", "gate"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 6, - Definition = new WorkflowDefinitionDto - { - Name = "OrderWithApproval", - Steps = - [ - new StepDefinitionDto { Name = "ValidateOrder", Type = "Action" }, - new StepDefinitionDto - { - Name = "CheckApprovalNeeded", - Type = "Conditional", - Then = new StepDefinitionDto { Name = "ManagerApproval", Type = "ApprovalStep" }, - Else = new StepDefinitionDto { Name = "AutoApprove", Type = "Action" } - }, - new StepDefinitionDto { Name = "CheckInventory", Type = "Action" }, - new StepDefinitionDto { Name = "ChargePayment", Type = "Action" }, - new StepDefinitionDto { Name = "SendConfirmation", Type = "Action" } - ] - } - }; - - // ── AI & Agents ───────────────────────────────────────────── - - private static WorkflowTemplate TaskExtractionPipeline() => new() - { - Id = "task-extraction-pipeline", - Name = "Task Extraction Pipeline", - Description = "AI-powered pipeline that collects text from sources, normalizes input, extracts tasks via LLM, validates, and persists.", - Category = "AI & Agents", - Tags = ["ai", "extraction", "llm", "pipeline"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "TaskExtractionPipeline", - Steps = - [ - new StepDefinitionDto { Name = "CollectSources", Type = "Action" }, - new StepDefinitionDto { Name = "NormalizeInput", Type = "Action" }, - new StepDefinitionDto { Name = "ExtractTodos", Type = "LlmCallStep" }, - new StepDefinitionDto { Name = "ValidateAndDeduplicate", Type = "Action" }, - new StepDefinitionDto { Name = "PersistTodos", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate AgentTriageWorkflow() => new() - { - Id = "agent-triage-workflow", - Name = "Agent Triage Workflow", - Description = "Agent loop for triaging tasks by priority, with parallel branches for agent execution and human task enrichment.", - Category = "AI & Agents", - Tags = ["ai", "agent", "triage", "parallel"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 4, - Definition = new WorkflowDefinitionDto - { - Name = "AgentTriageWorkflow", - Steps = - [ - new StepDefinitionDto { Name = "TriageTasks", Type = "AgentDecisionStep" }, - new StepDefinitionDto - { - Name = "ExecuteAndEnrich", - Type = "Parallel", - Steps = - [ - new StepDefinitionDto { Name = "AgentExecution", Type = "AgentLoopStep" }, - new StepDefinitionDto { Name = "EnrichHumanTasks", Type = "HumanTaskStep" } - ] - }, - new StepDefinitionDto { Name = "AggregateResults", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate MultimodalLocalRouter() => new() - { - Id = "multimodal-local-router", - Name = "Multimodal Local Router", - Description = "Capture a multimodal brief, let a cheap local model route and plan the work, then hand specialist passes to downstream OpenAI and Anthropic models before human review.", - Category = "AI & Agents", - Tags = ["ai", "agent", "multimodal", "local-model", "routing", "provider-selection"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 8, - PreviewImageUrl = "/images/templates/multimodal-local-router-preview.svg", - IsFeatured = true, - FeaturedReason = "Shows local-first routing and specialist-model handoff with reusable prompt variables.", - Definition = new WorkflowDefinitionDto - { - Name = "MultimodalLocalRouter", - Steps = - [ - new StepDefinitionDto - { - Name = "Capture Brief", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Capture multimodal inputs from {recordings}, {transcript}, and any uploaded notes." - } - }, - new StepDefinitionDto - { - Name = "Transcribe Brief", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Normalize {recordings} and {transcript} into a single working brief." - } - }, - new StepDefinitionDto - { - Name = "Route Brief", - Type = "AgentDecisionStep", - Config = new Dictionary - { - ["provider"] = "ollama", - ["model"] = "phi4-mini", - ["prompt"] = "Review {{Transcribe Brief.Output}} and decide which downstream specialist models should draft, verify, or escalate the task.", - ["options"] = "openai-draft,anthropic-audit,dual-specialists,escalate-to-human" - } - }, - new StepDefinitionDto - { - Name = "Plan Specialist Passes", - Type = "AgentPlanStep", - Config = new Dictionary - { - ["provider"] = "ollama", - ["model"] = "llama3.2", - ["objective"] = "Use {{Route Brief.Decision}} and {{Transcribe Brief.Output}} to produce an execution plan for the downstream specialist passes." - } - }, - new StepDefinitionDto - { - Name = "Specialist Passes", - Type = "Parallel", - Steps = - [ - new StepDefinitionDto - { - Name = "Draft with OpenAI", - Type = "LlmCallStep", - Config = new Dictionary - { - ["provider"] = "openai", - ["model"] = "gpt-4o-mini", - ["prompt"] = "Using {{Plan Specialist Passes.Plan}} and {{Transcribe Brief.Output}}, draft the primary deliverable." - } - }, - new StepDefinitionDto - { - Name = "Audit with Anthropic", - Type = "LlmCallStep", - Config = new Dictionary - { - ["provider"] = "anthropic", - ["model"] = "claude-sonnet-4-20250514", - ["prompt"] = "Using {{Plan Specialist Passes.Plan}} and {{Transcribe Brief.Output}}, identify risks, missing context, and follow-up questions." - } - } - ] - }, - new StepDefinitionDto - { - Name = "Merge Specialist Outputs", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Combine {{Draft with OpenAI.Response}} with {{Audit with Anthropic.Response}} into a final package for approval." - } - }, - new StepDefinitionDto - { - Name = "Review Package", - Type = "HumanTaskStep", - Config = new Dictionary - { - ["title"] = "Review specialist output package", - ["instructions"] = "Review the merged draft, audit notes, and routing recommendation before publishing." - } - } - ] - } - }; - - // ── Voice & Audio ─────────────────────────────────────────── - - private static WorkflowTemplate QuickTranscript() => new() - { - Id = "quick-transcript", - Name = "Quick Transcript", - Description = "Record audio, transcribe it, clean up with LLM, and present for human review.", - Category = "Voice & Audio", - Tags = ["voice", "transcription", "llm", "review"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "QuickTranscript", - Steps = - [ - new StepDefinitionDto { Name = "RecordAudio", Type = "Action" }, - new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, - new StepDefinitionDto { Name = "LlmCleanup", Type = "LlmCallStep" }, - new StepDefinitionDto { Name = "StoreCleanup", Type = "Action" }, - new StepDefinitionDto { Name = "ReviewTranscript", Type = "HumanTaskStep" } - ] - } - }; - - private static WorkflowTemplate MeetingNotes() => new() - { - Id = "meeting-notes", - Name = "Meeting Notes", - Description = "Transcribe a meeting, identify speakers, extract formatted notes and action items, then review.", - Category = "Voice & Audio", - Tags = ["voice", "meeting", "speakers", "action-items"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 7, - Definition = new WorkflowDefinitionDto - { - Name = "MeetingNotes", - Steps = - [ - new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, - new StepDefinitionDto { Name = "CountSpeakers", Type = "Action" }, - new StepDefinitionDto { Name = "LabelSpeakers", Type = "Action" }, - new StepDefinitionDto { Name = "FormatMeetingNotes", Type = "LlmCallStep" }, - new StepDefinitionDto { Name = "ExtractActionItems", Type = "LlmCallStep" }, - new StepDefinitionDto { Name = "StoreResults", Type = "Action" }, - new StepDefinitionDto { Name = "ReviewMeetingNotes", Type = "HumanTaskStep" } - ] - } - }; - - private static WorkflowTemplate BlogFromInterview() => new() - { - Id = "blog-from-interview", - Name = "Blog from Interview", - Description = "5-phase voice-first workflow: capture an interview topic, let a local agent shape questions, collect answers, draft a blog post with prompt wiring, and send it to human review.", - Category = "Voice & Audio", - Tags = ["voice", "agent", "blog", "interview", "compaction"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 10, - PreviewImageUrl = "/images/templates/blog-from-interview-preview.svg", - IsFeatured = true, - FeaturedReason = "A complete voice-to-draft sample with multimodal intake, local question generation, and downstream editorial drafting.", - Definition = new WorkflowDefinitionDto - { - Name = "BlogInterview", - Steps = - [ - // Phase 1 - new StepDefinitionDto - { - Name = "RecordTopicIntro", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Capture the topic briefing from {recordings} and any initial {transcript} notes." - } - }, - new StepDefinitionDto - { - Name = "TranscribeTopic", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Normalize {recordings} into a clean transcript for the interview topic." - } - }, - new StepDefinitionDto - { - Name = "CleanupTopic", - Type = "LlmCallStep", - Config = new Dictionary - { - ["provider"] = "ollama", - ["model"] = "qwen2.5", - ["prompt"] = "Clean up {{TranscribeTopic.Output}} into a concise topic brief with audience, angle, and must-cover themes." - } - }, - new StepDefinitionDto - { - Name = "ReviewTopic", - Type = "HumanTaskStep", - Config = new Dictionary - { - ["assignee"] = "content-editor", - ["description"] = "Review {{CleanupTopic.Response}} and confirm the interview direction before questions are generated.", - ["priority"] = "High" - } - }, - // Phase 2 - new StepDefinitionDto - { - Name = "GenerateQuestions", - Type = "AgentLoopStep", - Config = new Dictionary - { - ["provider"] = "ollama", - ["model"] = "llama3.2", - ["systemPrompt"] = "Using {{CleanupTopic.Response}} and any existing {questions}, generate a tight interview question set that will produce a publishable blog post.", - ["maxIterations"] = "4" - } - }, - new StepDefinitionDto - { - Name = "ParseQuestions", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Convert {{GenerateQuestions.Iterations}} into an editor-friendly question plan and sync it with {questions}." - } - }, - // Phase 3 - new StepDefinitionDto - { - Name = "RecordAnswers", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Capture interview answers from {qaPairs}, {recordings}, and the active transcript." - } - }, - // Phase 4 - new StepDefinitionDto - { - Name = "SynthesizeBlog", - Type = "LlmCallStep", - Config = new Dictionary - { - ["provider"] = "openai", - ["model"] = "gpt-4o-mini", - ["prompt"] = "Draft a blog post using {{CleanupTopic.Response}}, {qaPairs}, and {{ParseQuestions.Output}}. Keep the structure scannable and quote-ready." - } - }, - new StepDefinitionDto - { - Name = "StoreBlogPost", - Type = "Action", - Config = new Dictionary - { - ["expression"] = "Persist {{SynthesizeBlog.Response}} as the working article draft." - } - }, - // Phase 5 - new StepDefinitionDto - { - Name = "ReviewBlog", - Type = "HumanTaskStep", - Config = new Dictionary - { - ["assignee"] = "editorial-review", - ["description"] = "Review {{SynthesizeBlog.Response}} and approve any final changes before publishing.", - ["priority"] = "High" - } - } - ] - } - }; - - private static WorkflowTemplate BrainDumpSynthesis() => new() - { - Id = "brain-dump-synthesis", - Name = "Brain Dump Synthesis", - Description = "Record unstructured audio, transcribe, clean up, synthesize into a structured document via agent loop.", - Category = "Voice & Audio", - Tags = ["voice", "agent", "synthesis", "unstructured"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 7, - Definition = new WorkflowDefinitionDto - { - Name = "BrainDumpSynthesis", - Steps = - [ - new StepDefinitionDto { Name = "RecordBrainDump", Type = "Action" }, - new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, - new StepDefinitionDto { Name = "LlmCleanup", Type = "LlmCallStep" }, - new StepDefinitionDto { Name = "ReviewCleanup", Type = "HumanTaskStep" }, - new StepDefinitionDto { Name = "Synthesize", Type = "AgentLoopStep" }, - new StepDefinitionDto { Name = "StoreOutput", Type = "Action" }, - new StepDefinitionDto { Name = "ReviewFinal", Type = "HumanTaskStep" } - ] - } - }; - - private static WorkflowTemplate PodcastTranscript() => new() - { - Id = "podcast-transcript", - Name = "Podcast Transcript", - Description = "Transcribe a podcast, label speakers, then parallel-branch for summary and full transcript formatting before merging.", - Category = "Voice & Audio", - Tags = ["voice", "podcast", "parallel", "summary"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 6, - Definition = new WorkflowDefinitionDto - { - Name = "PodcastTranscript", - Steps = - [ - new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, - new StepDefinitionDto { Name = "LabelSpeakers", Type = "Action" }, - new StepDefinitionDto - { - Name = "ParallelProcessing", - Type = "Parallel", - Steps = - [ - new StepDefinitionDto { Name = "Summarize", Type = "LlmCallStep" }, - new StepDefinitionDto { Name = "FormatTranscript", Type = "LlmCallStep" } - ] - }, - new StepDefinitionDto { Name = "MergeResults", Type = "Action" }, - new StepDefinitionDto { Name = "ReviewPodcast", Type = "HumanTaskStep" } - ] - } - }; - - // ── Integration Patterns ──────────────────────────────────── - - private static WorkflowTemplate ContentBasedRouter() => new() - { - Id = "content-based-router", - Name = "Content-Based Router", - Description = "Route incoming messages to different processing steps based on message type or content.", - Category = "Integration Patterns", - Tags = ["integration", "routing", "eip", "conditional"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 4, - Definition = new WorkflowDefinitionDto - { - Name = "ContentBasedRouter", - Steps = - [ - new StepDefinitionDto { Name = "ReceiveMessage", Type = "Action" }, - new StepDefinitionDto { Name = "ClassifyMessage", Type = "Action" }, - new StepDefinitionDto - { - Name = "RouteByType", - Type = "ContentBasedRouter", - Steps = - [ - new StepDefinitionDto { Name = "HandleOrderMessage", Type = "Action" }, - new StepDefinitionDto { Name = "HandleInventoryMessage", Type = "Action" }, - new StepDefinitionDto { Name = "HandleNotificationMessage", Type = "Action" } - ] - }, - new StepDefinitionDto { Name = "LogRouting", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate ScatterGather() => new() - { - Id = "scatter-gather", - Name = "Scatter-Gather", - Description = "Fan out a request to multiple services in parallel, then aggregate all responses.", - Category = "Integration Patterns", - Tags = ["integration", "scatter-gather", "parallel", "aggregation"], - Difficulty = TemplateDifficulty.Advanced, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "ScatterGather", - Steps = - [ - new StepDefinitionDto { Name = "PrepareRequest", Type = "Action" }, - new StepDefinitionDto - { - Name = "ScatterToServices", - Type = "Parallel", - Steps = - [ - new StepDefinitionDto { Name = "CallServiceA", Type = "HttpStep" }, - new StepDefinitionDto { Name = "CallServiceB", Type = "HttpStep" }, - new StepDefinitionDto { Name = "CallServiceC", Type = "HttpStep" } - ] - }, - new StepDefinitionDto { Name = "AggregateResponses", Type = "Action" }, - new StepDefinitionDto { Name = "SelectBestResult", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate PublishSubscribe() => new() - { - Id = "publish-subscribe", - Name = "Publish-Subscribe", - Description = "Event-driven workflow that publishes events and has parallel subscribers reacting to them.", - Category = "Integration Patterns", - Tags = ["integration", "events", "pub-sub", "parallel"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "PublishSubscribe", - Steps = - [ - new StepDefinitionDto { Name = "ProcessInput", Type = "Action" }, - new StepDefinitionDto { Name = "PublishEvent", Type = "PublishEventStep" }, - new StepDefinitionDto - { - Name = "Subscribers", - Type = "Parallel", - Steps = - [ - new StepDefinitionDto { Name = "NotificationSubscriber", Type = "Action" }, - new StepDefinitionDto { Name = "AuditLogSubscriber", Type = "Action" }, - new StepDefinitionDto { Name = "AnalyticsSubscriber", Type = "Action" } - ] - }, - new StepDefinitionDto { Name = "ConfirmDelivery", Type = "Action" } - ] - } - }; - - private static WorkflowTemplate HttpApiOrchestration() => new() - { - Id = "http-api-orchestration", - Name = "HTTP API Orchestration", - Description = "Chain multiple REST API calls with data mapping between each step.", - Category = "Integration Patterns", - Tags = ["integration", "http", "rest", "orchestration"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "HttpApiOrchestration", - Steps = - [ - new StepDefinitionDto { Name = "FetchUserProfile", Type = "HttpStep" }, - new StepDefinitionDto { Name = "MapUserData", Type = "DataMapStep" }, - new StepDefinitionDto { Name = "FetchUserOrders", Type = "HttpStep" }, - new StepDefinitionDto { Name = "EnrichOrderData", Type = "DataMapStep" }, - new StepDefinitionDto { Name = "SendSummaryEmail", Type = "HttpStep" } - ] - } - }; - - private static WorkflowTemplate WebhookHandler() => new() - { - Id = "webhook-handler", - Name = "Webhook Handler", - Description = "Trigger a workflow from an incoming webhook, validate the payload, process, and respond.", - Category = "Integration Patterns", - Tags = ["integration", "webhook", "trigger", "http"], - Difficulty = TemplateDifficulty.Intermediate, - StepCount = 5, - Definition = new WorkflowDefinitionDto - { - Name = "WebhookHandler", - Steps = - [ - new StepDefinitionDto { Name = "ReceiveWebhook", Type = "Action" }, - new StepDefinitionDto { Name = "ValidateSignature", Type = "Action" }, - new StepDefinitionDto { Name = "ParsePayload", Type = "DataMapStep" }, - new StepDefinitionDto - { - Name = "RouteByEvent", - Type = "Conditional", - Then = new StepDefinitionDto { Name = "HandleCreated", Type = "Action" }, - Else = new StepDefinitionDto { Name = "HandleUpdated", Type = "Action" } - }, - new StepDefinitionDto { Name = "SendAcknowledgement", Type = "HttpStep" } - ] - } - }; -} +using WorkflowFramework.Dashboard.Api.Models; +using WorkflowFramework.Serialization; + +namespace WorkflowFramework.Dashboard.Api.Services; + +/// +/// In-memory template library pre-loaded with all workflow templates. +/// +public sealed class InMemoryWorkflowTemplateLibrary : IWorkflowTemplateLibrary +{ + private readonly List _templates; + + public InMemoryWorkflowTemplateLibrary() + { + _templates = BuildAllTemplates(); + } + + public Task> GetTemplatesAsync(string? category = null, string? tag = null, CancellationToken ct = default) + { + var query = _templates.AsEnumerable(); + + if (!string.IsNullOrEmpty(category)) + query = query.Where(t => string.Equals(t.Category, category, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(tag)) + query = query.Where(t => t.Tags.Any(tg => string.Equals(tg, tag, StringComparison.OrdinalIgnoreCase))); + + var result = query.Select(t => new WorkflowTemplateSummary + { + Id = t.Id, + Name = t.Name, + Description = t.Description, + Category = t.Category, + Tags = t.Tags, + Difficulty = t.Difficulty, + StepCount = t.StepCount, + PreviewImageUrl = t.PreviewImageUrl, + IsFeatured = t.IsFeatured, + FeaturedReason = t.FeaturedReason + }).ToList(); + + return Task.FromResult>(result); + } + + public Task GetTemplateAsync(string id, CancellationToken ct = default) + { + var template = _templates.FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(template); + } + + public Task> GetCategoriesAsync(CancellationToken ct = default) + { + var categories = _templates.Select(t => t.Category).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(c => c).ToList(); + return Task.FromResult>(categories); + } + + private static List BuildAllTemplates() + { + var templates = new List(); + + // === Getting Started === + templates.Add(HelloWorld()); + templates.Add(SequentialPipeline()); + templates.Add(ConditionalBranching()); + templates.Add(ParallelExecution()); + templates.Add(ErrorHandling()); + templates.Add(RetryWithBackoff()); + templates.Add(LoopProcessing()); + + // === Data Processing === + templates.Add(CsvEtlPipeline()); + templates.Add(DataMappingTransform()); + templates.Add(SchemaValidation()); + + // === Order Management === + templates.Add(OrderProcessingSaga()); + templates.Add(ExpressOrderFlow()); + templates.Add(OrderWithApproval()); + + // === AI & Agents === + templates.Add(TaskExtractionPipeline()); + templates.Add(AgentTriageWorkflow()); + templates.Add(MultimodalLocalRouter()); + templates.Add(AiDslEmitter()); + + // === Voice & Audio === + templates.Add(QuickTranscript()); + templates.Add(MeetingNotes()); + templates.Add(BlogFromInterview()); + templates.Add(BrainDumpSynthesis()); + templates.Add(PodcastTranscript()); + + // === Integration Patterns === + templates.Add(ContentBasedRouter()); + templates.Add(ScatterGather()); + templates.Add(PublishSubscribe()); + templates.Add(HttpApiOrchestration()); + templates.Add(WebhookHandler()); + + return templates; + } + + // ── Getting Started ───────────────────────────────────────── + + private static WorkflowTemplate HelloWorld() => new() + { + Id = "hello-world", + Name = "Hello World", + Description = "The simplest possible workflow — a single action step that greets the user.", + Category = "Getting Started", + Tags = ["beginner", "simple", "action"], + Difficulty = TemplateDifficulty.Beginner, + StepCount = 1, + Definition = new WorkflowDefinitionDto + { + Name = "HelloWorkflow", + Steps = [new StepDefinitionDto { Name = "Greet", Type = "Action" }] + } + }; + + private static WorkflowTemplate SequentialPipeline() => new() + { + Id = "sequential-pipeline", + Name = "Sequential Pipeline", + Description = "A 3-step linear flow demonstrating sequential step execution.", + Category = "Getting Started", + Tags = ["beginner", "sequential", "pipeline"], + Difficulty = TemplateDifficulty.Beginner, + StepCount = 3, + Definition = new WorkflowDefinitionDto + { + Name = "SequentialPipeline", + Steps = + [ + new StepDefinitionDto { Name = "Step1_Prepare", Type = "Action" }, + new StepDefinitionDto { Name = "Step2_Process", Type = "Action" }, + new StepDefinitionDto { Name = "Step3_Finalize", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate ConditionalBranching() => new() + { + Id = "conditional-branching", + Name = "Conditional Branching", + Description = "If/else branching with different execution paths based on a condition.", + Category = "Getting Started", + Tags = ["beginner", "conditional", "branching"], + Difficulty = TemplateDifficulty.Beginner, + StepCount = 4, + Definition = new WorkflowDefinitionDto + { + Name = "ConditionalBranching", + Steps = + [ + new StepDefinitionDto { Name = "ValidateInput", Type = "Action" }, + new StepDefinitionDto + { + Name = "CheckCondition", + Type = "Conditional", + Then = new StepDefinitionDto { Name = "ProcessValid", Type = "Action" }, + Else = new StepDefinitionDto { Name = "HandleInvalid", Type = "Action" } + }, + new StepDefinitionDto { Name = "Summary", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate ParallelExecution() => new() + { + Id = "parallel-execution", + Name = "Parallel Execution", + Description = "Run multiple branches in parallel and wait for all to complete before continuing.", + Category = "Getting Started", + Tags = ["beginner", "parallel", "concurrency"], + Difficulty = TemplateDifficulty.Beginner, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "ParallelExecution", + Steps = + [ + new StepDefinitionDto { Name = "PrepareData", Type = "Action" }, + new StepDefinitionDto + { + Name = "ParallelBranches", + Type = "Parallel", + Steps = + [ + new StepDefinitionDto { Name = "BranchA_FetchExternal", Type = "Action" }, + new StepDefinitionDto { Name = "BranchB_ComputeLocal", Type = "Action" }, + new StepDefinitionDto { Name = "BranchC_ValidateRules", Type = "Action" } + ] + }, + new StepDefinitionDto { Name = "MergeResults", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate ErrorHandling() => new() + { + Id = "error-handling", + Name = "Error Handling", + Description = "Try/catch/finally pattern for robust error handling in workflows.", + Category = "Getting Started", + Tags = ["intermediate", "error-handling", "try-catch"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "ErrorHandling", + Steps = + [ + new StepDefinitionDto + { + Name = "SafeOperation", + Type = "TryCatch", + TryBody = + [ + new StepDefinitionDto { Name = "RiskyStep", Type = "Action" }, + new StepDefinitionDto { Name = "DependentStep", Type = "Action" } + ], + CatchTypes = ["System.InvalidOperationException", "System.TimeoutException"], + FinallyBody = + [ + new StepDefinitionDto { Name = "CleanupResources", Type = "Action" } + ] + }, + new StepDefinitionDto { Name = "Continue", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate RetryWithBackoff() => new() + { + Id = "retry-with-backoff", + Name = "Retry with Backoff", + Description = "Wrap a flaky operation in a retry step with configurable max attempts.", + Category = "Getting Started", + Tags = ["intermediate", "retry", "resilience"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 3, + Definition = new WorkflowDefinitionDto + { + Name = "RetryWithBackoff", + Steps = + [ + new StepDefinitionDto { Name = "SetupConnection", Type = "Action" }, + new StepDefinitionDto + { + Name = "RetryableCall", + Type = "Retry", + MaxAttempts = 3, + Steps = [new StepDefinitionDto { Name = "CallExternalApi", Type = "Action" }] + }, + new StepDefinitionDto { Name = "ProcessResponse", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate LoopProcessing() => new() + { + Id = "loop-processing", + Name = "Loop Processing", + Description = "ForEach and While loop patterns for iterative data processing.", + Category = "Getting Started", + Tags = ["intermediate", "loop", "foreach", "while"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 4, + Definition = new WorkflowDefinitionDto + { + Name = "LoopProcessing", + Steps = + [ + new StepDefinitionDto { Name = "LoadItems", Type = "Action" }, + new StepDefinitionDto + { + Name = "ProcessEachItem", + Type = "ForEach", + Steps = + [ + new StepDefinitionDto { Name = "TransformItem", Type = "Action" }, + new StepDefinitionDto { Name = "ValidateItem", Type = "Action" } + ] + }, + new StepDefinitionDto + { + Name = "PollUntilComplete", + Type = "While", + Steps = [new StepDefinitionDto { Name = "CheckStatus", Type = "Action" }] + }, + new StepDefinitionDto { Name = "Summarize", Type = "Action" } + ] + } + }; + + // ── Data Processing ───────────────────────────────────────── + + private static WorkflowTemplate CsvEtlPipeline() => new() + { + Id = "csv-etl-pipeline", + Name = "CSV ETL Pipeline", + Description = "Extract CSV data, transform and filter records, validate, and write output.", + Category = "Data Processing", + Tags = ["etl", "csv", "transform", "pipeline"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 4, + Definition = new WorkflowDefinitionDto + { + Name = "CsvEtlPipeline", + Steps = + [ + new StepDefinitionDto { Name = "Extract", Type = "Action" }, + new StepDefinitionDto { Name = "Transform", Type = "DataMapStep" }, + new StepDefinitionDto { Name = "Validate", Type = "Action" }, + new StepDefinitionDto { Name = "Load", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate DataMappingTransform() => new() + { + Id = "data-mapping-transform", + Name = "Data Mapping & Transform", + Description = "Use DataMapStep to map and transform fields between data formats.", + Category = "Data Processing", + Tags = ["data-mapping", "transform", "fields"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 3, + Definition = new WorkflowDefinitionDto + { + Name = "DataMappingTransform", + Steps = + [ + new StepDefinitionDto { Name = "ReadSource", Type = "Action" }, + new StepDefinitionDto { Name = "MapFields", Type = "DataMapStep" }, + new StepDefinitionDto { Name = "WriteTarget", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate SchemaValidation() => new() + { + Id = "schema-validation", + Name = "Schema Validation", + Description = "Validate data against a JSON schema with conditional error handling.", + Category = "Data Processing", + Tags = ["validation", "schema", "json"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 4, + Definition = new WorkflowDefinitionDto + { + Name = "SchemaValidation", + Steps = + [ + new StepDefinitionDto { Name = "LoadData", Type = "Action" }, + new StepDefinitionDto { Name = "ValidateSchema", Type = "Action" }, + new StepDefinitionDto + { + Name = "CheckValid", + Type = "Conditional", + Then = new StepDefinitionDto { Name = "ProcessData", Type = "Action" }, + Else = new StepDefinitionDto { Name = "ReportErrors", Type = "Action" } + } + ] + } + }; + + // ── Order Management ──────────────────────────────────────── + + private static WorkflowTemplate OrderProcessingSaga() => new() + { + Id = "order-processing-saga", + Name = "Order Processing Saga", + Description = "Multi-step order saga with inventory reservation, payment charging, and compensation on failure.", + Category = "Order Management", + Tags = ["saga", "compensation", "order", "transaction"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 6, + Definition = new WorkflowDefinitionDto + { + Name = "OrderProcessingSaga", + Steps = + [ + new StepDefinitionDto { Name = "ValidateOrder", Type = "Action" }, + new StepDefinitionDto + { + Name = "OrderSaga", + Type = "Saga", + Steps = + [ + new StepDefinitionDto { Name = "CheckInventory", Type = "Action" }, + new StepDefinitionDto { Name = "ChargePayment", Type = "Action" }, + new StepDefinitionDto { Name = "ShipOrder", Type = "Action" } + ] + }, + new StepDefinitionDto { Name = "SendConfirmation", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate ExpressOrderFlow() => new() + { + Id = "express-order-flow", + Name = "Express Order Flow", + Description = "Fast-path conditional routing — express orders get prioritized, standard orders follow normal processing.", + Category = "Order Management", + Tags = ["conditional", "routing", "order", "express"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "ExpressOrderFlow", + Steps = + [ + new StepDefinitionDto { Name = "ValidateOrder", Type = "Action" }, + new StepDefinitionDto { Name = "CheckInventory", Type = "Action" }, + new StepDefinitionDto + { + Name = "ShippingRoute", + Type = "Conditional", + Then = new StepDefinitionDto { Name = "PrioritizeOrder", Type = "Action" }, + Else = new StepDefinitionDto { Name = "StandardProcessing", Type = "Action" } + }, + new StepDefinitionDto { Name = "ChargePayment", Type = "Action" }, + new StepDefinitionDto { Name = "SendConfirmation", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate OrderWithApproval() => new() + { + Id = "order-with-approval", + Name = "Order with Approval", + Description = "Order workflow with a human approval gate for high-value orders.", + Category = "Order Management", + Tags = ["approval", "human-task", "order", "gate"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 6, + Definition = new WorkflowDefinitionDto + { + Name = "OrderWithApproval", + Steps = + [ + new StepDefinitionDto { Name = "ValidateOrder", Type = "Action" }, + new StepDefinitionDto + { + Name = "CheckApprovalNeeded", + Type = "Conditional", + Then = new StepDefinitionDto { Name = "ManagerApproval", Type = "ApprovalStep" }, + Else = new StepDefinitionDto { Name = "AutoApprove", Type = "Action" } + }, + new StepDefinitionDto { Name = "CheckInventory", Type = "Action" }, + new StepDefinitionDto { Name = "ChargePayment", Type = "Action" }, + new StepDefinitionDto { Name = "SendConfirmation", Type = "Action" } + ] + } + }; + + // ── AI & Agents ───────────────────────────────────────────── + + private static WorkflowTemplate TaskExtractionPipeline() => new() + { + Id = "task-extraction-pipeline", + Name = "Task Extraction Pipeline", + Description = "AI-powered pipeline that collects text from sources, normalizes input, extracts tasks via LLM, validates, and persists.", + Category = "AI & Agents", + Tags = ["ai", "extraction", "llm", "pipeline"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "TaskExtractionPipeline", + Steps = + [ + new StepDefinitionDto { Name = "CollectSources", Type = "Action" }, + new StepDefinitionDto { Name = "NormalizeInput", Type = "Action" }, + new StepDefinitionDto { Name = "ExtractTodos", Type = "LlmCallStep" }, + new StepDefinitionDto { Name = "ValidateAndDeduplicate", Type = "Action" }, + new StepDefinitionDto { Name = "PersistTodos", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate AgentTriageWorkflow() => new() + { + Id = "agent-triage-workflow", + Name = "Agent Triage Workflow", + Description = "Agent loop for triaging tasks by priority, with parallel branches for agent execution and human task enrichment.", + Category = "AI & Agents", + Tags = ["ai", "agent", "triage", "parallel"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 4, + Definition = new WorkflowDefinitionDto + { + Name = "AgentTriageWorkflow", + Steps = + [ + new StepDefinitionDto { Name = "TriageTasks", Type = "AgentDecisionStep" }, + new StepDefinitionDto + { + Name = "ExecuteAndEnrich", + Type = "Parallel", + Steps = + [ + new StepDefinitionDto { Name = "AgentExecution", Type = "AgentLoopStep" }, + new StepDefinitionDto { Name = "EnrichHumanTasks", Type = "HumanTaskStep" } + ] + }, + new StepDefinitionDto { Name = "AggregateResults", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate MultimodalLocalRouter() => new() + { + Id = "multimodal-local-router", + Name = "Multimodal Local Router", + Description = "Capture a multimodal brief, let a cheap local model route and plan the work, then hand specialist passes to downstream OpenAI and Anthropic models before human review.", + Category = "AI & Agents", + Tags = ["ai", "agent", "multimodal", "local-model", "routing", "provider-selection"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 8, + PreviewImageUrl = "/images/templates/multimodal-local-router-preview.svg", + IsFeatured = true, + FeaturedReason = "Shows local-first routing and specialist-model handoff with reusable prompt variables.", + Definition = new WorkflowDefinitionDto + { + Name = "MultimodalLocalRouter", + Steps = + [ + new StepDefinitionDto + { + Name = "Capture Brief", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Capture multimodal inputs from {recordings}, {transcript}, and any uploaded notes." + } + }, + new StepDefinitionDto + { + Name = "Transcribe Brief", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Normalize {recordings} and {transcript} into a single working brief." + } + }, + new StepDefinitionDto + { + Name = "Route Brief", + Type = "AgentDecisionStep", + Config = new Dictionary + { + ["provider"] = "ollama", + ["model"] = "phi4-mini", + ["prompt"] = "Review {{Transcribe Brief.Output}} and decide which downstream specialist models should draft, verify, or escalate the task.", + ["options"] = "openai-draft,anthropic-audit,dual-specialists,escalate-to-human" + } + }, + new StepDefinitionDto + { + Name = "Plan Specialist Passes", + Type = "AgentPlanStep", + Config = new Dictionary + { + ["provider"] = "ollama", + ["model"] = "llama3.2", + ["objective"] = "Use {{Route Brief.Decision}} and {{Transcribe Brief.Output}} to produce an execution plan for the downstream specialist passes." + } + }, + new StepDefinitionDto + { + Name = "Specialist Passes", + Type = "Parallel", + Steps = + [ + new StepDefinitionDto + { + Name = "Draft with OpenAI", + Type = "LlmCallStep", + Config = new Dictionary + { + ["provider"] = "openai", + ["model"] = "gpt-4o-mini", + ["prompt"] = "Using {{Plan Specialist Passes.Plan}} and {{Transcribe Brief.Output}}, draft the primary deliverable." + } + }, + new StepDefinitionDto + { + Name = "Audit with Anthropic", + Type = "LlmCallStep", + Config = new Dictionary + { + ["provider"] = "anthropic", + ["model"] = "claude-sonnet-4-20250514", + ["prompt"] = "Using {{Plan Specialist Passes.Plan}} and {{Transcribe Brief.Output}}, identify risks, missing context, and follow-up questions." + } + } + ] + }, + new StepDefinitionDto + { + Name = "Merge Specialist Outputs", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Combine {{Draft with OpenAI.Response}} with {{Audit with Anthropic.Response}} into a final package for approval." + } + }, + new StepDefinitionDto + { + Name = "Review Package", + Type = "HumanTaskStep", + Config = new Dictionary + { + ["title"] = "Review specialist output package", + ["instructions"] = "Review the merged draft, audit notes, and routing recommendation before publishing." + } + } + ] + } + }; + + private static WorkflowTemplate AiDslEmitter() => new() + { + Id = "ai-dsl-emitter", + Name = "AI DSL Emitter", + Description = "An LLM iteratively emits workflow step definitions as JSON. A human reviews and approves the plan, then WorkflowDslExecutorStep materialises and runs those steps at runtime. Works offline with the built-in echo provider or live with Ollama.", + Category = "AI & Agents", + Tags = ["ai", "dsl", "dynamic", "human-in-the-loop", "echo", "ollama"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 5, + PreviewImageUrl = "/images/templates/ai-dsl-emitter-preview.svg", + IsFeatured = true, + FeaturedReason = "Demonstrates LLM-driven dynamic workflow construction: the model plans, a human approves, and the framework executes the emitted steps at runtime.", + Definition = new WorkflowDefinitionDto + { + Name = "AiDslEmitterDemo", + Steps = + [ + new StepDefinitionDto + { + Name = "SelectProvider", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Resolve the agent provider from {provider} (echo or ollama) and store it in context for DslEmitterStep." + } + }, + new StepDefinitionDto + { + Name = "EmitSteps", + Type = "DslEmitterStep", + Config = new Dictionary + { + ["provider"] = "echo", + ["systemPrompt"] = "You are a workflow planning assistant. Respond ONLY with a valid JSON array of step definitions. Each step must have \"name\" and \"type\" fields. When finished, respond with exactly: []", + ["maxIterations"] = "5", + ["doneSignal"] = "[]" + } + }, + new StepDefinitionDto + { + Name = "ApprovePlan", + Type = "ApprovalStep", + Config = new Dictionary + { + ["title"] = "Review AI-emitted workflow plan", + ["instructions"] = "The LLM emitted {{EmitSteps.EmittedSteps}}. Approve to execute these steps or reject to abort." + } + }, + new StepDefinitionDto + { + Name = "BridgeContext", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Copy {{EmitSteps.EmittedSteps}} into WorkflowDslExecutor.Steps so the executor can pick them up." + } + }, + new StepDefinitionDto + { + Name = "ExecuteEmittedSteps", + Type = "WorkflowDslExecutorStep" + } + ] + } + }; + + // ── Voice & Audio ─────────────────────────────────────────── + + private static WorkflowTemplate QuickTranscript() => new() + { + Id = "quick-transcript", + Name = "Quick Transcript", + Description = "Record audio, transcribe it, clean up with LLM, and present for human review.", + Category = "Voice & Audio", + Tags = ["voice", "transcription", "llm", "review"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "QuickTranscript", + Steps = + [ + new StepDefinitionDto { Name = "RecordAudio", Type = "Action" }, + new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, + new StepDefinitionDto { Name = "LlmCleanup", Type = "LlmCallStep" }, + new StepDefinitionDto { Name = "StoreCleanup", Type = "Action" }, + new StepDefinitionDto { Name = "ReviewTranscript", Type = "HumanTaskStep" } + ] + } + }; + + private static WorkflowTemplate MeetingNotes() => new() + { + Id = "meeting-notes", + Name = "Meeting Notes", + Description = "Transcribe a meeting, identify speakers, extract formatted notes and action items, then review.", + Category = "Voice & Audio", + Tags = ["voice", "meeting", "speakers", "action-items"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 7, + Definition = new WorkflowDefinitionDto + { + Name = "MeetingNotes", + Steps = + [ + new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, + new StepDefinitionDto { Name = "CountSpeakers", Type = "Action" }, + new StepDefinitionDto { Name = "LabelSpeakers", Type = "Action" }, + new StepDefinitionDto { Name = "FormatMeetingNotes", Type = "LlmCallStep" }, + new StepDefinitionDto { Name = "ExtractActionItems", Type = "LlmCallStep" }, + new StepDefinitionDto { Name = "StoreResults", Type = "Action" }, + new StepDefinitionDto { Name = "ReviewMeetingNotes", Type = "HumanTaskStep" } + ] + } + }; + + private static WorkflowTemplate BlogFromInterview() => new() + { + Id = "blog-from-interview", + Name = "Blog from Interview", + Description = "5-phase voice-first workflow: capture an interview topic, let a local agent shape questions, collect answers, draft a blog post with prompt wiring, and send it to human review.", + Category = "Voice & Audio", + Tags = ["voice", "agent", "blog", "interview", "compaction"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 10, + PreviewImageUrl = "/images/templates/blog-from-interview-preview.svg", + IsFeatured = true, + FeaturedReason = "A complete voice-to-draft sample with multimodal intake, local question generation, and downstream editorial drafting.", + Definition = new WorkflowDefinitionDto + { + Name = "BlogInterview", + Steps = + [ + // Phase 1 + new StepDefinitionDto + { + Name = "RecordTopicIntro", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Capture the topic briefing from {recordings} and any initial {transcript} notes." + } + }, + new StepDefinitionDto + { + Name = "TranscribeTopic", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Normalize {recordings} into a clean transcript for the interview topic." + } + }, + new StepDefinitionDto + { + Name = "CleanupTopic", + Type = "LlmCallStep", + Config = new Dictionary + { + ["provider"] = "ollama", + ["model"] = "qwen2.5", + ["prompt"] = "Clean up {{TranscribeTopic.Output}} into a concise topic brief with audience, angle, and must-cover themes." + } + }, + new StepDefinitionDto + { + Name = "ReviewTopic", + Type = "HumanTaskStep", + Config = new Dictionary + { + ["assignee"] = "content-editor", + ["description"] = "Review {{CleanupTopic.Response}} and confirm the interview direction before questions are generated.", + ["priority"] = "High" + } + }, + // Phase 2 + new StepDefinitionDto + { + Name = "GenerateQuestions", + Type = "AgentLoopStep", + Config = new Dictionary + { + ["provider"] = "ollama", + ["model"] = "llama3.2", + ["systemPrompt"] = "Using {{CleanupTopic.Response}} and any existing {questions}, generate a tight interview question set that will produce a publishable blog post.", + ["maxIterations"] = "4" + } + }, + new StepDefinitionDto + { + Name = "ParseQuestions", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Convert {{GenerateQuestions.Iterations}} into an editor-friendly question plan and sync it with {questions}." + } + }, + // Phase 3 + new StepDefinitionDto + { + Name = "RecordAnswers", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Capture interview answers from {qaPairs}, {recordings}, and the active transcript." + } + }, + // Phase 4 + new StepDefinitionDto + { + Name = "SynthesizeBlog", + Type = "LlmCallStep", + Config = new Dictionary + { + ["provider"] = "openai", + ["model"] = "gpt-4o-mini", + ["prompt"] = "Draft a blog post using {{CleanupTopic.Response}}, {qaPairs}, and {{ParseQuestions.Output}}. Keep the structure scannable and quote-ready." + } + }, + new StepDefinitionDto + { + Name = "StoreBlogPost", + Type = "Action", + Config = new Dictionary + { + ["expression"] = "Persist {{SynthesizeBlog.Response}} as the working article draft." + } + }, + // Phase 5 + new StepDefinitionDto + { + Name = "ReviewBlog", + Type = "HumanTaskStep", + Config = new Dictionary + { + ["assignee"] = "editorial-review", + ["description"] = "Review {{SynthesizeBlog.Response}} and approve any final changes before publishing.", + ["priority"] = "High" + } + } + ] + } + }; + + private static WorkflowTemplate BrainDumpSynthesis() => new() + { + Id = "brain-dump-synthesis", + Name = "Brain Dump Synthesis", + Description = "Record unstructured audio, transcribe, clean up, synthesize into a structured document via agent loop.", + Category = "Voice & Audio", + Tags = ["voice", "agent", "synthesis", "unstructured"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 7, + Definition = new WorkflowDefinitionDto + { + Name = "BrainDumpSynthesis", + Steps = + [ + new StepDefinitionDto { Name = "RecordBrainDump", Type = "Action" }, + new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, + new StepDefinitionDto { Name = "LlmCleanup", Type = "LlmCallStep" }, + new StepDefinitionDto { Name = "ReviewCleanup", Type = "HumanTaskStep" }, + new StepDefinitionDto { Name = "Synthesize", Type = "AgentLoopStep" }, + new StepDefinitionDto { Name = "StoreOutput", Type = "Action" }, + new StepDefinitionDto { Name = "ReviewFinal", Type = "HumanTaskStep" } + ] + } + }; + + private static WorkflowTemplate PodcastTranscript() => new() + { + Id = "podcast-transcript", + Name = "Podcast Transcript", + Description = "Transcribe a podcast, label speakers, then parallel-branch for summary and full transcript formatting before merging.", + Category = "Voice & Audio", + Tags = ["voice", "podcast", "parallel", "summary"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 6, + Definition = new WorkflowDefinitionDto + { + Name = "PodcastTranscript", + Steps = + [ + new StepDefinitionDto { Name = "Transcribe", Type = "Action" }, + new StepDefinitionDto { Name = "LabelSpeakers", Type = "Action" }, + new StepDefinitionDto + { + Name = "ParallelProcessing", + Type = "Parallel", + Steps = + [ + new StepDefinitionDto { Name = "Summarize", Type = "LlmCallStep" }, + new StepDefinitionDto { Name = "FormatTranscript", Type = "LlmCallStep" } + ] + }, + new StepDefinitionDto { Name = "MergeResults", Type = "Action" }, + new StepDefinitionDto { Name = "ReviewPodcast", Type = "HumanTaskStep" } + ] + } + }; + + // ── Integration Patterns ──────────────────────────────────── + + private static WorkflowTemplate ContentBasedRouter() => new() + { + Id = "content-based-router", + Name = "Content-Based Router", + Description = "Route incoming messages to different processing steps based on message type or content.", + Category = "Integration Patterns", + Tags = ["integration", "routing", "eip", "conditional"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 4, + Definition = new WorkflowDefinitionDto + { + Name = "ContentBasedRouter", + Steps = + [ + new StepDefinitionDto { Name = "ReceiveMessage", Type = "Action" }, + new StepDefinitionDto { Name = "ClassifyMessage", Type = "Action" }, + new StepDefinitionDto + { + Name = "RouteByType", + Type = "ContentBasedRouter", + Steps = + [ + new StepDefinitionDto { Name = "HandleOrderMessage", Type = "Action" }, + new StepDefinitionDto { Name = "HandleInventoryMessage", Type = "Action" }, + new StepDefinitionDto { Name = "HandleNotificationMessage", Type = "Action" } + ] + }, + new StepDefinitionDto { Name = "LogRouting", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate ScatterGather() => new() + { + Id = "scatter-gather", + Name = "Scatter-Gather", + Description = "Fan out a request to multiple services in parallel, then aggregate all responses.", + Category = "Integration Patterns", + Tags = ["integration", "scatter-gather", "parallel", "aggregation"], + Difficulty = TemplateDifficulty.Advanced, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "ScatterGather", + Steps = + [ + new StepDefinitionDto { Name = "PrepareRequest", Type = "Action" }, + new StepDefinitionDto + { + Name = "ScatterToServices", + Type = "Parallel", + Steps = + [ + new StepDefinitionDto { Name = "CallServiceA", Type = "HttpStep" }, + new StepDefinitionDto { Name = "CallServiceB", Type = "HttpStep" }, + new StepDefinitionDto { Name = "CallServiceC", Type = "HttpStep" } + ] + }, + new StepDefinitionDto { Name = "AggregateResponses", Type = "Action" }, + new StepDefinitionDto { Name = "SelectBestResult", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate PublishSubscribe() => new() + { + Id = "publish-subscribe", + Name = "Publish-Subscribe", + Description = "Event-driven workflow that publishes events and has parallel subscribers reacting to them.", + Category = "Integration Patterns", + Tags = ["integration", "events", "pub-sub", "parallel"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "PublishSubscribe", + Steps = + [ + new StepDefinitionDto { Name = "ProcessInput", Type = "Action" }, + new StepDefinitionDto { Name = "PublishEvent", Type = "PublishEventStep" }, + new StepDefinitionDto + { + Name = "Subscribers", + Type = "Parallel", + Steps = + [ + new StepDefinitionDto { Name = "NotificationSubscriber", Type = "Action" }, + new StepDefinitionDto { Name = "AuditLogSubscriber", Type = "Action" }, + new StepDefinitionDto { Name = "AnalyticsSubscriber", Type = "Action" } + ] + }, + new StepDefinitionDto { Name = "ConfirmDelivery", Type = "Action" } + ] + } + }; + + private static WorkflowTemplate HttpApiOrchestration() => new() + { + Id = "http-api-orchestration", + Name = "HTTP API Orchestration", + Description = "Chain multiple REST API calls with data mapping between each step.", + Category = "Integration Patterns", + Tags = ["integration", "http", "rest", "orchestration"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "HttpApiOrchestration", + Steps = + [ + new StepDefinitionDto { Name = "FetchUserProfile", Type = "HttpStep" }, + new StepDefinitionDto { Name = "MapUserData", Type = "DataMapStep" }, + new StepDefinitionDto { Name = "FetchUserOrders", Type = "HttpStep" }, + new StepDefinitionDto { Name = "EnrichOrderData", Type = "DataMapStep" }, + new StepDefinitionDto { Name = "SendSummaryEmail", Type = "HttpStep" } + ] + } + }; + + private static WorkflowTemplate WebhookHandler() => new() + { + Id = "webhook-handler", + Name = "Webhook Handler", + Description = "Trigger a workflow from an incoming webhook, validate the payload, process, and respond.", + Category = "Integration Patterns", + Tags = ["integration", "webhook", "trigger", "http"], + Difficulty = TemplateDifficulty.Intermediate, + StepCount = 5, + Definition = new WorkflowDefinitionDto + { + Name = "WebhookHandler", + Steps = + [ + new StepDefinitionDto { Name = "ReceiveWebhook", Type = "Action" }, + new StepDefinitionDto { Name = "ValidateSignature", Type = "Action" }, + new StepDefinitionDto { Name = "ParsePayload", Type = "DataMapStep" }, + new StepDefinitionDto + { + Name = "RouteByEvent", + Type = "Conditional", + Then = new StepDefinitionDto { Name = "HandleCreated", Type = "Action" }, + Else = new StepDefinitionDto { Name = "HandleUpdated", Type = "Action" } + }, + new StepDefinitionDto { Name = "SendAcknowledgement", Type = "HttpStep" } + ] + } + }; +} diff --git a/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs b/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs index 393a1e3..88d8213 100644 --- a/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs +++ b/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs @@ -1,418 +1,450 @@ -using WorkflowFramework.Dashboard.Api.Models; -using WorkflowFramework.Serialization; - -namespace WorkflowFramework.Dashboard.Api.Services; - -/// -/// Seeds the workflow definition store with pre-built sample workflows on startup. -/// -public static class SampleWorkflowSeeder -{ - public static async Task SeedAsync(IWorkflowDefinitionStore store, CancellationToken ct = default) - { - foreach (var workflow in CreateSamples()) - { - await store.SeedAsync(workflow, ct); - } - } - - private static List CreateSamples() - { - var now = DateTimeOffset.UtcNow; - return - [ - // a) Hello World - new SavedWorkflowDefinition - { - Id = "sample-hello-world", - Description = "Simple two-step greeting workflow demonstrating basic action steps.", - Tags = ["sample", "basic", "beginner"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Hello World", - Version = 1, - Steps = - [ - Step("Greet", "Action", Cfg("expression", "Console.WriteLine('Hello from step!')")), - Step("Farewell", "Action", Cfg("expression", "Console.WriteLine('Goodbye!')")) - ] - } - }, - - // b) Order Processing Pipeline - new SavedWorkflowDefinition - { - Id = "sample-order-processing", - Description = "Order validation and processing with conditional branching.", - Tags = ["sample", "basic", "conditional"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Order Processing Pipeline", - Version = 1, - Steps = - [ - Step("ValidateOrder", "Action", Cfg("expression", "Validate order total > 0")), - new StepDefinitionDto - { - Name = "CheckValidity", Type = "Conditional", - Config = Cfg("expression", "ctx.Data.IsValid"), - Then = Step("ProcessOrder", "Action", Cfg("expression", "Process the order")), - Else = Step("RejectOrder", "Action", Cfg("expression", "Reject invalid order")) - }, - Step("Summary", "Action", Cfg("expression", "Print order summary")) - ] - } - }, - - // c) Data Pipeline (ETL) - new SavedWorkflowDefinition - { - Id = "sample-data-pipeline", - Description = "ETL data pipeline: extract CSV records, filter, transform with markup, and load output.", - Tags = ["sample", "data", "etl", "pipeline"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Data Pipeline (ETL)", - Version = 1, - Steps = - [ - Step("Extract", "Action", Cfg("expression", "Parse CSV source data into records")), - Step("Transform", "Action", Cfg("expression", "Filter records (value > 10), uppercase names, apply 10% markup")), - Step("Load", "Action", Cfg("expression", "Generate output CSV from transformed records")) - ] - } - }, - - // d) TaskStream — AI Task Extraction - new SavedWorkflowDefinition - { - Id = "sample-taskstream", - Description = "AI-powered task extraction from messages using agent loops with Ollama.", - Tags = ["sample", "ai", "ollama", "taskstream", "agent"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "TaskStream — AI Task Extraction", - Version = 1, - Steps = - [ - Step("IngestMessages", "Action", Cfg("expression", "Load messages from configured sources")), - Step("ExtractTasks", "AgentLoopStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["systemPrompt"] = "Extract actionable tasks from the following messages. For each task, identify: title, category (work/personal/shopping/health), priority (1-4), and any deadlines.", - ["maxIterations"] = "5", ["tools"] = "create_todo,search_todos,update_todo,categorize" - }), - Step("TriageTasks", "Action", Cfg("expression", "Categorize and prioritize extracted tasks")), - Step("EnrichTasks", "AgentLoopStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["systemPrompt"] = "For each task, add context, estimate effort, suggest deadlines, and identify dependencies.", - ["maxIterations"] = "3", ["tools"] = "search_todos,update_todo,add_context" - }), - Step("GenerateReport", "Action", Cfg("expression", "Generate markdown summary report")) - ] - } - }, - - // e) Local Ollama Smoke Test - new SavedWorkflowDefinition - { - Id = "sample-local-ollama-smoke", - Description = "Deterministic local-model smoke test that exercises dashboard execution with the configured Ollama provider and model.", - Tags = ["sample", "ai", "ollama", "local-first", "smoke"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Local Ollama Smoke Test", - Version = 1, - Steps = - [ - Step("PrepareContext", "Action", Cfg("expression", "Confirm the local dashboard workflow is healthy")), - Step("GenerateLocalReply", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", - ["model"] = "qwen3:30b-instruct", - ["prompt"] = "You are validating a local WorkflowFramework dashboard smoke test. Context: {{PrepareContext.Expression}}. Reply with one short sentence confirming the local pipeline is working.", - ["temperature"] = "0.1", - ["maxTokens"] = "48" - }), - Step("PersistResult", "Action", Cfg("expression", "Persist {{GenerateLocalReply.Response}}")) - ] - } - }, - - // f) Quick Transcript - new SavedWorkflowDefinition - { - Id = "sample-quick-transcript", - Description = "Record audio, transcribe with Whisper, clean up with LLM, and review.", - Tags = ["sample", "voice", "ollama", "transcript"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Quick Transcript", - Version = 1, - Steps = - [ - Step("RecordAudio", "Action", Cfg("expression", "Record audio via microphone")), - Step("Transcribe", "Action", Cfg("expression", "Transcribe audio using Whisper")), - Step("LlmCleanup", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Clean up and format the following raw transcript, fixing grammar and punctuation while preserving meaning:\n\n{rawTranscript}", - ["temperature"] = "0.3" - }), - Step("StoreCleanup", "Action", Cfg("expression", "Store cleaned transcript")), - Step("ReviewTranscript", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the cleaned-up transcript for accuracy", ["priority"] = "Medium" - }) - ] - } - }, - - // g) Meeting Notes - new SavedWorkflowDefinition - { - Id = "sample-meeting-notes", - Description = "Transcribe meetings, format notes with LLM, extract action items, and review.", - Tags = ["sample", "voice", "ollama", "meeting"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Meeting Notes", - Version = 1, - Steps = - [ - Step("Transcribe", "Action", Cfg("expression", "Transcribe meeting recording")), - Step("CountSpeakers", "Action", Cfg("expression", "Detect number of speakers")), - Step("LabelSpeakers", "Action", Cfg("expression", "Label speakers in transcript")), - Step("FormatMeetingNotes", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Format the following labeled transcript into professional meeting notes with sections for: Attendees, Key Discussion Points, Decisions Made, and Next Steps:\n\n{labeledTranscript}", - ["temperature"] = "0.3" - }), - Step("ExtractActionItems", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Extract all action items from the following meeting notes. For each item include: assignee, description, deadline (if mentioned), and priority:\n\n{meetingNotes}", - ["temperature"] = "0.2" - }), - Step("StoreResults", "Action", Cfg("expression", "Store meeting notes and action items")), - Step("ReviewMeetingNotes", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the formatted meeting notes and action items", ["priority"] = "Medium" - }) - ] - } - }, - - // h) Blog Interview - new SavedWorkflowDefinition - { - Id = "sample-blog-interview", - Description = "Voice-driven blog post creation: record topic, generate questions, synthesize blog.", - Tags = ["sample", "voice", "ollama", "blog", "agent"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Blog Interview", - Version = 1, - Steps = - [ - Step("RecordTopicIntro", "Action", Cfg("expression", "Record topic introduction audio")), - Step("TranscribeTopic", "Action", Cfg("expression", "Transcribe topic introduction")), - Step("CleanupTopic", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Clean up this topic introduction transcript:\n\n{topicTranscript}", ["temperature"] = "0.3" - }), - Step("ReviewTopic", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the topic introduction before generating questions" - }), - Step("GenerateQuestions", "AgentLoopStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["systemPrompt"] = "Based on the topic introduction, generate 5-7 thoughtful interview questions that would make an engaging blog post.", - ["maxIterations"] = "3", ["tools"] = "word_count,format_text" - }), - Step("ParseQuestions", "Action", Cfg("expression", "Parse numbered questions from agent output")), - Step("RecordAnswers", "Action", Cfg("expression", "Record and transcribe answers to each question")), - Step("SynthesizeBlog", "AgentLoopStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["systemPrompt"] = "Synthesize the following interview Q&A pairs into a compelling, well-structured blog post. Include an engaging introduction, organize by themes rather than Q&A format, and end with a strong conclusion.", - ["maxIterations"] = "3", ["tools"] = "word_count,format_text" - }), - Step("StoreBlogPost", "Action", Cfg("expression", "Store final blog post")), - Step("ReviewBlog", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the final blog post before publishing", ["priority"] = "High" - }) - ] - } - }, - - // i) Brain Dump Synthesis - new SavedWorkflowDefinition - { - Id = "sample-brain-dump", - Description = "Record unstructured thoughts, transcribe, and synthesize into organized document.", - Tags = ["sample", "voice", "ollama", "synthesis"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Brain Dump Synthesis", - Version = 1, - Steps = - [ - Step("RecordBrainDump", "Action", Cfg("expression", "Record unstructured brain dump audio")), - Step("Transcribe", "Action", Cfg("expression", "Transcribe brain dump recording")), - Step("LlmCleanup", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Clean up this raw brain dump transcript:\n\n{rawTranscript}", ["temperature"] = "0.3" - }), - Step("ReviewCleanup", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the cleaned-up brain dump before synthesis" - }), - Step("Synthesize", "AgentLoopStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["systemPrompt"] = "Transform this cleaned-up brain dump into a well-organized document. Identify themes, group related ideas, create a logical structure with headers, and ensure NO ideas are lost — even tangential thoughts should be preserved in an appendix.", - ["maxIterations"] = "3", ["tools"] = "word_count,format_text,outline_generator" - }), - Step("StoreOutput", "Action", Cfg("expression", "Store structured document")), - Step("ReviewFinal", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the final structured document", ["priority"] = "Medium" - }) - ] - } - }, - - // i) Podcast Transcript - new SavedWorkflowDefinition - { - Id = "sample-podcast-transcript", - Description = "Transcribe podcast, label speakers, summarize and format in parallel.", - Tags = ["sample", "voice", "ollama", "podcast", "parallel"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Podcast Transcript", - Version = 1, - Steps = - [ - Step("Transcribe", "Action", Cfg("expression", "Transcribe podcast episode")), - Step("LabelSpeakers", "Action", Cfg("expression", "Label speakers in podcast")), - new StepDefinitionDto - { - Name = "SummarizeAndFormat", Type = "Parallel", - Steps = - [ - Step("Summarize", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Create an executive summary of this podcast transcript, highlighting key topics discussed, notable quotes, and main takeaways:\n\n{labeledTranscript}", - ["temperature"] = "0.4" - }), - Step("FormatTranscript", "LlmCallStep", new Dictionary - { - ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", - ["prompt"] = "Format and clean up this podcast transcript for readability:\n\n{labeledTranscript}", - ["temperature"] = "0.3" - }) - ] - }, - Step("MergeResults", "Action", Cfg("expression", "Merge summary and formatted transcript")), - Step("ReviewPodcast", "HumanTaskStep", new Dictionary - { - ["assignee"] = "user", ["description"] = "Review the podcast summary and full transcript", ["priority"] = "Medium" - }) - ] - } - }, - - // j) HTTP API Orchestration - new SavedWorkflowDefinition - { - Id = "sample-http-orchestration", - Description = "Orchestrate multiple HTTP API calls: fetch data, transform, and post results.", - Tags = ["sample", "http", "api", "integration"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "HTTP API Orchestration", - Version = 1, - Steps = - [ - Step("FetchUserData", "HttpStep", new Dictionary - { - ["url"] = "https://jsonplaceholder.typicode.com/users/1", ["method"] = "GET", ["contentType"] = "application/json" - }), - Step("FetchUserPosts", "HttpStep", new Dictionary - { - ["url"] = "https://jsonplaceholder.typicode.com/posts?userId=1", ["method"] = "GET" - }), - Step("TransformData", "Action", Cfg("expression", "Merge user profile with posts data")), - Step("SendNotification", "HttpStep", new Dictionary - { - ["url"] = "https://httpbin.org/post", ["method"] = "POST", - ["body"] = "{\"message\": \"Data processed\", \"status\": \"complete\"}", - ["headers"] = "{\"Content-Type\": \"application/json\"}" - }) - ] - } - }, - - // k) Order Saga with Compensation - new SavedWorkflowDefinition - { - Id = "sample-order-saga", - Description = "Order processing with saga pattern and compensation (rollback on failure).", - Tags = ["sample", "saga", "compensation", "order"], - LastModified = now, - Definition = new WorkflowDefinitionDto - { - Name = "Order Saga with Compensation", - Version = 1, - Steps = - [ - new StepDefinitionDto - { - Name = "OrderSaga", Type = "Saga", - Steps = - [ - Step("ValidateOrder", "Action", Cfg("expression", "Validate order ID and items")), - Step("CheckInventory", "Action", Cfg("expression", "Reserve inventory for items (compensate: release reservation)")), - new StepDefinitionDto - { - Name = "ShippingDecision", Type = "Conditional", - Config = Cfg("expression", "ctx.Data.IsExpressShipping"), - Then = Step("PrioritizeOrder", "Action", Cfg("expression", "Prioritize order for express shipping")), - Else = Step("StandardProcessing", "Action", Cfg("expression", "Standard processing applied")) - }, - Step("ChargePayment", "Action", Cfg("expression", "Charge payment (compensate: issue refund)")), - Step("SendConfirmation", "Action", Cfg("expression", "Send confirmation email")) - ] - } - ] - } - } - ]; - } - - private static StepDefinitionDto Step(string name, string type, Dictionary? config = null) => - new() { Name = name, Type = type, Config = config }; - - private static Dictionary Cfg(string key, string value) => - new() { [key] = value }; -} +using WorkflowFramework.Dashboard.Api.Models; +using WorkflowFramework.Serialization; + +namespace WorkflowFramework.Dashboard.Api.Services; + +/// +/// Seeds the workflow definition store with pre-built sample workflows on startup. +/// +public static class SampleWorkflowSeeder +{ + public static async Task SeedAsync(IWorkflowDefinitionStore store, CancellationToken ct = default) + { + foreach (var workflow in CreateSamples()) + { + await store.SeedAsync(workflow, ct); + } + } + + private static List CreateSamples() + { + var now = DateTimeOffset.UtcNow; + return + [ + // a) Hello World + new SavedWorkflowDefinition + { + Id = "sample-hello-world", + Description = "Simple two-step greeting workflow demonstrating basic action steps.", + Tags = ["sample", "basic", "beginner"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Hello World", + Version = 1, + Steps = + [ + Step("Greet", "Action", Cfg("expression", "Console.WriteLine('Hello from step!')")), + Step("Farewell", "Action", Cfg("expression", "Console.WriteLine('Goodbye!')")) + ] + } + }, + + // b) Order Processing Pipeline + new SavedWorkflowDefinition + { + Id = "sample-order-processing", + Description = "Order validation and processing with conditional branching.", + Tags = ["sample", "basic", "conditional"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Order Processing Pipeline", + Version = 1, + Steps = + [ + Step("ValidateOrder", "Action", Cfg("expression", "Validate order total > 0")), + new StepDefinitionDto + { + Name = "CheckValidity", Type = "Conditional", + Config = Cfg("expression", "ctx.Data.IsValid"), + Then = Step("ProcessOrder", "Action", Cfg("expression", "Process the order")), + Else = Step("RejectOrder", "Action", Cfg("expression", "Reject invalid order")) + }, + Step("Summary", "Action", Cfg("expression", "Print order summary")) + ] + } + }, + + // c) Data Pipeline (ETL) + new SavedWorkflowDefinition + { + Id = "sample-data-pipeline", + Description = "ETL data pipeline: extract CSV records, filter, transform with markup, and load output.", + Tags = ["sample", "data", "etl", "pipeline"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Data Pipeline (ETL)", + Version = 1, + Steps = + [ + Step("Extract", "Action", Cfg("expression", "Parse CSV source data into records")), + Step("Transform", "Action", Cfg("expression", "Filter records (value > 10), uppercase names, apply 10% markup")), + Step("Load", "Action", Cfg("expression", "Generate output CSV from transformed records")) + ] + } + }, + + // d) TaskStream — AI Task Extraction + new SavedWorkflowDefinition + { + Id = "sample-taskstream", + Description = "AI-powered task extraction from messages using agent loops with Ollama.", + Tags = ["sample", "ai", "ollama", "taskstream", "agent"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "TaskStream — AI Task Extraction", + Version = 1, + Steps = + [ + Step("IngestMessages", "Action", Cfg("expression", "Load messages from configured sources")), + Step("ExtractTasks", "AgentLoopStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["systemPrompt"] = "Extract actionable tasks from the following messages. For each task, identify: title, category (work/personal/shopping/health), priority (1-4), and any deadlines.", + ["maxIterations"] = "5", ["tools"] = "create_todo,search_todos,update_todo,categorize" + }), + Step("TriageTasks", "Action", Cfg("expression", "Categorize and prioritize extracted tasks")), + Step("EnrichTasks", "AgentLoopStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["systemPrompt"] = "For each task, add context, estimate effort, suggest deadlines, and identify dependencies.", + ["maxIterations"] = "3", ["tools"] = "search_todos,update_todo,add_context" + }), + Step("GenerateReport", "Action", Cfg("expression", "Generate markdown summary report")) + ] + } + }, + + // e) Local Ollama Smoke Test + new SavedWorkflowDefinition + { + Id = "sample-local-ollama-smoke", + Description = "Deterministic local-model smoke test that exercises dashboard execution with the configured Ollama provider and model.", + Tags = ["sample", "ai", "ollama", "local-first", "smoke"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Local Ollama Smoke Test", + Version = 1, + Steps = + [ + Step("PrepareContext", "Action", Cfg("expression", "Confirm the local dashboard workflow is healthy")), + Step("GenerateLocalReply", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", + ["model"] = "qwen3:30b-instruct", + ["prompt"] = "You are validating a local WorkflowFramework dashboard smoke test. Context: {{PrepareContext.Expression}}. Reply with one short sentence confirming the local pipeline is working.", + ["temperature"] = "0.1", + ["maxTokens"] = "48" + }), + Step("PersistResult", "Action", Cfg("expression", "Persist {{GenerateLocalReply.Response}}")) + ] + } + }, + + // f) Quick Transcript + new SavedWorkflowDefinition + { + Id = "sample-quick-transcript", + Description = "Record audio, transcribe with Whisper, clean up with LLM, and review.", + Tags = ["sample", "voice", "ollama", "transcript"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Quick Transcript", + Version = 1, + Steps = + [ + Step("RecordAudio", "Action", Cfg("expression", "Record audio via microphone")), + Step("Transcribe", "Action", Cfg("expression", "Transcribe audio using Whisper")), + Step("LlmCleanup", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Clean up and format the following raw transcript, fixing grammar and punctuation while preserving meaning:\n\n{rawTranscript}", + ["temperature"] = "0.3" + }), + Step("StoreCleanup", "Action", Cfg("expression", "Store cleaned transcript")), + Step("ReviewTranscript", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the cleaned-up transcript for accuracy", ["priority"] = "Medium" + }) + ] + } + }, + + // g) Meeting Notes + new SavedWorkflowDefinition + { + Id = "sample-meeting-notes", + Description = "Transcribe meetings, format notes with LLM, extract action items, and review.", + Tags = ["sample", "voice", "ollama", "meeting"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Meeting Notes", + Version = 1, + Steps = + [ + Step("Transcribe", "Action", Cfg("expression", "Transcribe meeting recording")), + Step("CountSpeakers", "Action", Cfg("expression", "Detect number of speakers")), + Step("LabelSpeakers", "Action", Cfg("expression", "Label speakers in transcript")), + Step("FormatMeetingNotes", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Format the following labeled transcript into professional meeting notes with sections for: Attendees, Key Discussion Points, Decisions Made, and Next Steps:\n\n{labeledTranscript}", + ["temperature"] = "0.3" + }), + Step("ExtractActionItems", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Extract all action items from the following meeting notes. For each item include: assignee, description, deadline (if mentioned), and priority:\n\n{meetingNotes}", + ["temperature"] = "0.2" + }), + Step("StoreResults", "Action", Cfg("expression", "Store meeting notes and action items")), + Step("ReviewMeetingNotes", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the formatted meeting notes and action items", ["priority"] = "Medium" + }) + ] + } + }, + + // h) Blog Interview + new SavedWorkflowDefinition + { + Id = "sample-blog-interview", + Description = "Voice-driven blog post creation: record topic, generate questions, synthesize blog.", + Tags = ["sample", "voice", "ollama", "blog", "agent"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Blog Interview", + Version = 1, + Steps = + [ + Step("RecordTopicIntro", "Action", Cfg("expression", "Record topic introduction audio")), + Step("TranscribeTopic", "Action", Cfg("expression", "Transcribe topic introduction")), + Step("CleanupTopic", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Clean up this topic introduction transcript:\n\n{topicTranscript}", ["temperature"] = "0.3" + }), + Step("ReviewTopic", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the topic introduction before generating questions" + }), + Step("GenerateQuestions", "AgentLoopStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["systemPrompt"] = "Based on the topic introduction, generate 5-7 thoughtful interview questions that would make an engaging blog post.", + ["maxIterations"] = "3", ["tools"] = "word_count,format_text" + }), + Step("ParseQuestions", "Action", Cfg("expression", "Parse numbered questions from agent output")), + Step("RecordAnswers", "Action", Cfg("expression", "Record and transcribe answers to each question")), + Step("SynthesizeBlog", "AgentLoopStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["systemPrompt"] = "Synthesize the following interview Q&A pairs into a compelling, well-structured blog post. Include an engaging introduction, organize by themes rather than Q&A format, and end with a strong conclusion.", + ["maxIterations"] = "3", ["tools"] = "word_count,format_text" + }), + Step("StoreBlogPost", "Action", Cfg("expression", "Store final blog post")), + Step("ReviewBlog", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the final blog post before publishing", ["priority"] = "High" + }) + ] + } + }, + + // i) Brain Dump Synthesis + new SavedWorkflowDefinition + { + Id = "sample-brain-dump", + Description = "Record unstructured thoughts, transcribe, and synthesize into organized document.", + Tags = ["sample", "voice", "ollama", "synthesis"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Brain Dump Synthesis", + Version = 1, + Steps = + [ + Step("RecordBrainDump", "Action", Cfg("expression", "Record unstructured brain dump audio")), + Step("Transcribe", "Action", Cfg("expression", "Transcribe brain dump recording")), + Step("LlmCleanup", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Clean up this raw brain dump transcript:\n\n{rawTranscript}", ["temperature"] = "0.3" + }), + Step("ReviewCleanup", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the cleaned-up brain dump before synthesis" + }), + Step("Synthesize", "AgentLoopStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["systemPrompt"] = "Transform this cleaned-up brain dump into a well-organized document. Identify themes, group related ideas, create a logical structure with headers, and ensure NO ideas are lost — even tangential thoughts should be preserved in an appendix.", + ["maxIterations"] = "3", ["tools"] = "word_count,format_text,outline_generator" + }), + Step("StoreOutput", "Action", Cfg("expression", "Store structured document")), + Step("ReviewFinal", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the final structured document", ["priority"] = "Medium" + }) + ] + } + }, + + // i) Podcast Transcript + new SavedWorkflowDefinition + { + Id = "sample-podcast-transcript", + Description = "Transcribe podcast, label speakers, summarize and format in parallel.", + Tags = ["sample", "voice", "ollama", "podcast", "parallel"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Podcast Transcript", + Version = 1, + Steps = + [ + Step("Transcribe", "Action", Cfg("expression", "Transcribe podcast episode")), + Step("LabelSpeakers", "Action", Cfg("expression", "Label speakers in podcast")), + new StepDefinitionDto + { + Name = "SummarizeAndFormat", Type = "Parallel", + Steps = + [ + Step("Summarize", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Create an executive summary of this podcast transcript, highlighting key topics discussed, notable quotes, and main takeaways:\n\n{labeledTranscript}", + ["temperature"] = "0.4" + }), + Step("FormatTranscript", "LlmCallStep", new Dictionary + { + ["provider"] = "ollama", ["model"] = "qwen3:30b-instruct", + ["prompt"] = "Format and clean up this podcast transcript for readability:\n\n{labeledTranscript}", + ["temperature"] = "0.3" + }) + ] + }, + Step("MergeResults", "Action", Cfg("expression", "Merge summary and formatted transcript")), + Step("ReviewPodcast", "HumanTaskStep", new Dictionary + { + ["assignee"] = "user", ["description"] = "Review the podcast summary and full transcript", ["priority"] = "Medium" + }) + ] + } + }, + + // j) HTTP API Orchestration + new SavedWorkflowDefinition + { + Id = "sample-http-orchestration", + Description = "Orchestrate multiple HTTP API calls: fetch data, transform, and post results.", + Tags = ["sample", "http", "api", "integration"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "HTTP API Orchestration", + Version = 1, + Steps = + [ + Step("FetchUserData", "HttpStep", new Dictionary + { + ["url"] = "https://jsonplaceholder.typicode.com/users/1", ["method"] = "GET", ["contentType"] = "application/json" + }), + Step("FetchUserPosts", "HttpStep", new Dictionary + { + ["url"] = "https://jsonplaceholder.typicode.com/posts?userId=1", ["method"] = "GET" + }), + Step("TransformData", "Action", Cfg("expression", "Merge user profile with posts data")), + Step("SendNotification", "HttpStep", new Dictionary + { + ["url"] = "https://httpbin.org/post", ["method"] = "POST", + ["body"] = "{\"message\": \"Data processed\", \"status\": \"complete\"}", + ["headers"] = "{\"Content-Type\": \"application/json\"}" + }) + ] + } + }, + + // k) AI DSL Emitter Demo + new SavedWorkflowDefinition + { + Id = "sample-ai-dsl-emitter", + Description = "LLM dynamically emits workflow step definitions as JSON, a human approves the plan, then the framework executes those steps at runtime. Runs offline with the echo provider.", + Tags = ["sample", "ai", "dsl", "dynamic", "human-in-the-loop", "echo"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "AI DSL Emitter", + Version = 1, + Steps = + [ + Step("SelectProvider", "Action", Cfg("expression", "Resolve the agent provider from {provider} (echo or ollama) and store it in context for DslEmitterStep.")), + Step("EmitSteps", "DslEmitterStep", new Dictionary + { + ["provider"] = "echo", + ["systemPrompt"] = "You are a workflow planning assistant. Respond ONLY with a valid JSON array of step definitions. Each step must have \"name\" and \"type\" fields. When finished, respond with exactly: []", + ["maxIterations"] = "5", + ["doneSignal"] = "[]" + }), + Step("ApprovePlan", "ApprovalStep", new Dictionary + { + ["title"] = "Review AI-emitted workflow plan", + ["instructions"] = "The LLM emitted {{EmitSteps.EmittedSteps}}. Approve to execute these steps or reject to abort." + }), + Step("BridgeContext", "Action", Cfg("expression", "Copy {{EmitSteps.EmittedSteps}} into WorkflowDslExecutor.Steps so the executor can pick them up.")), + Step("ExecuteEmittedSteps", "WorkflowDslExecutorStep") + ] + } + }, + + // l) Order Saga with Compensation + new SavedWorkflowDefinition + { + Id = "sample-order-saga", + Description = "Order processing with saga pattern and compensation (rollback on failure).", + Tags = ["sample", "saga", "compensation", "order"], + LastModified = now, + Definition = new WorkflowDefinitionDto + { + Name = "Order Saga with Compensation", + Version = 1, + Steps = + [ + new StepDefinitionDto + { + Name = "OrderSaga", Type = "Saga", + Steps = + [ + Step("ValidateOrder", "Action", Cfg("expression", "Validate order ID and items")), + Step("CheckInventory", "Action", Cfg("expression", "Reserve inventory for items (compensate: release reservation)")), + new StepDefinitionDto + { + Name = "ShippingDecision", Type = "Conditional", + Config = Cfg("expression", "ctx.Data.IsExpressShipping"), + Then = Step("PrioritizeOrder", "Action", Cfg("expression", "Prioritize order for express shipping")), + Else = Step("StandardProcessing", "Action", Cfg("expression", "Standard processing applied")) + }, + Step("ChargePayment", "Action", Cfg("expression", "Charge payment (compensate: issue refund)")), + Step("SendConfirmation", "Action", Cfg("expression", "Send confirmation email")) + ] + } + ] + } + } + ]; + } + + private static StepDefinitionDto Step(string name, string type, Dictionary? config = null) => + new() { Name = name, Type = type, Config = config }; + + private static Dictionary Cfg(string key, string value) => + new() { [key] = value }; +} diff --git a/src/WorkflowFramework.Dashboard.Api/Services/StepTypeRegistry.cs b/src/WorkflowFramework.Dashboard.Api/Services/StepTypeRegistry.cs index 67558a8..0edad98 100644 --- a/src/WorkflowFramework.Dashboard.Api/Services/StepTypeRegistry.cs +++ b/src/WorkflowFramework.Dashboard.Api/Services/StepTypeRegistry.cs @@ -1,441 +1,466 @@ -using System.Text.Json; -using WorkflowFramework.Dashboard.Api.Models; - -namespace WorkflowFramework.Dashboard.Api.Services; - -/// -/// Registry of all available step types with metadata for the designer UI. -/// -public sealed class StepTypeRegistry -{ - private readonly Dictionary _types = new(StringComparer.OrdinalIgnoreCase); - - /// Gets all registered step types. - public IReadOnlyList All => _types.Values.ToList(); - - /// Gets a step type by its type key. - public StepTypeInfo? Get(string type) => _types.GetValueOrDefault(type); - - /// Registers a step type. - public StepTypeRegistry Register(StepTypeInfo info) - { - _types[info.Type] = info; - return this; - } - - private static JsonElement? Schema(string json) => JsonDocument.Parse(json).RootElement; - - /// - /// Creates a registry pre-populated with all known step types. - /// - public static StepTypeRegistry CreateDefault() - { - var registry = new StepTypeRegistry(); - var aiProviderOptions = AiProviderCatalog.SerializeProviderOptions(); - var aiModelOptionGroups = AiProviderCatalog.SerializeModelOptionGroups(); - - // ── Core ────────────────────────────────────────────────────── - Register(registry, "Action", "Core", "Executes a custom action delegate.", - Schema(""" - { - "properties": { - "expression": { "type": "string", "uiType": "textarea", "label": "Expression", "helpText": "C# expression to evaluate", "rows": 3, "supportsVariables": true, "variableSyntax": "Use {{Step Name.Output}} for upstream step outputs or {InputName} for run inputs." } - }, - "required": [] - } - """)); - - Register(registry, "Conditional", "Core", "Branches execution based on a condition.", - Schema(""" - { - "properties": { - "expression": { "type": "string", "uiType": "textarea", "label": "Condition Expression", "helpText": "Boolean expression that determines which branch to take", "required": true, "rows": 2 } - }, - "required": ["expression"] - } - """)); - - Register(registry, "Parallel", "Core", "Executes child steps in parallel.", - Schema(""" - { - "properties": { - "maxConcurrency": { "type": "number", "uiType": "number", "label": "Max Concurrency", "helpText": "Maximum number of steps to run simultaneously (0 = unlimited)", "min": 0, "max": 100, "default": "0" } - }, - "required": [] - } - """)); - - Register(registry, "ForEach", "Core", "Iterates over a collection executing child steps for each item.", - Schema(""" - { - "properties": { - "collectionExpression": { "type": "string", "uiType": "textarea", "label": "Collection Expression", "helpText": "Expression that resolves to an enumerable collection", "required": true, "rows": 2 }, - "itemVariable": { "type": "string", "label": "Item Variable", "helpText": "Variable name for the current item in each iteration", "default": "item" } - }, - "required": ["collectionExpression"] - } - """)); - - Register(registry, "While", "Core", "Loops while a condition is true (checked before each iteration).", - Schema(""" - { - "properties": { - "expression": { "type": "string", "uiType": "textarea", "label": "Loop Condition", "helpText": "Boolean expression checked before each iteration", "required": true, "rows": 2 } - }, - "required": ["expression"] - } - """)); - - Register(registry, "DoWhile", "Core", "Loops while a condition is true (checked after each iteration).", - Schema(""" - { - "properties": { - "expression": { "type": "string", "uiType": "textarea", "label": "Loop Condition", "helpText": "Boolean expression checked after each iteration", "required": true, "rows": 2 } - }, - "required": ["expression"] - } - """)); - - Register(registry, "Retry", "Core", "Retries child steps on failure with configurable attempts.", - Schema(""" - { - "properties": { - "maxAttempts": { "type": "number", "uiType": "number", "label": "Max Attempts", "helpText": "Maximum number of retry attempts", "min": 1, "max": 50, "default": "3" } - }, - "required": [] - } - """)); - - Register(registry, "Timeout", "Core", "Wraps an inner step with a timeout duration.", - Schema(""" - { - "properties": { - "timeoutSeconds": { "type": "number", "uiType": "number", "label": "Timeout (seconds)", "helpText": "Maximum time to wait before cancelling the step", "required": true, "min": 0.1, "max": 3600, "step": 0.1 } - }, - "required": ["timeoutSeconds"] - } - """)); - - Register(registry, "Delay", "Core", "Pauses execution for a specified duration.", - Schema(""" - { - "properties": { - "delaySeconds": { "type": "number", "uiType": "slider", "label": "Delay (seconds)", "helpText": "Duration to pause in seconds", "required": true, "min": 0.1, "max": 60, "step": 0.1 } - }, - "required": ["delaySeconds"] - } - """)); - - Register(registry, "TryCatch", "Core", "Provides try/catch/finally error handling around steps.", - Schema(""" - { - "properties": { - "catchTypes": { "type": "string", "label": "Catch Exception Types", "helpText": "Comma-separated list of exception type names to catch (empty = catch all)" } - }, - "required": [] - } - """)); - - Register(registry, "SubWorkflow", "Core", "Invokes another workflow by name.", - Schema(""" - { - "properties": { - "subWorkflowName": { "type": "string", "uiType": "workflowSelect", "label": "Workflow Name", "helpText": "Choose a saved workflow to invoke, or type a workflow name for a child flow you plan to create.", "required": true } - }, - "required": ["subWorkflowName"] - } - """)); - - Register(registry, "Saga", "Core", "Executes steps with compensation/rollback on failure.", - Schema(""" - { - "properties": { - "compensateOnFailure": { "type": "boolean", "uiType": "boolean", "label": "Compensate on Failure", "helpText": "Automatically run compensation steps when a failure occurs", "default": "true" } - }, - "required": [] - } - """)); - - // ── Integration ─────────────────────────────────────────────── - Register(registry, "ContentBasedRouter", "Integration", "Routes messages based on content evaluation.", - Schema(""" - { - "properties": { - "routingExpression": { "type": "string", "uiType": "textarea", "label": "Routing Expression", "helpText": "Expression evaluated to determine the routing destination", "required": true, "rows": 3 } - }, - "required": ["routingExpression"] - } - """)); - - Register(registry, "MessageFilter", "Integration", "Filters messages based on criteria.", - Schema(""" - { - "properties": { - "filterExpression": { "type": "string", "uiType": "textarea", "label": "Filter Expression", "helpText": "Boolean expression — messages that evaluate to true pass through", "required": true, "rows": 3 } - }, - "required": ["filterExpression"] - } - """)); - - Register(registry, "RecipientList", "Integration", "Sends messages to a dynamic list of recipients.", - Schema(""" - { - "properties": { - "recipientExpression": { "type": "string", "uiType": "textarea", "label": "Recipient Expression", "helpText": "Expression that resolves to a list of recipient endpoints", "required": true, "rows": 3 } - }, - "required": ["recipientExpression"] - } - """)); - - Register(registry, "Splitter", "Integration", "Splits a message into multiple parts for individual processing.", - Schema(""" - { - "properties": { - "splitExpression": { "type": "string", "uiType": "textarea", "label": "Split Expression", "helpText": "Expression that splits the message into parts", "required": true, "rows": 2 }, - "aggregateResults": { "type": "boolean", "uiType": "boolean", "label": "Aggregate Results", "helpText": "Whether to aggregate individual results back into a single message" } - }, - "required": ["splitExpression"] - } - """)); - - Register(registry, "Aggregator", "Integration", "Aggregates multiple messages into a single message.", - Schema(""" - { - "properties": { - "correlationExpression": { "type": "string", "uiType": "textarea", "label": "Correlation Expression", "helpText": "Expression to correlate related messages", "required": true, "rows": 2 }, - "completionSize": { "type": "number", "uiType": "number", "label": "Completion Size", "helpText": "Number of messages to collect before aggregating", "min": 1, "max": 10000 }, - "completionTimeout": { "type": "number", "uiType": "number", "label": "Completion Timeout (ms)", "helpText": "Max time to wait for messages before aggregating", "min": 0, "max": 3600000 } - }, - "required": ["correlationExpression"] - } - """)); - - Register(registry, "ScatterGather", "Integration", "Broadcasts to multiple recipients and aggregates responses.", - Schema(""" - { - "properties": { - "timeout": { "type": "number", "uiType": "number", "label": "Timeout (ms)", "helpText": "Maximum time to wait for all responses", "min": 0, "max": 3600000 }, - "aggregationStrategy": { "type": "string", "uiType": "select", "label": "Aggregation Strategy", "helpText": "How to combine responses", "options": ["first", "all", "best"] } - }, - "required": [] - } - """)); - - Register(registry, "WireTap", "Integration", "Sends a copy of the message to a secondary channel.", - Schema(""" - { - "properties": { - "destinationChannel": { "type": "string", "label": "Destination Channel", "helpText": "Channel to send the message copy to", "required": true } - }, - "required": ["destinationChannel"] - } - """)); - - Register(registry, "DeadLetter", "Integration", "Routes failed messages to a dead letter channel.", - Schema(""" - { - "properties": { - "channelName": { "type": "string", "label": "Channel Name", "helpText": "Dead letter channel name", "required": true }, - "maxRetries": { "type": "number", "uiType": "number", "label": "Max Retries", "helpText": "Number of retries before sending to dead letter", "min": 0, "max": 100, "default": "3" } - }, - "required": ["channelName"] - } - """)); - - // ── AI/Agents ───────────────────────────────────────────────── - Register(registry, "AgentLoopStep", "AI/Agents", "Runs an autonomous agent loop with tool access.", - Schema($$""" - { - "properties": { - "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use for this agent", "required": true, "options": {{aiProviderOptions}} }, - "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier (provider-specific)", "required": true, "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, - "systemPrompt": { "type": "string", "uiType": "textarea", "label": "System Prompt", "helpText": "Instructions that define the agent's behavior and role", "rows": 6, "supportsVariables": true, "variableSyntax": "Use a double-brace step output token like StepName.Response or a single-brace run input token like InputName." }, - "maxIterations": { "type": "number", "uiType": "number", "label": "Max Iterations", "helpText": "Maximum number of agent loop iterations", "min": 1, "max": 100, "default": "10" } - }, - "required": ["provider", "model"] - } - """)); - - Register(registry, "AgentDecisionStep", "AI/Agents", "Uses an AI agent to make a routing decision.", - Schema($$""" - { - "properties": { - "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use", "required": true, "options": {{aiProviderOptions}} }, - "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier", "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, - "prompt": { "type": "string", "uiType": "textarea", "label": "Decision Prompt", "helpText": "Prompt describing the decision to make and available options", "required": true, "rows": 6, "supportsVariables": true, "variableSyntax": "Use a double-brace upstream output token such as StepName.Decision or StepName.Body, or a single-brace run input token like InputName." }, - "options": { "type": "string", "uiType": "json", "label": "Decision Options", "helpText": "JSON array of possible decision outcomes", "rows": 4 } - }, - "required": ["provider", "prompt"] - } - """)); - - Register(registry, "AgentPlanStep", "AI/Agents", "Generates an execution plan using an AI agent.", - Schema($$""" - { - "properties": { - "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use", "required": true, "options": {{aiProviderOptions}} }, - "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier", "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, - "objective": { "type": "string", "uiType": "textarea", "label": "Objective", "helpText": "High-level objective for the agent to plan", "required": true, "rows": 4, "supportsVariables": true, "variableSyntax": "Use a double-brace upstream output token like StepName.Plan or a single-brace run input token like InputName." } - }, - "required": ["provider", "objective"] - } - """)); - - Register(registry, "LlmCallStep", "AI/Agents", "Makes a direct call to a language model.", - Schema($$""" - { - "properties": { - "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use", "required": true, "options": {{aiProviderOptions}} }, - "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier", "required": true, "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, - "prompt": { "type": "string", "uiType": "textarea", "label": "Prompt", "helpText": "The prompt to send to the language model", "required": true, "rows": 6, "supportsVariables": true, "variableSyntax": "Use a double-brace upstream output token like StepName.Response or a single-brace run input token like InputName." }, - "temperature": { "type": "number", "uiType": "slider", "label": "Temperature", "helpText": "Controls randomness: 0 = deterministic, 2 = very creative", "min": 0, "max": 2, "step": 0.1, "default": "0.7" }, - "maxTokens": { "type": "number", "uiType": "number", "label": "Max Tokens", "helpText": "Maximum number of tokens in the response", "min": 1, "max": 128000 } - }, - "required": ["provider", "model", "prompt"] - } - """)); - - Register(registry, "ToolCallStep", "AI/Agents", "Invokes a registered tool/function.", - Schema(""" - { - "properties": { - "toolName": { "type": "string", "label": "Tool Name", "helpText": "Name of the registered tool to invoke", "required": true }, - "parameters": { "type": "string", "uiType": "json", "label": "Parameters", "helpText": "JSON object of parameters to pass to the tool", "rows": 6 } - }, - "required": ["toolName"] - } - """)); - - // ── Data ────────────────────────────────────────────────────── - Register(registry, "DataMapStep", "Data", "Transforms data using field mapping rules.", - Schema(""" - { - "properties": { - "mappings": { "type": "string", "uiType": "json", "label": "Field Mappings", "helpText": "JSON object mapping source fields to destination fields", "required": true, "rows": 8 } - }, - "required": ["mappings"] - } - """)); - - Register(registry, "FormatConvertStep", "Data", "Converts data between formats (JSON, XML, CSV, etc.).", - Schema(""" - { - "properties": { - "sourceFormat": { "type": "string", "uiType": "select", "label": "Source Format", "helpText": "Format of the input data", "required": true, "options": ["JSON", "XML", "CSV", "YAML"] }, - "targetFormat": { "type": "string", "uiType": "select", "label": "Target Format", "helpText": "Desired output format", "required": true, "options": ["JSON", "XML", "CSV", "YAML"] } - }, - "required": ["sourceFormat", "targetFormat"] - } - """)); - - Register(registry, "SchemaValidateStep", "Data", "Validates data against a JSON schema.", - Schema(""" - { - "properties": { - "schema": { "type": "string", "uiType": "json", "label": "JSON Schema", "helpText": "JSON schema to validate data against", "required": true, "rows": 10 } - }, - "required": ["schema"] - } - """)); - - Register(registry, "BatchProcessStep", "Data", "Processes data in configurable batch sizes.", - Schema(""" - { - "properties": { - "batchSize": { "type": "number", "uiType": "number", "label": "Batch Size", "helpText": "Number of items to process per batch", "required": true, "min": 1, "max": 10000, "default": "100" } - }, - "required": ["batchSize"] - } - """)); - - // ── HTTP ────────────────────────────────────────────────────── - Register(registry, "HttpStep", "HTTP", "Makes an HTTP request to an external service.", - Schema(""" - { - "properties": { - "url": { "type": "string", "label": "URL", "helpText": "The endpoint URL to send the request to", "required": true }, - "method": { "type": "string", "uiType": "select", "label": "HTTP Method", "helpText": "HTTP method for the request", "options": ["GET", "POST", "PUT", "DELETE", "PATCH"], "default": "GET" }, - "headers": { "type": "string", "uiType": "json", "label": "Headers", "helpText": "JSON object of HTTP headers (e.g. {\"Authorization\": \"Bearer ...\"})", "rows": 4 }, - "body": { "type": "string", "uiType": "json", "label": "Request Body", "helpText": "Request body content (typically JSON)", "rows": 6 }, - "contentType": { "type": "string", "label": "Content Type", "helpText": "Content-Type header value", "default": "application/json" } - }, - "required": ["url"] - } - """)); - - Register(registry, "WebhookTriggerStep", "HTTP", "Waits for an incoming webhook request.", - Schema(""" - { - "properties": { - "path": { "type": "string", "label": "Webhook Path", "helpText": "URL path to listen on (e.g. /api/webhook/my-hook)", "required": true }, - "method": { "type": "string", "uiType": "select", "label": "HTTP Method", "helpText": "Expected HTTP method", "options": ["GET", "POST", "PUT"], "default": "POST" }, - "responseBody": { "type": "string", "uiType": "json", "label": "Response Body", "helpText": "JSON response to return to the caller", "rows": 4 } - }, - "required": ["path"] - } - """)); - - // ── Events ──────────────────────────────────────────────────── - Register(registry, "PublishEventStep", "Events", "Publishes an event to the event bus.", - Schema(""" - { - "properties": { - "eventType": { "type": "string", "label": "Event Type", "helpText": "Type name of the event to publish", "required": true }, - "payload": { "type": "string", "uiType": "json", "label": "Event Payload", "helpText": "JSON payload data for the event", "rows": 6 } - }, - "required": ["eventType"] - } - """)); - - Register(registry, "WaitForEventStep", "Events", "Pauses execution until a matching event is received.", - Schema(""" - { - "properties": { - "eventType": { "type": "string", "label": "Event Type", "helpText": "Type name of the event to wait for", "required": true }, - "timeoutMs": { "type": "number", "uiType": "number", "label": "Timeout (ms)", "helpText": "Maximum time to wait for the event (0 = wait indefinitely)", "min": 0, "max": 3600000 } - }, - "required": ["eventType"] - } - """)); - - // ── Human ───────────────────────────────────────────────────── - Register(registry, "HumanTaskStep", "Human", "Creates a task for human completion.", - Schema(""" - { - "properties": { - "assignee": { "type": "string", "label": "Assignee", "helpText": "User or group to assign the task to", "required": true }, - "description": { "type": "string", "uiType": "textarea", "label": "Task Description", "helpText": "Detailed description of what needs to be done", "rows": 4 }, - "priority": { "type": "string", "uiType": "select", "label": "Priority", "helpText": "Task priority level", "options": ["Low", "Medium", "High", "Critical"], "default": "Medium" }, - "dueDate": { "type": "string", "label": "Due Date", "helpText": "Optional due date (ISO 8601 format)" } - }, - "required": ["assignee"] - } - """)); - - Register(registry, "ApprovalStep", "Human", "Pauses workflow pending human approval.", - Schema(""" - { - "properties": { - "assignee": { "type": "string", "label": "Approver", "helpText": "User or group who must approve", "required": true }, - "message": { "type": "string", "uiType": "textarea", "label": "Approval Message", "helpText": "Message shown to the approver explaining what needs approval", "rows": 4 }, - "requiredApprovals": { "type": "number", "uiType": "number", "label": "Required Approvals", "helpText": "Number of approvals needed to proceed", "min": 1, "max": 100, "default": "1" } - }, - "required": ["assignee"] - } - """)); - - return registry; - } - - private static void Register(StepTypeRegistry registry, string type, string category, string description, JsonElement? configSchema = null) - { - registry.Register(new StepTypeInfo - { - Type = type, - Name = type, - Category = category, - Description = description, - ConfigSchema = configSchema - }); - } -} +using System.Text.Json; +using WorkflowFramework.Dashboard.Api.Models; + +namespace WorkflowFramework.Dashboard.Api.Services; + +/// +/// Registry of all available step types with metadata for the designer UI. +/// +public sealed class StepTypeRegistry +{ + private readonly Dictionary _types = new(StringComparer.OrdinalIgnoreCase); + + /// Gets all registered step types. + public IReadOnlyList All => _types.Values.ToList(); + + /// Gets a step type by its type key. + public StepTypeInfo? Get(string type) => _types.GetValueOrDefault(type); + + /// Registers a step type. + public StepTypeRegistry Register(StepTypeInfo info) + { + _types[info.Type] = info; + return this; + } + + private static JsonElement? Schema(string json) => JsonDocument.Parse(json).RootElement; + + /// + /// Creates a registry pre-populated with all known step types. + /// + public static StepTypeRegistry CreateDefault() + { + var registry = new StepTypeRegistry(); + var aiProviderOptions = AiProviderCatalog.SerializeProviderOptions(); + var aiModelOptionGroups = AiProviderCatalog.SerializeModelOptionGroups(); + + // ── Core ────────────────────────────────────────────────────── + Register(registry, "Action", "Core", "Executes a custom action delegate.", + Schema(""" + { + "properties": { + "expression": { "type": "string", "uiType": "textarea", "label": "Expression", "helpText": "C# expression to evaluate", "rows": 3, "supportsVariables": true, "variableSyntax": "Use {{Step Name.Output}} for upstream step outputs or {InputName} for run inputs." } + }, + "required": [] + } + """)); + + Register(registry, "Conditional", "Core", "Branches execution based on a condition.", + Schema(""" + { + "properties": { + "expression": { "type": "string", "uiType": "textarea", "label": "Condition Expression", "helpText": "Boolean expression that determines which branch to take", "required": true, "rows": 2 } + }, + "required": ["expression"] + } + """)); + + Register(registry, "Parallel", "Core", "Executes child steps in parallel.", + Schema(""" + { + "properties": { + "maxConcurrency": { "type": "number", "uiType": "number", "label": "Max Concurrency", "helpText": "Maximum number of steps to run simultaneously (0 = unlimited)", "min": 0, "max": 100, "default": "0" } + }, + "required": [] + } + """)); + + Register(registry, "ForEach", "Core", "Iterates over a collection executing child steps for each item.", + Schema(""" + { + "properties": { + "collectionExpression": { "type": "string", "uiType": "textarea", "label": "Collection Expression", "helpText": "Expression that resolves to an enumerable collection", "required": true, "rows": 2 }, + "itemVariable": { "type": "string", "label": "Item Variable", "helpText": "Variable name for the current item in each iteration", "default": "item" } + }, + "required": ["collectionExpression"] + } + """)); + + Register(registry, "While", "Core", "Loops while a condition is true (checked before each iteration).", + Schema(""" + { + "properties": { + "expression": { "type": "string", "uiType": "textarea", "label": "Loop Condition", "helpText": "Boolean expression checked before each iteration", "required": true, "rows": 2 } + }, + "required": ["expression"] + } + """)); + + Register(registry, "DoWhile", "Core", "Loops while a condition is true (checked after each iteration).", + Schema(""" + { + "properties": { + "expression": { "type": "string", "uiType": "textarea", "label": "Loop Condition", "helpText": "Boolean expression checked after each iteration", "required": true, "rows": 2 } + }, + "required": ["expression"] + } + """)); + + Register(registry, "Retry", "Core", "Retries child steps on failure with configurable attempts.", + Schema(""" + { + "properties": { + "maxAttempts": { "type": "number", "uiType": "number", "label": "Max Attempts", "helpText": "Maximum number of retry attempts", "min": 1, "max": 50, "default": "3" } + }, + "required": [] + } + """)); + + Register(registry, "Timeout", "Core", "Wraps an inner step with a timeout duration.", + Schema(""" + { + "properties": { + "timeoutSeconds": { "type": "number", "uiType": "number", "label": "Timeout (seconds)", "helpText": "Maximum time to wait before cancelling the step", "required": true, "min": 0.1, "max": 3600, "step": 0.1 } + }, + "required": ["timeoutSeconds"] + } + """)); + + Register(registry, "Delay", "Core", "Pauses execution for a specified duration.", + Schema(""" + { + "properties": { + "delaySeconds": { "type": "number", "uiType": "slider", "label": "Delay (seconds)", "helpText": "Duration to pause in seconds", "required": true, "min": 0.1, "max": 60, "step": 0.1 } + }, + "required": ["delaySeconds"] + } + """)); + + Register(registry, "TryCatch", "Core", "Provides try/catch/finally error handling around steps.", + Schema(""" + { + "properties": { + "catchTypes": { "type": "string", "label": "Catch Exception Types", "helpText": "Comma-separated list of exception type names to catch (empty = catch all)" } + }, + "required": [] + } + """)); + + Register(registry, "SubWorkflow", "Core", "Invokes another workflow by name.", + Schema(""" + { + "properties": { + "subWorkflowName": { "type": "string", "uiType": "workflowSelect", "label": "Workflow Name", "helpText": "Choose a saved workflow to invoke, or type a workflow name for a child flow you plan to create.", "required": true } + }, + "required": ["subWorkflowName"] + } + """)); + + Register(registry, "Saga", "Core", "Executes steps with compensation/rollback on failure.", + Schema(""" + { + "properties": { + "compensateOnFailure": { "type": "boolean", "uiType": "boolean", "label": "Compensate on Failure", "helpText": "Automatically run compensation steps when a failure occurs", "default": "true" } + }, + "required": [] + } + """)); + + // ── Integration ─────────────────────────────────────────────── + Register(registry, "ContentBasedRouter", "Integration", "Routes messages based on content evaluation.", + Schema(""" + { + "properties": { + "routingExpression": { "type": "string", "uiType": "textarea", "label": "Routing Expression", "helpText": "Expression evaluated to determine the routing destination", "required": true, "rows": 3 } + }, + "required": ["routingExpression"] + } + """)); + + Register(registry, "MessageFilter", "Integration", "Filters messages based on criteria.", + Schema(""" + { + "properties": { + "filterExpression": { "type": "string", "uiType": "textarea", "label": "Filter Expression", "helpText": "Boolean expression — messages that evaluate to true pass through", "required": true, "rows": 3 } + }, + "required": ["filterExpression"] + } + """)); + + Register(registry, "RecipientList", "Integration", "Sends messages to a dynamic list of recipients.", + Schema(""" + { + "properties": { + "recipientExpression": { "type": "string", "uiType": "textarea", "label": "Recipient Expression", "helpText": "Expression that resolves to a list of recipient endpoints", "required": true, "rows": 3 } + }, + "required": ["recipientExpression"] + } + """)); + + Register(registry, "Splitter", "Integration", "Splits a message into multiple parts for individual processing.", + Schema(""" + { + "properties": { + "splitExpression": { "type": "string", "uiType": "textarea", "label": "Split Expression", "helpText": "Expression that splits the message into parts", "required": true, "rows": 2 }, + "aggregateResults": { "type": "boolean", "uiType": "boolean", "label": "Aggregate Results", "helpText": "Whether to aggregate individual results back into a single message" } + }, + "required": ["splitExpression"] + } + """)); + + Register(registry, "Aggregator", "Integration", "Aggregates multiple messages into a single message.", + Schema(""" + { + "properties": { + "correlationExpression": { "type": "string", "uiType": "textarea", "label": "Correlation Expression", "helpText": "Expression to correlate related messages", "required": true, "rows": 2 }, + "completionSize": { "type": "number", "uiType": "number", "label": "Completion Size", "helpText": "Number of messages to collect before aggregating", "min": 1, "max": 10000 }, + "completionTimeout": { "type": "number", "uiType": "number", "label": "Completion Timeout (ms)", "helpText": "Max time to wait for messages before aggregating", "min": 0, "max": 3600000 } + }, + "required": ["correlationExpression"] + } + """)); + + Register(registry, "ScatterGather", "Integration", "Broadcasts to multiple recipients and aggregates responses.", + Schema(""" + { + "properties": { + "timeout": { "type": "number", "uiType": "number", "label": "Timeout (ms)", "helpText": "Maximum time to wait for all responses", "min": 0, "max": 3600000 }, + "aggregationStrategy": { "type": "string", "uiType": "select", "label": "Aggregation Strategy", "helpText": "How to combine responses", "options": ["first", "all", "best"] } + }, + "required": [] + } + """)); + + Register(registry, "WireTap", "Integration", "Sends a copy of the message to a secondary channel.", + Schema(""" + { + "properties": { + "destinationChannel": { "type": "string", "label": "Destination Channel", "helpText": "Channel to send the message copy to", "required": true } + }, + "required": ["destinationChannel"] + } + """)); + + Register(registry, "DeadLetter", "Integration", "Routes failed messages to a dead letter channel.", + Schema(""" + { + "properties": { + "channelName": { "type": "string", "label": "Channel Name", "helpText": "Dead letter channel name", "required": true }, + "maxRetries": { "type": "number", "uiType": "number", "label": "Max Retries", "helpText": "Number of retries before sending to dead letter", "min": 0, "max": 100, "default": "3" } + }, + "required": ["channelName"] + } + """)); + + // ── AI/Agents ───────────────────────────────────────────────── + Register(registry, "AgentLoopStep", "AI/Agents", "Runs an autonomous agent loop with tool access.", + Schema($$""" + { + "properties": { + "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use for this agent", "required": true, "options": {{aiProviderOptions}} }, + "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier (provider-specific)", "required": true, "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, + "systemPrompt": { "type": "string", "uiType": "textarea", "label": "System Prompt", "helpText": "Instructions that define the agent's behavior and role", "rows": 6, "supportsVariables": true, "variableSyntax": "Use a double-brace step output token like StepName.Response or a single-brace run input token like InputName." }, + "maxIterations": { "type": "number", "uiType": "number", "label": "Max Iterations", "helpText": "Maximum number of agent loop iterations", "min": 1, "max": 100, "default": "10" } + }, + "required": ["provider", "model"] + } + """)); + + Register(registry, "AgentDecisionStep", "AI/Agents", "Uses an AI agent to make a routing decision.", + Schema($$""" + { + "properties": { + "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use", "required": true, "options": {{aiProviderOptions}} }, + "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier", "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, + "prompt": { "type": "string", "uiType": "textarea", "label": "Decision Prompt", "helpText": "Prompt describing the decision to make and available options", "required": true, "rows": 6, "supportsVariables": true, "variableSyntax": "Use a double-brace upstream output token such as StepName.Decision or StepName.Body, or a single-brace run input token like InputName." }, + "options": { "type": "string", "uiType": "json", "label": "Decision Options", "helpText": "JSON array of possible decision outcomes", "rows": 4 } + }, + "required": ["provider", "prompt"] + } + """)); + + Register(registry, "AgentPlanStep", "AI/Agents", "Generates an execution plan using an AI agent.", + Schema($$""" + { + "properties": { + "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use", "required": true, "options": {{aiProviderOptions}} }, + "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier", "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, + "objective": { "type": "string", "uiType": "textarea", "label": "Objective", "helpText": "High-level objective for the agent to plan", "required": true, "rows": 4, "supportsVariables": true, "variableSyntax": "Use a double-brace upstream output token like StepName.Plan or a single-brace run input token like InputName." } + }, + "required": ["provider", "objective"] + } + """)); + + Register(registry, "LlmCallStep", "AI/Agents", "Makes a direct call to a language model.", + Schema($$""" + { + "properties": { + "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "The AI provider to use", "required": true, "options": {{aiProviderOptions}} }, + "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier", "required": true, "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, + "prompt": { "type": "string", "uiType": "textarea", "label": "Prompt", "helpText": "The prompt to send to the language model", "required": true, "rows": 6, "supportsVariables": true, "variableSyntax": "Use a double-brace upstream output token like StepName.Response or a single-brace run input token like InputName." }, + "temperature": { "type": "number", "uiType": "slider", "label": "Temperature", "helpText": "Controls randomness: 0 = deterministic, 2 = very creative", "min": 0, "max": 2, "step": 0.1, "default": "0.7" }, + "maxTokens": { "type": "number", "uiType": "number", "label": "Max Tokens", "helpText": "Maximum number of tokens in the response", "min": 1, "max": 128000 } + }, + "required": ["provider", "model", "prompt"] + } + """)); + + Register(registry, "ToolCallStep", "AI/Agents", "Invokes a registered tool/function.", + Schema(""" + { + "properties": { + "toolName": { "type": "string", "label": "Tool Name", "helpText": "Name of the registered tool to invoke", "required": true }, + "parameters": { "type": "string", "uiType": "json", "label": "Parameters", "helpText": "JSON object of parameters to pass to the tool", "rows": 6 } + }, + "required": ["toolName"] + } + """)); + + Register(registry, "DslEmitterStep", "AI/Agents", "Iteratively prompts an LLM to emit workflow step definitions as a JSON array.", + Schema($$""" + { + "properties": { + "provider": { "type": "string", "uiType": "providerSelect", "label": "AI Provider", "helpText": "Provider used to call the LLM (use 'echo' for offline demos)", "required": true, "options": {{aiProviderOptions}} }, + "model": { "type": "string", "uiType": "modelSelect", "label": "Model", "helpText": "Model identifier (provider-specific)", "dependsOn": "provider", "optionGroups": {{aiModelOptionGroups}} }, + "systemPrompt": { "type": "string", "uiType": "textarea", "label": "System Prompt", "helpText": "Instructions telling the LLM to emit step definitions as a JSON array", "rows": 8 }, + "maxIterations": { "type": "number", "uiType": "number", "label": "Max Iterations", "helpText": "Maximum LLM turns before stopping", "min": 1, "max": 20, "default": "5" }, + "doneSignal": { "type": "string", "label": "Done Signal", "helpText": "Exact string the LLM returns to signal it is finished emitting steps (default: [])", "default": "[]" }, + "includeSchemaInPrompt":{ "type": "boolean","uiType": "checkbox", "label": "Include Schema in Prompt", "helpText": "Append the step-definition JSON schema to the system prompt", "default": "false" } + }, + "required": ["provider"] + } + """)); + + Register(registry, "WorkflowDslExecutorStep", "AI/Agents", "Materialises and executes step definitions emitted by DslEmitterStep.", + Schema(""" + { + "properties": { + "stepsKey": { "type": "string", "label": "Steps Context Key", "helpText": "Context property key that holds the List to execute (default: WorkflowDslExecutor.Steps)", "default": "WorkflowDslExecutor.Steps" } + }, + "required": [] + } + """)); + + // ── Data ────────────────────────────────────────────────────── + Register(registry, "DataMapStep", "Data", "Transforms data using field mapping rules.", + Schema(""" + { + "properties": { + "mappings": { "type": "string", "uiType": "json", "label": "Field Mappings", "helpText": "JSON object mapping source fields to destination fields", "required": true, "rows": 8 } + }, + "required": ["mappings"] + } + """)); + + Register(registry, "FormatConvertStep", "Data", "Converts data between formats (JSON, XML, CSV, etc.).", + Schema(""" + { + "properties": { + "sourceFormat": { "type": "string", "uiType": "select", "label": "Source Format", "helpText": "Format of the input data", "required": true, "options": ["JSON", "XML", "CSV", "YAML"] }, + "targetFormat": { "type": "string", "uiType": "select", "label": "Target Format", "helpText": "Desired output format", "required": true, "options": ["JSON", "XML", "CSV", "YAML"] } + }, + "required": ["sourceFormat", "targetFormat"] + } + """)); + + Register(registry, "SchemaValidateStep", "Data", "Validates data against a JSON schema.", + Schema(""" + { + "properties": { + "schema": { "type": "string", "uiType": "json", "label": "JSON Schema", "helpText": "JSON schema to validate data against", "required": true, "rows": 10 } + }, + "required": ["schema"] + } + """)); + + Register(registry, "BatchProcessStep", "Data", "Processes data in configurable batch sizes.", + Schema(""" + { + "properties": { + "batchSize": { "type": "number", "uiType": "number", "label": "Batch Size", "helpText": "Number of items to process per batch", "required": true, "min": 1, "max": 10000, "default": "100" } + }, + "required": ["batchSize"] + } + """)); + + // ── HTTP ────────────────────────────────────────────────────── + Register(registry, "HttpStep", "HTTP", "Makes an HTTP request to an external service.", + Schema(""" + { + "properties": { + "url": { "type": "string", "label": "URL", "helpText": "The endpoint URL to send the request to", "required": true }, + "method": { "type": "string", "uiType": "select", "label": "HTTP Method", "helpText": "HTTP method for the request", "options": ["GET", "POST", "PUT", "DELETE", "PATCH"], "default": "GET" }, + "headers": { "type": "string", "uiType": "json", "label": "Headers", "helpText": "JSON object of HTTP headers (e.g. {\"Authorization\": \"Bearer ...\"})", "rows": 4 }, + "body": { "type": "string", "uiType": "json", "label": "Request Body", "helpText": "Request body content (typically JSON)", "rows": 6 }, + "contentType": { "type": "string", "label": "Content Type", "helpText": "Content-Type header value", "default": "application/json" } + }, + "required": ["url"] + } + """)); + + Register(registry, "WebhookTriggerStep", "HTTP", "Waits for an incoming webhook request.", + Schema(""" + { + "properties": { + "path": { "type": "string", "label": "Webhook Path", "helpText": "URL path to listen on (e.g. /api/webhook/my-hook)", "required": true }, + "method": { "type": "string", "uiType": "select", "label": "HTTP Method", "helpText": "Expected HTTP method", "options": ["GET", "POST", "PUT"], "default": "POST" }, + "responseBody": { "type": "string", "uiType": "json", "label": "Response Body", "helpText": "JSON response to return to the caller", "rows": 4 } + }, + "required": ["path"] + } + """)); + + // ── Events ──────────────────────────────────────────────────── + Register(registry, "PublishEventStep", "Events", "Publishes an event to the event bus.", + Schema(""" + { + "properties": { + "eventType": { "type": "string", "label": "Event Type", "helpText": "Type name of the event to publish", "required": true }, + "payload": { "type": "string", "uiType": "json", "label": "Event Payload", "helpText": "JSON payload data for the event", "rows": 6 } + }, + "required": ["eventType"] + } + """)); + + Register(registry, "WaitForEventStep", "Events", "Pauses execution until a matching event is received.", + Schema(""" + { + "properties": { + "eventType": { "type": "string", "label": "Event Type", "helpText": "Type name of the event to wait for", "required": true }, + "timeoutMs": { "type": "number", "uiType": "number", "label": "Timeout (ms)", "helpText": "Maximum time to wait for the event (0 = wait indefinitely)", "min": 0, "max": 3600000 } + }, + "required": ["eventType"] + } + """)); + + // ── Human ───────────────────────────────────────────────────── + Register(registry, "HumanTaskStep", "Human", "Creates a task for human completion.", + Schema(""" + { + "properties": { + "assignee": { "type": "string", "label": "Assignee", "helpText": "User or group to assign the task to", "required": true }, + "description": { "type": "string", "uiType": "textarea", "label": "Task Description", "helpText": "Detailed description of what needs to be done", "rows": 4 }, + "priority": { "type": "string", "uiType": "select", "label": "Priority", "helpText": "Task priority level", "options": ["Low", "Medium", "High", "Critical"], "default": "Medium" }, + "dueDate": { "type": "string", "label": "Due Date", "helpText": "Optional due date (ISO 8601 format)" } + }, + "required": ["assignee"] + } + """)); + + Register(registry, "ApprovalStep", "Human", "Pauses workflow pending human approval.", + Schema(""" + { + "properties": { + "assignee": { "type": "string", "label": "Approver", "helpText": "User or group who must approve", "required": true }, + "message": { "type": "string", "uiType": "textarea", "label": "Approval Message", "helpText": "Message shown to the approver explaining what needs approval", "rows": 4 }, + "requiredApprovals": { "type": "number", "uiType": "number", "label": "Required Approvals", "helpText": "Number of approvals needed to proceed", "min": 1, "max": 100, "default": "1" } + }, + "required": ["assignee"] + } + """)); + + return registry; + } + + private static void Register(StepTypeRegistry registry, string type, string category, string description, JsonElement? configSchema = null) + { + registry.Register(new StepTypeInfo + { + Type = type, + Name = type, + Category = category, + Description = description, + ConfigSchema = configSchema + }); + } +} diff --git a/src/WorkflowFramework.Dashboard.Web/wwwroot/images/templates/ai-dsl-emitter-preview.svg b/src/WorkflowFramework.Dashboard.Web/wwwroot/images/templates/ai-dsl-emitter-preview.svg new file mode 100644 index 0000000..ed7d952 --- /dev/null +++ b/src/WorkflowFramework.Dashboard.Web/wwwroot/images/templates/ai-dsl-emitter-preview.svg @@ -0,0 +1,78 @@ + + AI DSL Emitter preview + An LLM emits workflow step definitions as JSON, a human approves, and the framework executes them at runtime. + + + + + + + + + + + + + + + + + + + + + + + AI DSL Emitter + LLM plans → human approves → framework executes — at runtime. + + + + + Select Provider + echo (offline) or ollama + resolves IAgentProvider + + + + Emit DSL Steps + DslEmitterStep + LLM emits JSON step array + + + + Human Approval + ApprovalStep + review & approve emitted plan + + + + Bridge Context + Action + wire emitted steps → executor + + + + Execute Emitted Steps + WorkflowDslExecutorStep + materialise & run each emitted step + + + + + + + + + + + + + + + + Why it is featured + LLM-driven dynamic workflow construction with + human-in-the-loop approval and runtime execution. + + diff --git a/tests/WorkflowFramework.Dashboard.Api.Tests/SampleWorkflowSeederTests.cs b/tests/WorkflowFramework.Dashboard.Api.Tests/SampleWorkflowSeederTests.cs index 3254a01..1f34b0c 100644 --- a/tests/WorkflowFramework.Dashboard.Api.Tests/SampleWorkflowSeederTests.cs +++ b/tests/WorkflowFramework.Dashboard.Api.Tests/SampleWorkflowSeederTests.cs @@ -1,32 +1,55 @@ -using FluentAssertions; -using WorkflowFramework.Dashboard.Api.Services; -using Xunit; - -namespace WorkflowFramework.Dashboard.Api.Tests; - -public sealed class SampleWorkflowSeederTests -{ - [Fact] - public async Task SeedAsync_IncludesRunnableLocalOllamaSmokeWorkflow() - { - var store = new InMemoryWorkflowDefinitionStore(); - - await SampleWorkflowSeeder.SeedAsync(store); - - var workflow = (await store.GetAllAsync()) - .SingleOrDefault(item => item.Definition.Name == "Local Ollama Smoke Test"); - - workflow.Should().NotBeNull(); - workflow!.Tags.Should().Contain(["ollama", "smoke", "local-first"]); - workflow.Definition.Steps.Should().HaveCount(3); - - var llmStep = workflow.Definition.Steps.Single(step => step.Name == "GenerateLocalReply"); - llmStep.Type.Should().Be("LlmCallStep"); - llmStep.Config.Should().ContainKey("prompt"); - llmStep.Config!["prompt"].Should().Contain("{{PrepareContext.Expression}}"); - llmStep.Config.Should().ContainKey("provider"); - llmStep.Config["provider"].Should().Be("ollama"); - llmStep.Config.Should().ContainKey("model"); - llmStep.Config["model"].Should().Be("qwen3:30b-instruct"); - } -} +using FluentAssertions; +using WorkflowFramework.Dashboard.Api.Services; +using Xunit; + +namespace WorkflowFramework.Dashboard.Api.Tests; + +public sealed class SampleWorkflowSeederTests +{ + [Fact] + public async Task SeedAsync_IncludesRunnableLocalOllamaSmokeWorkflow() + { + var store = new InMemoryWorkflowDefinitionStore(); + + await SampleWorkflowSeeder.SeedAsync(store); + + var workflow = (await store.GetAllAsync()) + .SingleOrDefault(item => item.Definition.Name == "Local Ollama Smoke Test"); + + workflow.Should().NotBeNull(); + workflow!.Tags.Should().Contain(["ollama", "smoke", "local-first"]); + workflow.Definition.Steps.Should().HaveCount(3); + + var llmStep = workflow.Definition.Steps.Single(step => step.Name == "GenerateLocalReply"); + llmStep.Type.Should().Be("LlmCallStep"); + llmStep.Config.Should().ContainKey("prompt"); + llmStep.Config!["prompt"].Should().Contain("{{PrepareContext.Expression}}"); + llmStep.Config.Should().ContainKey("provider"); + llmStep.Config["provider"].Should().Be("ollama"); + llmStep.Config.Should().ContainKey("model"); + llmStep.Config["model"].Should().Be("qwen3:30b-instruct"); + } + + [Fact] + public async Task SeedAsync_IncludesAiDslEmitterWorkflow() + { + var store = new InMemoryWorkflowDefinitionStore(); + + await SampleWorkflowSeeder.SeedAsync(store); + + var workflow = (await store.GetAllAsync()) + .SingleOrDefault(item => item.Definition.Name == "AI DSL Emitter"); + + workflow.Should().NotBeNull(); + workflow!.Tags.Should().Contain(["ai", "dsl", "dynamic", "human-in-the-loop", "echo"]); + workflow.Definition.Steps.Should().HaveCount(5); + + var emitStep = workflow.Definition.Steps.Single(s => s.Name == "EmitSteps"); + emitStep.Type.Should().Be("DslEmitterStep"); + emitStep.Config.Should().ContainKey("provider"); + emitStep.Config!["provider"].Should().Be("echo"); + + workflow.Definition.Steps.Single(s => s.Name == "ExecuteEmittedSteps") + .Type.Should().Be("WorkflowDslExecutorStep"); + } +} diff --git a/tests/WorkflowFramework.Dashboard.Api.Tests/WorkflowTemplateLibraryTests.cs b/tests/WorkflowFramework.Dashboard.Api.Tests/WorkflowTemplateLibraryTests.cs index 0f89ba6..a4b8fad 100644 --- a/tests/WorkflowFramework.Dashboard.Api.Tests/WorkflowTemplateLibraryTests.cs +++ b/tests/WorkflowFramework.Dashboard.Api.Tests/WorkflowTemplateLibraryTests.cs @@ -1,236 +1,270 @@ -using Xunit; -using FluentAssertions; -using WorkflowFramework.Dashboard.Api.Models; -using WorkflowFramework.Dashboard.Api.Services; - -namespace WorkflowFramework.Dashboard.Api.Tests; - -public class WorkflowTemplateLibraryTests -{ - private readonly InMemoryWorkflowTemplateLibrary _library = new(); - - [Fact] - public async Task GetTemplatesAsync_ReturnsAllTemplates() - { - var templates = await _library.GetTemplatesAsync(); - templates.Should().HaveCount(26); - } - - [Fact] - public async Task GetCategoriesAsync_ReturnsExpectedCategories() - { - var categories = await _library.GetCategoriesAsync(); - categories.Should().Contain("Getting Started"); - categories.Should().Contain("Data Processing"); - categories.Should().Contain("Order Management"); - categories.Should().Contain("AI & Agents"); - categories.Should().Contain("Voice & Audio"); - categories.Should().Contain("Integration Patterns"); - categories.Should().HaveCount(6); - } - - [Fact] - public async Task GetTemplatesAsync_FiltersByCategory() - { - var templates = await _library.GetTemplatesAsync(category: "Getting Started"); - templates.Should().HaveCount(7); - templates.Should().OnlyContain(t => t.Category == "Getting Started"); - } - - [Fact] - public async Task GetTemplatesAsync_FiltersByTag() - { - var templates = await _library.GetTemplatesAsync(tag: "parallel"); - templates.Should().HaveCountGreaterThanOrEqualTo(3); - templates.Should().OnlyContain(t => t.Tags.Contains("parallel")); - } - - [Fact] - public async Task GetTemplatesAsync_FiltersByCategoryAndTag() - { - var templates = await _library.GetTemplatesAsync(category: "Getting Started", tag: "conditional"); - templates.Should().HaveCount(1); - templates.First().Id.Should().Be("conditional-branching"); - } - - [Fact] - public async Task GetTemplateAsync_ReturnsNullForUnknown() - { - var template = await _library.GetTemplateAsync("nonexistent"); - template.Should().BeNull(); - } - - [Fact] - public async Task GetTemplateAsync_IsCaseInsensitive() - { - var template = await _library.GetTemplateAsync("HELLO-WORLD"); - template.Should().NotBeNull(); - } - - [Theory] - [InlineData("hello-world", "Hello World", "Getting Started", 1)] - [InlineData("sequential-pipeline", "Sequential Pipeline", "Getting Started", 3)] - [InlineData("conditional-branching", "Conditional Branching", "Getting Started", 4)] - [InlineData("parallel-execution", "Parallel Execution", "Getting Started", 5)] - [InlineData("error-handling", "Error Handling", "Getting Started", 5)] - [InlineData("retry-with-backoff", "Retry with Backoff", "Getting Started", 3)] - [InlineData("loop-processing", "Loop Processing", "Getting Started", 4)] - [InlineData("csv-etl-pipeline", "CSV ETL Pipeline", "Data Processing", 4)] - [InlineData("data-mapping-transform", "Data Mapping & Transform", "Data Processing", 3)] - [InlineData("schema-validation", "Schema Validation", "Data Processing", 4)] - [InlineData("order-processing-saga", "Order Processing Saga", "Order Management", 6)] - [InlineData("express-order-flow", "Express Order Flow", "Order Management", 5)] - [InlineData("order-with-approval", "Order with Approval", "Order Management", 6)] - [InlineData("task-extraction-pipeline", "Task Extraction Pipeline", "AI & Agents", 5)] - [InlineData("agent-triage-workflow", "Agent Triage Workflow", "AI & Agents", 4)] - [InlineData("multimodal-local-router", "Multimodal Local Router", "AI & Agents", 8)] - [InlineData("quick-transcript", "Quick Transcript", "Voice & Audio", 5)] - [InlineData("meeting-notes", "Meeting Notes", "Voice & Audio", 7)] - [InlineData("blog-from-interview", "Blog from Interview", "Voice & Audio", 10)] - [InlineData("brain-dump-synthesis", "Brain Dump Synthesis", "Voice & Audio", 7)] - [InlineData("podcast-transcript", "Podcast Transcript", "Voice & Audio", 6)] - [InlineData("content-based-router", "Content-Based Router", "Integration Patterns", 4)] - [InlineData("scatter-gather", "Scatter-Gather", "Integration Patterns", 5)] - [InlineData("publish-subscribe", "Publish-Subscribe", "Integration Patterns", 5)] - [InlineData("http-api-orchestration", "HTTP API Orchestration", "Integration Patterns", 5)] - [InlineData("webhook-handler", "Webhook Handler", "Integration Patterns", 5)] - public async Task GetTemplateAsync_LoadsCorrectly(string id, string expectedName, string expectedCategory, int expectedStepCount) - { - var template = await _library.GetTemplateAsync(id); - - template.Should().NotBeNull(); - template!.Name.Should().Be(expectedName); - template.Category.Should().Be(expectedCategory); - template.StepCount.Should().Be(expectedStepCount); - template.Description.Should().NotBeNullOrEmpty(); - template.Tags.Should().NotBeEmpty(); - template.Definition.Should().NotBeNull(); - template.Definition.Steps.Should().NotBeEmpty(); - template.Definition.Name.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task AllTemplates_HaveUniqueIds() - { - var templates = await _library.GetTemplatesAsync(); - templates.Select(t => t.Id).Should().OnlyHaveUniqueItems(); - } - - [Fact] - public async Task AllTemplates_HaveValidDifficulty() - { - var templates = await _library.GetTemplatesAsync(); - foreach (var summary in templates) - { - var template = await _library.GetTemplateAsync(summary.Id); - template!.Difficulty.Should().BeOneOf( - TemplateDifficulty.Beginner, - TemplateDifficulty.Intermediate, - TemplateDifficulty.Advanced); - } - } - - [Fact] - public async Task GetTemplatesAsync_ExposesFeaturedStarterWorkflows() - { - var templates = await _library.GetTemplatesAsync(); - - templates.Where(template => template.IsFeatured) - .Select(template => template.Id) - .Should() - .Contain(["multimodal-local-router", "blog-from-interview"]); - - templates.Where(template => template.IsFeatured) - .Should() - .OnlyContain(template => !string.IsNullOrWhiteSpace(template.FeaturedReason)); - } - - [Fact] - public async Task GetTemplatesAsync_ExposesPreviewImages_ForFeaturedStarters() - { - var templates = await _library.GetTemplatesAsync(); - - templates.Where(template => template.IsFeatured) - .Should() - .OnlyContain(template => !string.IsNullOrWhiteSpace(template.PreviewImageUrl)); - - templates.Where(template => template.IsFeatured) - .Select(template => template.PreviewImageUrl) - .Should() - .Contain([ - "/images/templates/multimodal-local-router-preview.svg", - "/images/templates/blog-from-interview-preview.svg" - ]); - } - - [Fact] - public async Task GetTemplateAsync_MultimodalLocalRouter_DemonstratesLocalRoutingAndSpecialistModels() - { - var template = await _library.GetTemplateAsync("multimodal-local-router"); - - template.Should().NotBeNull(); - template!.PreviewImageUrl.Should().Be("/images/templates/multimodal-local-router-preview.svg"); - var steps = template!.Definition.Steps; - - var routeBrief = steps.Single(step => step.Name == "Route Brief"); - routeBrief.Type.Should().Be("AgentDecisionStep"); - routeBrief.Config.Should().Contain(new KeyValuePair("provider", "ollama")); - routeBrief.Config.Should().Contain(new KeyValuePair("model", "phi4-mini")); - routeBrief.Config!["prompt"].Should().Contain("{{Transcribe Brief.Output}}"); - - var planSpecialists = steps.Single(step => step.Name == "Plan Specialist Passes"); - planSpecialists.Type.Should().Be("AgentPlanStep"); - planSpecialists.Config.Should().Contain(new KeyValuePair("provider", "ollama")); - planSpecialists.Config.Should().Contain(new KeyValuePair("model", "llama3.2")); - planSpecialists.Config!["objective"].Should().Contain("{{Route Brief.Decision}}"); - - var specialistPasses = steps.Single(step => step.Name == "Specialist Passes"); - specialistPasses.Type.Should().Be("Parallel"); - specialistPasses.Steps.Should().NotBeNull(); - specialistPasses.Steps!.Should().HaveCount(2); - - specialistPasses.Steps.Single(step => step.Name == "Draft with OpenAI").Config.Should() - .Contain(new KeyValuePair("provider", "openai")); - specialistPasses.Steps.Single(step => step.Name == "Audit with Anthropic").Config.Should() - .Contain(new KeyValuePair("provider", "anthropic")); - - steps.Single(step => step.Name == "Merge Specialist Outputs").Config!["expression"] - .Should().Contain("{{Draft with OpenAI.Response}}") - .And.Contain("{{Audit with Anthropic.Response}}"); - } - - [Fact] - public async Task GetTemplateAsync_BlogFromInterview_UsesVoiceInputs_AndPromptWiring() - { - var template = await _library.GetTemplateAsync("blog-from-interview"); - - template.Should().NotBeNull(); - template!.IsFeatured.Should().BeTrue(); - template.FeaturedReason.Should().NotBeNullOrWhiteSpace(); - template.PreviewImageUrl.Should().Be("/images/templates/blog-from-interview-preview.svg"); - var steps = template!.Definition.Steps; - - steps.Single(step => step.Name == "RecordTopicIntro").Config!["expression"] - .Should().Contain("{recordings}") - .And.Contain("{transcript}"); - - var cleanupTopic = steps.Single(step => step.Name == "CleanupTopic"); - cleanupTopic.Type.Should().Be("LlmCallStep"); - cleanupTopic.Config.Should().Contain(new KeyValuePair("provider", "ollama")); - cleanupTopic.Config.Should().Contain(new KeyValuePair("model", "qwen2.5")); - cleanupTopic.Config!["prompt"].Should().Contain("{{TranscribeTopic.Output}}"); - - var generateQuestions = steps.Single(step => step.Name == "GenerateQuestions"); - generateQuestions.Type.Should().Be("AgentLoopStep"); - generateQuestions.Config.Should().Contain(new KeyValuePair("provider", "ollama")); - generateQuestions.Config.Should().Contain(new KeyValuePair("model", "llama3.2")); - generateQuestions.Config!["systemPrompt"].Should().Contain("{{CleanupTopic.Response}}"); - - var synthesizeBlog = steps.Single(step => step.Name == "SynthesizeBlog"); - synthesizeBlog.Type.Should().Be("LlmCallStep"); - synthesizeBlog.Config.Should().Contain(new KeyValuePair("provider", "openai")); - synthesizeBlog.Config.Should().Contain(new KeyValuePair("model", "gpt-4o-mini")); - synthesizeBlog.Config!["prompt"].Should().Contain("{qaPairs}"); - synthesizeBlog.Config["prompt"].Should().Contain("{{ParseQuestions.Output}}"); - } -} +using Xunit; +using FluentAssertions; +using WorkflowFramework.Dashboard.Api.Models; +using WorkflowFramework.Dashboard.Api.Services; + +namespace WorkflowFramework.Dashboard.Api.Tests; + +public class WorkflowTemplateLibraryTests +{ + private readonly InMemoryWorkflowTemplateLibrary _library = new(); + + [Fact] + public async Task GetTemplatesAsync_ReturnsAllTemplates() + { + var templates = await _library.GetTemplatesAsync(); + templates.Should().HaveCount(27); + } + + [Fact] + public async Task GetCategoriesAsync_ReturnsExpectedCategories() + { + var categories = await _library.GetCategoriesAsync(); + categories.Should().Contain("Getting Started"); + categories.Should().Contain("Data Processing"); + categories.Should().Contain("Order Management"); + categories.Should().Contain("AI & Agents"); + categories.Should().Contain("Voice & Audio"); + categories.Should().Contain("Integration Patterns"); + categories.Should().HaveCount(6); + } + + [Fact] + public async Task GetTemplatesAsync_FiltersByCategory() + { + var templates = await _library.GetTemplatesAsync(category: "Getting Started"); + templates.Should().HaveCount(7); + templates.Should().OnlyContain(t => t.Category == "Getting Started"); + } + + [Fact] + public async Task GetTemplatesAsync_FiltersByTag() + { + var templates = await _library.GetTemplatesAsync(tag: "parallel"); + templates.Should().HaveCountGreaterThanOrEqualTo(3); + templates.Should().OnlyContain(t => t.Tags.Contains("parallel")); + } + + [Fact] + public async Task GetTemplatesAsync_FiltersByCategoryAndTag() + { + var templates = await _library.GetTemplatesAsync(category: "Getting Started", tag: "conditional"); + templates.Should().HaveCount(1); + templates.First().Id.Should().Be("conditional-branching"); + } + + [Fact] + public async Task GetTemplateAsync_ReturnsNullForUnknown() + { + var template = await _library.GetTemplateAsync("nonexistent"); + template.Should().BeNull(); + } + + [Fact] + public async Task GetTemplateAsync_IsCaseInsensitive() + { + var template = await _library.GetTemplateAsync("HELLO-WORLD"); + template.Should().NotBeNull(); + } + + [Theory] + [InlineData("hello-world", "Hello World", "Getting Started", 1)] + [InlineData("sequential-pipeline", "Sequential Pipeline", "Getting Started", 3)] + [InlineData("conditional-branching", "Conditional Branching", "Getting Started", 4)] + [InlineData("parallel-execution", "Parallel Execution", "Getting Started", 5)] + [InlineData("error-handling", "Error Handling", "Getting Started", 5)] + [InlineData("retry-with-backoff", "Retry with Backoff", "Getting Started", 3)] + [InlineData("loop-processing", "Loop Processing", "Getting Started", 4)] + [InlineData("csv-etl-pipeline", "CSV ETL Pipeline", "Data Processing", 4)] + [InlineData("data-mapping-transform", "Data Mapping & Transform", "Data Processing", 3)] + [InlineData("schema-validation", "Schema Validation", "Data Processing", 4)] + [InlineData("order-processing-saga", "Order Processing Saga", "Order Management", 6)] + [InlineData("express-order-flow", "Express Order Flow", "Order Management", 5)] + [InlineData("order-with-approval", "Order with Approval", "Order Management", 6)] + [InlineData("task-extraction-pipeline", "Task Extraction Pipeline", "AI & Agents", 5)] + [InlineData("agent-triage-workflow", "Agent Triage Workflow", "AI & Agents", 4)] + [InlineData("multimodal-local-router", "Multimodal Local Router", "AI & Agents", 8)] + [InlineData("ai-dsl-emitter", "AI DSL Emitter", "AI & Agents", 5)] + [InlineData("quick-transcript", "Quick Transcript", "Voice & Audio", 5)] + [InlineData("meeting-notes", "Meeting Notes", "Voice & Audio", 7)] + [InlineData("blog-from-interview", "Blog from Interview", "Voice & Audio", 10)] + [InlineData("brain-dump-synthesis", "Brain Dump Synthesis", "Voice & Audio", 7)] + [InlineData("podcast-transcript", "Podcast Transcript", "Voice & Audio", 6)] + [InlineData("content-based-router", "Content-Based Router", "Integration Patterns", 4)] + [InlineData("scatter-gather", "Scatter-Gather", "Integration Patterns", 5)] + [InlineData("publish-subscribe", "Publish-Subscribe", "Integration Patterns", 5)] + [InlineData("http-api-orchestration", "HTTP API Orchestration", "Integration Patterns", 5)] + [InlineData("webhook-handler", "Webhook Handler", "Integration Patterns", 5)] + public async Task GetTemplateAsync_LoadsCorrectly(string id, string expectedName, string expectedCategory, int expectedStepCount) + { + var template = await _library.GetTemplateAsync(id); + + template.Should().NotBeNull(); + template!.Name.Should().Be(expectedName); + template.Category.Should().Be(expectedCategory); + template.StepCount.Should().Be(expectedStepCount); + template.Description.Should().NotBeNullOrEmpty(); + template.Tags.Should().NotBeEmpty(); + template.Definition.Should().NotBeNull(); + template.Definition.Steps.Should().NotBeEmpty(); + template.Definition.Name.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task AllTemplates_HaveUniqueIds() + { + var templates = await _library.GetTemplatesAsync(); + templates.Select(t => t.Id).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task AllTemplates_HaveValidDifficulty() + { + var templates = await _library.GetTemplatesAsync(); + foreach (var summary in templates) + { + var template = await _library.GetTemplateAsync(summary.Id); + template!.Difficulty.Should().BeOneOf( + TemplateDifficulty.Beginner, + TemplateDifficulty.Intermediate, + TemplateDifficulty.Advanced); + } + } + + [Fact] + public async Task GetTemplatesAsync_ExposesFeaturedStarterWorkflows() + { + var templates = await _library.GetTemplatesAsync(); + + templates.Where(template => template.IsFeatured) + .Select(template => template.Id) + .Should() + .Contain(["multimodal-local-router", "blog-from-interview", "ai-dsl-emitter"]); + + templates.Where(template => template.IsFeatured) + .Should() + .OnlyContain(template => !string.IsNullOrWhiteSpace(template.FeaturedReason)); + } + + [Fact] + public async Task GetTemplatesAsync_ExposesPreviewImages_ForFeaturedStarters() + { + var templates = await _library.GetTemplatesAsync(); + + templates.Where(template => template.IsFeatured) + .Should() + .OnlyContain(template => !string.IsNullOrWhiteSpace(template.PreviewImageUrl)); + + templates.Where(template => template.IsFeatured) + .Select(template => template.PreviewImageUrl) + .Should() + .Contain([ + "/images/templates/multimodal-local-router-preview.svg", + "/images/templates/blog-from-interview-preview.svg", + "/images/templates/ai-dsl-emitter-preview.svg" + ]); + } + + [Fact] + public async Task GetTemplateAsync_MultimodalLocalRouter_DemonstratesLocalRoutingAndSpecialistModels() + { + var template = await _library.GetTemplateAsync("multimodal-local-router"); + + template.Should().NotBeNull(); + template!.PreviewImageUrl.Should().Be("/images/templates/multimodal-local-router-preview.svg"); + var steps = template!.Definition.Steps; + + var routeBrief = steps.Single(step => step.Name == "Route Brief"); + routeBrief.Type.Should().Be("AgentDecisionStep"); + routeBrief.Config.Should().Contain(new KeyValuePair("provider", "ollama")); + routeBrief.Config.Should().Contain(new KeyValuePair("model", "phi4-mini")); + routeBrief.Config!["prompt"].Should().Contain("{{Transcribe Brief.Output}}"); + + var planSpecialists = steps.Single(step => step.Name == "Plan Specialist Passes"); + planSpecialists.Type.Should().Be("AgentPlanStep"); + planSpecialists.Config.Should().Contain(new KeyValuePair("provider", "ollama")); + planSpecialists.Config.Should().Contain(new KeyValuePair("model", "llama3.2")); + planSpecialists.Config!["objective"].Should().Contain("{{Route Brief.Decision}}"); + + var specialistPasses = steps.Single(step => step.Name == "Specialist Passes"); + specialistPasses.Type.Should().Be("Parallel"); + specialistPasses.Steps.Should().NotBeNull(); + specialistPasses.Steps!.Should().HaveCount(2); + + specialistPasses.Steps.Single(step => step.Name == "Draft with OpenAI").Config.Should() + .Contain(new KeyValuePair("provider", "openai")); + specialistPasses.Steps.Single(step => step.Name == "Audit with Anthropic").Config.Should() + .Contain(new KeyValuePair("provider", "anthropic")); + + steps.Single(step => step.Name == "Merge Specialist Outputs").Config!["expression"] + .Should().Contain("{{Draft with OpenAI.Response}}") + .And.Contain("{{Audit with Anthropic.Response}}"); + } + + [Fact] + public async Task GetTemplateAsync_BlogFromInterview_UsesVoiceInputs_AndPromptWiring() + { + var template = await _library.GetTemplateAsync("blog-from-interview"); + + template.Should().NotBeNull(); + template!.IsFeatured.Should().BeTrue(); + template.FeaturedReason.Should().NotBeNullOrWhiteSpace(); + template.PreviewImageUrl.Should().Be("/images/templates/blog-from-interview-preview.svg"); + var steps = template!.Definition.Steps; + + steps.Single(step => step.Name == "RecordTopicIntro").Config!["expression"] + .Should().Contain("{recordings}") + .And.Contain("{transcript}"); + + var cleanupTopic = steps.Single(step => step.Name == "CleanupTopic"); + cleanupTopic.Type.Should().Be("LlmCallStep"); + cleanupTopic.Config.Should().Contain(new KeyValuePair("provider", "ollama")); + cleanupTopic.Config.Should().Contain(new KeyValuePair("model", "qwen2.5")); + cleanupTopic.Config!["prompt"].Should().Contain("{{TranscribeTopic.Output}}"); + + var generateQuestions = steps.Single(step => step.Name == "GenerateQuestions"); + generateQuestions.Type.Should().Be("AgentLoopStep"); + generateQuestions.Config.Should().Contain(new KeyValuePair("provider", "ollama")); + generateQuestions.Config.Should().Contain(new KeyValuePair("model", "llama3.2")); + generateQuestions.Config!["systemPrompt"].Should().Contain("{{CleanupTopic.Response}}"); + + var synthesizeBlog = steps.Single(step => step.Name == "SynthesizeBlog"); + synthesizeBlog.Type.Should().Be("LlmCallStep"); + synthesizeBlog.Config.Should().Contain(new KeyValuePair("provider", "openai")); + synthesizeBlog.Config.Should().Contain(new KeyValuePair("model", "gpt-4o-mini")); + synthesizeBlog.Config!["prompt"].Should().Contain("{qaPairs}"); + synthesizeBlog.Config["prompt"].Should().Contain("{{ParseQuestions.Output}}"); + } + + [Fact] + public async Task GetTemplateAsync_AiDslEmitter_IsFeaturedAndHasCorrectStepTypes() + { + var template = await _library.GetTemplateAsync("ai-dsl-emitter"); + + template.Should().NotBeNull(); + template!.IsFeatured.Should().BeTrue(); + template.FeaturedReason.Should().NotBeNullOrWhiteSpace(); + template.PreviewImageUrl.Should().Be("/images/templates/ai-dsl-emitter-preview.svg"); + template.Category.Should().Be("AI & Agents"); + template.Tags.Should().Contain(["ai", "dsl", "dynamic", "human-in-the-loop"]); + + var steps = template.Definition.Steps; + steps.Should().HaveCount(5); + + steps.Single(s => s.Name == "SelectProvider").Type.Should().Be("Action"); + + var emitStep = steps.Single(s => s.Name == "EmitSteps"); + emitStep.Type.Should().Be("DslEmitterStep"); + emitStep.Config.Should().ContainKey("provider"); + emitStep.Config!["provider"].Should().Be("echo"); + emitStep.Config.Should().ContainKey("systemPrompt"); + emitStep.Config.Should().ContainKey("doneSignal"); + + var approvalStep = steps.Single(s => s.Name == "ApprovePlan"); + approvalStep.Type.Should().Be("ApprovalStep"); + approvalStep.Config!["instructions"].Should().Contain("{{EmitSteps.EmittedSteps}}"); + + steps.Single(s => s.Name == "BridgeContext").Type.Should().Be("Action"); + steps.Single(s => s.Name == "ExecuteEmittedSteps").Type.Should().Be("WorkflowDslExecutorStep"); + } +} From 986b7c833d1aa9abb74f619f8b82fdca494ca85d Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 28 Apr 2026 22:35:46 -0500 Subject: [PATCH 3/4] fix: remove stale {provider} placeholder from seeded AI DSL Emitter workflow The SelectProvider step in SampleWorkflowSeeder had the old expression "Resolve the agent provider from {provider} (echo or ollama)..." with an unresolved {provider} template variable. This made the seeded/open-existing workflow appear broken in the dashboard. Aligned the expression to match InMemoryWorkflowTemplateLibrary: "Provider is configured as echo (offline/demo default). To run live, change the provider field in EmitSteps to ollama before executing." Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/SampleWorkflowSeeder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs b/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs index 88d8213..50c9ea9 100644 --- a/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs +++ b/src/WorkflowFramework.Dashboard.Api/Services/SampleWorkflowSeeder.cs @@ -386,7 +386,7 @@ private static List CreateSamples() Version = 1, Steps = [ - Step("SelectProvider", "Action", Cfg("expression", "Resolve the agent provider from {provider} (echo or ollama) and store it in context for DslEmitterStep.")), + Step("SelectProvider", "Action", Cfg("expression", "Provider is configured as echo (offline/demo default). To run live, change the provider field in EmitSteps to ollama before executing.")), Step("EmitSteps", "DslEmitterStep", new Dictionary { ["provider"] = "echo", From 3f5e01abe38eb9556ffe5e49fd3b3ed03fd909d6 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 14:52:11 -0500 Subject: [PATCH 4/4] =?UTF-8?q?test(phase-g.3):=20channel=20steps=20?= =?UTF-8?q?=E2=80=94=20characterization=20scenarios=20+=20bespoke=20ration?= =?UTF-8?q?ale=20(~28=20scenarios)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 4 channel steps (WireTap, ChannelAdapter, DeadLetter, MessageBridge) are intentionally bespoke — rationale documented in each step file header. PatternKit primitives (Decorator, Adapter, Bridge) model structural/transformational patterns, not the EIP side-effect and error-routing semantics these steps implement. - Add 28 TinyBDD characterization scenarios across 4 new files in tests/.../Integration/Channel/: WireTapStep (7), ChannelAdapterStep (5), DeadLetterStep (7), MessageBridgeStep (5) — all green on net8/9/10 Co-Authored-By: Claude Opus 4.6 --- .../Channel/ChannelAdapterStep.cs | 5 + .../Channel/DeadLetterStep.cs | 6 + .../Channel/MessageBridgeStep.cs | 6 + .../Channel/WireTapStep.cs | 6 + .../Channel/ChannelAdapterStepScenarios.cs | 138 +++++++++++++ .../Channel/DeadLetterStepScenarios.cs | 182 ++++++++++++++++++ .../Channel/MessageBridgeStepScenarios.cs | 135 +++++++++++++ .../Channel/WireTapStepScenarios.cs | 157 +++++++++++++++ 8 files changed, 635 insertions(+) create mode 100644 tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs create mode 100644 tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs create mode 100644 tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs create mode 100644 tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs index 069d756..274a9bc 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs @@ -1,3 +1,8 @@ +// Intentionally bespoke — PatternKit AsyncAdapter is a type-mapping pattern +// (produce a TOut from a TIn). ChannelAdapterStep is a side-effect operation (send a +// message to an IChannelAdapter); it has no output type and no conversion pipeline. +// Using AsyncAdapter here would be a category error. Characterization tests added in +// Phase G.3. using WorkflowFramework.Extensions.Integration.Abstractions; namespace WorkflowFramework.Extensions.Integration.Channel; diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs index f16df07..f988292 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs @@ -1,3 +1,9 @@ +// Intentionally bespoke — DeadLetterStep wraps a step with error-routing: on failure, +// it extracts the dead-letter payload from context and routes it to an IDeadLetterStore. +// PatternKit 0.105.0 has no dead-letter or error-routing primitive. The try/catch +// + IDeadLetterStore pattern is specific to EIP and cannot be cleanly expressed +// via PatternKit Decorator (which transforms inputs, not catches exceptions to external +// stores). Characterization tests added in Phase G.3. using WorkflowFramework.Extensions.Integration.Abstractions; namespace WorkflowFramework.Extensions.Integration.Channel; diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs index 2508e00..9effd85 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs @@ -1,3 +1,9 @@ +// Intentionally bespoke — MessageBridgeStep receives from one IChannelAdapter and sends +// to another in a single atomic step. PatternKit Bridge connects two hierarchies +// abstractly (implementation vs abstraction); it does not model a runtime channel +// receive→forward pipeline. The implementation is a two-call thin wrapper; +// no PatternKit primitive reduces the code further. Characterization tests added in +// Phase G.3. using WorkflowFramework.Extensions.Integration.Abstractions; namespace WorkflowFramework.Extensions.Integration.Channel; diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs index d76e3a9..eb50c9f 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs @@ -1,3 +1,9 @@ +// Intentionally bespoke — WireTapStep's core contract (run a side-effect without disrupting +// the main flow, with optional error swallowing) is simpler than PatternKit's +// AsyncActionDecorator pipeline. AsyncActionDecorator wraps a component and transforms/ +// decorates it; WireTapStep wraps nothing — it IS the side-effect. Applying Decorator here +// would add indirection without modelling the pattern more clearly. Characterization tests +// added in Phase G.3. namespace WorkflowFramework.Extensions.Integration.Channel; /// diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs new file mode 100644 index 0000000..49c39c2 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs @@ -0,0 +1,138 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; +using WorkflowFramework.Extensions.Integration.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("ChannelAdapterStep — characterization (Phase G.3)")] +public class ChannelAdapterStepScenarios : TinyBddTestBase +{ + public ChannelAdapterStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("ChannelAdapterStep Name returns 'ChannelAdapter'"), Fact] + public async Task NameIsChannelAdapter() + { + var adapter = Substitute.For(); + var sut = new ChannelAdapterStep(adapter, _ => new object()); + + await Given("ChannelAdapterStep instance", () => sut) + .Then("Name is 'ChannelAdapter'", s => + { + s.Name.Should().Be("ChannelAdapter"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Adapter SendAsync is called with message from selector"), Fact] + public async Task AdapterSendAsyncCalledWithSelectedMessage() + { + object? sentMessage = null; + var adapter = Substitute.For(); + adapter.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { sentMessage = ci[0]; return Task.CompletedTask; }); + + var ctx = new WorkflowContext(); + ctx.Properties["payload"] = "hello"; + + var sut = new ChannelAdapterStep( + adapter, + c => c.Properties["payload"]!); + + await sut.ExecuteAsync(ctx); + + await Given("message sent to adapter", () => sentMessage) + .Then("sent message equals 'hello'", msg => + { + msg.Should().Be("hello"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null adapter throws ArgumentNullException"), Fact] + public async Task NullAdapterThrows() + { + Exception? caught = null; + try { _ = new ChannelAdapterStep(null!, _ => new object()); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null adapter", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null messageSelector throws ArgumentNullException"), Fact] + public async Task NullMessageSelectorThrows() + { + var adapter = Substitute.For(); + Exception? caught = null; + try { _ = new ChannelAdapterStep(adapter, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null message selector", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Adapter SendAsync receives context cancellation token"), Fact] + public async Task SendAsyncReceivesCancellationToken() + { + var cts = new CancellationTokenSource(); + CancellationToken received = default; + var adapter = Substitute.For(); + adapter.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { received = (CancellationToken)ci[1]; return Task.CompletedTask; }); + + var ctx = new WorkflowContext(cts.Token); + var sut = new ChannelAdapterStep(adapter, _ => "msg"); + + await sut.ExecuteAsync(ctx); + + await Given("cancellation token received by SendAsync", () => received) + .Then("token equals the context cancellation token", t => + { + t.Should().Be(cts.Token); + return true; + }) + .AssertPassed(); + } + + [Scenario("Message selector can return complex objects"), Fact] + public async Task MessageSelectorReturnsComplexObject() + { + object? sentMessage = null; + var adapter = Substitute.For(); + adapter.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { sentMessage = ci[0]; return Task.CompletedTask; }); + + var complexPayload = new { Id = 42, Name = "Test" }; + var ctx = new WorkflowContext(); + ctx.Properties["complex"] = complexPayload; + + var sut = new ChannelAdapterStep(adapter, c => c.Properties["complex"]!); + await sut.ExecuteAsync(ctx); + + await Given("message sent to adapter for complex object", () => sentMessage) + .Then("sent message is the complex payload", msg => + { + msg.Should().BeSameAs(complexPayload); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs new file mode 100644 index 0000000..955eb0c --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs @@ -0,0 +1,182 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; +using WorkflowFramework.Extensions.Integration.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("DeadLetterStep — characterization (Phase G.3)")] +public class DeadLetterStepScenarios : TinyBddTestBase +{ + public DeadLetterStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("DeadLetterStep Name returns 'DeadLetter'"), Fact] + public async Task NameIsDeadLetter() + { + var store = Substitute.For(); + var inner = Substitute.For(); + var sut = new DeadLetterStep(store, inner); + + await Given("DeadLetterStep instance", () => sut) + .Then("Name is 'DeadLetter'", s => + { + s.Name.Should().Be("DeadLetter"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Inner step succeeds — dead letter store is not called"), Fact] + public async Task SuccessfulInnerStepDoesNotCallStore() + { + var store = Substitute.For(); + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()).Returns(Task.CompletedTask); + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(new WorkflowContext()); + + await store.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + + await Given("store not called on success", () => true) + .Then("store was not called (verified by NSubstitute)", _ => true) + .AssertPassed(); + } + + [Scenario("Inner step fails — dead letter store SendAsync is called"), Fact] + public async Task FailingInnerStepCallsStore() + { + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new InvalidOperationException("inner failure")); + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(new WorkflowContext()); + + await store.Received(1).SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + + await Given("store called when inner step throws", () => true) + .Then("store.SendAsync was called once (verified by NSubstitute)", _ => true) + .AssertPassed(); + } + + [Scenario("Exception message is passed to store SendAsync"), Fact] + public async Task ExceptionMessagePassedToStore() + { + string? storedReason = null; + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { storedReason = (string)ci[1]; return Task.CompletedTask; }); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new Exception("specific error message")); + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("reason passed to dead letter store", () => storedReason) + .Then("reason contains the exception message", reason => + { + reason.Should().Contain("specific error message"); + return true; + }) + .AssertPassed(); + } + + [Scenario("__CurrentMessage context property is used as dead letter message"), Fact] + public async Task CurrentMessagePropertyUsedAsDeadLetterMessage() + { + object? storedMessage = null; + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { storedMessage = ci[0]; return Task.CompletedTask; }); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new Exception("fail")); + + var ctx = new WorkflowContext(); + var expectedMessage = new { Id = 7 }; + ctx.Properties["__CurrentMessage"] = expectedMessage; + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(ctx); + + await Given("message stored in dead letter", () => storedMessage) + .Then("message is the __CurrentMessage property value", msg => + { + msg.Should().BeSameAs(expectedMessage); + return true; + }) + .AssertPassed(); + } + + [Scenario("Context is used as message when __CurrentMessage is absent"), Fact] + public async Task ContextUsedAsMessageWhenNoCurrentMessage() + { + object? storedMessage = null; + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { storedMessage = ci[0]; return Task.CompletedTask; }); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new Exception("fail")); + + var ctx = new WorkflowContext(); // No __CurrentMessage + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(ctx); + + await Given("message stored when __CurrentMessage absent", () => storedMessage) + .Then("stored message is the workflow context itself", msg => + { + msg.Should().BeSameAs(ctx); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null store throws ArgumentNullException"), Fact] + public async Task NullStoreThrows() + { + var inner = Substitute.For(); + Exception? caught = null; + try { _ = new DeadLetterStep(null!, inner); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null store", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null inner step throws ArgumentNullException"), Fact] + public async Task NullInnerStepThrows() + { + var store = Substitute.For(); + Exception? caught = null; + try { _ = new DeadLetterStep(store, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null inner step", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs new file mode 100644 index 0000000..9aff5b0 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs @@ -0,0 +1,135 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; +using WorkflowFramework.Extensions.Integration.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("MessageBridgeStep — characterization (Phase G.3)")] +public class MessageBridgeStepScenarios : TinyBddTestBase +{ + public MessageBridgeStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("MessageBridgeStep Name returns 'MessageBridge'"), Fact] + public async Task NameIsMessageBridge() + { + var source = Substitute.For(); + var dest = Substitute.For(); + var sut = new MessageBridgeStep(source, dest); + + await Given("MessageBridgeStep instance", () => sut) + .Then("Name is 'MessageBridge'", s => + { + s.Name.Should().Be("MessageBridge"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Message received from source is forwarded to destination"), Fact] + public async Task MessageForwardedFromSourceToDestination() + { + var payload = new object(); + object? received = null; + + var source = Substitute.For(); + source.ReceiveAsync(Arg.Any()).Returns(Task.FromResult(payload)); + + var dest = Substitute.For(); + dest.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { received = ci[0]; return Task.CompletedTask; }); + + var sut = new MessageBridgeStep(source, dest); + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("message received by destination", () => received) + .Then("destination received the source message", msg => + { + msg.Should().BeSameAs(payload); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null message from source — destination SendAsync is not called"), Fact] + public async Task NullMessageFromSourceSkipsSend() + { + var source = Substitute.For(); + source.ReceiveAsync(Arg.Any()).Returns(Task.FromResult(null)); + + var dest = Substitute.For(); + + var sut = new MessageBridgeStep(source, dest); + await sut.ExecuteAsync(new WorkflowContext()); + + await dest.DidNotReceive().SendAsync(Arg.Any(), Arg.Any()); + + await Given("destination not called when source returns null", () => true) + .Then("destination SendAsync was NOT called (verified by NSubstitute)", _ => true) + .AssertPassed(); + } + + [Scenario("Null source throws ArgumentNullException"), Fact] + public async Task NullSourceThrows() + { + var dest = Substitute.For(); + Exception? caught = null; + try { _ = new MessageBridgeStep(null!, dest); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null source", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null destination throws ArgumentNullException"), Fact] + public async Task NullDestinationThrows() + { + var source = Substitute.For(); + Exception? caught = null; + try { _ = new MessageBridgeStep(source, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null destination", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Context cancellation token is passed to source ReceiveAsync"), Fact] + public async Task CancellationTokenPassedToSourceReceive() + { + var cts = new CancellationTokenSource(); + CancellationToken receivedToken = default; + + var source = Substitute.For(); + source.ReceiveAsync(Arg.Any()) + .Returns(ci => { receivedToken = (CancellationToken)ci[0]; return Task.FromResult(null); }); + + var dest = Substitute.For(); + var ctx = new WorkflowContext(cts.Token); + + var sut = new MessageBridgeStep(source, dest); + await sut.ExecuteAsync(ctx); + + await Given("cancellation token passed to source ReceiveAsync", () => receivedToken) + .Then("token equals context cancellation token", t => + { + t.Should().Be(cts.Token); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs new file mode 100644 index 0000000..821fab1 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs @@ -0,0 +1,157 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("WireTapStep — characterization (Phase G.3)")] +public class WireTapStepScenarios : TinyBddTestBase +{ + public WireTapStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("WireTapStep Name returns 'WireTap'"), Fact] + public async Task NameIsWireTap() + { + var sut = new WireTapStep(_ => Task.CompletedTask); + + await Given("WireTapStep instance", () => sut) + .Then("Name is 'WireTap'", s => + { + s.Name.Should().Be("WireTap"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action runs on happy path"), Fact] + public async Task TapActionRunsOnHappyPath() + { + var tapCalled = false; + var sut = new WireTapStep(_ => { tapCalled = true; return Task.CompletedTask; }); + + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("whether tap action was called", () => tapCalled) + .Then("tap action ran", called => + { + called.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action receives workflow context"), Fact] + public async Task TapActionReceivesContext() + { + IWorkflowContext? received = null; + var ctx = new WorkflowContext(); + ctx.Properties["token"] = "abc"; + + var sut = new WireTapStep(c => { received = c; return Task.CompletedTask; }); + await sut.ExecuteAsync(ctx); + + await Given("context received by tap action", () => received) + .Then("it has the token property", rc => + { + rc.Should().NotBeNull(); + rc!.Properties["token"].Should().Be("abc"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action error is swallowed when swallowErrors is true (default)"), Fact] + public async Task TapErrorIsSwallowedByDefault() + { + var sut = new WireTapStep(_ => throw new Exception("tap error"), swallowErrors: true); + + Exception? caught = null; + try { await sut.ExecuteAsync(new WorkflowContext()); } + catch (Exception ex) { caught = ex; } + + await Given("exception after faulting tap with swallowErrors=true", () => caught) + .Then("no exception propagates", ex => + { + ex.Should().BeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action error propagates when swallowErrors is false"), Fact] + public async Task TapErrorPropagatesWhenSwallowFalse() + { + var sut = new WireTapStep(_ => throw new InvalidOperationException("tap exploded"), swallowErrors: false); + + Exception? caught = null; + try { await sut.ExecuteAsync(new WorkflowContext()); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("exception after faulting tap with swallowErrors=false", () => caught) + .Then("InvalidOperationException propagates", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + ex!.Message.Should().Contain("tap exploded"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null tap action throws ArgumentNullException"), Fact] + public async Task NullTapActionThrows() + { + Exception? caught = null; + try { _ = new WireTapStep(null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null tap action", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("WireTap does not mutate context IsAborted"), Fact] + public async Task WireTapDoesNotAbortContext() + { + var ctx = new WorkflowContext(); + var sut = new WireTapStep(_ => Task.CompletedTask); + + await sut.ExecuteAsync(ctx); + + await Given("context IsAborted after wire tap", () => ctx.IsAborted) + .Then("context is not aborted", aborted => + { + aborted.Should().BeFalse(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Async tap action is awaited"), Fact] + public async Task AsyncTapActionIsAwaited() + { + var tapCompleted = false; + var sut = new WireTapStep(async _ => + { + await Task.Delay(10); + tapCompleted = true; + }); + + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("tapCompleted flag after async tap action", () => tapCompleted) + .Then("async tap completed before ExecuteAsync returned", completed => + { + completed.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } +}