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.