Harden output converter contracts

(cherry picked from commit 0737def83d)
This commit is contained in:
Sipke Schoorstra 2026-07-31 13:29:01 +02:00
parent 61fc376dac
commit dff7d9f987
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
6 changed files with 62 additions and 11 deletions

View file

@ -64,12 +64,12 @@ This checklist maps the approved requirements to implementation and automated ev
## Verification log
- Core output-converter and serialization tests: 45 passed on `net10.0`.
- Core output-converter and serialization tests: 47 passed on `net10.0`.
- Management definition-validation tests: 3 passed on `net10.0`.
- API endpoint tests: 6 passed on `net10.0`.
- API-client component tests: 2 passed on `net10.0`.
- Runtime component tests: 3 passed on `net10.0` (workflow-output scenario plus the corrected variable and missing-registration scenarios).
- Studio output-converter tests: 8 passed on `net10.0` against this Core worktree.
- Studio output-converter tests: 9 passed on `net10.0` against this Core worktree.
- Core project build: passed on `net10.0` with zero warnings and errors.
- Core, Management, API, and API-client projects built successfully for `net8.0`, `net9.0`, and `net10.0` during the solution build.
- The complete solution build could not finish with `--no-restore` because unrelated projects had no `project.assets.json` in this worktree (`Elsa.Workflows.IntegrationTests`, `Elsa.Hosting.Management`, and `Elsa.Workflows.Runtime.UnitTests`).

View file

@ -9,14 +9,18 @@ public record ExceptionState(
string Type,
string Message,
string? StackTrace,
ExceptionState? InnerException,
IReadOnlyDictionary<string, string>? Metadata = null)
ExceptionState? InnerException)
{
/// <summary>
/// Gets privacy-safe structured exception metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionState"/> class.
/// </summary>
[JsonConstructor]
public ExceptionState() : this(null!, null!, null, null, null)
public ExceptionState() : this(null!, null!, null, null)
{
}

View file

@ -32,8 +32,8 @@ public class OutputBindingDestinationResolver : IOutputBindingDestinationResolve
if (referenceId == null)
return null;
var variable = activityNode
.Ancestors()
var variable = new[] { activityNode }
.Concat(activityNode.Ancestors())
.Select(x => x.Activity)
.OfType<IVariableContainer>()
.SelectMany(x => x.Variables)

View file

@ -11,14 +11,18 @@ public record ExceptionState(
Type Type,
string Message,
string? StackTrace,
ExceptionState? InnerException,
IReadOnlyDictionary<string, string>? Metadata = null)
ExceptionState? InnerException)
{
/// <summary>
/// Gets privacy-safe structured exception metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Constructor
/// </summary>
[JsonConstructor]
public ExceptionState() : this(default!, default!, default, default, default)
public ExceptionState() : this(default!, default!, default, default)
{
}
@ -35,6 +39,9 @@ public record ExceptionState(
var metadata = metadataProvider?.GetSafeMetadata();
var innerException = metadataProvider == null ? FromException(ex.InnerException) : null;
return new ExceptionState(ex.GetType(), ex.Message, ex.StackTrace, innerException, metadata);
return new ExceptionState(ex.GetType(), ex.Message, ex.StackTrace, innerException)
{
Metadata = metadata
};
}
}

View file

@ -80,6 +80,29 @@ public class OutputBindingDestinationResolverTests
Assert.Equal(OutputBindingDestinationKind.Variable, destination.Kind);
}
[Fact]
public void Resolve_StaticVariable_IncludesCurrentVariableContainer()
{
var referenceId = "local-destination";
var activity = new Sequence
{
Id = "activity",
Variables = [new Variable<string>("LocalDestination", "", referenceId)]
};
var node = new ActivityNode(activity, "Body");
var workflow = new Workflow { Root = activity };
var graph = new WorkflowGraph(workflow, node, [node]);
var output = new Output<int>(new MemoryBlockReference(referenceId));
var destination = _resolver.Resolve(graph, node, output);
Assert.NotNull(destination);
Assert.Equal(referenceId, destination.Id);
Assert.Equal(typeof(string), destination.Type);
Assert.True(destination.AllowsNull);
Assert.Equal(OutputBindingDestinationKind.Variable, destination.Kind);
}
[Theory]
[InlineData(typeof(string), true)]
[InlineData(typeof(int?), true)]

View file

@ -43,4 +43,21 @@ public class OutputConversionExceptionStateTests
Assert.Null(state.Metadata);
Assert.DoesNotContain("do not persist", state.Message);
}
[Fact]
public void Metadata_PreservesTheOriginalFourPositionRecordContract()
{
var state = new ExceptionState(typeof(InvalidOperationException), "failure", "stack", null)
{
Metadata = new Dictionary<string, string> { ["stage"] = "Invocation" }
};
var (type, message, stackTrace, innerException) = state;
Assert.Equal(typeof(InvalidOperationException), type);
Assert.Equal("failure", message);
Assert.Equal("stack", stackTrace);
Assert.Null(innerException);
Assert.Equal("Invocation", state.Metadata!["stage"]);
}
}