Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 11 additions & 16 deletions src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#nullable enable
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -28,26 +29,20 @@ public FailedTaskStructuredErrorReader(IWorkflowService workflowService)

/// <summary>
/// Reads the structured error declared by the deepest failed task of <paramref name="workflowId"/>,
/// following the Try pattern: returns <c>false</c> (with a <c>null</c> <paramref name="error"/>) when
/// the execution has no failed task or the failed task declared nothing. Synchronous — an <c>out</c>
/// parameter rules out <c>async</c> — so the underlying Conductor call blocks the calling thread.
/// or <c>null</c> when the execution has no failed task or the failed task declared nothing.
/// </summary>
public bool TryRead(string workflowId, out StructuredError error, CancellationToken cancellationToken)
public async Task<StructuredError?> ReadOrNullAsync(string? workflowId, CancellationToken cancellationToken)
{
var failed = FindDeepestFailedTaskAsync(workflowId, cancellationToken).GetAwaiter().GetResult();
var failed = await FindDeepestFailedTaskAsync(workflowId, cancellationToken);

if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured))
{
error = structured;
return true;
}
return structured;

error = null;
return false;
return null;
}

/// <summary>
/// Like <see cref="TryRead"/>, but always yields an error: a failure that declared nothing is
/// Like <see cref="ReadOrNullAsync"/>, but never returns <c>null</c>: a failure that declared nothing is
/// classified as <see cref="StructuredError.UnclassifiedCode"/> with <paramref name="genericReason"/> as the
/// sanitized reason, so raw internals never cross a boundary by default. The returned
/// <see cref="StructuredError.Message"/> always carries the most specific diagnostic available: the declared
Expand All @@ -64,9 +59,9 @@ public bool TryRead(string workflowId, out StructuredError error, CancellationTo
/// inside the diagnostic message, never as the sanitized reason.
/// </param>
public async Task<StructuredError> ReadOrFallbackAsync(
string workflowId,
string? workflowId,
string genericReason,
string fallbackReason,
string? fallbackReason,
CancellationToken cancellationToken
)
{
Expand Down Expand Up @@ -98,7 +93,7 @@ CancellationToken cancellationToken
/// so any failed sub-workflow is descended into first (walking from the last), and FORK/JOIN aggregators
/// are skipped when picking a leaf. Returns <c>null</c> when the execution has no failed task.
/// </summary>
public async Task<ConductorSharp.Client.Generated.Task> FindDeepestFailedTaskAsync(string workflowId, CancellationToken cancellationToken)
public async Task<ConductorSharp.Client.Generated.Task?> FindDeepestFailedTaskAsync(string? workflowId, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(workflowId))
return null;
Expand All @@ -124,7 +119,7 @@ CancellationToken cancellationToken
return failedTasks.LastOrDefault(t => t.TaskType is not ("JOIN" or "FORK")) ?? failedTasks[^1];
}

private static string BuildDiagnosticMessage(ConductorSharp.Client.Generated.Task task, string fallbackReason)
private static string BuildDiagnosticMessage(ConductorSharp.Client.Generated.Task? task, string? fallbackReason)
{
if (task == null)
return fallbackReason ?? "No failed task found.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,42 +159,36 @@ private static GeneratedTask FailedTask(
private static Workflow Execution(params GeneratedTask[] tasks) => new() { Tasks = tasks };

[Fact]
public void TryRead_returns_the_declared_structured_error()
public async Task ReadOrNull_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);
var error = await reader.ReadOrNullAsync("wf", CancellationToken.None);

Assert.True(found);
Assert.NotNull(error);
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()
public async Task ReadOrNull_returns_null_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);
Assert.Null(await reader.ReadOrNullAsync("wf", CancellationToken.None));
}

[Fact]
public void TryRead_returns_false_when_nothing_failed()
public async Task ReadOrNull_returns_null_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);
Assert.Null(await reader.ReadOrNullAsync("wf", CancellationToken.None));
}

[Fact]
public void Descends_into_the_failed_sub_workflow_instead_of_stopping_on_the_join()
public async Task 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.
Expand All @@ -207,23 +201,23 @@ public void Descends_into_the_failed_sub_workflow_instead_of_stopping_on_the_joi
}
);

var found = reader.TryRead("parent", out var error, CancellationToken.None);
var error = await reader.ReadOrNullAsync("parent", CancellationToken.None);

Assert.True(found);
Assert.NotNull(error);
Assert.Equal("LEAF", error.Code);
}

[Fact]
public void Skips_fork_and_join_aggregators_when_picking_the_leaf()
public async Task 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);
var error = await reader.ReadOrNullAsync("wf", CancellationToken.None);

Assert.True(found);
Assert.NotNull(error);
Assert.Equal("SIMPLE_LEAF", error.Code);
}

Expand Down
Loading