Introduce selfserve_studio_action_runner and related classes for configurable Studio action workflows

- Added `selfserve_studio_action_runner` to manage Studio action execution, including conditional validation, retry mechanisms, and operation dispatching.
- Introduced `selfserve_studio_actions` to define action constants, normalize configurations, and validate operations and policies.
- Updated `selfserve_config_versioning` to support action nodes, including validation hooks, schema migration normalization, and legacy action parsing.
- Enhanced `SelfserveStudioGraphTest` and `SelfserveStudioDebugPayload` tests to validate action serialization and runtime signal processing.
- Added test cases for event-driven Studio actions and non-blocking configuration warnings.
This commit is contained in:
Jeppe Bundgaard
2026-04-29 10:57:33 +02:00
parent eeccacb2a7
commit 93cac644a1
7 changed files with 1228 additions and 14 deletions
@@ -10,9 +10,11 @@ require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
use classes\selfserve_schema_bootstrap;
use modules\selfserve\helpers\selfserve_task_gate_type;
use modules\selfserve\classes\selfserve_studio_actions;
use objects\departments_o;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
@@ -287,6 +289,7 @@ class selfserve_config_versioning
'conditions' => $conditions,
'rules' => $rules,
'tasks' => $tasks,
'actions' => [],
'snapshot_meta' => [
'captured_at' => date('c'),
'source' => 'legacy_tables',
@@ -331,6 +334,7 @@ class selfserve_config_versioning
'conditions' => array_values($conditions),
'rules' => [],
'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])),
'actions' => array_values((array)($legacyConfig['actions'] ?? [])),
'v2_meta' => [
'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1),
'migrated_at' => date('c'),
@@ -363,6 +367,7 @@ class selfserve_config_versioning
$conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : [];
$rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
$actions = is_array($config['actions'] ?? null) ? $config['actions'] : [];
$questionIds = [];
foreach ($questions as $question) {
@@ -448,6 +453,7 @@ class selfserve_config_versioning
'conditions' => count($conditions),
'rules' => count($rules),
'tasks' => count($tasks),
'actions' => count($actions),
],
'validated_at' => date('c'),
];
@@ -473,6 +479,7 @@ class selfserve_config_versioning
$questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : [];
$conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : [];
$tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : [];
$actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : [];
$questionIds = [];
foreach ($questions as $question) {
@@ -569,6 +576,58 @@ class selfserve_config_versioning
}
}
$actionIds = [];
foreach ($actions as $action) {
if (!is_array($action)) {
$errors[] = 'Action has invalid payload.';
continue;
}
$actionId = (int)($action['id'] ?? 0);
if ($actionId <= 0) {
$errors[] = 'Action without valid id.';
continue;
}
if (isset($actionIds[$actionId])) {
$errors[] = 'Duplicate action id ' . $actionId . '.';
}
$actionIds[$actionId] = true;
$event = strtolower(trim((string)($action['event'] ?? '')));
if (!in_array($event, selfserve_studio_actions::events(), true)) {
$errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.';
}
$washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)));
if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) {
$errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.';
}
if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) {
$warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.';
}
$operation = strtolower(trim((string)($action['operation'] ?? '')));
if (!in_array($operation, selfserve_studio_actions::operations(), true)) {
$errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.';
}
if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) {
$errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.';
}
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
$failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE)));
if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) {
$errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.';
}
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
if ($conditionId !== null) {
$usedConditionIds[$conditionId] = true;
if (!isset($conditionIds[$conditionId])) {
$errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.';
}
}
}
foreach ($usedConditionIds as $conditionId => $_used) {
if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) {
$errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.';
@@ -589,6 +648,7 @@ class selfserve_config_versioning
'conditions' => count($conditions),
'rules' => 0,
'tasks' => count($tasks),
'actions' => count($actions),
],
'validated_at' => date('c'),
];
@@ -880,6 +940,10 @@ class selfserve_config_versioning
}, (array)($config['conditions'] ?? [])));
$config['rules'] = [];
$config['tasks'] = array_values((array)($config['tasks'] ?? []));
$config['actions'] = array_values(array_map(
static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []),
(array)($config['actions'] ?? [])
));
$config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : [];
$config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config);
return $config;
@@ -892,7 +956,7 @@ class selfserve_config_versioning
protected function nextIdsForConfig(array $config): array
{
$next = [];
foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task'] as $key => $name) {
foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) {
$max = 0;
foreach ((array)($config[$key] ?? []) as $row) {
if (is_array($row)) {
@@ -0,0 +1,208 @@
<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/selfserve.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
use classes\selfserve;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_port;
use modules\selfserve\helpers\selfserve_lane_relay;
class selfserve_studio_action_runner
{
/**
* @param int|object $lane Lane id or selfserve lane instance.
* @param array<string,mixed> $context
* @return array<int,array<string,mixed>>
*/
public function executeForLaneEvent(int|object $lane, string $event, string $washMode = selfserve_studio_actions::MODE_BOTH, array $context = []): array
{
$laneObject = is_int($lane) ? (new selfserve())->lane($lane) : $lane;
$departmentId = $this->departmentIdForLane($laneObject);
if ($departmentId <= 0) {
return [];
}
$published = (new selfserve_config_versioning())->getPublishedV2Config($departmentId);
$config = is_array($published['config'] ?? null) ? (array)$published['config'] : [];
$actions = $this->matchingActions($config, $laneObject, $event, $washMode, $context);
$results = [];
foreach ($actions as $action) {
$results[] = $this->executeAction($laneObject, $action);
}
return $results;
}
/**
* @param array<string,mixed> $config
* @param object $lane
* @param array<string,mixed> $context
* @return array<int,array<string,mixed>>
*/
public function matchingActions(array $config, object $lane, string $event, string $washMode, array $context = []): array
{
$event = strtolower(trim($event));
$washMode = strtolower(trim($washMode));
$laneId = (int)($lane->id ?? 0);
$departmentId = $this->departmentIdForLane($lane);
$machineTypeId = $this->machineTypeIdForLane($lane);
$productId = $this->nullableInt($context['product'] ?? $context['product_id'] ?? $context['vehicle_type_id'] ?? null);
$conditionResults = is_array($context['condition_results'] ?? null) ? (array)$context['condition_results'] : null;
$actions = [];
foreach ((array)($config['actions'] ?? []) as $row) {
if (!is_array($row)) {
continue;
}
$action = selfserve_studio_actions::normalize((array)$row);
if (!$action['enabled'] || $action['event'] !== $event) {
continue;
}
if (!in_array($action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $washMode], true)) {
continue;
}
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
continue;
}
if ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
continue;
}
if ((int)$action['product'] !== 0 && ($productId === null || (int)$action['product'] !== $productId)) {
continue;
}
if ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== $machineTypeId) {
continue;
}
$conditionId = $action['condition_id'];
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
continue;
}
$actions[] = $action;
}
usort($actions, static fn(array $left, array $right): int => ((int)$left['order_priority'] <=> (int)$right['order_priority']) ?: ((int)$left['id'] <=> (int)$right['id']));
return $actions;
}
/**
* @param object $lane
* @param array<string,mixed> $action
* @return array<string,mixed>
*/
private function executeAction(object $lane, array $action): array
{
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
$attempts = max(1, min(4, ((int)($options['retry_count'] ?? 0)) + 1));
$delayMs = max(0, min(10000, (int)($options['delay_ms'] ?? 0)));
$lastError = null;
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
if ($delayMs > 0) {
usleep($delayMs * 1000);
}
$this->dispatchAction($lane, $action);
return [
'action_id' => (int)$action['id'],
'name' => (string)$action['name'],
'event' => (string)$action['event'],
'operation' => (string)$action['operation'],
'status' => 'sent',
'attempts' => $attempt,
];
} catch (\Throwable $e) {
$lastError = $e;
}
}
$result = [
'action_id' => (int)$action['id'],
'name' => (string)$action['name'],
'event' => (string)$action['event'],
'operation' => (string)$action['operation'],
'status' => 'failed',
'attempts' => $attempts,
'error' => $lastError?->getMessage(),
];
if (($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE) === selfserve_studio_actions::FAILURE_BLOCK) {
throw new \RuntimeException('Self-serve studio action failed: ' . (string)$action['name'], 0, $lastError);
}
return $result;
}
/**
* @param object $lane
* @param array<string,mixed> $action
*/
private function dispatchAction(object $lane, array $action): void
{
$toggleAfter = $this->nullableInt($action['options']['toggle_after_seconds'] ?? null);
switch ((string)$action['operation']) {
case selfserve_studio_actions::OP_OPEN_PROPERTY_ENTRANCE_GATE:
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
return;
case selfserve_studio_actions::OP_OPEN_PROPERTY_EXIT_GATE:
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments());
return;
case selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT:
$lane->open(selfserve_lane_port::ENTRANCE, $toggleAfter);
return;
case selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT:
$lane->open(selfserve_lane_port::EXIT, $toggleAfter);
return;
case selfserve_studio_actions::OP_SET_CLEANER_RELAY:
$lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, (bool)$action['relay_state']);
return;
case selfserve_studio_actions::OP_SET_MACHINE_RELAY:
$lane->setRelayStatusHard(selfserve_lane_relay::MACHINE, (bool)$action['relay_state']);
return;
case selfserve_studio_actions::OP_SET_PROGRAM_PICKER_RELAY:
$lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, (bool)$action['relay_state']);
return;
}
throw new \RuntimeException('Unsupported self-serve studio action operation: ' . (string)$action['operation']);
}
private function departmentIdForLane(object $lane): int
{
try {
return empty($lane->department_lane) || empty($lane->department_lane->department)
? 0
: (int)$lane->department_lane->department->value();
} catch (\Throwable) {
return 0;
}
}
private function machineTypeIdForLane(object $lane): int
{
try {
return empty($lane->department_lane) || empty($lane->department_lane->machine_type_id)
? 0
: (int)$lane->department_lane->machine_type_id->value();
} catch (\Throwable) {
return 0;
}
}
private function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === 'null') {
return null;
}
$intValue = (int)$value;
return $intValue <= 0 ? null : $intValue;
}
}
@@ -0,0 +1,217 @@
<?php
namespace modules\selfserve\classes;
class selfserve_studio_actions
{
public const EVENT_WASH_START_COMMAND = 'wash_start_command';
public const EVENT_WASH_STOP_COMMAND = 'wash_stop_command';
public const EVENT_MACHINE_START_TRIGGERED = 'machine_start_triggered';
public const MODE_MANUAL = 'manual';
public const MODE_MACHINE = 'machine';
public const MODE_BOTH = 'both';
public const OP_OPEN_PROPERTY_ENTRANCE_GATE = 'open_property_entrance_gate';
public const OP_OPEN_PROPERTY_EXIT_GATE = 'open_property_exit_gate';
public const OP_OPEN_LANE_ENTRANCE_PORT = 'open_lane_entrance_port';
public const OP_OPEN_LANE_EXIT_PORT = 'open_lane_exit_port';
public const OP_SET_CLEANER_RELAY = 'set_cleaner_relay';
public const OP_SET_MACHINE_RELAY = 'set_machine_relay';
public const OP_SET_PROGRAM_PICKER_RELAY = 'set_program_picker_relay';
public const FAILURE_CONTINUE = 'continue';
public const FAILURE_BLOCK = 'block';
/**
* @return array<int,string>
*/
public static function events(): array
{
return [
self::EVENT_WASH_START_COMMAND,
self::EVENT_WASH_STOP_COMMAND,
self::EVENT_MACHINE_START_TRIGGERED,
];
}
/**
* @return array<int,string>
*/
public static function washModes(): array
{
return [
self::MODE_MANUAL,
self::MODE_MACHINE,
self::MODE_BOTH,
];
}
/**
* @return array<int,string>
*/
public static function operations(): array
{
return [
self::OP_OPEN_PROPERTY_ENTRANCE_GATE,
self::OP_OPEN_PROPERTY_EXIT_GATE,
self::OP_OPEN_LANE_ENTRANCE_PORT,
self::OP_OPEN_LANE_EXIT_PORT,
self::OP_SET_CLEANER_RELAY,
self::OP_SET_MACHINE_RELAY,
self::OP_SET_PROGRAM_PICKER_RELAY,
];
}
/**
* @return array<int,string>
*/
public static function failurePolicies(): array
{
return [
self::FAILURE_CONTINUE,
self::FAILURE_BLOCK,
];
}
public static function isRelayOperation(string $operation): bool
{
return in_array($operation, [
self::OP_SET_CLEANER_RELAY,
self::OP_SET_MACHINE_RELAY,
self::OP_SET_PROGRAM_PICKER_RELAY,
], true);
}
public static function isOpenOperation(string $operation): bool
{
return in_array($operation, [
self::OP_OPEN_PROPERTY_ENTRANCE_GATE,
self::OP_OPEN_PROPERTY_EXIT_GATE,
self::OP_OPEN_LANE_ENTRANCE_PORT,
self::OP_OPEN_LANE_EXIT_PORT,
], true);
}
public static function relayRoleForOperation(string $operation): string
{
return match ($operation) {
self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'PROPERTY_ENTRANCE',
self::OP_OPEN_PROPERTY_EXIT_GATE => 'PROPERTY_EXIT',
self::OP_OPEN_LANE_ENTRANCE_PORT => 'ENTRY',
self::OP_OPEN_LANE_EXIT_PORT => 'EXIT',
self::OP_SET_CLEANER_RELAY => 'CLEANER',
self::OP_SET_MACHINE_RELAY => 'MACHINE',
self::OP_SET_PROGRAM_PICKER_RELAY => 'PROGRAM_PICKER',
default => 'ACTION',
};
}
public static function eventLabel(string $event): string
{
return match ($event) {
self::EVENT_WASH_START_COMMAND => 'On self-serve wash start command',
self::EVENT_WASH_STOP_COMMAND => 'On self-serve wash stop command',
self::EVENT_MACHINE_START_TRIGGERED => 'On self-serve wash machine start triggered',
default => 'On action event',
};
}
public static function operationLabel(string $operation, ?bool $relayState = null): string
{
$state = $relayState === null ? '' : ($relayState ? 'ON ' : 'OFF ');
return match ($operation) {
self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'Open property entrance gate',
self::OP_OPEN_PROPERTY_EXIT_GATE => 'Open property exit gate',
self::OP_OPEN_LANE_ENTRANCE_PORT => 'Open lane entrance port',
self::OP_OPEN_LANE_EXIT_PORT => 'Open lane exit port',
self::OP_SET_CLEANER_RELAY => 'Turn ' . $state . 'CLEANER',
self::OP_SET_MACHINE_RELAY => 'Turn ' . $state . 'MACHINE',
self::OP_SET_PROGRAM_PICKER_RELAY => 'Turn ' . $state . 'PROGRAM PICKER',
default => 'Action',
};
}
public static function runtimeStageForEvent(string $event): string
{
return match ($event) {
self::EVENT_WASH_START_COMMAND => 'start',
self::EVENT_WASH_STOP_COMMAND => 'stop',
self::EVENT_MACHINE_START_TRIGGERED => 'machine_start',
default => 'action',
};
}
/**
* @param array<string,mixed> $action
* @return array<string,mixed>
*/
public static function normalize(array $action): array
{
$event = strtolower(trim((string)($action['event'] ?? self::EVENT_WASH_START_COMMAND)));
if (!in_array($event, self::events(), true)) {
$event = self::EVENT_WASH_START_COMMAND;
}
$operation = strtolower(trim((string)($action['operation'] ?? self::OP_OPEN_LANE_ENTRANCE_PORT)));
if (!in_array($operation, self::operations(), true)) {
$operation = self::OP_OPEN_LANE_ENTRANCE_PORT;
}
$washMode = strtolower(trim((string)($action['wash_mode'] ?? self::MODE_BOTH)));
if (!in_array($washMode, self::washModes(), true)) {
$washMode = self::MODE_BOTH;
}
$options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
$failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? self::FAILURE_CONTINUE)));
if (!in_array($failurePolicy, self::failurePolicies(), true)) {
$failurePolicy = self::FAILURE_CONTINUE;
}
$relayState = array_key_exists('relay_state', $action)
? filter_var($action['relay_state'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)
: null;
if (self::isRelayOperation($operation) && $relayState === null) {
$relayState = true;
}
$label = trim((string)($action['name'] ?? $action['label'] ?? ''));
if ($label === '') {
$label = self::operationLabel($operation, $relayState);
}
return [
'id' => (int)($action['id'] ?? 0),
'department' => (int)($action['department'] ?? 0),
'lane' => (int)($action['lane'] ?? 0),
'product' => (int)($action['product'] ?? 0),
'machine_type_id' => self::nullableInt($action['machine_type_id'] ?? null),
'condition_id' => self::nullableInt($action['condition_id'] ?? null),
'name' => $label,
'description' => (string)($action['description'] ?? ''),
'event' => $event,
'wash_mode' => $washMode,
'operation' => $operation,
'relay_state' => $relayState,
'enabled' => filter_var($action['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
'order_priority' => (int)($action['order_priority'] ?? 0),
'options' => [
'delay_ms' => max(0, (int)($options['delay_ms'] ?? 0)),
'toggle_after_seconds' => self::nullableInt($options['toggle_after_seconds'] ?? null),
'retry_count' => max(0, min(3, (int)($options['retry_count'] ?? 0))),
'failure_policy' => $failurePolicy,
'record_event' => filter_var($options['record_event'] ?? true, FILTER_VALIDATE_BOOLEAN),
],
];
}
private static function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === 'null') {
return null;
}
$intValue = (int)$value;
return $intValue <= 0 ? null : $intValue;
}
}
@@ -7,6 +7,7 @@ require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_virtual_hardware.php';
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
@@ -36,6 +37,7 @@ use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\helpers\selfserve_task_gate_type;
use objects\selfserve_config_versions_o;
@@ -264,6 +266,51 @@ class selfserve_studio_graph
}
}
$actionsByEvent = [];
$actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']);
foreach ($actionRows as $index => $action) {
$normalizedAction = selfserve_studio_actions::normalize($action);
$id = (int)($normalizedAction['id'] ?? 0);
if ($id <= 0) {
continue;
}
$actionsByEvent[$normalizedAction['event']][] = $normalizedAction;
$nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [
'object_id' => $id,
'raw' => $normalizedAction,
'scope' => $this->scopeForRow($normalizedAction, $lookups),
'subtitle' => $this->actionSubtitle($normalizedAction, $lookups),
'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']),
'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']),
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']),
], 1360, 160 + ($index * 130));
$eventSource = match ((string)$normalizedAction['event']) {
selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start',
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish',
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible',
default => 'checkpoint:start',
};
$edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']);
$conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null);
if ($conditionId !== null) {
$edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows');
}
$this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction);
}
foreach ($actionsByEvent as $event => $actions) {
$orderedActions = $this->sortedRows($actions, ['order_priority', 'id']);
for ($i = 1; $i < count($orderedActions); $i++) {
$sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0);
$targetId = (int)($orderedActions[$i]['id'] ?? 0);
if ($sourceId > 0 && $targetId > 0) {
$edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then');
}
}
}
$this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
$this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace);
@@ -678,6 +725,7 @@ class selfserve_studio_graph
'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'),
'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'),
'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'),
'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'),
'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])),
'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
@@ -761,7 +809,7 @@ class selfserve_studio_graph
}
}
foreach (['conditions', 'questions', 'tasks'] as $section) {
foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) {
foreach ((array)($config[$section] ?? []) as $row) {
if (!is_array($row)) {
continue;
@@ -1039,6 +1087,29 @@ class selfserve_studio_graph
'buttons' => [],
'dynamic_images_vehicle_type' => null,
],
'action' => [
'id' => $id,
'department' => $departmentId,
'lane' => 0,
'product' => 0,
'machine_type_id' => null,
'condition_id' => null,
'name' => 'Open lane entrance port',
'description' => '',
'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND,
'wash_mode' => selfserve_studio_actions::MODE_BOTH,
'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT,
'relay_state' => null,
'enabled' => true,
'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])),
'options' => [
'delay_ms' => 0,
'toggle_after_seconds' => 1,
'retry_count' => 0,
'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE,
'record_event' => true,
],
],
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
@@ -1120,6 +1191,13 @@ class selfserve_studio_graph
}
}
unset($task);
$actionRows = &$this->configRows($config, 'action');
foreach ($actionRows as &$action) {
if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) {
$action['condition_id'] = null;
}
}
unset($action);
}
}
@@ -1129,8 +1207,8 @@ class selfserve_studio_graph
*/
private function applyConfigReorder(array &$config, string $entity, array $items): void
{
if (!in_array($entity, ['question', 'task'], true)) {
throw new \RuntimeException('Only questions and tasks can be reordered.');
if (!in_array($entity, ['question', 'task', 'action'], true)) {
throw new \RuntimeException('Only questions, tasks, and actions can be reordered.');
}
$priorities = [];
@@ -1217,6 +1295,15 @@ class selfserve_studio_graph
$rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null;
return;
}
if ($sourceType === 'condition' && $targetType === 'action') {
$rows = &$this->configRows($config, 'action');
$index = $this->configRowIndex($rows, $targetId);
if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) {
$rows[$index]['condition_id'] = $disconnect ? null : $sourceId;
}
return;
}
}
/**
@@ -1288,6 +1375,7 @@ class selfserve_studio_graph
'question' => 'questions',
'condition' => 'conditions',
'task' => 'tasks',
'action' => 'actions',
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
if (!isset($config[$key]) || !is_array($config[$key])) {
@@ -1315,7 +1403,7 @@ class selfserve_studio_graph
private function allocateConfigId(array &$config, string $entity): int
{
$name = match ($entity) {
'question', 'condition', 'task' => $entity,
'question', 'condition', 'task', 'action' => $entity,
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) {
@@ -1362,6 +1450,8 @@ class selfserve_studio_graph
$data['name'] = $data['label'];
} elseif ($entity === 'task' && !array_key_exists('task', $data)) {
$data['task'] = $data['label'];
} elseif ($entity === 'action' && !array_key_exists('name', $data)) {
$data['name'] = $data['label'];
}
}
@@ -1369,6 +1459,7 @@ class selfserve_studio_graph
'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'],
'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'],
'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'],
'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'],
default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
};
@@ -1389,6 +1480,9 @@ class selfserve_studio_graph
$row['condition_id'] = null;
}
}
if ($entity === 'action') {
$row = selfserve_studio_actions::normalize($row);
}
return $row;
}
@@ -1398,18 +1492,24 @@ class selfserve_studio_graph
if ($field === 'expression') {
return $this->normalizeExpressionNode($value);
}
if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) {
if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) {
return $this->nullableInt($value);
}
if (in_array($field, ['department', 'lane', 'product', 'order_priority'], true)) {
if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) {
return (int)$value;
}
if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) {
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
if ($field === 'services') {
return $this->normalizeServiceList($value);
}
if ($field === 'buttons') {
return $this->normalizeArrayPayload($value);
}
if ($field === 'options') {
return is_array($value) ? (array)$value : [];
}
if ($field === 'gate_type') {
return $this->normalizeGateType((string)$value);
}
@@ -2281,6 +2381,7 @@ class selfserve_studio_graph
'question' => 'question',
'condition', 'rule' => 'name',
'task' => 'task',
'action' => 'name',
default => 'label',
};
$label = trim((string)($row[$field] ?? ''));
@@ -2290,6 +2391,24 @@ class selfserve_studio_graph
return $this->labelFor($entity . 's', $id, $lookups);
}
/**
* @param array<string,mixed> $action
* @param array<string,mixed> $lookups
*/
private function actionSubtitle(array $action, array $lookups): string
{
$parts = [
selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')),
ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)),
selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null),
];
$scope = $this->scopeLabel($action, $lookups);
if ($scope !== 'Shared scope') {
$parts[] = $scope;
}
return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== ''));
}
/**
* @param array<string,mixed> $lookups
*/
@@ -2760,6 +2879,7 @@ class selfserve_studio_graph
'conditions' => 'condition',
'rules' => 'rule',
'tasks' => 'task',
'actions' => 'action',
'lanes' => 'lane',
default => $entity,
};
@@ -7,6 +7,8 @@ require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php';
@@ -35,6 +37,8 @@ use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_studio_action_runner;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\helpers\selfserve_task_gate_type;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services;
@@ -173,7 +177,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$lane->setWashStartTime(time());
}
$washStartedAt = (int)$lane->getWashStartTime();
$this->enableCleanerRelayForStartedWash($lane);
$session->markMachineStartTriggered(
$washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null
@@ -183,6 +186,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'reg' => $effectiveReg,
'customer_number' => $customerNumber,
]);
(new selfserve_studio_action_runner())->executeForLaneEvent(
$lane,
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED,
selfserve_studio_actions::MODE_MACHINE,
[
'lane_id' => $laneId,
'reg' => $effectiveReg,
'customer_number' => $customerNumber,
'session_id' => (int)$session->id,
'source_payload' => $payload,
]
);
$this->enableCleanerRelayForStartedWash($lane);
return $this->getSessionSummary((int)$session->id);
}
@@ -595,6 +611,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'conditions' => $conditions,
'rules' => $rules,
'tasks' => $tasks,
'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [],
'visible_answers' => $visibleAnswers,
],
];
@@ -699,11 +716,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups);
$conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups);
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups);
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
$annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $hardware, (array)($options['graph'] ?? []));
$annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? []));
return [
'summary' => $summary,
@@ -729,6 +747,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'conditions' => $conditions,
'rules' => $rules,
'tasks' => $tasks,
'actions' => $actions,
'hardware' => $hardware,
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
'graph_annotations' => $annotations,
@@ -1020,14 +1039,117 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $items;
}
/**
* @param array<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $lookups
* @return array<int,array<string,mixed>>
*/
protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array
{
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['condition_results']
: [];
$lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : [];
$laneId = (int)($lane['id'] ?? 0);
$departmentId = (int)($lane['department'] ?? 0);
$vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null);
$machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null);
$eventModes = [
selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot),
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot),
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE,
];
$items = [];
foreach ($actions as $row) {
if (!is_array($row)) {
continue;
}
$action = selfserve_studio_actions::normalize((array)$row);
$actionId = (int)($action['id'] ?? 0);
if ($actionId <= 0) {
continue;
}
$expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH;
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
$scopeMatches = true;
$scopeReason = null;
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
$scopeMatches = false;
$scopeReason = 'Action department scope does not match the simulated lane.';
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
$scopeMatches = false;
$scopeReason = 'Action lane scope does not match the simulated lane.';
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
$scopeMatches = false;
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
$scopeMatches = false;
$scopeReason = 'Action machine type scope does not match the simulated lane.';
}
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
$enabled = (bool)($action['enabled'] ?? true);
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
$reason = 'Action would run for this simulator event.';
if (!$enabled) {
$reason = 'Action is disabled.';
} elseif (!$modeMatches) {
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
} elseif (!$scopeMatches) {
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
} elseif (!$conditionSatisfied) {
$reason = 'Action condition gate did not pass.';
}
$items[] = [
'id' => $actionId,
'node_id' => 'action:' . $actionId,
'label' => (string)$action['name'],
'active' => $active,
'state' => $active ? 'active' : 'skipped',
'reason' => $reason,
'event' => (string)$action['event'],
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
'wash_mode' => (string)$action['wash_mode'],
'simulated_wash_mode' => $expectedMode,
'operation' => (string)$action['operation'],
'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']),
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
'relay_state' => $action['relay_state'],
'condition_id' => $conditionId,
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
'condition_satisfied' => $conditionSatisfied,
'scope' => [
'department' => (int)$action['department'],
'lane' => (int)$action['lane'],
'product' => (int)$action['product'],
'machine_type_id' => $action['machine_type_id'],
],
'options' => (array)$action['options'],
'order_priority' => (int)$action['order_priority'],
'raw' => $action,
];
}
usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event'])
?: ((int)$a['order_priority'] <=> (int)$b['order_priority'])
?: ((int)$a['id'] <=> (int)$b['id']));
return $items;
}
/**
* @param array<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $gatewayWorkspace
* @param array<string,mixed> $lookups
* @param array<int,array<string,mixed>> $actions
* @return array<string,mixed>
*/
protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups): array
protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array
{
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
$allowedServices = (array)($snapshot['allowed_services'] ?? []);
@@ -1051,7 +1173,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
}
}
$signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots);
$signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions);
$dryRunOperations = [];
if (($snapshot['allowed'] ?? false) === true) {
@@ -1089,9 +1211,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
* @param array<string,mixed> $gatewayWorkspace
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
* @param array<int,array<string,mixed>> $laneRelaySlots
* @param array<int,array<string,mixed>> $actions
* @return array<int,array<string,mixed>>
*/
protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots): array
protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array
{
$timeline = [];
$sequence = 1;
@@ -1115,6 +1238,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
null
);
$this->appendDebugActionSignalRows(
$timeline,
$sequence,
$actions,
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$allowed,
$gatewayWorkspace,
$bindingsByService,
$laneRelaySlots,
$snapshot
);
$machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot);
$machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE');
$timeline[] = $this->debugRelaySignalTimelineRow(
@@ -1147,6 +1282,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.'
);
$this->appendDebugActionSignalRows(
$timeline,
$sequence,
$actions,
selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED,
$allowed && $machineAvailable,
$gatewayWorkspace,
$bindingsByService,
$laneRelaySlots,
$snapshot
);
$cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot);
$cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER');
$timeline[] = $this->debugRelaySignalTimelineRow(
@@ -1161,6 +1308,18 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.'
);
$this->appendDebugActionSignalRows(
$timeline,
$sequence,
$actions,
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
$allowed,
$gatewayWorkspace,
$bindingsByService,
$laneRelaySlots,
$snapshot
);
$exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot);
$exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT');
$timeline[] = $this->debugRelaySignalTimelineRow(
@@ -1218,6 +1377,146 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $timeline;
}
protected function debugWashModeForStart(array $snapshot): string
{
return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)
? selfserve_studio_actions::MODE_MACHINE
: selfserve_studio_actions::MODE_MANUAL;
}
protected function debugWashModeForStop(array $snapshot): string
{
return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true)
? selfserve_studio_actions::MODE_MACHINE
: selfserve_studio_actions::MODE_MANUAL;
}
/**
* @param array<int,array<string,mixed>> $timeline
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $gatewayWorkspace
* @param array<string,array<int,array<string,mixed>>> $bindingsByService
* @param array<int,array<string,mixed>> $laneRelaySlots
* @param array<string,mixed> $snapshot
*/
protected function appendDebugActionSignalRows(
array &$timeline,
int &$sequence,
array $actions,
string $event,
bool $eventAllowed,
array $gatewayWorkspace,
array $bindingsByService,
array $laneRelaySlots,
array $snapshot
): void {
$eventActions = array_values(array_filter(
$actions,
static fn(array $action): bool => (string)($action['event'] ?? '') === $event
));
usort($eventActions, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0))
?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
foreach ($eventActions as $action) {
$raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action);
$operation = (string)($raw['operation'] ?? '');
$relayRole = selfserve_studio_actions::relayRoleForOperation($operation);
$runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event);
$isRelayOperation = selfserve_studio_actions::isRelayOperation($operation);
$isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true);
$relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot);
$binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole);
$active = (bool)($action['active'] ?? false);
$reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.');
$payload = [
'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0),
'action' => (string)($raw['name'] ?? $action['label'] ?? ''),
'event' => $event,
'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH),
'operation' => $operation,
'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null),
'enabled' => (bool)($raw['enabled'] ?? true),
'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0),
'options' => (array)($raw['options'] ?? []),
];
if ($isRelayOperation) {
$payload['id'] = $relayId;
$payload['channel'] = 0;
$payload['on'] = (bool)($raw['relay_state'] ?? true);
$signalType = 'studio_action_relay_switch';
} elseif ($isPropertyGate) {
$payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE';
$signalType = 'studio_action_gate_open';
} else {
$payload['id'] = $relayId;
$payload['on'] = true;
$payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1;
$signalType = 'studio_action_relay_pulse';
}
if (!$active) {
$timeline[] = $this->debugSignalTimelineRow(
$sequence++,
$runtimeStage,
$signalType,
$relayRole,
$relayId,
$binding,
$payload,
$binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'),
'skipped',
$reason
);
continue;
}
if (!$eventAllowed) {
$timeline[] = $this->debugSignalTimelineRow(
$sequence++,
$runtimeStage,
$signalType,
$relayRole,
$relayId,
$binding,
$payload,
$binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'),
'blocked',
'Action event would not fire because the dry-run scenario is blocked before this stage.'
);
continue;
}
if ($isPropertyGate) {
$timeline[] = $this->debugSignalTimelineRow(
$sequence++,
$runtimeStage,
$signalType,
$relayRole,
null,
null,
$payload,
'real',
'sent',
null
);
continue;
}
$timeline[] = $this->debugRelaySignalTimelineRow(
$sequence++,
$runtimeStage,
$signalType,
$relayRole,
$relayId,
$binding,
$payload,
true,
'Action relay is not configured for the simulated lane.'
);
}
}
/**
* @param array<string,mixed> $snapshot
* @param array<int,array<string,mixed>> $laneRelaySlots
@@ -1545,11 +1844,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $actions
* @param array<string,mixed> $hardware
* @param array<string,mixed> $graph
* @return array<string,mixed>
*/
protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $graph): array
protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array
{
$nodes = [
'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'],
@@ -1601,6 +1901,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
}
}
foreach ($actions as $action) {
$nodes[(string)$action['node_id']] = [
'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable',
'label' => (string)($action['reason'] ?? 'Action evaluated.'),
];
}
$edges = [];
foreach ((array)($graph['edges'] ?? []) as $edge) {
@@ -10,10 +10,14 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
use Exception;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\classes\selfserve_studio_action_runner;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\classes\selfserve_wash_flow;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_log_action;
@@ -200,6 +204,37 @@ trait selfserve_lane_command_t
$this->setMachineRelayStatusForWashStart();
}
protected function resolveSelfServeActionWashModeForStart(): string
{
try {
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
if (is_array($services)) {
foreach ($services as $service) {
if (strtoupper((string)$service) === 'MACHINE') {
return selfserve_studio_actions::MODE_MACHINE;
}
}
}
}
} catch (\Throwable) {
// Fall through to manual mode when the cached service set is unavailable.
}
return selfserve_studio_actions::MODE_MANUAL;
}
/**
* Execute configured Studio actions from the published flow for this lane.
*
* @param array<string,mixed> $context
* @return array<int,array<string,mixed>>
*/
protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array
{
return (new selfserve_studio_action_runner())->executeForLaneEvent($this, $event, $washMode, $context);
}
/**
* Disable relays after STOP in deterministic order:
* 1. Cleaner relay
@@ -391,6 +426,14 @@ trait selfserve_lane_command_t
$this->setLaneState(selfserve_lane_state::IN_WASH);
// Start the wash timer
$this->setWashStartTime(time());
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart(),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
$this->runRelaySideEffectsForWashStart($arguments);
// Log the lane start event
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
@@ -404,6 +447,15 @@ trait selfserve_lane_command_t
}
// Snapshot the physical machine ON signal before session completion/reset.
$machine_start_triggered = $this->hasMachineStartSignalForStop();
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
[
'customer_number' => $arguments->customer_number,
'reg' => $this->getLicensePlate(),
'machine_start_triggered' => $machine_start_triggered,
]
);
// Open the exit port
$this->open(selfserve_lane_port::EXIT);
// Turn off relays in deterministic order after STOP
@@ -4,6 +4,7 @@ app_require('modules/selfserve/classes/selfserve_studio_graph.php');
app_require('modules/selfserve/classes/selfserve_virtual_hardware.php');
use modules\selfserve\classes\selfserve_studio_graph;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_virtual_hardware;
use modules\selfserve\classes\selfserve_wash_flow;
@@ -115,6 +116,112 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph
expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']);
});
it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void {
$service = selfserve_studio_graph_without_constructor();
$graph = $service->buildGraphFromConfig([
'schema_version' => 2,
'questions' => [],
'conditions' => [
['id' => 10, 'name' => 'Machine selected'],
],
'rules' => [],
'tasks' => [],
'actions' => [
[
'id' => 80,
'name' => 'Open lane entry',
'event' => 'wash_start_command',
'wash_mode' => 'both',
'operation' => 'open_lane_entrance_port',
'condition_id' => 10,
'lane' => 7,
'order_priority' => 1,
],
[
'id' => 81,
'name' => 'Cleaner off',
'event' => 'wash_start_command',
'wash_mode' => 'machine',
'operation' => 'set_cleaner_relay',
'relay_state' => false,
'lane' => 7,
'order_priority' => 2,
],
],
], [
'lookups' => [
'labels' => [
'lanes' => ['7' => 'Lane 7'],
'conditions' => ['10' => 'Machine selected'],
'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'],
],
],
]);
$nodeIds = array_column($graph['nodes'], 'id');
$edgeIds = array_column($graph['edges'], 'id');
$actionNode = array_values(array_filter(
$graph['nodes'],
static fn(array $node): bool => ($node['id'] ?? null) === 'action:81'
))[0] ?? null;
expect($nodeIds)->toContain('action:80')
->and($nodeIds)->toContain('action:81')
->and($edgeIds)->toContain('action-event:wash_start_command:80')
->and($edgeIds)->toContain('action-gate:10:80')
->and($edgeIds)->toContain('action-order:wash_start_command:80:81')
->and($edgeIds)->toContain('scope:lane:7:action:80')
->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER')
->and($actionNode['data']['relay_role'])->toBe('CLEANER');
});
it('validates action configuration and keeps warnings non-blocking', function (): void {
$versioning = new class extends selfserve_config_versioning {
public function __construct()
{
}
};
$validation = $versioning->validateConfig([
'schema_version' => 2,
'questions' => [
['id' => 1, 'question' => 'Machine selected?'],
],
'conditions' => [
[
'id' => 10,
'name' => 'Gate',
'expression' => [
'type' => 'group',
'operator' => 'ALL',
'children' => [
['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
],
],
],
],
'rules' => [],
'tasks' => [],
'actions' => [
[
'id' => 80,
'name' => 'Manual machine start action',
'event' => 'machine_start_triggered',
'wash_mode' => 'manual',
'operation' => 'set_machine_relay',
'condition_id' => 10,
'relay_state' => true,
'options' => ['failure_policy' => 'block'],
],
],
]);
expect($validation['valid'])->toBeTrue()
->and($validation['stats']['actions'])->toBe(1)
->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event');
});
it('serializes v2 condition expressions without standalone rule nodes', function (): void {
$service = selfserve_studio_graph_without_constructor();
@@ -586,6 +693,146 @@ it('renders virtual gateway nodes and task service edges in the studio graph', f
->and($bindingNode['data']['raw']['virtual'])->toBeTrue();
});
it('inserts configured action signals into the simulator timeline in runtime order', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => [
'id' => 7,
'department' => 6,
'name' => 'Lane 7',
'relay_in_id' => 'ENTRY-7',
'relay_out_id' => 'EXIT-7',
'relay_machine_id' => 'M-7',
'relay_machine_program_picker_id' => 'PICKER-7',
'relay_machine_cleaner_id' => 'CLEAN-7',
],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 2,
'answers' => [],
'answer_sources' => [],
'questions' => [],
'tasks' => [
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']],
],
'allowed_services' => ['MACHINE'],
'machine_available' => true,
'all_visible_questions_answered' => true,
'allowed' => true,
'config_version_id' => 90,
'config_source' => 'draft',
'evaluation_trace' => [
'visible_question_ids' => [],
'visibility_condition_results' => [],
'condition_results' => [21 => true],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
],
],
'debug_candidates' => [
'questions' => [],
'conditions' => [
['id' => 21, 'name' => 'Gate'],
],
'rules' => [],
'tasks' => [
['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1],
],
'actions' => [
[
'id' => 81,
'name' => 'Open entry on start',
'event' => 'wash_start_command',
'wash_mode' => 'both',
'operation' => 'open_lane_entrance_port',
'condition_id' => 21,
'order_priority' => 1,
'options' => ['toggle_after_seconds' => 2],
],
[
'id' => 82,
'name' => 'Program picker off when machine starts',
'event' => 'machine_start_triggered',
'wash_mode' => 'machine',
'operation' => 'set_program_picker_relay',
'relay_state' => false,
'order_priority' => 1,
],
[
'id' => 83,
'name' => 'Cleaner off on stop',
'event' => 'wash_stop_command',
'wash_mode' => 'machine',
'operation' => 'set_cleaner_relay',
'relay_state' => false,
'order_priority' => 1,
],
],
],
], [
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']],
['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']],
['relay_id' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']],
['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']],
],
],
],
'lanes' => [
[
'id' => 7,
'relay_slots' => [
['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'],
['slot' => 'MACHINE', 'relay_id' => 'M-7'],
['slot' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'],
['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'],
['slot' => 'EXIT', 'relay_id' => 'EXIT-7'],
],
],
],
],
'lookups' => [
'labels' => [
'lanes' => ['7' => 'Lane 7'],
'conditions' => ['21' => 'Gate'],
'tasks' => ['41' => 'Start machine'],
],
],
]);
expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11))
->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([
'SESSION',
'ENTRY',
'MACHINE',
'MACHINE',
'PROGRAM_PICKER',
'CLEANER',
'CLEANER',
'EXIT',
'CLEANER',
'MACHINE',
'SESSION',
])
->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse')
->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2])
->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch')
->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false])
->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false])
->and($debug['actions'][0]['state'])->toBe('active')
->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active');
});
it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void {
$service = selfserve_wash_flow_without_constructor();