feat(executor): coerce templated string leaves to schema-declared primitive types

This commit is contained in:
Claude
2026-08-30 10:54:26 +00:00
parent 72e31e8ea5
commit 70f556aff5
2 changed files with 186 additions and 2 deletions

View File

@@ -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

View File

@@ -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);
}
}