diff --git a/src/ConductorSharp.Client/ConductorSharp.Client.csproj b/src/ConductorSharp.Client/ConductorSharp.Client.csproj
index ff98cdd..8e698cc 100644
--- a/src/ConductorSharp.Client/ConductorSharp.Client.csproj
+++ b/src/ConductorSharp.Client/ConductorSharp.Client.csproj
@@ -6,7 +6,7 @@
Codaxy
Codaxy
ConductorSharp.Client
- 4.3.0
+ 4.4.0
Client library for Netflix Conductor, with some additional quality of life features.
https://github.com/codaxy/conductor-sharp
netflix;conductor
diff --git a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj
index 79be75f..d091539 100644
--- a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj
+++ b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj
@@ -6,7 +6,7 @@
Codaxy
Codaxy
ConductorSharp.Engine
- 4.3.0
+ 4.4.0
Client library for Netflix Conductor, with some additional quality of life features.
https://github.com/codaxy/conductor-sharp
netflix;conductor
diff --git a/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs b/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs
index 912acb5..6c1b670 100644
--- a/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs
+++ b/src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs
@@ -49,6 +49,8 @@ params Assembly[] handlerAssemblies
Builder.AddScoped();
+ Builder.AddTransient();
+
Builder.AddSingleton();
Builder.AddTransient();
diff --git a/src/ConductorSharp.Engine/Model/StructuredError.cs b/src/ConductorSharp.Engine/Model/StructuredError.cs
index b712a37..6049751 100644
--- a/src/ConductorSharp.Engine/Model/StructuredError.cs
+++ b/src/ConductorSharp.Engine/Model/StructuredError.cs
@@ -12,6 +12,12 @@ public class StructuredError
/// Current structured-error payload shape version.
public const int CurrentVersion = 1;
+ ///
+ /// Reserved classification code for failures that declared no structured error. Producers must not
+ /// use it for declared errors; consumers may treat it as "no classification available".
+ ///
+ public const string UnclassifiedCode = "UNCLASSIFIED";
+
/// Stable, opaque classification code (e.g. an implementation-defined code, or UNCLASSIFIED).
public string Code { get; set; }
diff --git a/src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs b/src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs
new file mode 100644
index 0000000..d7af8cc
--- /dev/null
+++ b/src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs
@@ -0,0 +1,136 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using ConductorSharp.Client.Service;
+using ConductorSharp.Engine.Model;
+using TaskStatus = ConductorSharp.Client.Generated.TaskStatus;
+
+namespace ConductorSharp.Engine.Util
+{
+ ///
+ /// Walks a failed workflow execution through sub-workflows to the deepest failed task and reads the
+ /// structured error it declared via the shared structured_error task-output contract
+ /// ().
+ ///
+ /// This is the single reader for the contract: consumers that harvest a failure classification out of an
+ /// execution (failure workflows, notification builders, drill-downs) should use it instead of re-implementing
+ /// the descent, so they cannot drift apart on which task "the" failure is.
+ ///
+ ///
+ public class FailedTaskStructuredErrorReader
+ {
+ private readonly IWorkflowService _workflowService;
+
+ public FailedTaskStructuredErrorReader(IWorkflowService workflowService)
+ {
+ _workflowService = workflowService;
+ }
+
+ ///
+ /// Reads the structured error declared by the deepest failed task of ,
+ /// following the Try pattern: returns false (with a null ) when
+ /// the execution has no failed task or the failed task declared nothing. Synchronous — an out
+ /// parameter rules out async — so the underlying Conductor call blocks the calling thread.
+ ///
+ public bool TryRead(string workflowId, out StructuredError error, CancellationToken cancellationToken)
+ {
+ var failed = FindDeepestFailedTaskAsync(workflowId, cancellationToken).GetAwaiter().GetResult();
+
+ if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured))
+ {
+ error = structured;
+ return true;
+ }
+
+ error = null;
+ return false;
+ }
+
+ ///
+ /// Like , but always yields an error: a failure that declared nothing is
+ /// classified as with as the
+ /// sanitized reason, so raw internals never cross a boundary by default. The returned
+ /// always carries the most specific diagnostic available: the declared
+ /// message when there is one, otherwise an internal locator (workflow id, task id, reference name, raw
+ /// reason) suitable for drill-down.
+ ///
+ /// Execution to walk.
+ ///
+ /// Sanitized reason used when the failure declared no reason of its own. Callers own this text — it is
+ /// what crosses their boundary.
+ ///
+ ///
+ /// Optional raw failure reason already known to the caller (e.g. the failure workflow's input); used only
+ /// inside the diagnostic message, never as the sanitized reason.
+ ///
+ public async Task ReadOrFallbackAsync(
+ string workflowId,
+ string genericReason,
+ string fallbackReason,
+ CancellationToken cancellationToken
+ )
+ {
+ var failed = await FindDeepestFailedTaskAsync(workflowId, cancellationToken);
+
+ if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured))
+ {
+ return new StructuredError
+ {
+ Code = structured.Code,
+ Reason = string.IsNullOrEmpty(structured.Reason) ? genericReason : structured.Reason,
+ Message = string.IsNullOrEmpty(structured.Message) ? BuildDiagnosticMessage(failed, fallbackReason) : structured.Message,
+ ReferenceError = structured.ReferenceError,
+ Version = structured.Version
+ };
+ }
+
+ return new StructuredError
+ {
+ Code = StructuredError.UnclassifiedCode,
+ Reason = genericReason,
+ Message = BuildDiagnosticMessage(failed, fallbackReason)
+ };
+ }
+
+ ///
+ /// Walks the execution to the task that actually failed. A phase often runs as a SUB_WORKFLOW inside a
+ /// dynamic fork, and the aggregating JOIN task is frequently the last failed task in the parent —
+ /// so any failed sub-workflow is descended into first (walking from the last), and FORK/JOIN aggregators
+ /// are skipped when picking a leaf. Returns null when the execution has no failed task.
+ ///
+ public async Task FindDeepestFailedTaskAsync(string workflowId, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(workflowId))
+ return null;
+
+ var workflow = await _workflowService.GetExecutionStatusAsync(workflowId, true, cancellationToken);
+
+ var failedTasks = (workflow.Tasks ?? []).Where(t => t.Status is TaskStatus.FAILED or TaskStatus.FAILED_WITH_TERMINAL_ERROR).ToList();
+
+ if (failedTasks.Count == 0)
+ return null;
+
+ for (var i = failedTasks.Count - 1; i >= 0; i--)
+ {
+ var subWorkflowId = failedTasks[i].SubWorkflowId;
+ if (!string.IsNullOrEmpty(subWorkflowId))
+ {
+ var deeper = await FindDeepestFailedTaskAsync(subWorkflowId, cancellationToken);
+ if (deeper != null)
+ return deeper;
+ }
+ }
+
+ return failedTasks.LastOrDefault(t => t.TaskType is not ("JOIN" or "FORK")) ?? failedTasks[^1];
+ }
+
+ private static string BuildDiagnosticMessage(ConductorSharp.Client.Generated.Task task, string fallbackReason)
+ {
+ if (task == null)
+ return fallbackReason ?? "No failed task found.";
+
+ var raw = task.ReasonForIncompletion ?? fallbackReason;
+ return $"workflowId={task.WorkflowInstanceId}; taskId={task.TaskId}; ref={task.ReferenceTaskName}; reason={raw}";
+ }
+ }
+}
diff --git a/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs b/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs
index c640c95..93c9761 100644
--- a/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs
+++ b/src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs
@@ -6,16 +6,15 @@
namespace ConductorSharp.Engine.Util
{
///
- /// Defines the structured_error task-output contract (key + shape) and the read side used by all consumers
- /// (). There are two producers, both emitting the same shape because they serialize the same
- /// type with the same serializer settings:
- ///
- /// - the execution-manager catch block, which serializes when a
- /// worker throws a ; and
- /// - external signal senders (which have no exception to catch), which render via .
- ///
+ /// Defines the structured_error task-output contract (key + shape): the write side
+ /// () and the tolerant read side used by all consumers ().
+ /// The execution-manager catch block emits the same shape without this class, by serializing
+ /// with the same serializer settings when a worker throws a
+ /// . Internal on purpose: consumers
+ /// read the contract through , and producers outside this
+ /// assembly declare errors by throwing, not by rendering the payload themselves.
///
- public static class StructuredErrorSerializer
+ internal static class StructuredErrorSerializer
{
/// Well-known task-output key carrying the structured error payload.
public const string OutputKey = "structured_error";
@@ -24,19 +23,15 @@ public static class StructuredErrorSerializer
/// Renders a to an output-data fragment ({ "structured_error": { ... } })
/// using the standard snake_case IO serializer settings. Returns an empty dictionary for a null error.
///
- public static IDictionary ToOutputData(StructuredError error)
+ public static IDictionary Serialize(StructuredError error)
{
if (error == null)
return new Dictionary();
- return new Dictionary { [OutputKey] = ToOutputValue(error) };
- }
-
- /// Renders just the value placed under , in the canonical snake_case shape.
- public static object ToOutputValue(StructuredError error)
- {
var json = JsonConvert.SerializeObject(error, ConductorConstants.IoJsonSerializerSettings);
- return JsonConvert.DeserializeObject>(json, ConductorConstants.IoJsonSerializerSettings);
+ var value = JsonConvert.DeserializeObject>(json, ConductorConstants.IoJsonSerializerSettings);
+
+ return new Dictionary { [OutputKey] = value };
}
///
@@ -45,7 +40,7 @@ public static object ToOutputValue(StructuredError error)
/// payload returns false and never throws, so a parse problem degrades error quality (falling back to
/// the generic path) rather than failing the failure workflow.
///
- public static bool TryParse(IDictionary taskOutput, out StructuredError error)
+ public static bool TryDeserialize(IDictionary taskOutput, out StructuredError error)
{
error = null;
diff --git a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj
index 65f9208..c7033c9 100644
--- a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj
+++ b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj
@@ -4,7 +4,7 @@
net6.0
enable
enable
- 4.3.0
+ 4.4.0
Codaxy
Codaxy
diff --git a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj
index c93109d..7e05ca5 100644
--- a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj
+++ b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj
@@ -7,7 +7,7 @@
False
Codaxy
Codaxy
- 4.3.0
+ 4.4.0
diff --git a/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs b/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs
index 9535c46..cfd2a94 100644
--- a/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs
+++ b/src/ConductorSharp.Patterns/Extensions/ContainerBuilderExtensions.cs
@@ -15,6 +15,7 @@ public static IExecutionManagerBuilder AddConductorSharpPatterns(this IExecution
{
executionManagerBuilder.Builder.RegisterWorkerTask();
executionManagerBuilder.Builder.RegisterWorkerTask();
+ executionManagerBuilder.Builder.RegisterWorkerTask();
executionManagerBuilder.Builder.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(WaitSeconds).Assembly));
return executionManagerBuilder;
diff --git a/src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs b/src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs
new file mode 100644
index 0000000..cd1a318
--- /dev/null
+++ b/src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using ConductorSharp.Engine;
+using ConductorSharp.Engine.Builders.Metadata;
+using ConductorSharp.Engine.Model;
+using ConductorSharp.Engine.Util;
+using MediatR;
+
+namespace ConductorSharp.Patterns.Tasks
+{
+ #region models
+ public class BuildFailureErrorRequest : IRequest
+ {
+ ///
+ /// Id of the failed workflow execution to classify (typically the failure workflow's workflowId input).
+ ///
+ public string? WorkflowId { get; set; }
+
+ ///
+ /// Sanitized reason used when the failure declared no reason of its own. This is the text that crosses the
+ /// caller's boundary — supply domain wording here; defaults to a neutral generic.
+ ///
+ public string? GenericReason { get; set; }
+
+ ///
+ /// Optional raw failure reason already known to the caller (e.g. the failure workflow's reason input);
+ /// used only inside the diagnostic message, never as the sanitized reason.
+ ///
+ public string? FallbackReason { get; set; }
+ }
+
+ public record BuildFailureErrorResponse(StructuredError Error);
+
+ #endregion
+
+ ///
+ /// Walks the given failed execution to its deepest failed task (via
+ /// ) and returns the structured error it declared — or the
+ /// fallback with the sanitized GenericReason when it
+ /// declared nothing. Intended for failure workflows that persist a failure classification atomically with the
+ /// failed state.
+ ///
+ /// Registered by AddConductorSharpPatterns under the shared task name. The task is a stateless,
+ /// read-only Conductor API lookup, so on a shared cluster it is safe for multiple services to register
+ /// and poll the same queue — whichever picks the task up produces the same result. Use Conductor task
+ /// domains if poller isolation is ever required.
+ ///
+ ///
+ [OriginalName(Constants.TaskNamePrefix + "_build_failure_error")]
+ public class BuildFailureError(FailedTaskStructuredErrorReader errorReader)
+ : TaskRequestHandler
+ {
+ public const string DefaultGenericReason = "The workflow failed.";
+
+ private readonly FailedTaskStructuredErrorReader _errorReader = errorReader;
+
+ public override async Task Handle(BuildFailureErrorRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(request.WorkflowId))
+ throw new Exception("No workflowId provided");
+
+ var error = await _errorReader.ReadOrFallbackAsync(
+ request.WorkflowId,
+ string.IsNullOrEmpty(request.GenericReason) ? DefaultGenericReason : request.GenericReason,
+ request.FallbackReason,
+ cancellationToken
+ );
+
+ return new BuildFailureErrorResponse(error);
+ }
+ }
+}
diff --git a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj
index 8ccaa36..1359741 100644
--- a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj
+++ b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj
@@ -7,7 +7,7 @@
disable
true
dotnet-conductorsharp
- 4.3.0
+ 4.4.0
diff --git a/test/ConductorSharp.Engine.Tests/Unit/FailedTaskStructuredErrorReaderTests.cs b/test/ConductorSharp.Engine.Tests/Unit/FailedTaskStructuredErrorReaderTests.cs
new file mode 100644
index 0000000..97cb24e
--- /dev/null
+++ b/test/ConductorSharp.Engine.Tests/Unit/FailedTaskStructuredErrorReaderTests.cs
@@ -0,0 +1,283 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using ConductorSharp.Client.Generated;
+using ConductorSharp.Client.Service;
+using ConductorSharp.Engine.Model;
+using ConductorSharp.Engine.Util;
+using Xunit;
+using GeneratedTask = ConductorSharp.Client.Generated.Task;
+using Task = System.Threading.Tasks.Task;
+using TaskStatus = ConductorSharp.Client.Generated.TaskStatus;
+
+namespace ConductorSharp.Engine.Tests.Unit
+{
+ public class FailedTaskStructuredErrorReaderTests
+ {
+ private const string GenericReason = "The request could not be completed.";
+
+ // Serves executions from an in-memory map so the descent through sub-workflows can be exercised
+ // without a Conductor server. Only GetExecutionStatusAsync is meaningful.
+ private sealed class FakeWorkflowService(Dictionary executions) : IWorkflowService
+ {
+ public Task GetExecutionStatusAsync(
+ string workflowId,
+ bool? includeTasks = false,
+ CancellationToken cancellationToken = default
+ ) => Task.FromResult(executions[workflowId]);
+
+ public Task DecideAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ public Task DeleteAsync(string workflowId, bool? archiveWorkflow = null, CancellationToken cancellationToken = default) =>
+ throw new NotImplementedException();
+
+ public Task>> GetCorrelatedAsync(
+ string name,
+ IEnumerable correlationIds,
+ bool? includeClosed = false,
+ bool? includeTasks = false,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task> ListCorrelatedAsync(
+ string name,
+ string correlationId,
+ bool? includeClosed = false,
+ bool? includeTasks = false,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task GetExternalStorageLocationAsync(
+ string path,
+ string operation,
+ string payloadType,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task> ListRunningAsync(
+ string name,
+ int? version,
+ long? startTime = null,
+ long? endTime = null,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task PauseAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ public Task RerunAsync(
+ string workflowId,
+ RerunWorkflowRequest rerunWorkflowRequest,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task ResetCallbacksAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ public Task RestartAsync(string workflowId, bool? useLatestDefinitions = null, CancellationToken cancellationToken = default) =>
+ throw new NotImplementedException();
+
+ public Task ResumeAsync(string workflowId, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ public Task RetryAsync(string workflowId, bool? resumeSubworkflowTasks = null, CancellationToken cancellationToken = default) =>
+ throw new NotImplementedException();
+
+ public Task SearchAsync(
+ int? start = null,
+ int? size = null,
+ string sort = null,
+ string freeText = null,
+ string query = null,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task SearchByTasksAsync(
+ int? start = null,
+ int? size = null,
+ string sort = null,
+ string freeText = null,
+ string query = null,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task SearchV2Async(
+ int? start = null,
+ int? size = null,
+ string sort = null,
+ string freeText = null,
+ string query = null,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task SearchV2ByTasksAsync(
+ int? start = null,
+ int? size = null,
+ string sort = null,
+ string freeText = null,
+ string query = null,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task SkipTaskAsync(
+ string workflowId,
+ string taskReferenceName,
+ SkipTaskRequest skipTaskRequest,
+ CancellationToken cancellationToken = default
+ ) => throw new NotImplementedException();
+
+ public Task StartAsync(StartWorkflowRequest startWorkflowRequest, CancellationToken cancellationToken = default) =>
+ throw new NotImplementedException();
+
+ public Task TerminateAsync(string workflowId, string reason = null, CancellationToken cancellationToken = default) =>
+ throw new NotImplementedException();
+
+ public Task TestAsync(WorkflowTestRequest workflowTestRequest, CancellationToken cancellationToken = default) =>
+ throw new NotImplementedException();
+ }
+
+ private static FailedTaskStructuredErrorReader Reader(Dictionary executions) => new(new FakeWorkflowService(executions));
+
+ private static GeneratedTask FailedTask(
+ string taskType = "SIMPLE",
+ string subWorkflowId = null,
+ IDictionary outputData = null,
+ string reason = null,
+ string taskId = null,
+ string reference = null,
+ string workflowInstanceId = null
+ ) =>
+ new()
+ {
+ Status = TaskStatus.FAILED,
+ TaskType = taskType,
+ SubWorkflowId = subWorkflowId,
+ OutputData = outputData,
+ ReasonForIncompletion = reason,
+ TaskId = taskId,
+ ReferenceTaskName = reference,
+ WorkflowInstanceId = workflowInstanceId
+ };
+
+ private static Workflow Execution(params GeneratedTask[] tasks) => new() { Tasks = tasks };
+
+ [Fact]
+ public void TryRead_returns_the_declared_structured_error()
+ {
+ var output = StructuredErrorSerializer.Serialize(new StructuredError { Code = "RESOURCE_UNAVAILABLE", Reason = "No port available" });
+ var reader = Reader(new() { ["wf"] = Execution(FailedTask(outputData: output)) });
+
+ var found = reader.TryRead("wf", out var error, CancellationToken.None);
+
+ Assert.True(found);
+ Assert.Equal("RESOURCE_UNAVAILABLE", error.Code);
+ Assert.Equal("No port available", error.Reason);
+ }
+
+ [Fact]
+ public void TryRead_returns_false_when_the_failed_task_declared_nothing()
+ {
+ var reader = Reader(new() { ["wf"] = Execution(FailedTask(reason: "raw internals")) });
+
+ var found = reader.TryRead("wf", out var error, CancellationToken.None);
+
+ Assert.False(found);
+ Assert.Null(error);
+ }
+
+ [Fact]
+ public void TryRead_returns_false_when_nothing_failed()
+ {
+ var reader = Reader(new() { ["wf"] = Execution() });
+
+ var found = reader.TryRead("wf", out var error, CancellationToken.None);
+
+ Assert.False(found);
+ Assert.Null(error);
+ }
+
+ [Fact]
+ public void Descends_into_the_failed_sub_workflow_instead_of_stopping_on_the_join()
+ {
+ // Parent: a failed SUB_WORKFLOW and the aggregating JOIN that failed after it. The declared error
+ // lives on the leaf task inside the child execution.
+ var output = StructuredErrorSerializer.Serialize(new StructuredError { Code = "LEAF", Reason = "leaf failed" });
+ var reader = Reader(
+ new()
+ {
+ ["parent"] = Execution(FailedTask(taskType: "SUB_WORKFLOW", subWorkflowId: "child"), FailedTask(taskType: "JOIN")),
+ ["child"] = Execution(FailedTask(outputData: output))
+ }
+ );
+
+ var found = reader.TryRead("parent", out var error, CancellationToken.None);
+
+ Assert.True(found);
+ Assert.Equal("LEAF", error.Code);
+ }
+
+ [Fact]
+ public void Skips_fork_and_join_aggregators_when_picking_the_leaf()
+ {
+ var output = StructuredErrorSerializer.Serialize(new StructuredError { Code = "SIMPLE_LEAF", Reason = "r" });
+ var reader = Reader(
+ new() { ["wf"] = Execution(FailedTask(taskType: "FORK"), FailedTask(outputData: output), FailedTask(taskType: "JOIN")) }
+ );
+
+ var found = reader.TryRead("wf", out var error, CancellationToken.None);
+
+ Assert.True(found);
+ Assert.Equal("SIMPLE_LEAF", error.Code);
+ }
+
+ [Fact]
+ public async Task ReadOrFallback_falls_back_to_unclassified_with_the_sanitized_generic_reason()
+ {
+ var reader = Reader(
+ new() { ["wf"] = Execution(FailedTask(reason: "raw stack trace", taskId: "t1", reference: "ref1", workflowInstanceId: "wf")) }
+ );
+
+ var error = await reader.ReadOrFallbackAsync("wf", GenericReason, fallbackReason: null, CancellationToken.None);
+
+ Assert.Equal(StructuredError.UnclassifiedCode, error.Code);
+ Assert.Equal(GenericReason, error.Reason);
+ // Raw internals go into the diagnostic message for drill-down, never into the reason.
+ Assert.Contains("taskId=t1", error.Message);
+ Assert.Contains("raw stack trace", error.Message);
+ }
+
+ [Fact]
+ public async Task ReadOrFallback_keeps_the_declared_message_and_fills_a_diagnostic_one_when_absent()
+ {
+ var withMessage = StructuredErrorSerializer.Serialize(
+ new StructuredError
+ {
+ Code = "C",
+ Reason = "r",
+ Message = "declared detail"
+ }
+ );
+ var withoutMessage = new StructuredError { Code = "C", Reason = "r" };
+ withoutMessage.Message = null;
+ var reader = Reader(
+ new()
+ {
+ ["with"] = Execution(FailedTask(outputData: withMessage)),
+ ["without"] = Execution(FailedTask(outputData: StructuredErrorSerializer.Serialize(withoutMessage), taskId: "t9"))
+ }
+ );
+
+ Assert.Equal("declared detail", (await reader.ReadOrFallbackAsync("with", GenericReason, null, CancellationToken.None)).Message);
+ Assert.Contains("taskId=t9", (await reader.ReadOrFallbackAsync("without", GenericReason, null, CancellationToken.None)).Message);
+ }
+
+ [Fact]
+ public async Task ReadOrFallback_with_no_workflow_id_still_returns_a_classification()
+ {
+ var reader = Reader(new());
+
+ var error = await reader.ReadOrFallbackAsync(null, GenericReason, "reason from failure workflow input", CancellationToken.None);
+
+ Assert.Equal(StructuredError.UnclassifiedCode, error.Code);
+ Assert.Equal(GenericReason, error.Reason);
+ Assert.Equal("reason from failure workflow input", error.Message);
+ }
+ }
+}
diff --git a/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs b/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs
index 83a710a..925450f 100644
--- a/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs
+++ b/test/ConductorSharp.Engine.Tests/Unit/StructuredErrorTests.cs
@@ -114,7 +114,7 @@ public void Message_survives_the_round_trip()
new StructuredErrorException("CODE", "Short reason", "https://example.org/entity/9", "Long diagnostic detail")
);
- Assert.True(StructuredErrorSerializer.TryParse(dict, out var parsed));
+ Assert.True(StructuredErrorSerializer.TryDeserialize(dict, out var parsed));
Assert.Equal("CODE", parsed.Code);
Assert.Equal("Short reason", parsed.Reason);
Assert.Equal("Long diagnostic detail", parsed.Message);
@@ -139,9 +139,9 @@ public void RoundTrip_helper_output_is_parsed_back()
ReferenceError = "https://example.org/entity/7"
};
- var outputData = StructuredErrorSerializer.ToOutputData(error);
+ var outputData = StructuredErrorSerializer.Serialize(error);
- Assert.True(StructuredErrorSerializer.TryParse(outputData, out var parsed));
+ Assert.True(StructuredErrorSerializer.TryDeserialize(outputData, out var parsed));
Assert.Equal(error.Code, parsed.Code);
Assert.Equal(error.Reason, parsed.Reason);
Assert.Equal(error.Message, parsed.Message);
@@ -154,7 +154,7 @@ public void RoundTrip_catch_block_output_is_parsed_back()
{
var dict = SerializeCatchOutput(new StructuredErrorException("RESOURCE_UNAVAILABLE", "No port available"));
- Assert.True(StructuredErrorSerializer.TryParse(dict, out var parsed));
+ Assert.True(StructuredErrorSerializer.TryDeserialize(dict, out var parsed));
Assert.Equal("RESOURCE_UNAVAILABLE", parsed.Code);
Assert.Equal("No port available", parsed.Reason);
}
@@ -164,14 +164,14 @@ public void TryParse_returns_false_when_key_absent()
{
var dict = new Dictionary { ["error_message"] = "boom" };
- Assert.False(StructuredErrorSerializer.TryParse(dict, out var parsed));
+ Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out var parsed));
Assert.Null(parsed);
}
[Fact]
public void TryParse_returns_false_on_null_input()
{
- Assert.False(StructuredErrorSerializer.TryParse(null, out var parsed));
+ Assert.False(StructuredErrorSerializer.TryDeserialize(null, out var parsed));
Assert.Null(parsed);
}
@@ -180,7 +180,7 @@ public void TryParse_returns_false_on_malformed_payload()
{
var dict = new Dictionary { [StructuredErrorSerializer.OutputKey] = "not-a-structured-error" };
- Assert.False(StructuredErrorSerializer.TryParse(dict, out _));
+ Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out _));
}
[Fact]
@@ -191,7 +191,7 @@ public void TryParse_returns_false_when_code_missing()
[StructuredErrorSerializer.OutputKey] = new Dictionary { ["reason"] = "no code here" }
};
- Assert.False(StructuredErrorSerializer.TryParse(dict, out _));
+ Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out _));
}
[Fact]
@@ -203,7 +203,7 @@ public void TryParse_tolerates_a_message_only_payload_by_degrading()
[StructuredErrorSerializer.OutputKey] = new Dictionary { ["message"] = "detail but no code" }
};
- Assert.False(StructuredErrorSerializer.TryParse(dict, out _));
+ Assert.False(StructuredErrorSerializer.TryDeserialize(dict, out _));
}
}
}