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
@@ -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();