diff --git a/EvenBetterFastSim/WPF/ViewModels/Graph/ScenarioNodeViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Graph/ScenarioNodeViewModel.cs
index 7983d07..14ce21f 100644
--- a/EvenBetterFastSim/WPF/ViewModels/Graph/ScenarioNodeViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/Graph/ScenarioNodeViewModel.cs
@@ -65,6 +65,14 @@ public string? BindingSummary
partial void OnMatchConditionsJsonChanged(string? value) => OnPropertyChanged(nameof(ConditionSummary));
partial void OnResponseBindingsJsonChanged(string? value) => OnPropertyChanged(nameof(BindingSummary));
+ /// Live state while a scenario runs (drives the node's highlight on the canvas).
+ [ObservableProperty]
+ public partial ScenarioNodeRunState RunState { get; set; }
+
+ /// e.g. "waiting for S6F11" — shown on the node while it's the current/stuck step.
+ [ObservableProperty]
+ public partial string? RunDetail { get; set; }
+
public bool CanToggleMode => Type is NodeType.Send or NodeType.SendAndWait or NodeType.Receive;
/// Receive node — can carry match conditions on the incoming message.
diff --git a/EvenBetterFastSim/WPF/ViewModels/MainViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/MainViewModel.cs
index 0c7e29c..0928e74 100644
--- a/EvenBetterFastSim/WPF/ViewModels/MainViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/MainViewModel.cs
@@ -418,6 +418,10 @@ private void SaveLibrary()
LibraryMessagePackService.Save(path, SecsGemMessageLibrary);
else
LibraryXmlExportService.Save(path, SecsGemMessageLibrary);
+
+ // Remember the just-saved file so this instance re-opens it on next launch
+ // instead of the previously loaded library (persisted on window close).
+ ApplicationSettings.DefaultLibraryLoadPath = path;
}
[RelayCommand]
diff --git a/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs
index ebd14cd..0c0f1a6 100644
--- a/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs
@@ -2,10 +2,13 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
+using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EvenBetterFastSim.WPF.ViewModels.Graph;
using SecsGemBaseItems.Data_Containers;
+using SecsGemBaseItems.Data_Containers.Interfaces;
+using SecsGemBaseItems.Enums;
using SecsGemBaseItems.Responders;
namespace EvenBetterFastSim.WPF.ViewModels.Responders;
@@ -19,8 +22,8 @@ public enum NodeResponderMode
Bindings
}
-/// An upstream Receive node offered as a value source: its id, a display label, and a clone of its message shape.
-public sealed record UpstreamReceive(string NodeId, string Label, SecsGemDataMessage Message);
+/// An upstream node (Receive or Send) offered as a value source: its id, a display label, and a clone of its message shape.
+public sealed record UpstreamMessage(string NodeId, string Label, SecsGemDataMessage Message);
///
/// One dialog, two faces. For a Receive node it edits the match conditions that gate the step;
@@ -33,6 +36,23 @@ public partial class NodeResponderViewModel : ObservableObject, IBaseViewModel
private ScenarioNodeViewModel? node;
+ ///
+ /// The message whose shape the editor works on: a clone of the node's Primary (Send / Receive-primary)
+ /// or Reply (Receive with ) message. Structural edits
+ /// mutate this tree; it is written back to the node on OK only when is set.
+ ///
+ private SecsGemDataMessage? workingMessage;
+
+ /// True when the working message is the node's Reply message rather than its Primary.
+ private bool editingReplyMessage;
+
+ /// Set once the user adds / removes / moves / retypes an item, so OK persists the new shape.
+ private bool structureDirty;
+
+ /// Mode + parameter lookup last passed to , reused when the tree is rebuilt.
+ private ResponderFieldMode fieldMode;
+ private Func>? fieldParameterLookup;
+
/// Bindings mode: cloned message shapes keyed by the Receive node id that captured them.
private readonly Dictionary triggerShapes = [];
@@ -59,6 +79,11 @@ public partial class NodeResponderViewModel : ObservableObject, IBaseViewModel
[ObservableProperty] public partial string TestResult { get; set; } = string.Empty;
public ObservableCollection TestPreviewFields { get; } = [];
+ /// Row selected in the field tree — target for the Add / Duplicate / Delete / Move buttons.
+ [ObservableProperty] public partial ResponderFieldViewModel? SelectedField { get; set; }
+
+ partial void OnSelectedFieldChanged(ResponderFieldViewModel? value) => NotifyStructureCommands();
+
// ---- entry points --------------------------------------------------
public void InitializeForReceive(ScenarioNodeViewModel receiveNode)
@@ -70,13 +95,17 @@ public void InitializeForReceive(ScenarioNodeViewModel receiveNode)
HeaderText = $"Match — {expected?.Name ?? receiveNode.Title}";
SubtitleText = "This step proceeds only when the received message satisfies every condition. Leave rows on \"any\" to ignore them.";
- BuildFields(expected, ResponderFieldMode.Trigger, parameterLookup: null);
+ editingReplyMessage = receiveNode.UseReplyMessage;
+ workingMessage = expected is null ? null : (SecsGemDataMessage)expected.Clone();
+
+ BuildFields(workingMessage, ResponderFieldMode.Trigger, parameterLookup: null);
ApplyStoredConditions();
+ NotifyStructureCommands();
OnPropertyChanged(nameof(IsConditions));
OnPropertyChanged(nameof(IsBindings));
}
- public void InitializeForSend(ScenarioNodeViewModel sendNode, IReadOnlyList upstreamReceives)
+ public void InitializeForSend(ScenarioNodeViewModel sendNode, IReadOnlyList upstreamMessages)
{
node = sendNode;
Mode = NodeResponderMode.Bindings;
@@ -84,7 +113,10 @@ public void InitializeForSend(ScenarioNodeViewModel sendNode, IReadOnlyList>? parameterLookup)
{
+ fieldMode = mode;
+ fieldParameterLookup = parameterLookup;
+
Fields.Clear();
if (message is null) return;
- var clone = (SecsGemDataMessage)message.Clone();
+ // Wrap the working message directly (no extra clone) so item identity is stable across
+ // rebuilds — CommitStructuralChange relies on it to carry user edits over.
var index = 0;
- foreach (var item in clone.Children.OfType())
+ foreach (var item in message.Children.OfType())
{
Fields.Add(new ResponderFieldViewModel(item, index.ToString(), mode, parameterLookup));
index++;
}
+
+ foreach (var field in Flatten(Fields))
+ field.FormatChangeRequested += OnFieldFormatChangeRequested;
}
private void CollectParameters(ResponderFieldViewModel field, string sourceNodeId, string sourceLabel, List into)
@@ -202,7 +242,7 @@ private static IEnumerable Flatten(IEnumerable
id is not null && samples.TryGetValue(id, out var m) ? m : fallback;
- var outgoing = (SecsGemDataMessage)node.Transaction.PrimaryMessage.Clone();
+ var outgoing = (SecsGemDataMessage)workingMessage.Clone();
foreach (var binding in CurrentBindings())
binding.Apply(outgoing, Resolve);
@@ -244,6 +284,218 @@ private IEnumerable CurrentConditions() =>
private IEnumerable CurrentBindings() =>
Flatten(Fields).Select(f => f.ToBinding()).OfType();
+ // ---- structural message editing --------------------------------
+ //
+ // The toolbar in NodeResponderView drives these. Each one mutates `workingMessage`, then
+ // CommitStructuralChange rebuilds the field tree (so positional paths are recomputed) while
+ // carrying the user's in-progress condition / binding edits across by item identity.
+
+ /// Add Item targets a container: a selected List, or the message body when nothing is selected.
+ private bool CanAddChildItem() => workingMessage is not null && SelectedField is null or { IsList: true };
+ private bool HasSelectedField() => workingMessage is not null && SelectedField is not null;
+
+ private void NotifyStructureCommands()
+ {
+ AddItemCommand.NotifyCanExecuteChanged();
+ AddSiblingCommand.NotifyCanExecuteChanged();
+ DuplicateItemCommand.NotifyCanExecuteChanged();
+ DeleteItemCommand.NotifyCanExecuteChanged();
+ MoveItemUpCommand.NotifyCanExecuteChanged();
+ MoveItemDownCommand.NotifyCanExecuteChanged();
+ }
+
+ /// Adds a U1 item as the last child of the selected List (or of the message body).
+ [RelayCommand(CanExecute = nameof(CanAddChildItem))]
+ private void AddItem()
+ {
+ if (workingMessage is null) return;
+
+ ICanBeParent container = SelectedField is { IsList: true } listField ? listField.Item : workingMessage;
+ var newItem = SecsGemItem.Create(SecsGemItemFormatType.U1);
+ newItem.SetParent(container);
+ CommitStructuralChange(newItem);
+ }
+
+ /// Adds a U1 item as the next sibling of the selected item.
+ [RelayCommand(CanExecute = nameof(HasSelectedField))]
+ private void AddSibling()
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var newItem = SecsGemItem.Create(SecsGemItemFormatType.U1);
+ newItem.SetParent(location.Container);
+ MoveWithin(location.Container, ChildrenOf(location.Container).Count - 1, location.Index + 1);
+ CommitStructuralChange(newItem);
+ }
+
+ [RelayCommand(CanExecute = nameof(HasSelectedField))]
+ private void DuplicateItem()
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var clone = field.Item.Clone();
+ clone.SetParent(location.Container);
+ MoveWithin(location.Container, ChildrenOf(location.Container).Count - 1, location.Index + 1);
+ CommitStructuralChange(clone);
+ }
+
+ [RelayCommand(CanExecute = nameof(HasSelectedField))]
+ private void DeleteItem()
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var siblings = ChildrenOf(location.Container);
+ field.Item.SetParent(null);
+ var next = siblings.Count == 0 ? null : siblings[Math.Min(location.Index, siblings.Count - 1)] as SecsGemItem;
+ CommitStructuralChange(next);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanMoveItemUp))]
+ private void MoveItemUp() => MoveSelected(-1);
+
+ private bool CanMoveItemUp() =>
+ SelectedField is { } f && ContainerOf(f) is { } location && location.Index > 0;
+
+ [RelayCommand(CanExecute = nameof(CanMoveItemDown))]
+ private void MoveItemDown() => MoveSelected(+1);
+
+ private bool CanMoveItemDown() =>
+ SelectedField is { } f && ContainerOf(f) is { } location
+ && location.Index < ChildrenOf(location.Container).Count - 1;
+
+ private void MoveSelected(int delta)
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var target = location.Index + delta;
+ if (target < 0 || target >= ChildrenOf(location.Container).Count) return;
+ MoveWithin(location.Container, location.Index, target);
+ CommitStructuralChange(field.Item);
+ }
+
+ private void OnFieldFormatChangeRequested(ResponderFieldViewModel field)
+ {
+ // The change comes from a ComboBox selection commit; rebuilding Fields synchronously
+ // underneath it upsets the binding pipeline, so hop through the dispatcher when there is one.
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher is null || dispatcher.CheckAccess() == false)
+ ApplyFormatChange(field);
+ else
+ dispatcher.BeginInvoke(() => ApplyFormatChange(field));
+ }
+
+ private void ApplyFormatChange(ResponderFieldViewModel field)
+ {
+ if (workingMessage is null || field.Format == field.Item.FormatType) return;
+ if (ContainerOf(field) is not { } location) return;
+
+ var replacement = SecsGemItem.Create(field.Format);
+ replacement.Description = field.Item.Description;
+ if (field.Format == SecsGemItemFormatType.List)
+ {
+ // Preserve any children when a value item becomes a list.
+ foreach (var child in field.Item.Children.OfType().ToList())
+ child.SetParent(replacement);
+ }
+ else
+ {
+ // Best-effort value carry-over; parsing drops anything the new format can't represent.
+ replacement.SetValuesFromStrings(field.Item.GetStringValues());
+ }
+
+ var siblings = ChildrenOf(location.Container);
+ field.Item.SetParent(null);
+ replacement.SetParent(location.Container);
+ MoveWithin(location.Container, siblings.Count - 1, location.Index);
+ CommitStructuralChange(replacement);
+ }
+
+ /// The container (message body or a List item) holding a field, plus the field's index in it.
+ private readonly record struct FieldLocation(ICanBeParent Container, int Index);
+
+ private FieldLocation? ContainerOf(ResponderFieldViewModel field)
+ {
+ if (workingMessage is null) return null;
+
+ var segments = field.Path.Split('.');
+ if (!int.TryParse(segments[^1], out var index)) return null;
+
+ if (segments.Length == 1)
+ return new FieldLocation(workingMessage, index);
+
+ var parentPath = string.Join('.', segments[..^1]);
+ return SecsGemItemPath.TryResolve(workingMessage, parentPath, out var parentItem)
+ ? new FieldLocation(parentItem, index)
+ : null;
+ }
+
+ private static ObservableCollection ChildrenOf(ICanBeParent container) =>
+ (ObservableCollection)((IDataItem)container).Children;
+
+ private static void MoveWithin(ICanBeParent container, int from, int to)
+ {
+ var children = ChildrenOf(container);
+ if (from >= 0 && to >= 0 && from < children.Count && to < children.Count && from != to)
+ children.Move(from, to);
+ }
+
+ ///
+ /// Rebuilds the field tree from the mutated , re-applies the user's
+ /// pending condition / binding edits (keyed by item identity, stable across the rebuild), and
+ /// re-selects .
+ ///
+ private void CommitStructuralChange(SecsGemItem? itemToSelect)
+ {
+ structureDirty = true;
+
+ var conditionEdits = new Dictionary();
+ var bindingEdits = new Dictionary();
+ foreach (var field in Flatten(Fields))
+ {
+ if (fieldMode == ResponderFieldMode.Trigger)
+ {
+ if (field.Condition != ConditionKind.Any)
+ conditionEdits[field.Item] = (field.Condition, field.ConditionValue);
+ }
+ else
+ {
+ bindingEdits[field.Item] =
+ (field.Source, field.StaticValue, field.SourceMessage?.NodeId, field.SourceParameter?.Path);
+ }
+ }
+
+ BuildFields(workingMessage, fieldMode, fieldParameterLookup);
+
+ foreach (var field in Flatten(Fields))
+ {
+ if (conditionEdits.TryGetValue(field.Item, out var condition))
+ {
+ field.Condition = condition.Kind;
+ field.ConditionValue = condition.Value;
+ }
+
+ if (bindingEdits.TryGetValue(field.Item, out var binding))
+ {
+ field.Source = binding.Source;
+ if (binding.Source == BindingSourceKind.Literal)
+ {
+ field.StaticValue = binding.StaticValue;
+ }
+ else
+ {
+ field.SourceMessage = SourceMessages.FirstOrDefault(m => m.NodeId == binding.NodeId)
+ ?? SourceMessages.FirstOrDefault();
+ field.SourceParameter = field.AvailableParameters.FirstOrDefault(p => p.Path == binding.ParamPath);
+ }
+ }
+ }
+
+ SelectedField = itemToSelect is null
+ ? null
+ : Flatten(Fields).FirstOrDefault(f => ReferenceEquals(f.Item, itemToSelect));
+ NotifyStructureCommands();
+ }
+
// ---- commit --------------------------------------------------
[RelayCommand]
@@ -255,6 +507,17 @@ private void AcceptButtonClick()
return;
}
+ // Persist a structurally edited message shape back onto the node's transaction. Paths inside
+ // the conditions / bindings below are recomputed from the (already rebuilt) field tree, so
+ // they line up with the new shape.
+ if (structureDirty && workingMessage is not null && node.Transaction is { } transaction)
+ {
+ if (editingReplyMessage)
+ transaction.ReplyMessage = workingMessage;
+ else
+ transaction.PrimaryMessage = workingMessage;
+ }
+
if (Mode == NodeResponderMode.Conditions)
{
var conditions = CurrentConditions().ToList();
diff --git a/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs
index c0219c1..2c0b4d0 100644
--- a/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs
@@ -40,6 +40,25 @@ public partial class ResponderFieldViewModel : ObservableObject
public bool IsList => Item.FormatType == SecsGemItemFormatType.List;
public bool IsLeaf => !IsList;
+ /// Every SECS/GEM format type, offered in the row's inline format dropdown.
+ public IReadOnlyList FormatChoices { get; } = Enum.GetValues();
+
+ ///
+ /// The item's format. Two-way bound to the row's dropdown; a change raises
+ /// so the owner can swap the underlying item in place.
+ ///
+ [ObservableProperty]
+ public partial SecsGemItemFormatType Format { get; set; }
+
+ /// Raised when the user picks a different for this row.
+ public event Action? FormatChangeRequested;
+
+ partial void OnFormatChanged(SecsGemItemFormatType value)
+ {
+ if (value != Item.FormatType)
+ FormatChangeRequested?.Invoke(this);
+ }
+
/// Leaf value present in the template when the editor opened (to detect edited literals).
public string InitialLeafValue { get; }
@@ -97,6 +116,7 @@ public ResponderFieldViewModel(
Item = item;
Path = path;
Mode = mode;
+ Format = item.FormatType;
this.parameterLookup = parameterLookup;
InitialLeafValue = IsLeaf ? string.Join(",", item.GetStringValues()) : string.Empty;
diff --git a/EvenBetterFastSim/WPF/ViewModels/ScenariosViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/ScenariosViewModel.cs
index 33c1707..b0fae40 100644
--- a/EvenBetterFastSim/WPF/ViewModels/ScenariosViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/ScenariosViewModel.cs
@@ -52,7 +52,6 @@ public partial class ScenariosViewModel : ObservableObject
private readonly DataMessageHandler dataMessageHandler;
private readonly ILogService logService;
private readonly ILogger logger;
- private CancellationTokenSource? runCts;
[ObservableProperty]
private ObservableCollection scenarios = [];
@@ -75,12 +74,8 @@ public partial class ScenariosViewModel : ObservableObject
[ObservableProperty]
private PendingConnectionViewModel pendingConnection = new();
- [ObservableProperty]
- [NotifyCanExecuteChangedFor(nameof(RunScenarioCommand))]
- private bool isRunning;
-
- /// The scenario whose per-row Loop toggle is currently on, if any.
- private ScenarioListItem? loopingScenario;
+ /// Scenarios currently executing (a one-off run or a loop), each with its own cancellation source.
+ private readonly Dictionary activeRuns = [];
public ICommand DisconnectConnectorCommand { get; }
@@ -139,6 +134,17 @@ private void LogInfo(string message) =>
private void LogError(string message) =>
logService.LogMessages.Add(new LoggedString { Level = LogLevel.Error, Message = message });
+ ///
+ /// When a run is cancelled while a Receive/Wait step is still pending, the execution service
+ /// reports that step as a timeout (e.g. "Timed out waiting to receive 'S6F11'"). Turn that into
+ /// a " waiting ..." suffix so the log reads "cancelled by the user waiting to receive 'S6F11'"
+ /// instead of a spurious "failed: Timed out".
+ ///
+ private static string CancelledWhileDetail(string? errorMessage) =>
+ errorMessage is not null && errorMessage.StartsWith("Timed out waiting", StringComparison.OrdinalIgnoreCase)
+ ? " " + errorMessage["Timed out ".Length..]
+ : string.Empty;
+
partial void OnSelectedScenarioChanged(ScenarioListItem? value)
{
if (value != null)
@@ -179,11 +185,7 @@ private void NewScenario()
private void DeleteScenario()
{
if (SelectedScenario == null) return;
- if (ReferenceEquals(loopingScenario, SelectedScenario))
- {
- SelectedScenario.IsLooping = false; // stop its loop before it goes away
- runCts?.Cancel();
- }
+ StopRun(SelectedScenario); // stop any run/loop before it goes away
var idx = Scenarios.IndexOf(SelectedScenario);
Scenarios.Remove(SelectedScenario);
SelectedScenario = Scenarios.Count > 0
@@ -205,23 +207,45 @@ private void RenameScenario()
}
///
- /// Overall per-run deadline in seconds; 0 = wait indefinitely (Receive nodes block until their
- /// message arrives or the run is cancelled). Also bounds each loop iteration.
+ /// Overall per-run deadline in seconds. Default 0 = wait indefinitely (Receive nodes block
+ /// until their message arrives, or the run is cancelled). A positive value caps each run / loop iteration.
///
[ObservableProperty]
- private int runTimeoutSeconds = 30;
+ private int runTimeoutSeconds;
- private bool CanRunScenario() => !IsRunning && loopingScenario == null && SelectedScenario != null;
+ ///
+ /// Delay in seconds between loop iterations ("retry"). Default 0 = the built-in 200 ms
+ /// breather; a positive value waits that long from the end of one iteration before restarting.
+ ///
+ [ObservableProperty]
+ private int runRetrySeconds;
+
+ private bool CanRunScenario() => SelectedScenario is { } s && !activeRuns.ContainsKey(s);
/// Runs the selected scenario once.
[RelayCommand(CanExecute = nameof(CanRunScenario))]
private async Task RunScenario()
{
- if (SelectedScenario is { } scenario)
- await RunOnceAsync(scenario, isLoopIteration: false);
+ if (SelectedScenario is not { } scenario || activeRuns.ContainsKey(scenario)) return;
+
+ using var runCts = new CancellationTokenSource();
+ activeRuns[scenario] = runCts;
+ RunScenarioCommand.NotifyCanExecuteChanged();
+ try
+ {
+ await RunOnceAsync(scenario, isLoopIteration: false, consumed: null, runCts.Token);
+ }
+ finally
+ {
+ activeRuns.Remove(scenario);
+ RunScenarioCommand.NotifyCanExecuteChanged();
+ }
}
- /// Toggles the per-row Loop switch for a scenario. One scenario loops at a time.
+ ///
+ /// Toggles the per-row Loop switch for a scenario. Any number of scenarios can loop at once —
+ /// they share one SECS connection, so design them to handle non-overlapping message types.
+ ///
[RelayCommand]
private void ToggleScenarioLoop(ScenarioListItem? scenario)
{
@@ -229,36 +253,56 @@ private void ToggleScenarioLoop(ScenarioListItem? scenario)
if (scenario.IsLooping)
{
- scenario.IsLooping = false;
- if (ReferenceEquals(loopingScenario, scenario))
- runCts?.Cancel();
+ scenario.IsLooping = false; // RunLoopAsync sees this and stops
+ if (activeRuns.TryGetValue(scenario, out var cts))
+ SafeCancel(cts);
return;
}
- // Only one loop at a time — stop whatever else is looping.
- if (loopingScenario is { } other)
- {
- other.IsLooping = false;
- runCts?.Cancel();
- }
+ if (activeRuns.ContainsKey(scenario)) return; // a one-off run is in progress
scenario.IsLooping = true;
- loopingScenario = scenario;
- RunScenarioCommand.NotifyCanExecuteChanged();
_ = RunLoopAsync(scenario);
}
+ private async Task RunLoopAsync(ScenarioListItem scenario)
+ {
+ using var loopCts = new CancellationTokenSource();
+ activeRuns[scenario] = loopCts;
+ RunScenarioCommand.NotifyCanExecuteChanged();
+
+ // One shared set for the whole loop session: a message the equipment sent once can satisfy
+ // only one Receive across all iterations, so a single trigger doesn't re-fire every loop.
+ var consumed = new System.Collections.Concurrent.ConcurrentDictionary(ReferenceEqualityComparer.Instance);
+
+ LogInfo($"Loop started for '{scenario.Name}' — it restarts from Start after each finish.");
+ try
+ {
+ while (scenario.IsLooping && !loopCts.IsCancellationRequested)
+ {
+ if (consumed.Count > 1000) consumed.Clear(); // messages older than the ~5 s inbound buffer can't be replayed anyway
+ await RunOnceAsync(scenario, isLoopIteration: true, consumed, loopCts.Token);
+ if (!scenario.IsLooping || loopCts.IsCancellationRequested) break;
+ var retryDelay = RunRetrySeconds > 0 ? TimeSpan.FromSeconds(RunRetrySeconds) : TimeSpan.FromMilliseconds(200);
+ try { await Task.Delay(retryDelay, loopCts.Token); } catch { /* cancelled */ }
+ }
+ }
+ finally
+ {
+ activeRuns.Remove(scenario);
+ scenario.IsLooping = false;
+ RunScenarioCommand.NotifyCanExecuteChanged();
+ LogInfo($"Loop stopped for '{scenario.Name}'.");
+ }
+ }
+
/// One pass of a scenario from Start to End (or failure / cancellation).
private async Task RunOnceAsync(
ScenarioListItem scenario,
bool isLoopIteration,
- HashSet? consumedAcrossRuns = null)
+ System.Collections.Concurrent.ConcurrentDictionary? consumed,
+ CancellationToken token)
{
- if (IsRunning) return;
-
- IsRunning = true;
- runCts = new CancellationTokenSource();
-
try
{
// Build from the scenario's own canvas so a loop keeps running even while another
@@ -271,57 +315,74 @@ private async Task RunOnceAsync(
if (!isLoopIteration)
LogInfo($"Starting scenario '{scenario.Name}'...");
+ // Canvas feedback: highlight the node currently running / stuck waiting.
+ var canvasNodes = scenario.CachedNodes;
+ if (canvasNodes != null)
+ foreach (var n in canvasNodes) { n.RunState = ScenarioNodeRunState.Idle; n.RunDetail = null; }
+
+ IProgress? progress = canvasNodes is null ? null
+ : new Progress(p =>
+ {
+ if (canvasNodes.FirstOrDefault(n => n.Id == p.NodeId) is not { } vm) return;
+ vm.RunState = p.State;
+ vm.RunDetail = p.Detail;
+ });
+
TimeSpan? deadline = RunTimeoutSeconds > 0 ? TimeSpan.FromSeconds(RunTimeoutSeconds) : null;
- var result = await scenarioExecutionService.ExecuteAsync(graph, runCts.Token, consumedAcrossRuns, deadline);
+ var result = await scenarioExecutionService.ExecuteAsync(graph, token, consumed, deadline, progress);
if (result.Success)
LogInfo($"Scenario '{scenario.Name}' completed ({result.CompletedSteps} steps)");
+ else if (token.IsCancellationRequested)
+ LogInfo($"Scenario '{scenario.Name}' cancelled by the user{CancelledWhileDetail(result.ErrorMessage)}");
else
LogError($"Scenario '{scenario.Name}' failed: {result.ErrorMessage}");
+
+ // A user cancel/stop isn't a failure: clear the transient run icons (waiting hourglass /
+ // danger triangle) the engine painted on whatever node was mid-flight when the token tripped.
+ if (token.IsCancellationRequested && canvasNodes != null)
+ foreach (var n in canvasNodes)
+ if (n.RunState is ScenarioNodeRunState.Running
+ or ScenarioNodeRunState.Waiting
+ or ScenarioNodeRunState.Failed)
+ {
+ n.RunState = ScenarioNodeRunState.Idle;
+ n.RunDetail = null;
+ }
}
catch (OperationCanceledException)
{
- LogInfo($"Scenario '{scenario.Name}' cancelled");
+ LogInfo($"Scenario '{scenario.Name}' cancelled by the user");
}
catch (Exception ex)
{
LogError($"Scenario '{scenario.Name}' errored: {ex.Message}");
}
- finally
- {
- IsRunning = false;
- runCts = null;
- }
}
- private async Task RunLoopAsync(ScenarioListItem scenario)
+ /// Stops a scenario's run or loop, if any.
+ private void StopRun(ScenarioListItem scenario)
{
- // One shared set for the whole loop session: a message the equipment sent once can satisfy
- // only one Receive across all iterations, so a single trigger doesn't re-fire every loop.
- var consumed = new HashSet(ReferenceEqualityComparer.Instance);
-
- LogInfo($"Loop started for '{scenario.Name}' — it restarts from Start after each finish.");
- while (scenario.IsLooping)
- {
- if (consumed.Count > 1000) consumed.Clear(); // messages older than the ~5 s inbound buffer can't be replayed anyway
- await RunOnceAsync(scenario, isLoopIteration: true, consumed);
- if (!scenario.IsLooping) break;
- try { await Task.Delay(200); } catch { /* ignore */ }
- }
- LogInfo($"Loop stopped for '{scenario.Name}'.");
-
- if (ReferenceEquals(loopingScenario, scenario))
- loopingScenario = null;
- RunScenarioCommand.NotifyCanExecuteChanged();
+ scenario.IsLooping = false;
+ if (activeRuns.TryGetValue(scenario, out var cts))
+ SafeCancel(cts);
}
+ /// Stops every running / looping scenario.
[RelayCommand]
private void CancelRun()
{
- if (loopingScenario is { } scenario)
- scenario.IsLooping = false; // RunLoopAsync exits after the current iteration
+ foreach (var (scenario, cts) in activeRuns.ToList())
+ {
+ scenario.IsLooping = false;
+ SafeCancel(cts);
+ }
+ }
- runCts?.Cancel();
+ private static void SafeCancel(CancellationTokenSource cts)
+ {
+ try { cts.Cancel(); }
+ catch (ObjectDisposedException) { /* run already finished */ }
}
public void Save()
@@ -507,7 +568,7 @@ private void OpenNodeEditor(ScenarioNodeViewModel? node)
}
case NodeType.Send or NodeType.SendAndWait:
{
- var upstreams = BuildUpstreamReceives(node);
+ var upstreams = BuildUpstreamMessages(node);
var vm = viewModelLocator.GetViewModel();
vm.InitializeForSend(node, upstreams);
windowManager.ShowDialog(vm);
@@ -522,42 +583,52 @@ private void EditSelectedNode() =>
OpenNodeEditor(SelectedNodes.Count == 1 ? SelectedNodes[0] : null);
///
- /// Walks flow edges backwards from collecting every Receive node on the
- /// path (earliest first), each as a value source the response editor can pull parameters from.
+ /// Walks flow edges backwards from through every incoming branch
+ /// (so a fork joined by an AND contributes all its steps), collecting each Receive and Send
+ /// node as a value source the response editor can pull parameters from.
///
- private IReadOnlyList BuildUpstreamReceives(ScenarioNodeViewModel start)
+ private IReadOnlyList BuildUpstreamMessages(ScenarioNodeViewModel start)
{
- var receives = new List();
- var visited = new HashSet();
- var current = start;
+ var sources = new List();
+ var visited = new HashSet { start.Id };
+ var queue = new Queue();
+ queue.Enqueue(start);
- while (current is not null && visited.Add(current.Id))
+ while (queue.Count > 0)
{
- var input = current.Input.FirstOrDefault();
- if (input is null) break;
- var connection = Connections.FirstOrDefault(c => c.Target == input);
- if (connection?.Source is null) break;
- var previous = FindNodeForConnector(connection.Source);
- if (previous is null) break;
- if (previous.Type == NodeType.Receive)
- receives.Add(previous);
- current = previous;
+ var node = queue.Dequeue();
+ foreach (var connection in Connections.Where(c => c.Target != null && node.Input.Contains(c.Target)))
+ {
+ if (connection.Source is null) continue;
+ var previous = FindNodeForConnector(connection.Source);
+ if (previous is null || !visited.Add(previous.Id)) continue;
+ if (previous.Type is NodeType.Receive or NodeType.Send or NodeType.SendAndWait)
+ sources.Add(previous);
+ queue.Enqueue(previous); // keep walking back through every node
+ }
}
- receives.Reverse(); // chain order
+ // Stable left-to-right order so the picker reads like the canvas.
+ sources.Sort((a, b) => a.Location.X.CompareTo(b.Location.X));
- var result = new List();
- var nameCounts = new Dictionary();
- foreach (var receive in receives)
+ var result = new List();
+ var labelCounts = new Dictionary();
+ foreach (var src in sources)
{
- if (receive.Transaction is null) continue;
- var source = receive.UseReplyMessage ? receive.Transaction.ReplyMessage : receive.Transaction.PrimaryMessage;
+ if (src.Transaction is null) continue;
+
+ var isSend = src.Type is NodeType.Send or NodeType.SendAndWait;
+ var message = src.UseReplyMessage ? src.Transaction.ReplyMessage : src.Transaction.PrimaryMessage;
- nameCounts.TryGetValue(source.Name, out var seen);
- nameCounts[source.Name] = seen + 1;
- var label = seen == 0 ? source.Name : $"{source.Name} #{seen + 1}";
+ var label = (isSend ? "→ " : "← ") + message.Name;
+ if (!isSend && !string.IsNullOrEmpty(src.ConditionSummary))
+ label += $" ({src.ConditionSummary})"; // distinguish same-type receives by their condition
+ labelCounts.TryGetValue(label, out var seen);
+ labelCounts[label] = seen + 1;
+ if (seen > 0)
+ label += $" #{seen + 1}";
- result.Add(new Responders.UpstreamReceive(receive.Id, label, (SecsGemDataMessage)source.Clone()));
+ result.Add(new Responders.UpstreamMessage(src.Id, label, (SecsGemDataMessage)message.Clone()));
}
return result;
}
diff --git a/EvenBetterFastSim/WPF/Windows/MainWindow.xaml b/EvenBetterFastSim/WPF/Windows/MainWindow.xaml
index a932ed5..f19e6ff 100644
--- a/EvenBetterFastSim/WPF/Windows/MainWindow.xaml
+++ b/EvenBetterFastSim/WPF/Windows/MainWindow.xaml
@@ -503,9 +503,14 @@
Style="{StaticResource ToolbarButtonStyle}" Appearance="Secondary" Margin="0,0,10,0" />
-
+
+
@@ -581,6 +586,7 @@
+
+
+
+
+
+
+
+
+
diff --git a/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml b/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml
index 2364604..461612e 100644
--- a/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml
+++ b/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml
@@ -4,6 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:responders="clr-namespace:EvenBetterFastSim.WPF.ViewModels.Responders"
+ xmlns:behaviors="clr-namespace:EvenBetterFastSim.Helpers"
d:DataContext="{d:DesignInstance Type=responders:NodeResponderViewModel}"
mc:Ignorable="d">
@@ -14,42 +15,39 @@
-
-
-
-
-
-
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
-
diff --git a/specification/features.md b/specification/features.md
index ded2536..0d249a5 100644
--- a/specification/features.md
+++ b/specification/features.md
@@ -2,6 +2,27 @@
Related docs: [project_overview.md](project_overview.md), [secsgembase_library.md](secsgembase_library.md), [dialog_pattern.md](dialog_pattern.md), [SESSION_REFACTORING_LOG.md](../SESSION_REFACTORING_LOG.md)
+## Responder panel — edit the message shape
+
+The node responder dialog (`NodeResponderView`, opened by double-click on a Send / Receive node) now has a
+message-editing toolbar above the field tree: **Add Item** (U1 child of the selected list / message body),
+**Add Sibling** (U1 after the selection), **Duplicate**, **Delete**, **Up**, **Down** (also `Alt+Up` /
+`Alt+Down`). Each row is a left-aligned strip whose first control is an inline **format dropdown** that
+retypes the item in place (values carried over best-effort; children preserved when a value item becomes a
+list) — there is no separate text label, the dropdown is the item's identity. Works in both modes — Match
+conditions (Receive) and Response values (Send).
+
+**Files:** `WPF/ViewModels/Responders/NodeResponderViewModel.cs` (`workingMessage` clone, `SelectedField`,
+`AddItem` / `AddSibling` / `DuplicateItem` / `DeleteItem` / `MoveItem*` commands, `ApplyFormatChange`,
+`CommitStructuralChange`), `WPF/ViewModels/Responders/ResponderFieldViewModel.cs` (`Format`, `FormatChoices`,
+`FormatChangeRequested`), `WPF/Windows/NodeResponderView.xaml` (toolbar, per-row format combo, selection
+binding via `EventToCommandAdaptor.TreeViewSelectedItem`).
+
+- Edits mutate a clone; on **OK** the new shape is written back to the node's `Transaction` (Primary, or
+ Reply when `UseReplyMessage`) only if something structural changed, then persisted via `ToModel()` JSON.
+- `BuildFields` wraps `workingMessage` directly (no re-clone) so `SecsGemItem` identity is stable across the
+ tree rebuild — `CommitStructuralChange` uses it to carry pending condition / binding edits over.
+
## Inspect received message items
Double-clicking a `SecsGemItem` in the messages log TreeView opens a read-only inspect dialog.