Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/mix.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
%{
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"predicator": {:hex, :predicator, "3.3.0", "9b5bb2be6723c60e5840be9c760c861e8c90d2f464f1e2dc286226e252cb18bc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9abb5f35d01abf3c552af7307ad2016c3374f12fa8df38ab78be174e20632be3"},
"predicator": {:hex, :predicator, "3.5.0", "bcdf48834287a575be4c3abf26e21879db651a503e2bccb240a5c09660a213ce", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b412569bb124ae0728840fa88d5e884aae6aa2ee35a6a278e08299c14a2dd3ef"},
"saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"},
"uxid": {:hex, :uxid, "2.1.0", "7d23902ae8f0898a59691194441ffcd65cab1eec5fdc9d1f4021ed036af7e372", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ef682ae60049610dc4afe472fc094c50e904c622beb428d836ab61031eb683cb"},
}
12 changes: 10 additions & 2 deletions lib/mix/tasks/test.update_features.ex
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,21 @@ defmodule Mix.Tasks.Test.UpdateFeatures do
" @tag required_features: []"
else
features_formatted = Enum.map_join(features_list, ", ", &inspect/1)
single_line_tag = " @tag required_features: [#{features_formatted}]"

" @tag required_features: [#{features_formatted}]"
# Use multiline format if the single line would be too long (>120 chars)
if String.length(single_line_tag) > 120 do
features_multiline = Enum.map_join(features_list, ",\n ", &inspect/1)
" @tag required_features: [\n #{features_multiline}\n ]"
else
single_line_tag
end
end

# Check if @required_features or @tag required_features: already exists
# Use multiline matching to handle multiline @tag required_features: blocks properly
case Regex.run(
~r/^(\s*)@(required_features\s+\[.*?\]|tag required_features:\s*\[.*?\])/m,
~r/^(\s*)@(required_features\s+\[[^\]]*(?:\n[^\]]*)*\]|tag required_features:\s*\[[^\]]*(?:\n[^\]]*)*\])/m,
content
) do
[existing_line, _indent, _match_group] ->
Expand Down
142 changes: 123 additions & 19 deletions lib/statifier/actions/send_action.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Statifier.Actions.SendAction do
- Interface with external systems via Event I/O Processors
"""

alias Predicator.Duration
alias Statifier.{Evaluator, Event, StateChart}
alias Statifier.Logging.LogManager
require LogManager
Expand Down Expand Up @@ -80,18 +81,25 @@ defmodule Statifier.Actions.SendAction do
"""
@spec execute(Statifier.StateChart.t(), t()) :: Statifier.StateChart.t()
def execute(state_chart, %__MODULE__{} = send_action) do
# Phase 1: Only support immediate internal sends
{:ok, event_name, target_uri, _delay} = evaluate_send_parameters(send_action, state_chart)

if target_uri == "#_internal" do
execute_internal_send(event_name, send_action, state_chart)
else
# Phase 1: Log unsupported external targets
LogManager.info(state_chart, "External send targets not yet supported", %{
action_type: "send_action",
target: target_uri,
event_name: event_name
})
{:ok, event_name, target_uri, delay_ms} = evaluate_send_parameters(send_action, state_chart)

cond do
target_uri == "#_internal" and delay_ms == 0 ->
# Immediate internal send - execute now
execute_internal_send(event_name, send_action, state_chart)

target_uri == "#_internal" and delay_ms > 0 ->
# Delayed internal send - requires StateMachine context
execute_delayed_send(event_name, send_action, state_chart, delay_ms)

true ->
# External targets not yet supported
LogManager.info(state_chart, "External send targets not yet supported", %{
action_type: "send_action",
target: target_uri,
event_name: event_name,
delay_ms: delay_ms
})
end
end

Expand Down Expand Up @@ -131,13 +139,55 @@ defmodule Statifier.Actions.SendAction do
end

defp evaluate_delay(send_action, state_chart) do
state_chart
|> evaluate_attribute_with_expr(
send_action.delay,
send_action.compiled_delay_expr,
send_action.delay_expr,
"0s"
)
delay_string =
state_chart
|> evaluate_attribute_with_expr(
send_action.delay,
send_action.compiled_delay_expr,
send_action.delay_expr,
"0s"
)

# Parse delay string to milliseconds using Predicator's duration parsing
case parse_delay_to_milliseconds(delay_string) do
{:ok, milliseconds} ->
milliseconds

{:error, reason} ->
LogManager.warn(state_chart, "Invalid delay expression, defaulting to 0ms", %{
action_type: "send_action",
delay_string: delay_string,
error: inspect(reason)
})

0
end
end

# Parse delay string to milliseconds using Predicator's duration support
defp parse_delay_to_milliseconds(delay_string) when is_binary(delay_string) do
case Predicator.evaluate(delay_string) do
{:ok, %{} = duration_map} ->
# Duration map returned - convert to milliseconds
{:ok, Duration.to_milliseconds(duration_map)}

{:ok, numeric_value} when is_number(numeric_value) ->
# Numeric value - assume milliseconds
{:ok, round(numeric_value)}

{:ok, string_value} when is_binary(string_value) ->
# Try evaluating as duration string again (might be nested evaluation)
case Predicator.evaluate(string_value) do
{:ok, %{} = duration_map} -> {:ok, Duration.to_milliseconds(duration_map)}
{:ok, numeric_value} when is_number(numeric_value) -> {:ok, round(numeric_value)}
error -> error
end

error ->
error
end
rescue
error -> {:error, error}
end

# Common helper for evaluating attributes that can be static or expressions
Expand Down Expand Up @@ -173,6 +223,60 @@ defmodule Statifier.Actions.SendAction do
end
end

# Execute delayed send - requires StateMachine context
defp execute_delayed_send(event_name, send_action, state_chart, delay_ms) do
# Check if we're running in StateMachine context via StateChart field
case state_chart.state_machine_pid do
pid when is_pid(pid) ->
# Generate send ID for tracking
send_id = generate_send_id(send_action)

# Build event data
event_data = build_event_data(send_action, state_chart)

# Create the delayed event
delayed_event = %Event{
name: event_name,
data: event_data,
origin: :internal
}

# Schedule the delayed send through StateMachine (async to avoid deadlock)
GenServer.cast(pid, {:schedule_delayed_send, send_id, delayed_event, delay_ms})

LogManager.info(state_chart, "Scheduled delayed send", %{
action_type: "send_action",
event_name: event_name,
delay_ms: delay_ms,
send_id: send_id
})

state_chart

nil ->
# Not in StateMachine context - warn and execute immediately
warned_state_chart =
LogManager.warn(
state_chart,
"Delayed send requires StateMachine context, executing immediately",
%{
action_type: "send_action",
event_name: event_name,
delay_ms: delay_ms
}
)

execute_internal_send(event_name, send_action, warned_state_chart)
end
end

# Generate unique send ID using UXID or use provided ID
defp generate_send_id(%__MODULE__{id: id}) when not is_nil(id), do: id

defp generate_send_id(%__MODULE__{}) do
UXID.generate!(prefix: "send", size: :s)
end

defp execute_internal_send(event_name, send_action, state_chart) do
# Build event data from namelist, params, and content
event_data = build_event_data(send_action, state_chart)
Expand Down
5 changes: 3 additions & 2 deletions lib/statifier/feature_detector.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ defmodule Statifier.FeatureDetector do
finalize_elements: :unsupported,
cancel_elements: :unsupported,

# Advanced send features (unsupported)
# Advanced send features (supported)
send_content_elements: :supported,
send_param_elements: :supported,
send_delay_expressions: :partial,
send_delay_expressions: :supported,

# State machine lifecycle (unsupported)
donedata_elements: :unsupported
Expand Down Expand Up @@ -179,6 +179,7 @@ defmodule Statifier.FeatureDetector do
|> add_if_present(xml, ~r/type\s*=\s*["']internal["']/, :internal_transitions)
|> add_if_present(xml, ~r/event\s*=\s*["']\*["']/, :wildcard_events)
|> add_if_present(xml, ~r/delayexpr\s*=/, :send_delay_expressions)
|> add_if_present(xml, ~r/delay\s*=/, :send_delay_expressions)
|> detect_compound_states(xml)
|> detect_targetless_transitions(xml)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/statifier/state_chart.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule Statifier.StateChart do
:document,
:configuration,
:current_event,
:state_machine_pid,
datamodel: %{},
internal_queue: [],
external_queue: [],
Expand All @@ -35,6 +36,7 @@ defmodule Statifier.StateChart do
document: Document.t(),
configuration: Configuration.t(),
current_event: Event.t() | nil,
state_machine_pid: pid() | nil,
datamodel: Statifier.Datamodel.t(),
internal_queue: [Event.t()],
external_queue: [Event.t()],
Expand Down
57 changes: 54 additions & 3 deletions lib/statifier/state_machine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,20 @@ defmodule Statifier.StateMachine do

@type init_arg :: String.t() | StateChart.t()

defstruct [:state_chart, :callback_module, :snapshot_interval, :snapshot_timer]
defstruct [
:state_chart,
:callback_module,
:snapshot_interval,
:snapshot_timer,
delayed_sends: %{}
]

@type t :: %__MODULE__{
state_chart: StateChart.t(),
callback_module: module() | nil,
snapshot_interval: non_neg_integer() | nil,
snapshot_timer: reference() | nil
snapshot_timer: reference() | nil,
delayed_sends: %{String.t() => reference()}
}

@doc """
Expand Down Expand Up @@ -271,9 +278,12 @@ defmodule Statifier.StateMachine do

case initialize_state_chart(actual_init_arg, interpreter_opts) do
{:ok, state_chart} ->
# Set StateMachine PID in StateChart for delayed send context
state_chart_with_pid = %{state_chart | state_machine_pid: self()}

# Initialize state
state = %__MODULE__{
state_chart: state_chart,
state_chart: state_chart_with_pid,
callback_module: callback_module,
snapshot_interval: snapshot_interval
}
Expand Down Expand Up @@ -323,6 +333,18 @@ defmodule Statifier.StateMachine do
{:noreply, new_state}
end

@impl GenServer
def handle_cast({:schedule_delayed_send, send_id, event, delay_ms}, state) do
# Schedule the delayed event
timer_ref = Process.send_after(self(), {:delayed_send, send_id, event}, delay_ms)

# Store the timer reference for potential cancellation
new_delayed_sends = Map.put(state.delayed_sends, send_id, timer_ref)
new_state = %{state | delayed_sends: new_delayed_sends}

{:noreply, new_state}
end

@impl GenServer
def handle_call(:active_states, _from, state) do
active = Configuration.active_leaf_states(state.state_chart.configuration)
Expand All @@ -334,13 +356,42 @@ defmodule Statifier.StateMachine do
{:reply, state.state_chart, state}
end

@impl GenServer
def handle_call({:cancel_delayed_send, send_id}, _from, state) do
case Map.pop(state.delayed_sends, send_id) do
{timer_ref, new_delayed_sends} when not is_nil(timer_ref) ->
Process.cancel_timer(timer_ref)
new_state = %{state | delayed_sends: new_delayed_sends}
{:reply, :ok, new_state}

{nil, _delayed_sends} ->
{:reply, {:error, :not_found}, state}
end
end

@impl GenServer
def handle_info(:snapshot_timer, state) do
call_callback(state, :handle_snapshot, [state.state_chart, %{}])
new_state = maybe_schedule_snapshot(state)
{:noreply, new_state}
end

@impl GenServer
def handle_info({:delayed_send, send_id, event}, state) do
# Remove the timer reference from delayed_sends
{_timer_ref, new_delayed_sends} = Map.pop(state.delayed_sends, send_id)

# Send the delayed event to the state chart
{:ok, new_state_chart} = Interpreter.send_event(state.state_chart, event)

# Update state and call callbacks
updated_state = %{state | state_chart: new_state_chart, delayed_sends: new_delayed_sends}

call_callback(updated_state, :handle_delayed_send, [send_id, event, new_state_chart, %{}])

{:noreply, updated_state}
end

## Private Implementation

# Initialize StateChart from various input types
Expand Down
5 changes: 3 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ defmodule Statifier.MixProject do

# Runtime
{:jason, "~> 1.4"},
{:predicator, "~> 3.3"},
{:saxy, "~> 1.6"}
{:predicator, "~> 3.5"},
{:saxy, "~> 1.6"},
{:uxid, "~> 2.1"}
]

def project do
Expand Down
4 changes: 3 additions & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"predicator": {:hex, :predicator, "3.3.0", "9b5bb2be6723c60e5840be9c760c861e8c90d2f464f1e2dc286226e252cb18bc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9abb5f35d01abf3c552af7307ad2016c3374f12fa8df38ab78be174e20632be3"},
"predicator": {:hex, :predicator, "3.5.0", "bcdf48834287a575be4c3abf26e21879db651a503e2bccb240a5c09660a213ce", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b412569bb124ae0728840fa88d5e884aae6aa2ee35a6a278e08299c14a2dd3ef"},
"saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"},
"typeid_elixir": {:hex, :typeid_elixir, "1.1.0", "cae12a03b9e404d69951dc75cf022e0ed90ee5392db3a641a07f45bcd0341131", [:mix], [{:ecto, "~> 3.10", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "143c2ae18843f2d4efc643acbb4af1406ab3aae40980b4d962e5c7a6895e1ba8"},
"uxid": {:hex, :uxid, "2.1.0", "7d23902ae8f0898a59691194441ffcd65cab1eec5fdc9d1f4021ed036af7e372", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ef682ae60049610dc4afe472fc094c50e904c622beb428d836ab61031eb683cb"},
}
6 changes: 5 additions & 1 deletion test/passing_tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"test/statifier/**/*_test.exs",
"test/mix/**/*_test.exs"
],
"last_updated": "2025-09-08",
"last_updated": "2025-09-10",
"scion_tests": [
"test/scion_tests/actionSend/send1_test.exs",
"test/scion_tests/actionSend/send2_test.exs",
Expand Down Expand Up @@ -34,6 +34,9 @@
"test/scion_tests/data/data_obj_literal_test.exs",
"test/scion_tests/default_initial_state/initial1_test.exs",
"test/scion_tests/default_initial_state/initial2_test.exs",
"test/scion_tests/delayedSend/send1_test.exs",
"test/scion_tests/delayedSend/send2_test.exs",
"test/scion_tests/delayedSend/send3_test.exs",
"test/scion_tests/documentOrder/documentOrder0_test.exs",
"test/scion_tests/foreach/test1_test.exs",
"test/scion_tests/hierarchy/hier0_test.exs",
Expand Down Expand Up @@ -91,6 +94,7 @@
"test/scion_tests/scxml_prefix_event_name_matching/star0_test.exs",
"test/scion_tests/scxml_prefix_event_name_matching/test0_test.exs",
"test/scion_tests/scxml_prefix_event_name_matching/test1_test.exs",
"test/scion_tests/send_data/send1_test.exs",
"test/scion_tests/send_internal/test0_test.exs",
"test/scion_tests/targetless_transition/test0_test.exs"
],
Expand Down
1 change: 0 additions & 1 deletion test/scion_tests/assign_current_small_step/test0_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ defmodule SCIONTest.AssignCurrentSmallStep.Test0Test do

<state id="f"/>


</scxml>
"""

Expand Down
Loading