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