From 669330e6c197982b938ccbd790fd0a394840dbad Mon Sep 17 00:00:00 2001 From: Ian Lang Date: Thu, 14 May 2026 14:46:04 -0700 Subject: [PATCH] feat: Add OutputBindings to TreeAction for declarative variable binding Adds a new OutputBindings property to TreeAction that allows schema authors to declaratively bind ActionResponse properties to named variables. These variables are accessible via Vars. in Roslyn expressions (ShouldSelect, action inputs, etc.), reducing schema verbosity and improving readability. Key changes: - TreeAction.OutputBindings: Dictionary mapping variable names to dot-path expressions into the ActionResponse - Vars ExpandoObject on CodeGenInputParams for Roslyn expression access - Dot-path resolution supporting nested properties, array indexing, and JObject/CLR object traversal - Session-scoped variable persistence via ForgeState for rehydration - OutputVars exposed on TreeNodeContext for AfterVisitNode callbacks - Thread-safe binding resolution (post all parallel actions) - OutputBindingException for clear error reporting - 14 unit tests covering basic, cross-node, overwrite, and accessor scenarios - Schema validation rules updated for OutputBindings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Forge.TreeWalker.UnitTests.csproj | 3 + .../ExampleSchemas/OutputBindingsSchema.json | 74 ++++ .../test/ForgeSchemaHelper.cs | 250 +++++++++++++ .../test/TreeWalkerUnitTests.cs | 167 +++++++++ .../contracts/ForgeSchemaValidationRules.json | 8 + Forge.TreeWalker/contracts/ForgeTree.cs | 9 + Forge.TreeWalker/src/ExpressionExecutor.cs | 21 +- Forge.TreeWalker/src/ForgeExceptions.cs | 25 ++ Forge.TreeWalker/src/ITreeSession.cs | 8 + Forge.TreeWalker/src/TreeNodeContext.cs | 15 +- Forge.TreeWalker/src/TreeWalkerSession.cs | 341 +++++++++++++++++- 11 files changed, 918 insertions(+), 3 deletions(-) create mode 100644 Forge.TreeWalker.UnitTests/test/ExampleSchemas/OutputBindingsSchema.json diff --git a/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj b/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj index 1b6e0e3..193b274 100644 --- a/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj +++ b/Forge.TreeWalker.UnitTests/Forge.TreeWalker.UnitTests.csproj @@ -85,6 +85,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/Forge.TreeWalker.UnitTests/test/ExampleSchemas/OutputBindingsSchema.json b/Forge.TreeWalker.UnitTests/test/ExampleSchemas/OutputBindingsSchema.json new file mode 100644 index 0000000..20eb7ef --- /dev/null +++ b/Forge.TreeWalker.UnitTests/test/ExampleSchemas/OutputBindingsSchema.json @@ -0,0 +1,74 @@ +{ + "Tree": { + "Root": { + "Type": "Action", + "Actions": { + "Root_CollectDiagnosticsAction": { + "Action": "CollectDiagnosticsAction", + "Input": { + "Command": "RunCollectDiagnostics.exe" + }, + "OutputBindings": { + "diagStatus": "Status", + "diagStatusCode": "StatusCode", + "diagOutput": "Output" + } + } + }, + "ChildSelector": [ + { + "Label": "DiagnosticsSuccess", + "ShouldSelect": "C#|Vars.diagStatus == \"Success\"", + "Child": "Analyze" + }, + { + "Label": "DiagnosticsFailure", + "Child": "DiagnosticsFailure" + } + ] + }, + "Analyze": { + "Type": "Action", + "Actions": { + "Analyze_AnalyzeAction": { + "Action": "CollectDiagnosticsAction", + "Input": { + "Command": "C#|\"Analyze --code=\" + Vars.diagStatusCode.ToString()" + }, + "OutputBindings": { + "analyzeStatus": "Status", + "analysisResult": "Output" + } + } + }, + "ChildSelector": [ + { + "Label": "AnalysisComplete", + "ShouldSelect": "C#|Vars.analyzeStatus == \"Success\"", + "Child": "AnalysisComplete" + }, + { + "Label": "AnalysisFailure", + "Child": "AnalysisFailure" + } + ] + }, + "AnalysisComplete": { + "Type": "Leaf", + "Actions": { + "AnalysisComplete_Summary": { + "Action": "LeafNodeSummaryAction", + "Input": { + "Status": "C#|Vars.analyzeStatus" + } + } + } + }, + "AnalysisFailure": { + "Type": "Leaf" + }, + "DiagnosticsFailure": { + "Type": "Leaf" + } + } +} diff --git a/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs b/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs index c265099..a53377d 100644 --- a/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs +++ b/Forge.TreeWalker.UnitTests/test/ForgeSchemaHelper.cs @@ -570,5 +570,255 @@ public static class ForgeSchemaHelper } } "; + + #region OutputBindings Schemas + + public const string OutputBindings_BasicStatus = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""RunDiagnostics"" + }, + ""OutputBindings"": { + ""diagStatus"": ""Status"", + ""diagStatusCode"": ""StatusCode"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.diagStatus == \""Success\"""", + ""Child"": ""Success"" + }, + { + ""Child"": ""Failure"" + } + ] + }, + ""Success"": { + ""Type"": ""Leaf"" + }, + ""Failure"": { + ""Type"": ""Leaf"" + } + } + }"; + + public const string OutputBindings_OutputProperty = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""RunDiagnostics"" + }, + ""OutputBindings"": { + ""diagOutput"": ""Output"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.diagOutput != null"", + ""Child"": ""HasOutput"" + }, + { + ""Child"": ""NoOutput"" + } + ] + }, + ""HasOutput"": { + ""Type"": ""Leaf"" + }, + ""NoOutput"": { + ""Type"": ""Leaf"" + } + } + }"; + + public const string OutputBindings_CrossNode = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""RunDiagnostics"" + }, + ""OutputBindings"": { + ""firstStatus"": ""Status"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.firstStatus == \""Success\"""", + ""Child"": ""SecondAction"" + } + ] + }, + ""SecondAction"": { + ""Type"": ""Action"", + ""Actions"": { + ""SecondAction_TardigradeAction"": { + ""Action"": ""TardigradeAction"", + ""OutputBindings"": { + ""secondStatus"": ""Status"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.firstStatus == \""Success\"" && Vars.secondStatus == \""Success\"""", + ""Child"": ""BothSuccess"" + }, + { + ""Child"": ""Failure"" + } + ] + }, + ""BothSuccess"": { + ""Type"": ""Leaf"" + }, + ""Failure"": { + ""Type"": ""Leaf"" + } + } + }"; + + public const string OutputBindings_VarOverwrite = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""RunDiagnostics"" + }, + ""OutputBindings"": { + ""status"": ""Status"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.status == \""Success\"""", + ""Child"": ""OverwriteAction"" + } + ] + }, + ""OverwriteAction"": { + ""Type"": ""Action"", + ""Actions"": { + ""OverwriteAction_TardigradeAction"": { + ""Action"": ""TardigradeAction"", + ""OutputBindings"": { + ""status"": ""Status"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.status == \""Success\"""", + ""Child"": ""Done"" + } + ] + }, + ""Done"": { + ""Type"": ""Leaf"" + } + } + }"; + + public const string OutputBindings_UsedInInput = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""FirstCommand"" + }, + ""OutputBindings"": { + ""firstOutput"": ""Output"" + } + } + }, + ""ChildSelector"": [ + { + ""Child"": ""UseVarInInput"" + } + ] + }, + ""UseVarInInput"": { + ""Type"": ""Action"", + ""Actions"": { + ""UseVarInInput_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""C#|\""Process_\"" + Vars.firstOutput.ToString()"" + }, + ""OutputBindings"": { + ""secondOutput"": ""Output"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|Vars.secondOutput != null"", + ""Child"": ""Done"" + } + ] + }, + ""Done"": { + ""Type"": ""Leaf"" + } + } + }"; + + public const string OutputBindings_GetVarAccessor = @" + { + ""Tree"": { + ""Root"": { + ""Type"": ""Action"", + ""Actions"": { + ""Root_CollectDiagnosticsAction"": { + ""Action"": ""CollectDiagnosticsAction"", + ""Input"": { + ""Command"": ""RunDiagnostics"" + }, + ""OutputBindings"": { + ""myStatus"": ""Status"" + } + } + }, + ""ChildSelector"": [ + { + ""ShouldSelect"": ""C#|(string)Session.GetVar(\""myStatus\"") == \""Success\"""", + ""Child"": ""Done"" + } + ] + }, + ""Done"": { + ""Type"": ""Leaf"" + } + } + }"; + + #endregion OutputBindings Schemas } } \ No newline at end of file diff --git a/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs b/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs index 8dbc702..fb099fa 100644 --- a/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs +++ b/Forge.TreeWalker.UnitTests/test/TreeWalkerUnitTests.cs @@ -1346,5 +1346,172 @@ private TreeWalkerSession InitializeSubroutineTree(SubroutineInput subroutineInp return new TreeWalkerSession(subroutineParameters); } + + #region OutputBindings + + [TestMethod] + public void TestOutputBindings_BasicStatus_Success() + { + // Test - OutputBindings can bind the Status property and use it in ShouldSelect. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.OutputBindings_BasicStatus); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion with OutputBindings."); + + // Verify the tree walked to the Success leaf. + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Success", currentNode, "Expected to reach Success node via OutputBindings ShouldSelect."); + } + + [TestMethod] + public void TestOutputBindings_OutputProperty_Success() + { + // Test - OutputBindings can bind the entire Output object. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.OutputBindings_OutputProperty); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("HasOutput", currentNode, "Expected to reach HasOutput node since Output is not null."); + } + + [TestMethod] + public void TestOutputBindings_CrossNode_Persists() + { + // Test - Variables bound in one node are accessible in a later node's ShouldSelect. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.OutputBindings_CrossNode); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("BothSuccess", currentNode, "Expected to reach BothSuccess node since both firstStatus and secondStatus are Success."); + } + + [TestMethod] + public void TestOutputBindings_VarOverwrite_Success() + { + // Test - A later action can overwrite a variable bound by an earlier action. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.OutputBindings_VarOverwrite); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Done", currentNode, "Expected to reach Done node since overwritten status is still Success."); + } + + [TestMethod] + public void TestOutputBindings_UsedInInput_Success() + { + // Test - Vars can be referenced in Input expressions of subsequent actions. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.OutputBindings_UsedInInput); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Done", currentNode, "Expected to reach Done node."); + } + + [TestMethod] + public void TestOutputBindings_GetVarAccessor_Success() + { + // Test - Session.GetVar() can be used in ShouldSelect expressions as an alternative to Vars. + this.TestInitialize(jsonSchema: ForgeSchemaHelper.OutputBindings_GetVarAccessor); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion."); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("Done", currentNode, "Expected to reach Done node via Session.GetVar accessor."); + } + + [TestMethod] + public void TestOutputBindings_GetVar_ReturnsNull_ForUnboundVariable() + { + // Test - GetVar returns null for a variable that hasn't been bound. + this.TestFromFileInitialize(filePath: TardigradeSchemaPath); + object result = this.session.GetVar("nonExistentVar"); + Assert.IsNull(result, "Expected GetVar to return null for an unbound variable."); + } + + [TestMethod] + public void TestOutputBindings_ResolveDotPath_Status() + { + // Test - ResolveDotPath resolves "Status" property from an ActionResponse. + ActionResponse response = new ActionResponse { Status = "Success", StatusCode = 200, Output = "TestOutput" }; + object result = TreeWalkerSession.ResolveDotPath(response, "Status"); + Assert.AreEqual("Success", result); + } + + [TestMethod] + public void TestOutputBindings_ResolveDotPath_StatusCode() + { + // Test - ResolveDotPath resolves "StatusCode" property from an ActionResponse. + ActionResponse response = new ActionResponse { Status = "Success", StatusCode = 42, Output = null }; + object result = TreeWalkerSession.ResolveDotPath(response, "StatusCode"); + Assert.AreEqual(42, result); + } + + [TestMethod] + public void TestOutputBindings_ResolveDotPath_Output() + { + // Test - ResolveDotPath resolves "Output" as the whole object. + var outputObj = new { Name = "test", Value = 42 }; + ActionResponse response = new ActionResponse { Status = "Success", Output = outputObj }; + object result = TreeWalkerSession.ResolveDotPath(response, "Output"); + Assert.AreEqual(outputObj, result); + } + + [TestMethod] + public void TestOutputBindings_ResolveDotPath_NullRoot_Throws() + { + // Test - ResolveDotPath throws ArgumentNullException on null root. + Assert.ThrowsException(() => + { + TreeWalkerSession.ResolveDotPath(null, "Status"); + }); + } + + [TestMethod] + public void TestOutputBindings_ResolveDotPath_EmptyPath_Throws() + { + // Test - ResolveDotPath throws ArgumentException on empty path. + ActionResponse response = new ActionResponse { Status = "Success" }; + Assert.ThrowsException(() => + { + TreeWalkerSession.ResolveDotPath(response, ""); + }); + } + + [TestMethod] + public void TestOutputBindings_ResolveDotPath_InvalidProperty_Throws() + { + // Test - ResolveDotPath throws OutputBindingException for a property that doesn't exist. + ActionResponse response = new ActionResponse { Status = "Success" }; + Assert.ThrowsException(() => + { + TreeWalkerSession.ResolveDotPath(response, "NonExistentProperty"); + }); + } + + [TestMethod] + public void TestOutputBindings_WalkTree_FromFile_Success() + { + // Test - Walk tree using the OutputBindingsSchema.json file. + this.TestFromFileInitialize(filePath: "test\\ExampleSchemas\\OutputBindingsSchema.json"); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion with OutputBindings schema file."); + + string currentNode = this.session.GetCurrentTreeNode().GetAwaiter().GetResult(); + Assert.AreEqual("AnalysisComplete", currentNode, "Expected to reach AnalysisComplete node."); + } + + [TestMethod] + public void TestOutputBindings_NoBindings_WorksNormally() + { + // Test - Actions without OutputBindings still work (backward compatibility). + this.TestFromFileInitialize(filePath: TardigradeSchemaPath); + string actualStatus = this.session.WalkTree("Root").GetAwaiter().GetResult(); + Assert.AreEqual("RanToCompletion", actualStatus, "Expected WalkTree to run to completion without OutputBindings."); + } + + #endregion OutputBindings } } \ No newline at end of file diff --git a/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json b/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json index 3af7616..3843d45 100644 --- a/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json +++ b/Forge.TreeWalker/contracts/ForgeSchemaValidationRules.json @@ -219,6 +219,14 @@ }, "ContinuationOnRetryExhaustion": { "type": "boolean" + }, + "OutputBindings": { + "type": "object", + "patternProperties": { + ".*?": { + "type": "string" + } + } } }, "additionalProperties": false, diff --git a/Forge.TreeWalker/contracts/ForgeTree.cs b/Forge.TreeWalker/contracts/ForgeTree.cs index 38d9834..1f66d89 100644 --- a/Forge.TreeWalker/contracts/ForgeTree.cs +++ b/Forge.TreeWalker/contracts/ForgeTree.cs @@ -163,6 +163,15 @@ public class TreeAction /// [DataMember] public bool ContinuationOnRetryExhaustion { get; set; } + + /// + /// Optional output bindings that extract values from the ActionResponse and bind them to named variables. + /// The key is the variable name accessible via Vars in Roslyn expressions (e.g., "Vars.myVar"). + /// The value is a dot-path into the ActionResponse (e.g., "Status", "Output.PropertyName", "Output.Items[0].Name"). + /// Bound variables persist for the entire session and are available in subsequent ShouldSelect and Input expressions. + /// + [DataMember] + public Dictionary OutputBindings { get; set; } } /// diff --git a/Forge.TreeWalker/src/ExpressionExecutor.cs b/Forge.TreeWalker/src/ExpressionExecutor.cs index 51bf3bd..e593ef5 100644 --- a/Forge.TreeWalker/src/ExpressionExecutor.cs +++ b/Forge.TreeWalker/src/ExpressionExecutor.cs @@ -13,6 +13,7 @@ namespace Microsoft.Forge.TreeWalker using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; + using System.Dynamic; using System.Reflection; using System.Threading.Tasks; using Microsoft.CodeAnalysis; @@ -163,7 +164,8 @@ private void Initialize() Assembly mscorlib = typeof(object).Assembly; Assembly systemCore = typeof(System.Linq.Enumerable).Assembly; Assembly cSharpAssembly = typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly; - scriptOptions = scriptOptions.AddReferences(mscorlib, systemCore, cSharpAssembly); + Assembly systemDynamic = typeof(System.Dynamic.ExpandoObject).Assembly; + scriptOptions = scriptOptions.AddReferences(mscorlib, systemCore, cSharpAssembly, systemDynamic); // Add required namespaces. scriptOptions = scriptOptions.AddImports( @@ -208,6 +210,16 @@ public bool ScriptCacheContainsKey(string expression) return this.scriptCache.ContainsKey(expression); } + /// + /// Gets the Vars ExpandoObject from the CodeGenInputParams. + /// This allows the TreeWalkerSession to populate output binding variables. + /// + /// The Vars ExpandoObject as an IDictionary. + public IDictionary GetVars() + { + return (IDictionary)this.parameters.Vars; + } + /// /// This class defines the global parameter that will be passed into the Roslyn expression evaluator. /// @@ -232,6 +244,13 @@ public class CodeGenInputParams /// For Subroutines, this is evaluated from the SubroutineInput on the schema. /// public dynamic TreeInput { get; set; } + + /// + /// The dynamic Vars object that holds output binding variables. + /// Variables are populated from TreeAction OutputBindings after actions complete. + /// Schema expressions can reference bound variables as Vars.variableName. + /// + public dynamic Vars { get; set; } = new ExpandoObject(); } /// diff --git a/Forge.TreeWalker/src/ForgeExceptions.cs b/Forge.TreeWalker/src/ForgeExceptions.cs index c894e52..5a56f54 100644 --- a/Forge.TreeWalker/src/ForgeExceptions.cs +++ b/Forge.TreeWalker/src/ForgeExceptions.cs @@ -90,4 +90,29 @@ public ActionNotFoundException(string message) { } } + + /// + /// Exception thrown when an output binding fails to resolve. + /// + public class OutputBindingException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The message. + public OutputBindingException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The inner exception. + public OutputBindingException(string message, Exception inner) + : base(message, inner) + { + } + } } diff --git a/Forge.TreeWalker/src/ITreeSession.cs b/Forge.TreeWalker/src/ITreeSession.cs index 56f599d..ede35b8 100644 --- a/Forge.TreeWalker/src/ITreeSession.cs +++ b/Forge.TreeWalker/src/ITreeSession.cs @@ -46,5 +46,13 @@ public interface ITreeSession /// Gets the string context if the actions in the current tree node were skipped, or null if actions were not skipped. /// string GetCurrentNodeSkipActionContext(); + + /// + /// Gets the value of a bound output variable by name. + /// Variables are populated from TreeAction OutputBindings after actions complete. + /// + /// The variable name as defined in OutputBindings. + /// The bound value if it exists, otherwise null. + object GetVar(string name); } } \ No newline at end of file diff --git a/Forge.TreeWalker/src/TreeNodeContext.cs b/Forge.TreeWalker/src/TreeNodeContext.cs index 82b13f0..d0a4091 100644 --- a/Forge.TreeWalker/src/TreeNodeContext.cs +++ b/Forge.TreeWalker/src/TreeNodeContext.cs @@ -10,6 +10,7 @@ namespace Microsoft.Forge.TreeWalker { using System; + using System.Collections.Generic; using System.Threading; /// @@ -60,6 +61,13 @@ public class TreeNodeContext /// public string CurrentNodeSkipActionContext { get; set; } + /// + /// The output binding variables resolved from TreeAction OutputBindings after actions complete. + /// Available in AfterVisitNode callbacks. Null in BeforeVisitNode (bindings haven't run yet). + /// Callbacks can use this to read bound output values and store them in external tables or state. + /// + public IDictionary OutputVars { get; private set; } + /// /// Instantiates an TreeNodeContext object. /// @@ -73,6 +81,9 @@ public class TreeNodeContext /// /// The string context if the actions in the current tree node should be skipped, or null if actions should not be skipped. /// + /// + /// The output binding variables resolved from TreeAction OutputBindings. Null in BeforeVisitNode. + /// public TreeNodeContext( Guid sessionId, string treeNodeKey, @@ -81,7 +92,8 @@ public TreeNodeContext( CancellationToken token, string treeName, Guid rootSessionId, - string currentNodeSkipActionContext) + string currentNodeSkipActionContext, + IDictionary outputVars = null) { if (sessionId == null) throw new ArgumentNullException("sessionId"); if (string.IsNullOrWhiteSpace(treeNodeKey)) throw new ArgumentNullException("treeNodeKey"); @@ -97,6 +109,7 @@ public TreeNodeContext( this.TreeName = treeName; this.RootSessionId = rootSessionId; this.CurrentNodeSkipActionContext = currentNodeSkipActionContext; + this.OutputVars = outputVars; } } } \ No newline at end of file diff --git a/Forge.TreeWalker/src/TreeWalkerSession.cs b/Forge.TreeWalker/src/TreeWalkerSession.cs index 38efbf3..31b073d 100644 --- a/Forge.TreeWalker/src/TreeWalkerSession.cs +++ b/Forge.TreeWalker/src/TreeWalkerSession.cs @@ -13,6 +13,8 @@ namespace Microsoft.Forge.TreeWalker using System.Collections; using System.Collections.Generic; using System.Diagnostics; + using System.Dynamic; + using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using System.Threading; @@ -59,6 +61,12 @@ public class TreeWalkerSession : ITreeSession /// public static string TreeInputSuffix = "TI"; + /// + /// The OutputVars suffix appended to the end of the key in forgeState that maps to the output binding variables. + /// Key: _OV + /// + public static string OutputVarsSuffix = "OV"; + /// /// The PreviousActionResponse suffix appended to the end of the key in forgeState that maps to a previously persisted ActionResponse. /// When a TreeNodeKey in a tree walking session was previously successfully visited, the ActionResponses get wiped and persisted to PreviousActionResponse. @@ -168,6 +176,9 @@ public TreeWalkerSession(TreeWalkerParameters parameters) this.expressionExecutor = new ExpressionExecutor(this as ITreeSession, parameters.UserContext, parameters.Dependencies, parameters.ScriptCache, this.Parameters.TreeInput); + // Rehydrate output binding variables from ForgeState if previously persisted. + this.RehydrateOutputVars(); + if (parameters.RootSessionId == Guid.Empty) { this.Parameters.RootSessionId = parameters.SessionId; @@ -282,6 +293,18 @@ public string GetCurrentNodeSkipActionContext() return this.currentNodeSkipActionContext; } + /// + /// Gets the value of a bound output variable by name. + /// Variables are populated from TreeAction OutputBindings after actions complete. + /// + /// The variable name as defined in OutputBindings. + /// The bound value if it exists, otherwise null. + public object GetVar(string name) + { + IDictionary vars = this.expressionExecutor.GetVars(); + return vars.TryGetValue(name, out object value) ? value : null; + } + /// /// Signals the WalkTree and VisitNode cancellation token sources to cancel. /// @@ -368,7 +391,8 @@ await this.EvaluateDynamicProperty(this.Schema.Tree[current].Properties, null), this.walkTreeCts.Token, this.Parameters.TreeName, this.Parameters.RootSessionId, - this.currentNodeSkipActionContext + this.currentNodeSkipActionContext, + this.GetOutputVarsSnapshot() ); await this.Parameters.CallbacksV2.AfterVisitNode(treeNodeContext).ConfigureAwait(false); @@ -703,6 +727,10 @@ internal async Task PerformActionTypeBehavior(TreeNode treeNode, string treeNode // Exceptions are thrown here if the action hit a timeout, was cancelled, or failed. await completedTask; } + + // After all actions on this node have completed, resolve output bindings sequentially. + // This is done after all parallel actions complete to avoid thread-safety issues with the shared Vars ExpandoObject. + await this.ResolveAllOutputBindingsForNode(treeNode, treeNodeKey).ConfigureAwait(false); } /// @@ -1204,6 +1232,317 @@ private async Task GetOrCommitTreeInput(object treeInput) } } + /// + /// After all actions on a node have completed, resolves output bindings for each action that has them. + /// This is called sequentially after all parallel actions complete, ensuring thread-safe access to the shared Vars ExpandoObject. + /// Also handles continuation responses (timeout/retry-exhaustion) that committed synthetic ActionResponses. + /// + /// The TreeNode whose actions have completed. + /// The TreeNode's key. + private async Task ResolveAllOutputBindingsForNode(TreeNode treeNode, string treeNodeKey) + { + if (treeNode.Actions == null) + { + return; + } + + bool hasBindings = false; + + foreach (KeyValuePair kvp in treeNode.Actions) + { + string treeActionKey = kvp.Key; + TreeAction treeAction = kvp.Value; + + if (treeAction.OutputBindings == null || treeAction.OutputBindings.Count == 0) + { + continue; + } + + ActionResponse actionResponse = await this.GetOutputAsync(treeActionKey).ConfigureAwait(false); + if (actionResponse != null) + { + this.ResolveOutputBindings(treeActionKey, treeAction, actionResponse); + hasBindings = true; + } + } + + if (hasBindings) + { + await this.CommitOutputVars().ConfigureAwait(false); + } + } + + /// + /// Processes output bindings for a TreeAction by resolving dot-paths against the ActionResponse + /// and storing the results as named variables on the Vars ExpandoObject. + /// + /// The TreeAction's key of the action that was executed. + /// The TreeAction with optional OutputBindings. + /// The ActionResponse returned from the action. + internal void ResolveOutputBindings(string treeActionKey, TreeAction treeAction, ActionResponse actionResponse) + { + if (treeAction.OutputBindings == null || treeAction.OutputBindings.Count == 0) + { + return; + } + + IDictionary vars = this.expressionExecutor.GetVars(); + + foreach (KeyValuePair binding in treeAction.OutputBindings) + { + string varName = binding.Key; + string dotPath = binding.Value; + + try + { + object resolvedValue = ResolveDotPath(actionResponse, dotPath); + vars[varName] = resolvedValue; + } + catch (Exception e) + { + throw new OutputBindingException( + string.Format( + "Failed to resolve output binding. TreeActionKey: {0}, Variable: {1}, DotPath: {2}.", + treeActionKey, + varName, + dotPath), + e); + } + } + } + + /// + /// Resolves a dot-path expression against a root object. + /// Supports property access, array indexing (e.g., "Items[0]"), and nested traversal. + /// Handles both JObject/JToken (from JSON deserialization) and CLR objects (via reflection). + /// + /// The root object to resolve against. + /// The dot-path expression (e.g., "Output.Metrics.SuccessRate", "Output.Items[0].Name"). + /// The resolved value with its original type. + internal static object ResolveDotPath(object root, string path) + { + if (root == null) + { + throw new ArgumentNullException("root", "Cannot resolve dot-path on a null object."); + } + + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Dot-path cannot be null or empty.", "path"); + } + + object current = root; + + // Split on '.' but respect bracket segments (e.g., "Items[0].Name" => ["Items[0]", "Name"]) + string[] segments = path.Split('.'); + + foreach (string segment in segments) + { + if (current == null) + { + throw new OutputBindingException( + string.Format("Null value encountered while resolving dot-path '{0}' at segment '{1}'.", path, segment)); + } + + // Check if segment contains array indexing (e.g., "Items[0]") + string propertyName = segment; + int arrayIndex = -1; + + int bracketStart = segment.IndexOf('['); + if (bracketStart >= 0) + { + int bracketEnd = segment.IndexOf(']', bracketStart); + if (bracketEnd < 0) + { + throw new OutputBindingException( + string.Format("Malformed array index in dot-path '{0}' at segment '{1}'. Missing closing bracket.", path, segment)); + } + + propertyName = segment.Substring(0, bracketStart); + string indexStr = segment.Substring(bracketStart + 1, bracketEnd - bracketStart - 1); + if (!int.TryParse(indexStr, out arrayIndex)) + { + throw new OutputBindingException( + string.Format("Invalid array index '{0}' in dot-path '{1}' at segment '{2}'.", indexStr, path, segment)); + } + + if (arrayIndex < 0) + { + throw new OutputBindingException( + string.Format("Negative array index '{0}' in dot-path '{1}' at segment '{2}'.", arrayIndex, path, segment)); + } + + // Reject trailing text after closing bracket (e.g., "Items[0]foo") + if (bracketEnd < segment.Length - 1) + { + throw new OutputBindingException( + string.Format("Unexpected text after array index in dot-path '{0}' at segment '{1}'.", path, segment)); + } + } + + // Resolve the property if property name is not empty + if (!string.IsNullOrEmpty(propertyName)) + { + current = ResolveProperty(current, propertyName, path, segment); + } + + // Apply array indexing if present + if (arrayIndex >= 0) + { + current = ResolveArrayIndex(current, arrayIndex, path, segment); + } + } + + // Unwrap JValue to its underlying .NET type + if (current is JValue jValue) + { + return jValue.Value; + } + + return current; + } + + /// + /// Resolves a property name on the given object. Handles JObject, JToken, and CLR objects. + /// + private static object ResolveProperty(object obj, string propertyName, string fullPath, string segment) + { + if (obj is JObject jObj) + { + JToken token = jObj[propertyName]; + if (token == null) + { + throw new OutputBindingException( + string.Format("Property '{0}' not found on JObject while resolving dot-path '{1}'.", propertyName, fullPath)); + } + + return token; + } + + // Try reflection for CLR objects + Type objType = obj.GetType(); + PropertyInfo prop = objType.GetProperty(propertyName); + if (prop != null) + { + return prop.GetValue(obj); + } + + // Try as IDictionary (e.g., ExpandoObject) + if (obj is IDictionary dict) + { + if (dict.TryGetValue(propertyName, out object value)) + { + return value; + } + } + + throw new OutputBindingException( + string.Format("Property '{0}' not found on type '{1}' while resolving dot-path '{2}'.", propertyName, objType.Name, fullPath)); + } + + /// + /// Resolves an array index on the given object. Handles JArray, IList, and arrays. + /// + private static object ResolveArrayIndex(object obj, int index, string fullPath, string segment) + { + if (obj is JArray jArray) + { + if (index < 0 || index >= jArray.Count) + { + throw new OutputBindingException( + string.Format("Array index {0} is out of bounds (length: {1}) in dot-path '{2}' at segment '{3}'.", index, jArray.Count, fullPath, segment)); + } + + return jArray[index]; + } + + if (obj is IList list) + { + if (index < 0 || index >= list.Count) + { + throw new OutputBindingException( + string.Format("Array index {0} is out of bounds (length: {1}) in dot-path '{2}' at segment '{3}'.", index, list.Count, fullPath, segment)); + } + + return list[index]; + } + + throw new OutputBindingException( + string.Format("Cannot apply array index to type '{0}' in dot-path '{1}' at segment '{2}'.", obj.GetType().Name, fullPath, segment)); + } + + /// + /// Persists the current Vars to ForgeState. + /// + private async Task CommitOutputVars() + { + IDictionary vars = this.expressionExecutor.GetVars(); + Dictionary snapshot = new Dictionary(vars); + await this.Parameters.ForgeState.Set(OutputVarsSuffix, snapshot).ConfigureAwait(false); + } + + /// + /// Rehydrates Vars from ForgeState if previously persisted. + /// + private void RehydrateOutputVars() + { + try + { + object persisted = this.Parameters.ForgeState.GetValue(OutputVarsSuffix).GetAwaiter().GetResult(); + if (persisted != null) + { + IDictionary vars = this.expressionExecutor.GetVars(); + + if (persisted is JObject jObj) + { + foreach (KeyValuePair kvp in jObj) + { + // Unwrap JValue to CLR types so Roslyn expressions work correctly + // (e.g., Vars.status == "Success" compares string to string, not JValue to string). + vars[kvp.Key] = UnwrapJToken(kvp.Value); + } + } + else if (persisted is IDictionary dict) + { + foreach (KeyValuePair kvp in dict) + { + vars[kvp.Key] = kvp.Value is JToken jt ? UnwrapJToken(jt) : kvp.Value; + } + } + } + } + catch + { + // No persisted vars; this is fine. + } + } + + /// + /// Unwraps a JToken to its underlying CLR type. + /// JValue becomes string/int/bool/etc, JObject/JArray stay as-is for dynamic access. + /// + private static object UnwrapJToken(JToken token) + { + if (token is JValue jValue) + { + return jValue.Value; + } + + // JObject and JArray support dynamic property access and indexing, + // so they work well as-is in Roslyn expressions. + return token; + } + + /// + /// Gets a snapshot of the current output binding variables. + /// + /// A dictionary of variable names to their bound values, or null if no variables are bound. + internal IDictionary GetOutputVarsSnapshot() + { + IDictionary vars = this.expressionExecutor.GetVars(); + return vars.Count > 0 ? new Dictionary(vars) : null; + } + /// /// Initializes the actionsMap from the given assembly. /// This map is generated using reflection to find all the classes with the applied ForgeActionAttribute from the given Assembly.