From db3d947a134bd49c2889f1d8b091d5b7cee17fea Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 28 Apr 2026 21:16:56 -0500 Subject: [PATCH 1/6] 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/6] 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/6] 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 e734307c3197d29c79242e372f641082854929b9 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 21:52:47 -0500 Subject: [PATCH 4/6] chore(deps): bump PatternKit.Core to 0.112.0 Upgrades PatternKit.Core from 0.105.0 to 0.112.0, which ships new messaging primitives (AsyncWireTap, AsyncScatterGather, AsyncContentEnricher, Normalizer, AsyncPollingConsumer, IIdempotencyStore, IClaimCheckStore, IOutboxStore) that are candidates for absorbing bespoke EIP step implementations. All 588 TinyBDD characterization tests pass against 0.112.0. Co-Authored-By: Claude Opus 4.6 --- Directory.Packages.props | 2 +- .../packages.lock.json | 40 +++++++++---------- .../packages.lock.json | 30 +++++++------- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index cd5fe5a..d16ef73 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -89,7 +89,7 @@ - + diff --git a/src/WorkflowFramework.Extensions.Integration/packages.lock.json b/src/WorkflowFramework.Extensions.Integration/packages.lock.json index 7acc2b3..db99d55 100644 --- a/src/WorkflowFramework.Extensions.Integration/packages.lock.json +++ b/src/WorkflowFramework.Extensions.Integration/packages.lock.json @@ -23,9 +23,9 @@ }, "PatternKit.Core": { "type": "Direct", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==", + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==", "dependencies": { "System.Threading.Tasks.Extensions": "4.6.3" } @@ -67,7 +67,7 @@ "workflowframework": { "type": "Project", "dependencies": { - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.integration.abstractions": { @@ -90,9 +90,9 @@ }, "PatternKit.Core": { "type": "Direct", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" }, "PolySharp": { "type": "Direct", @@ -113,7 +113,7 @@ "workflowframework": { "type": "Project", "dependencies": { - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.integration.abstractions": { @@ -136,9 +136,9 @@ }, "PatternKit.Core": { "type": "Direct", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", @@ -185,7 +185,7 @@ "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.integration.abstractions": { @@ -246,9 +246,9 @@ }, "PatternKit.Core": { "type": "Direct", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", @@ -296,7 +296,7 @@ "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.integration.abstractions": { @@ -364,9 +364,9 @@ }, "PatternKit.Core": { "type": "Direct", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", @@ -414,7 +414,7 @@ "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.integration.abstractions": { diff --git a/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json b/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json index 4ca3c55..9f0f5dc 100644 --- a/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json +++ b/tests/WorkflowFramework.Tests.TinyBDD/packages.lock.json @@ -342,7 +342,7 @@ "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.agents": { @@ -377,7 +377,7 @@ "workflowframework.extensions.integration": { "type": "Project", "dependencies": { - "PatternKit.Core": "[0.105.0, )", + "PatternKit.Core": "[0.112.0, )", "WorkflowFramework": "[1.0.0, )", "WorkflowFramework.Extensions.Integration.Abstractions": "[1.0.0, )" } @@ -472,9 +472,9 @@ }, "PatternKit.Core": { "type": "CentralTransitive", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" } }, "net8.0": { @@ -857,7 +857,7 @@ "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.agents": { @@ -894,7 +894,7 @@ "workflowframework.extensions.integration": { "type": "Project", "dependencies": { - "PatternKit.Core": "[0.105.0, )", + "PatternKit.Core": "[0.112.0, )", "WorkflowFramework": "[1.0.0, )", "WorkflowFramework.Extensions.Integration.Abstractions": "[1.0.0, )" } @@ -990,9 +990,9 @@ }, "PatternKit.Core": { "type": "CentralTransitive", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" }, "System.Diagnostics.DiagnosticSource": { "type": "CentralTransitive", @@ -1391,7 +1391,7 @@ "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )", "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", - "PatternKit.Core": "[0.105.0, )" + "PatternKit.Core": "[0.112.0, )" } }, "workflowframework.extensions.agents": { @@ -1428,7 +1428,7 @@ "workflowframework.extensions.integration": { "type": "Project", "dependencies": { - "PatternKit.Core": "[0.105.0, )", + "PatternKit.Core": "[0.112.0, )", "WorkflowFramework": "[1.0.0, )", "WorkflowFramework.Extensions.Integration.Abstractions": "[1.0.0, )" } @@ -1524,9 +1524,9 @@ }, "PatternKit.Core": { "type": "CentralTransitive", - "requested": "[0.105.0, )", - "resolved": "0.105.0", - "contentHash": "ajdoXIVxeDeTi1NhS0ykTQHk4u/FpdvYrGx9DKvpwzc3z65rSBIWSOLn1vOG2O2tYnZQTxaDC3TSno1MyLhjBg==" + "requested": "[0.112.0, )", + "resolved": "0.112.0", + "contentHash": "iy+Pb7E4Wv06rWF0QciwFvFVjDaT+b1svE4SqscA70vU05z1I8I+krqnKnG+hG7FZJ3NVrN5zZ+FpYJnoRziIw==" }, "System.Diagnostics.DiagnosticSource": { "type": "CentralTransitive", From 52400ef5c5728f5404e0b43d7159c7be9f045b11 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 21:56:01 -0500 Subject: [PATCH 5/6] refactor(integration-channel): use PatternKit AsyncWireTap (deletes ~15 lines) Refactors WireTapStep to delegate to PatternKit.Messaging.Channels. AsyncWireTap. The caller-supplied Func is wrapped in a typed tap handler; swallowErrors maps to TapErrorPolicy. Swallow/Propagate. Public constructor signature and Name property unchanged. All 8 Phase G.3 characterization tests pass without modification. Net: -15 lines of bespoke logic (try/catch + direct await replaced by primitive). Co-Authored-By: Claude Opus 4.6 --- .../Channel/WireTapStep.cs | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs index eb50c9f..536c6a0 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs @@ -1,9 +1,12 @@ -// 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. +// Refactored in feat/consume-patternkit-0.112 to delegate to +// PatternKit.Messaging.Channels.AsyncWireTap. +// The step wraps the caller-supplied Func into a +// PatternKit tap handler, maps swallowErrors to TapErrorPolicy, and exposes +// the same constructor signature and Name property as before. +// All Phase G.3 characterization tests pass without modification. +using PatternKit.Messaging; +using PatternKit.Messaging.Channels; + namespace WorkflowFramework.Extensions.Integration.Channel; /// @@ -12,8 +15,7 @@ namespace WorkflowFramework.Extensions.Integration.Channel; /// public sealed class WireTapStep : IStep { - private readonly Func _tapAction; - private readonly bool _swallowErrors; + private readonly AsyncWireTap _wireTap; /// /// Initializes a new instance of . @@ -22,8 +24,16 @@ public sealed class WireTapStep : IStep /// Whether to swallow errors from the tap action. Default true. public WireTapStep(Func tapAction, bool swallowErrors = true) { - _tapAction = tapAction ?? throw new ArgumentNullException(nameof(tapAction)); - _swallowErrors = swallowErrors; + if (tapAction is null) throw new ArgumentNullException(nameof(tapAction)); + + var policy = swallowErrors ? TapErrorPolicy.Swallow : TapErrorPolicy.Propagate; + + _wireTap = AsyncWireTap.Create("wire-tap") + .Tap("tap", async (message, _, ct) => + { + await tapAction(message.Payload).ConfigureAwait(false); + }, policy) + .Build(); } /// @@ -32,20 +42,7 @@ public WireTapStep(Func tapAction, bool swallowErrors = /// public async Task ExecuteAsync(IWorkflowContext context) { - if (_swallowErrors) - { - try - { - await _tapAction(context).ConfigureAwait(false); - } - catch - { - // Wire tap should not affect main flow - } - } - else - { - await _tapAction(context).ConfigureAwait(false); - } + var message = new Message(context); + await _wireTap.PublishAsync(message, cancellationToken: context.CancellationToken).ConfigureAwait(false); } } From 561901f06e5a6ef92dfdfa79ab804cb6fa77cd46 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 21:59:35 -0500 Subject: [PATCH 6/6] docs(patternkit): update adoption inventory and document 0.112.0 deferrals - Bumps adoption inventory to PatternKit 0.112.0 - Moves WireTapStep from Intentionally Bespoke to Adopted - Adds full deferral analysis for 7 steps evaluated against 0.112.0 primitives: NormalizerStep, ContentEnricherStep, IdempotentReceiverStep, ClaimCheckStep, PollingConsumerStep, ScatterGatherStep, TransactionalOutboxStep - Creates docs/patternkit-followup.md with per-step deferral rationale and resumption conditions for the next adoption pass Co-Authored-By: Claude Opus 4.6 --- docs/patternkit-adoption.md | 39 ++++-- docs/patternkit-followup.md | 243 ++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 docs/patternkit-followup.md diff --git a/docs/patternkit-adoption.md b/docs/patternkit-adoption.md index dfa3bca..0f17535 100644 --- a/docs/patternkit-adoption.md +++ b/docs/patternkit-adoption.md @@ -1,7 +1,7 @@ # PatternKit Adoption Inventory -**PatternKit version:** 0.105.0 -**Last updated:** 2026-05-22 (Phase I coverage tightening) +**PatternKit version:** 0.112.0 +**Last updated:** 2026-05-22 (feat/consume-patternkit-0.112 — WireTapStep adopted) This document lists every point in the WorkflowFramework codebase where a PatternKit primitive is used, and every point where a step is intentionally kept bespoke with the rationale for that decision. This is the canonical reference for Phase I and future phases. @@ -44,6 +44,19 @@ This document lists every point in the WorkflowFramework codebase where a Patter | **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Routing/ContentBasedRouterStepScenarios.cs` | | **Public API change** | None — swap is internal-only. | +### 4. `WireTapStep` — Wire tap channel pattern + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs` | +| **PatternKit namespace** | `PatternKit.Messaging.Channels` | +| **Primitive** | `AsyncWireTap` | +| **Purpose** | Wraps the caller-supplied `Func` into a PatternKit tap handler. The `swallowErrors` constructor parameter maps to `TapErrorPolicy.Swallow` / `TapErrorPolicy.Propagate`. The `IWorkflowContext` is wrapped in a `Message` for transit and unwrapped inside the tap lambda — no public API or behavioral change. | +| **Phase introduced** | feat/consume-patternkit-0.112 | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs` — all 8 Phase G.3 scenarios pass without modification. | +| **Public API change** | None — swap is internal-only. | +| **Net delta** | −15 lines of bespoke logic (try/catch error-swallowing replaced by `TapErrorPolicy`). | + --- ## Intentionally Bespoke @@ -146,13 +159,9 @@ The following EIP steps and other components were evaluated against PatternKit 0 | **Rationale** | PatternKit `AsyncAdapter` is a type-mapping pattern (produce `TOut` from `TIn`). `ChannelAdapterStep` is a side-effect operation (send/receive via `IChannelAdapter`). The send/receive contract doesn't fit the adapt-a-type signature. | | **Test coverage** | Phase G.3 characterization tests | -#### `WireTapStep` +#### ~~`WireTapStep`~~ — **Adopted in feat/consume-patternkit-0.112** -| Item | Detail | -|------|--------| -| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs` | -| **Rationale** | 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/intercepts results; `WireTapStep` purely fires-and-forgets a side channel. | -| **Test coverage** | Phase G.3 characterization tests | +Moved to the **Adopted** section above. Now delegates to `PatternKit.Messaging.Channels.AsyncWireTap`. #### `MessageBridgeStep` @@ -186,12 +195,18 @@ When evaluating a bespoke component for PatternKit adoption, the following crite ## Future Evaluation Targets -The following components are candidates for PatternKit adoption in later phases if suitable primitives become available: +The following components are candidates for PatternKit adoption in later phases if suitable primitives become available or interface alignment is achieved: -| Component | Potential Primitive | Blocking Reason Today | +| Component | Potential Primitive | Blocking Reason (assessed against 0.112.0) | |-----------|--------------------|-----------------------| -| `AggregatorStep` | PatternKit Aggregator (future) | No primitive in 0.105.0 | -| `ScatterGatherStep` | PatternKit ScatterGather (future) | No primitive in 0.105.0 | +| `NormalizerStep` | `Normalizer` (now in 0.112.0) | Behavioral mismatch: PatternKit uses content predicates (first match wins); bespoke uses O(1) dictionary keyed dispatch. Error message format differs — test pins format name in exception text. See `docs/patternkit-followup.md`. | +| `ContentEnricherStep` | `AsyncContentEnricher` (now in 0.112.0) | Semantic mismatch: PatternKit returns an enriched payload copy; bespoke mutates `IWorkflowContext` in place via a `Func`. Wrapping adds indirection for zero functional benefit. | +| `IdempotentReceiverStep` | `IdempotentReceiver` (now in 0.112.0) | Behavioral breaking change: PatternKit marks failed attempts as `Failed` in the store (allowing retry); bespoke registers the ID in a `HashSet` before calling inner, so a failed first attempt DOES suppress the second. Test `ReAttemptAfterExceptionIsSkipped` pins this behavior. | +| `ClaimCheckStep` / `ClaimRetrieveStep` | `ClaimCheck` (now in 0.112.0) | Interface mismatch: bespoke `IClaimCheckStore` is untyped (`object`); PatternKit `IClaimCheckStore` is typed. Bridging requires an adapter class, adding indirection without deleting complexity. | +| `PollingConsumerStep` | `AsyncPollingConsumer` (now in 0.112.0) | Semantic mismatch: PatternKit is a continuous polling loop (run until cancelled); bespoke is a single-shot poll (`PollAsync` → store results → return). Incompatible lifecycle models. | +| `ScatterGatherStep` | `AsyncScatterGather` (now in 0.112.0) | Integration complexity: handlers mutate a shared `IWorkflowContext` and write results to named context keys; PatternKit's per-recipient isolation model returns typed `TResponse` values. Adapting while preserving the `__Result_{handler.Name}` and `ResultsKey` contract would re-implement the existing complexity via a wrapper, defeating the purpose. | +| `TransactionalOutboxStep` | `IOutboxStore` (now in 0.112.0) | Interface mismatch: bespoke `IOutboxStore` uses `SaveAsync(object) → string`; PatternKit `IOutboxStore` uses `EnqueueAsync(Message) → OutboxMessage`. Different return types and message wrapper model. | +| `AggregatorStep` | PatternKit Aggregator (future) | No Aggregator primitive in 0.112.0 | | `PluginManager` | `Strategy` + `AbstractFactory` | Phase H.8 — not yet started | | `AgentLoopStep` / `AgentDecisionStep` | TypeDispatcher | Phase H.7 — not yet started | | `ResilienceMiddleware` (Polly) | `RetryPolicy` | Phase F pilot option B — deferred | diff --git a/docs/patternkit-followup.md b/docs/patternkit-followup.md new file mode 100644 index 0000000..8732674 --- /dev/null +++ b/docs/patternkit-followup.md @@ -0,0 +1,243 @@ +# PatternKit Follow-Up — 0.112.0 Adoption Deferrals + +**Branch:** `feat/consume-patternkit-0.112` +**Date assessed:** 2026-05-22 +**Assessor:** Claude Sonnet 4.6 (refactor agent) + +This document captures every step evaluated against PatternKit 0.112.0 during +`feat/consume-patternkit-0.112` that was deferred with the specific reason why. It +supersedes the equivalent "Future Evaluation Targets" notes in `docs/patternkit-adoption.md`. +Use this as the starting point for the next PatternKit adoption pass. + +--- + +## Deferred: NormalizerStep → Normalizer + +**File:** `src/WorkflowFramework.Extensions.Integration/Transformation/NormalizerStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Transformation.Normalizer` + +**Why deferred:** + +PatternKit's `Normalizer` dispatches via *content predicates* evaluated in +registration order (first-match-wins). `NormalizerStep` dispatches via an O(1) `Dictionary` +keyed lookup — the format detector returns a string key, not a `bool` predicate. + +Two specific blockers: + +1. **Predicate vs key dispatch model.** Converting each dictionary entry to a predicate + (`raw => format == key`) would change the dispatch from O(1) to O(n) and lose the dictionary + key contract from the constructor signature. + +2. **Error message behavioral mismatch.** When no format matches, the bespoke step throws + `InvalidOperationException($"No translator found for format '{format}' and no default translator configured.")`. + The test `UnknownFormatNoDefaultThrows` asserts that the message contains the format string. + PatternKit's miss reason is `"No format handler matched the raw input for normalizer '{name}'."` — + the unknown format key is absent. Fixing this without modifying the test requires a custom + exception mapping wrapper that adds complexity rather than removing it. + +**Resumption condition:** If PatternKit adds a keyed-dispatch variant of `Normalizer` that maps +string discriminators to handlers (similar to the `TypeDispatchRouter` sketch in `.plan/patternkit-extension-backlog.md`), +evaluate again. + +--- + +## Deferred: ContentEnricherStep → AsyncContentEnricher + +**File:** `src/WorkflowFramework.Extensions.Integration/Transformation/ContentEnricherStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Transformation.AsyncContentEnricher` + +**Why deferred:** + +`AsyncContentEnricher` is designed for functional enrichment: each step receives the +current payload, augments it, and returns a new payload. The step's implementation stores the +final enriched payload back in the `Message`. + +`ContentEnricherStep` is a thin named wrapper around `Func` — a +side-effecting context mutation. The `IWorkflowContext` is passed by reference; the delegate +mutates it in place. Adapting to PatternKit would require: + +```csharp +AsyncContentEnricher.Create() + .Enrich("enrich", async (ctx, _, ct) => { await enrichAction(ctx); return ctx; }) + .Build(); +``` + +The `return ctx` discards PatternKit's copy-returning semantics entirely. The wrapper buys +nothing — the code after the refactor is longer than the original. The step itself is 3 +non-trivial lines; PatternKit would add ~10 lines of builder setup. + +**Resumption condition:** Not warranted at this complexity level. If PatternKit adds a +side-effect-oriented enricher (`AsyncSideEffectEnricher` that does not require a return value), +revisit. Otherwise keep bespoke. + +--- + +## Deferred: IdempotentReceiverStep → IdempotentReceiver + +**File:** `src/WorkflowFramework.Extensions.Integration/Endpoint/IdempotentReceiverStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Reliability.IdempotentReceiver` + +**Why deferred — behavioral breaking change:** + +The bespoke step registers the message ID in the `HashSet` *before* calling the inner +step. This means: if the inner step throws, a subsequent call with the same ID is *silently +skipped* (because the ID is already registered). This is tested explicitly: + +``` +Test: ReAttemptAfterExceptionIsSkipped +Pins: "ID was added to the set BEFORE calling inner, so a second call IS skipped + even if inner threw." +``` + +PatternKit's `IdempotentReceiver` has the opposite semantics: + +1. `TryClaimAsync` claims the key (status = `Processing`) +2. Handler is called +3. On exception → `MarkFailedAsync` sets status to `Failed` + +A subsequent `TryClaimAsync` with the same key in `Failed` status returns `Claimed = false` AND +sets the status back to `Processing`, allowing the handler to be retried. This is the correct +resilient behavior for production idempotency, but it breaks the characterization test. + +**To unblock:** The test would need to be updated to remove the `ReAttemptAfterExceptionIsSkipped` +scenario (or relax it to document PatternKit's retry-on-failure semantics). This requires a +deliberate API-evolution decision — it is not safe to change as a refactor. + +--- + +## Deferred: ClaimCheckStep / ClaimRetrieveStep → ClaimCheck + +**File:** `src/WorkflowFramework.Extensions.Integration/Transformation/ClaimCheckStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Transformation.ClaimCheck` + +**Why deferred — interface mismatch:** + +The bespoke `IClaimCheckStore` (in `WorkflowFramework.Extensions.Integration.Abstractions`) is +untyped: + +```csharp +Task StoreAsync(object payload, CancellationToken ct); +Task RetrieveAsync(string ticket, CancellationToken ct); +``` + +PatternKit's `IClaimCheckStore` is typed: + +```csharp +ValueTask StoreAsync(string claimId, TPayload payload, MessageHeaders headers, CancellationToken ct); +ValueTask?> TryLoadAsync(string claimId, CancellationToken ct); +``` + +The differences are: +- Untyped `object` vs typed `TPayload` +- PatternKit requires `MessageHeaders` (not available in WF context model) +- PatternKit takes a `claimId` parameter; bespoke generates the ID internally +- PatternKit returns `ClaimCheckStoredPayload?` vs bespoke returning `object` +- PatternKit uses `ValueTask` vs bespoke `Task` + +Bridging these interfaces would require an adapter class that wraps `IClaimCheckStore` in +`IClaimCheckStore` (with dummy headers), adding ~20 lines of bridge code to save 0 +lines in the step itself. Net: code increase. + +**Resumption condition:** If `WorkflowFramework.Extensions.Integration.Abstractions` migrates to +PatternKit's `IClaimCheckStore` as its primary claim check interface, the step can +adopt directly. + +--- + +## Deferred: PollingConsumerStep → AsyncPollingConsumer + +**File:** `src/WorkflowFramework.Extensions.Integration/Endpoint/PollingConsumerStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Consumers.AsyncPollingConsumer` + +**Why deferred — semantic lifecycle mismatch:** + +`PollingConsumerStep` is a *single-shot* poll step: +1. Call `IPollingSource.PollAsync()` once +2. Write results to `context.Properties[ResultKey]` +3. Return + +`AsyncPollingConsumer` is a *continuous polling loop*: +1. Runs a `while (!ct.IsCancellationRequested)` loop +2. Calls the poll source on each iteration +3. Invokes a handler per-message +4. Sleeps between polls with configurable jitter/backoff + +These are different abstractions. The step is designed to be called from within a workflow +execution engine (once per workflow tick). PatternKit's consumer is designed to be run as a +background service (long-lived, driven to completion by cancellation). + +**Resumption condition:** If a future PatternKit release adds a `SinglePollConsumer` (one-shot +poll adaptor without the loop), that would fit. Alternatively, if WorkflowFramework adds a +background polling host that drives steps in a polling loop, `AsyncPollingConsumer` would be +the right fit there (not in the step itself). + +--- + +## Deferred: ScatterGatherStep → AsyncScatterGather + +**File:** `src/WorkflowFramework.Extensions.Integration/Composition/ScatterGatherStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Routing.AsyncScatterGather>` + +**Why deferred — integration complexity and shared-context mutation model:** + +The bespoke `ScatterGatherStep` relies on a pattern where: +- All handlers share the same `IWorkflowContext` reference +- Each handler writes its result to `context.Properties[$"__Result_{handler.Name}"]` +- After `Task.WhenAll`, the step reads each handler's named result key from context + +PatternKit's `AsyncScatterGather` expects *isolated* per-recipient handlers that return a typed +`TResponse` value. Adapting would require: + +1. Each recipient executes the handler (mutating shared context) then reads back its named key +2. The shared `IWorkflowContext` becomes a concurrency hazard when written by parallel recipients + (the existing implementation has the same hazard but the keys are named distinctly per handler) +3. The aggregator (`Func, IWorkflowContext, Task>`) has a different + signature from PatternKit's `ResponseAggregator(IReadOnlyList>, Message, MessageContext)` + +The adaptation would re-implement the shared-context fan-out logic inside PatternKit's +recipient framework without simplifying the code. The characterization tests in +`ScatterGatherStepTests.cs` pin the exact `__Result_{handler.Name}` key convention and the +`context.Properties[ResultsKey]` output. + +**Resumption condition:** If the scatter-gather handlers are refactored to return typed values +(rather than writing to named context keys), the step could adopt `AsyncScatterGather` directly. +This is an API-evolution decision beyond the scope of a refactor. + +--- + +## Deferred: TransactionalOutboxStep → IOutboxStore + +**File:** `src/WorkflowFramework.Extensions.Integration/Endpoint/TransactionalOutboxStep.cs` + +**PatternKit primitive:** `PatternKit.Messaging.Reliability.IOutboxStore` + +**Why deferred — interface mismatch:** + +Bespoke `IOutboxStore` (in Abstractions): + +```csharp +Task SaveAsync(object message, CancellationToken ct); +``` + +PatternKit `IOutboxStore`: + +```csharp +ValueTask> EnqueueAsync(Message message, string? id, DateTimeOffset? createdAt, CancellationToken ct); +``` + +Key differences: +- Bespoke: untyped `object`, returns outbox ID as `string` +- PatternKit: typed `TPayload` wrapped in `Message`, returns full `OutboxMessage` +- Bespoke: ID is generated by the store; PatternKit accepts an optional caller-provided ID +- The step writes `OutboxIdKey = id` to context; PatternKit's return value is `OutboxMessage`, + requiring `.Id` access — minor but meaningful difference in null-safety and API surface + +**Resumption condition:** If `WorkflowFramework.Extensions.Integration.Abstractions` migrates to +PatternKit's `IOutboxStore` as the primary interface, the step can adopt directly +(write `context.Properties[OutboxIdKey] = result.Id`).