feat(executor): coerce templated string leaves to schema-declared primitive types
This commit is contained in:
@@ -246,12 +246,25 @@ public sealed class Executor
|
||||
// for free. See TemplateEngine + CLAUDE.md "config-field templates".
|
||||
var firstInput = byPort.Count > 0 && byPort[0].Count > 0 ? byPort[0][0].Json : (JsonElement?)null;
|
||||
var templatedConfig = InterpolateStrings(cfgDoc.RootElement, firstInput);
|
||||
// After templating, coerce string leaves that the schema declares as
|
||||
// integer/number/boolean back to their typed JSON form. Nodes check
|
||||
// ValueKind strictly, so a templated `"{input.code}" -> "201"` string
|
||||
// would silently miss the ValueKind.Number check without this pass.
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
JsonElement coercedConfig;
|
||||
try
|
||||
{
|
||||
coercedConfig = CoerceToSchema(templatedConfig, _nodes.Get(node.TypeId).ConfigSchema);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new NodeResult(false, null, inputsJson, startedAt, DateTimeOffset.UtcNow, ex.Message);
|
||||
}
|
||||
var ctx = new ExecutionContext(
|
||||
executionId, workflowId, node.Id, templatedConfig,
|
||||
executionId, workflowId, node.Id, coercedConfig,
|
||||
answerChannelId, _lf.CreateLogger($"node.{node.Id}"),
|
||||
node.ConnectionId, _instances);
|
||||
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
var outputs = await action.ExecuteAsync(ctx, byPort, ct);
|
||||
@@ -263,6 +276,90 @@ public sealed class Executor
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the top-level properties of the schema. For each property whose
|
||||
// declared type is integer/number/boolean, if the config leaf is a JSON
|
||||
// string (post-interpolation), parse it back to its typed form. Missing
|
||||
// properties, non-primitive schema types, and string-typed schema fields
|
||||
// are left untouched. Throws InvalidOperationException with a readable
|
||||
// message on parse failure — the caller surfaces this as a normal node
|
||||
// failure with the message stored on the node execution row.
|
||||
private static JsonElement CoerceToSchema(JsonElement config, JsonSchema schema)
|
||||
{
|
||||
if (config.ValueKind != JsonValueKind.Object) return config;
|
||||
if (schema.Root.ValueKind != JsonValueKind.Object) return config;
|
||||
if (!schema.Root.TryGetProperty("properties", out var props)
|
||||
|| props.ValueKind != JsonValueKind.Object) return config;
|
||||
|
||||
// Fast path: nothing to coerce → skip the rewrite.
|
||||
var anyNeedsCoerce = false;
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
var t = TypeOf(prop.Value);
|
||||
if (t is null || t == "string") continue;
|
||||
if (!config.TryGetProperty(prop.Name, out var leaf)) continue;
|
||||
if (leaf.ValueKind == JsonValueKind.String) { anyNeedsCoerce = true; break; }
|
||||
}
|
||||
if (!anyNeedsCoerce) return config;
|
||||
|
||||
var buffer = new System.IO.MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
foreach (var p in config.EnumerateObject())
|
||||
{
|
||||
w.WritePropertyName(p.Name);
|
||||
if (props.TryGetProperty(p.Name, out var propSchema)
|
||||
&& p.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var t = TypeOf(propSchema);
|
||||
var s = p.Value.GetString() ?? "";
|
||||
switch (t)
|
||||
{
|
||||
case "integer":
|
||||
if (long.TryParse(s, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var i))
|
||||
w.WriteNumberValue(i);
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"config '{p.Name}': expected integer, got '{s}'");
|
||||
break;
|
||||
case "number":
|
||||
if (double.TryParse(s, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||
w.WriteNumberValue(d);
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"config '{p.Name}': expected number, got '{s}'");
|
||||
break;
|
||||
case "boolean":
|
||||
if (s == "true") w.WriteBooleanValue(true);
|
||||
else if (s == "false") w.WriteBooleanValue(false);
|
||||
else
|
||||
throw new InvalidOperationException(
|
||||
$"config '{p.Name}': expected boolean, got '{s}'");
|
||||
break;
|
||||
default:
|
||||
p.Value.WriteTo(w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Value.WriteTo(w);
|
||||
}
|
||||
}
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return JsonDocument.Parse(buffer.ToArray()).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string? TypeOf(JsonElement schema)
|
||||
{
|
||||
if (schema.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!schema.TryGetProperty("type", out var t) || t.ValueKind != JsonValueKind.String) return null;
|
||||
return t.GetString();
|
||||
}
|
||||
|
||||
// Walks the config JSON, running every string leaf through TemplateEngine.
|
||||
// Objects/arrays are rebuilt recursively; non-string leaves pass through.
|
||||
// Fast-path: if the raw text contains no `{`, no template can fire, so we
|
||||
|
||||
@@ -290,4 +290,91 @@ public class ExecutorTests
|
||||
new[] { new Core.Item(Item(1)) }, null, null, CancellationToken.None);
|
||||
Assert.Equal("failed", er.Get(eid)!.Status);
|
||||
}
|
||||
|
||||
// --- coercion of templated primitives ---------------------------------
|
||||
|
||||
// A test node that requires status to be a JSON number, mirroring
|
||||
// WebResponseNode's real check pattern. We assert on ctx.Config here.
|
||||
private sealed class NumberFieldNode : IActionNode
|
||||
{
|
||||
public List<INodeContext> Calls { get; } = new();
|
||||
public string Id { get; init; } = "test.numberfield";
|
||||
public string DisplayName => Id;
|
||||
public NodeCategory Category => NodeCategory.Terminal;
|
||||
public int InputCount => 1;
|
||||
public int OutputCount => 0;
|
||||
public string? ConnectionProviderId => null;
|
||||
public JsonSchema ConfigSchema => new(JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "status": { "type": "integer" } }
|
||||
}
|
||||
""").RootElement);
|
||||
public Task<IReadOnlyList<IReadOnlyList<Item>>> ExecuteAsync(
|
||||
INodeContext ctx, IReadOnlyList<IReadOnlyList<Item>> _, CancellationToken __)
|
||||
{
|
||||
// Strict JsonValueKind.Number check — the coercion pass must produce
|
||||
// a number here, not a string.
|
||||
var s = ctx.Config.GetProperty("status");
|
||||
if (s.ValueKind != JsonValueKind.Number)
|
||||
throw new InvalidOperationException($"expected number, got {s.ValueKind}");
|
||||
lock (Calls) Calls.Add(ctx);
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(Array.Empty<IReadOnlyList<Item>>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Template_into_integer_config_field_is_coerced_to_number()
|
||||
{
|
||||
var rec = new NumberFieldNode();
|
||||
var (er, nr, exec, _) = Build(new TestTrigger(), rec);
|
||||
|
||||
var graph = new WorkflowGraph
|
||||
{
|
||||
Nodes = new[]
|
||||
{
|
||||
new WorkflowNode("t", "test.trigger", null, "{}"),
|
||||
new WorkflowNode("s", "test.numberfield", null, """{"status":"{input.code}"}"""),
|
||||
},
|
||||
Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
|
||||
};
|
||||
|
||||
var trig = new Core.Item(JsonSerializer.SerializeToElement(new { code = 201 }));
|
||||
var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
|
||||
new[] { trig }, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("completed", er.Get(eid)!.Status);
|
||||
var got = rec.Calls.Single().Config.GetProperty("status");
|
||||
Assert.Equal(JsonValueKind.Number, got.ValueKind);
|
||||
Assert.Equal(201, got.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_parseable_integer_config_fails_node_with_clear_message()
|
||||
{
|
||||
var rec = new NumberFieldNode();
|
||||
var (er, nr, exec, _) = Build(new TestTrigger(), rec);
|
||||
|
||||
var graph = new WorkflowGraph
|
||||
{
|
||||
Nodes = new[]
|
||||
{
|
||||
new WorkflowNode("t", "test.trigger", null, "{}"),
|
||||
new WorkflowNode("s", "test.numberfield", null, """{"status":"{input.code}"}"""),
|
||||
},
|
||||
Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
|
||||
};
|
||||
|
||||
var trig = new Core.Item(JsonSerializer.SerializeToElement(new { code = "abc" }));
|
||||
var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
|
||||
new[] { trig }, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", er.Get(eid)!.Status);
|
||||
var row = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
|
||||
Assert.Equal("failed", row.Status);
|
||||
Assert.NotNull(row.Error);
|
||||
Assert.Contains("expected integer", row.Error!);
|
||||
Assert.Contains("status", row.Error!);
|
||||
Assert.Empty(rec.Calls);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user