Add .gitattributes for binary files and extend order booking update tests. Enhance dynamic image and routing logic with program_picker support.

This commit is contained in:
Jeppe Bundgaard
2026-05-26 14:04:27 +02:00
parent f1c123a840
commit bc7c0280f2
23 changed files with 9296 additions and 3064 deletions
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+190 -3
View File
@@ -78,6 +78,8 @@ tags:
description: License plate scanning operations
- name: Config
description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding
description: Branding options management
- name: Roles
@@ -4030,13 +4032,15 @@ paths:
- name: buttons
in: query
required: false
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
schema:
oneOf:
- type: string
- type: array
items:
type: integer
oneOf:
- type: integer
- type: string
- name: current_step
in: query
required: false
@@ -5844,7 +5848,7 @@ paths:
reference: {type: string}
po: {type: string}
pickup: {type: boolean}
order_id: {type: integer}
order_id: {type: integer, nullable: true}
items:
type: array
items:
@@ -11982,6 +11986,189 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/superuser/releases/operations:
get:
tags:
- Release Manager
summary: List release operation runs
operationId: listReleaseOperations
parameters:
- in: query
name: channel_id
schema:
type: integer
- in: query
name: operation_type
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
responses:
'200':
description: Release operation runs
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/releases/operations/{id}:
get:
tags:
- Release Manager
summary: Get release operation details
operationId: getReleaseOperation
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Release operation details
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/releases/test-runs:
post:
tags:
- Release Manager
summary: Run Release Manager diagnostics
operationId: runReleaseTest
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Release test operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/channels/{id}/sync:
post:
tags:
- Release Manager
summary: Sync latest branch commits into a release channel
operationId: syncReleaseChannel
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'202':
description: Channel sync operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/issues/actions:
post:
tags:
- Release Manager
summary: Run a Release Manager issue action
operationId: runReleaseIssueAction
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
issue_key:
type: string
action_id:
type: string
inputs:
type: object
additionalProperties: true
confirm:
type: boolean
additionalProperties: true
responses:
'200':
description: Issue action result
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
components:
securitySchemes:
BearerAuth:
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
File diff suppressed because it is too large Load Diff
@@ -157,6 +157,7 @@ class release_manager_schema_bootstrap
bundle_id BIGINT UNSIGNED NULL,
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
app VARCHAR(16) NOT NULL,
active_channel_app_key VARCHAR(96) NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
@@ -297,6 +298,47 @@ class release_manager_schema_bootstrap
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_runs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(64) NOT NULL,
subject_type VARCHAR(64) NULL,
subject_id VARCHAR(128) NULL,
channel_id BIGINT UNSIGNED NULL,
app VARCHAR(16) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
title VARCHAR(255) NULL,
summary TEXT NULL,
solution_hint TEXT NULL,
actor_user_id INT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_runs_channel (channel_id, created_at),
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
INDEX idx_release_operation_runs_type_status (operation_type, status),
INDEX idx_release_operation_runs_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_steps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_run_id BIGINT UNSIGNED NOT NULL,
step_key VARCHAR(64) NOT NULL,
label VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
message TEXT NULL,
diagnostic TEXT NULL,
solution_hint TEXT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_steps_run (operation_run_id, id),
INDEX idx_release_operation_steps_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
@@ -321,6 +363,7 @@ class release_manager_schema_bootstrap
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
@@ -336,9 +379,12 @@ class release_manager_schema_bootstrap
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
@@ -365,6 +411,8 @@ class release_manager_schema_bootstrap
'release_service_sets',
'release_deployments',
'release_bundles',
'release_operation_runs',
'release_operation_steps',
'release_timeline_sessions',
'release_timeline_events',
] as $table) {
@@ -457,4 +505,23 @@ class release_manager_schema_bootstrap
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
}
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
if ($table === '' || $index === '') {
return;
}
$indexSql = $db->escape_string($index);
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
}
}
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
const BUTTON_RESET = 'reset';
const BUTTON_START = 'start';
const BUTTON_PROGRAM_PICKER = 'program_picker';
// Thumb
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
public int $thumb_size = 1550; // height and width of the thumb
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
$this->drawProgramWheel();
$this->drawThumb();
$this->drawStepThumb();
$deferredStartButtons = $this->drawHighlightedButtonSequence();
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
$this->drawDeferredHighlightedButtons($deferredStartButtons);
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
if (is_string($button)) {
$trimmed = trim($button);
$specialButton = strtolower($trimmed);
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
return $specialButton;
}
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
private function getHighlightedButtonCoordinates(int|string $button): ?array
{
if ($button === self::BUTTON_PROGRAM_PICKER) {
return $this->getProgramPickerStepCoordinates();
}
if ($button === self::BUTTON_RESET) {
return [
'x' => 1736,
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
}
private function getProgramPickerStepCoordinates(): array
{
$thumbX = 650;
$thumbY = 1290;
$stepSize = 350;
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
$angle = $this->getRotationThumbPosition();
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
$rad = deg2rad($angle);
return [
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
'size' => $stepSize,
];
}
/**
* @throws \Exception
*/
@@ -841,6 +841,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
$decisions = $this->buildDebugDecisions(
$questions,
$conditions,
$rules,
$tasks,
$actions,
(array)($hardware['signal_timeline'] ?? []),
$dynamicImageButtons
);
$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);
@@ -871,6 +881,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'rules' => $rules,
'tasks' => $tasks,
'actions' => $actions,
'dynamic_image_buttons' => $dynamicImageButtons,
'decisions' => $decisions,
'hardware' => $hardware,
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
'graph_annotations' => $annotations,
@@ -981,6 +993,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
: [];
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
: [];
$items = [];
foreach ($questions as $question) {
@@ -992,20 +1007,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$visible = isset($visibleIds[$questionId]);
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
$causes = [];
if ($gateId !== null) {
$causes[] = $this->debugCause(
'condition',
$gateId,
$conditionLabel,
true,
($conditionResults[$gateId] ?? null),
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
);
}
if ($visible && $answer === null) {
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
}
$reason = $visible
? ($answer === null
? 'Question ' . $label . ' is visible but has no answer.'
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
$items[] = [
'kind' => 'question',
'id' => $questionId,
'node_id' => 'question:' . $questionId,
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
'node_ids' => ['question:' . $questionId],
'label' => $label,
'visible' => $visible,
'state' => $state,
'answer' => $answer,
'answer_source' => $sources[$questionId] ?? 'missing',
'condition_id' => $gateId,
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
'reason' => $visible
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
: 'Visibility condition did not pass.',
'condition' => $conditionLabel,
'gate_satisfied' => $gateSatisfied,
'reason' => $reason,
'causes' => $causes,
'order_priority' => (int)($question['order_priority'] ?? 0),
];
}
@@ -1036,19 +1074,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
$reason = $invalidReference
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
: ($satisfied
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
$items[] = [
'kind' => 'rule',
'id' => $ruleId,
'node_id' => 'rule:' . $ruleId,
'node_ids' => ['rule:' . $ruleId],
'condition_id' => (int)($rule['condition_id'] ?? 0),
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
'label' => $label,
'type' => (string)($rule['type'] ?? ''),
'object_type' => $objectType,
'object_id' => $objectId,
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
'object_label' => $objectLabel,
'actual_value' => $actual,
'satisfied' => $satisfied,
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
'invalid_reference' => $invalidReference,
'reason' => $reason,
'causes' => [
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
],
];
}
@@ -1082,10 +1134,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
$causes = [];
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
if ($failedExpressionCause !== null) {
$causes[] = $failedExpressionCause;
} elseif (!$result) {
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
if (($rule['satisfied'] ?? false) !== true) {
foreach ((array)($rule['causes'] ?? []) as $cause) {
if (is_array($cause)) {
$causes[] = $cause;
}
}
break;
}
}
}
$items[] = [
'kind' => 'condition',
'id' => $conditionId,
'node_id' => 'condition:' . $conditionId,
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
'node_ids' => ['condition:' . $conditionId],
'label' => $label,
'result' => $result,
'state' => $result ? 'passed' : 'failed',
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
@@ -1098,6 +1169,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'reason' => in_array($conditionId, $cycleIds, true)
? 'Condition dependency cycle detected.'
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
'causes' => $causes,
];
}
@@ -1130,6 +1202,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
}
}
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['condition_results']
: [];
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
? (array)$snapshot['debug_candidates']['visible_answers']
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
$items = [];
@@ -1149,25 +1227,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$active = isset($activeIds[$taskId]);
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
$items[] = [
'kind' => 'task',
'id' => $taskId,
'node_id' => 'task:' . $taskId,
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
'node_ids' => ['task:' . $taskId],
'label' => $label,
'description' => (string)($task['description'] ?? ''),
'active' => $active,
'state' => $active ? 'active' : 'blocked',
'gate_type' => $gateType,
'gate_ref_id' => $gateRefId,
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
'gate_ref_label' => $gateRefLabel,
'gate_satisfied' => $gateSatisfied,
'services' => $services,
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
'attachments' => $taskAttachments,
'relay_bindings' => $bindings,
'order_priority' => (int)($task['order_priority'] ?? 0),
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
'reason' => $gateDecision['reason'],
'causes' => $gateDecision['causes'],
];
}
@@ -1212,42 +1297,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
$scopeMatches = true;
$scopeReason = null;
$scopeCause = null;
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
$scopeMatches = false;
$scopeReason = 'Action department scope does not match the simulated lane.';
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
$scopeMatches = false;
$scopeReason = 'Action lane scope does not match the simulated lane.';
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
$scopeMatches = false;
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
} 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.';
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
}
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
$enabled = (bool)($action['enabled'] ?? true);
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
$label = (string)$action['name'];
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
$causes = [];
$reason = 'Action would run for this simulator event.';
if (!$enabled) {
$reason = 'Action is disabled.';
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
} elseif (!$modeMatches) {
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
} elseif (!$scopeMatches) {
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
if ($scopeCause !== null) {
$causes[] = $scopeCause;
}
} elseif (!$conditionSatisfied) {
$reason = 'Action condition gate did not pass.';
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
}
$items[] = [
'kind' => 'action',
'id' => $actionId,
'node_id' => 'action:' . $actionId,
'label' => (string)$action['name'],
'node_ids' => ['action:' . $actionId],
'label' => $label,
'active' => $active,
'state' => $active ? 'active' : 'skipped',
'reason' => $reason,
'causes' => $causes,
'event' => (string)$action['event'],
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
'wash_mode' => (string)$action['wash_mode'],
@@ -1257,7 +1359,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'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' => $conditionLabel,
'condition_satisfied' => $conditionSatisfied,
'scope' => [
'department' => (int)$action['department'],
@@ -1758,7 +1860,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
string $predictedStatus,
?string $reason
): array {
$id = 'signal:' . $sequence;
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
$targetBinding = $binding['node_id'] ?? null;
$nodeIds = [$id];
if (isset($payload['action_id'])) {
$nodeIds[] = 'action:' . (int)$payload['action_id'];
}
if (is_string($targetBinding) && $targetBinding !== '') {
$nodeIds[] = $targetBinding;
}
if ($relayId !== null && trim($relayId) !== '') {
$nodeIds[] = 'relay:' . $relayId;
}
$causes = $reason === null ? [] : [
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
];
return [
'kind' => 'signal',
'id' => $id,
'node_id' => $id,
'node_ids' => array_values(array_unique($nodeIds)),
'label' => $label,
'state' => $predictedStatus,
'sequence' => $sequence,
'runtime_stage' => $runtimeStage,
'signal_type' => $signalType,
@@ -1779,6 +1904,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'predicted_status' => $predictedStatus,
'skip_block_reason' => $reason,
'reason' => $reason,
'causes' => $causes,
];
}
@@ -2085,6 +2211,316 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
};
}
protected function debugRuleExpectedValue(string $type): mixed
{
return match (strtoupper(trim($type))) {
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
'IS_FALSE' => false,
'IS_SET' => 'set',
'IS_TRUE_OR_NOT_SET' => 'true or missing',
'IS_FALSE_OR_NOT_SET' => 'false or missing',
default => strtolower(str_replace('_', ' ', trim($type))),
};
}
protected function debugExpectedLabel(mixed $expected): string
{
return $this->debugValueLabel($expected);
}
protected function debugValueLabel(mixed $value): string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if ($value === null) {
return 'missing';
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? 'array' : $encoded;
}
if ($value instanceof \Stringable) {
return (string)$value;
}
return trim((string)$value) === '' ? 'empty' : (string)$value;
}
/**
* @return array<string,mixed>
*/
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
{
return [
'kind' => $kind,
'id' => $id,
'label' => $label,
'expected' => $expected,
'actual' => $actual,
'expected_label' => $this->debugExpectedLabel($expected),
'actual_label' => $this->debugValueLabel($actual),
'reason' => $reason,
];
}
protected function debugExpressionTraceReason(mixed $trace): ?string
{
if (!is_array($trace)) {
return null;
}
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
$failed = $this->firstFailedPredicateTrace($expression);
if ($failed !== null) {
return (string)($failed['reason'] ?? 'Predicate did not pass.');
}
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
}
/**
* @param array<string,mixed> $trace
* @param array<string,mixed> $lookups
* @return array<string,mixed>|null
*/
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
{
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
$failed = $this->firstFailedPredicateTrace($expression);
if ($failed === null) {
if (($expression['result'] ?? true) === false) {
return $this->debugCause(
'expression',
$trace['condition_id'] ?? null,
'Condition expression',
true,
false,
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
);
}
return null;
}
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
$subjectId = (int)($failed['subject_id'] ?? 0);
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
$actual = $failed['actual_value'] ?? null;
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
}
/**
* @param array<int,bool|null> $conditionResults
* @param array<int,bool|null> $visibleAnswers
* @return array{reason:string,causes:array<int,array<string,mixed>>}
*/
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
{
$gateType = strtoupper(trim($gateType));
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
if (!$active && $gateSatisfied) {
return [
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
'causes' => [
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
],
];
}
return [
'reason' => $active
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
'causes' => $active ? [] : [
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
],
];
}
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
if (!$active && $gateSatisfied) {
return [
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
'causes' => [
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
],
];
}
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
return [
'reason' => $reason,
'causes' => $active ? [] : [
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
],
];
}
/**
* @param array<int,array<string,mixed>> $tasks
* @return array<int,array<string,mixed>>
*/
protected function buildDebugDynamicImageButtons(array $tasks): array
{
$items = [];
foreach ($tasks as $task) {
$taskId = (int)($task['id'] ?? 0);
if ($taskId <= 0) {
continue;
}
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
$active = (bool)($task['active'] ?? false);
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
$id = $taskId . ':' . (int)$index;
$nodeId = 'dynamic_image_button:' . $id;
$reason = $active
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
$items[] = [
'kind' => 'dynamic_image_button',
'id' => $id,
'node_id' => $nodeId,
'node_ids' => ['task:' . $taskId, $nodeId],
'label' => $buttonLabel,
'task_id' => $taskId,
'task_label' => $taskLabel,
'button_index' => (int)$index,
'button' => $button,
'state' => $active ? 'active' : 'hidden',
'reason' => $reason,
'causes' => $active ? [] : [
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
],
];
}
}
return $items;
}
protected function taskUsesProgramPicker(array $task): bool
{
return in_array(
'PROGRAM_PICKER',
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
true
);
}
protected function dynamicImageButtonSequenceForTask(array $task): array
{
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
if (!$this->taskUsesProgramPicker($task)) {
return $buttons;
}
return $this->normalizeButtonList(array_merge(['program_picker'], $buttons));
}
/**
* @param array<int,array<string,mixed>> $questions
* @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<int,array<string,mixed>> $signals
* @param array<int,array<string,mixed>> $dynamicImageButtons
* @return array<int,array<string,mixed>>
*/
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
{
$decisions = [];
foreach ([
'question' => $questions,
'condition' => $conditions,
'rule' => $rules,
'task' => $tasks,
'action' => $actions,
'signal' => $signals,
'dynamic_image_button' => $dynamicImageButtons,
] as $kind => $items) {
foreach ($items as $item) {
if (is_array($item)) {
$decisions[] = $this->debugDecisionFromItem($kind, $item);
}
}
}
return $decisions;
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
protected function debugDecisionFromItem(string $kind, array $item): array
{
$nodeIds = [];
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
if (is_string($nodeId) && $nodeId !== '') {
$nodeIds[] = $nodeId;
}
}
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
$nodeIds[] = $item['node_id'];
}
$state = (string)($item['state'] ?? '');
if ($state === '') {
$state = match ($kind) {
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
default => 'unknown',
};
}
$causes = [];
foreach ((array)($item['causes'] ?? []) as $cause) {
if (is_array($cause)) {
$causes[] = $cause;
}
}
return [
'kind' => (string)($item['kind'] ?? $kind),
'id' => $item['id'] ?? ($item['node_id'] ?? null),
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
'state' => $state,
'reason' => (string)($item['reason'] ?? ''),
'node_ids' => array_values(array_unique($nodeIds)),
'causes' => $causes,
];
}
protected function debugDynamicImageButtonLabel(mixed $button): string
{
if (is_array($button)) {
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
return (string)$button[$key];
}
}
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? 'Button' : $encoded;
}
$label = trim((string)$button);
if (strtolower($label) === 'program_picker') {
return 'Program picker';
}
return $label === '' ? 'Button' : $label;
}
/**
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
@@ -2213,6 +2649,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $failed;
}
}
foreach (['when', 'then', 'default'] as $field) {
if (!is_array($trace[$field] ?? null)) {
continue;
}
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
if ($failed !== null) {
return $failed;
}
}
foreach (['branches', 'cases'] as $field) {
foreach ((array)($trace[$field] ?? []) as $child) {
if (!is_array($child)) {
continue;
}
$failed = $this->firstFailedPredicateTrace((array)$child);
if ($failed !== null) {
return $failed;
}
}
}
return null;
}
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
enum selfserve_lane_services
{
case MACHINE; // Relay that controls the machine power
case PROGRAM_PICKER; // Relay that controls the machine program picker
}
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
Notes:
- Route parameter name is `condition_id`. That value is used as the task gate id.
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
- If an active task does not expose `MACHINE`, the relay will not be enabled.
Typical failures:
@@ -480,7 +480,7 @@ Important enums:
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
- `selfserve_lane_services`: currently only `MACHINE`
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
### Machine Type And Wash Session Objects
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
if (is_string($btn)) {
$trimmed = trim($btn);
$specialButton = strtolower($trimmed);
if ($specialButton === 'reset' || $specialButton === 'start') {
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
$ids[] = $specialButton;
continue;
}
+229 -3
View File
@@ -78,6 +78,8 @@ tags:
description: License plate scanning operations
- name: Config
description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding
description: Branding options management
- name: Roles
@@ -4071,13 +4073,15 @@ paths:
- name: buttons
in: query
required: false
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
schema:
oneOf:
- type: string
- type: array
items:
type: integer
oneOf:
- type: integer
- type: string
- name: current_step
in: query
required: false
@@ -6186,7 +6190,7 @@ paths:
reference: {type: string}
po: {type: string}
pickup: {type: boolean}
order_id: {type: integer}
order_id: {type: integer, nullable: true}
items:
type: array
items:
@@ -12830,6 +12834,189 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/superuser/releases/operations:
get:
tags:
- Release Manager
summary: List release operation runs
operationId: listReleaseOperations
parameters:
- in: query
name: channel_id
schema:
type: integer
- in: query
name: operation_type
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
responses:
'200':
description: Release operation runs
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/releases/operations/{id}:
get:
tags:
- Release Manager
summary: Get release operation details
operationId: getReleaseOperation
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Release operation details
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/releases/test-runs:
post:
tags:
- Release Manager
summary: Run Release Manager diagnostics
operationId: runReleaseTest
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Release test operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/channels/{id}/sync:
post:
tags:
- Release Manager
summary: Sync latest branch commits into a release channel
operationId: syncReleaseChannel
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'202':
description: Channel sync operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/issues/actions:
post:
tags:
- Release Manager
summary: Run a Release Manager issue action
operationId: runReleaseIssueAction
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
issue_key:
type: string
action_id:
type: string
inputs:
type: object
additionalProperties: true
confirm:
type: boolean
additionalProperties: true
responses:
'200':
description: Issue action result
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
components:
securitySchemes:
BearerAuth:
@@ -17958,6 +18145,45 @@ components:
items:
type: object
additionalProperties: true
actions:
type: array
items:
type: object
additionalProperties: true
dynamic_image_buttons:
type: array
items:
type: object
additionalProperties: true
decisions:
type: array
items:
type: object
required: [kind, id, label, state, reason, node_ids, causes]
properties:
kind:
type: string
id:
oneOf:
- type: integer
- type: string
nullable: true
label:
type: string
state:
type: string
reason:
type: string
node_ids:
type: array
items:
type: string
causes:
type: array
items:
type: object
additionalProperties: true
additionalProperties: true
hardware:
type: object
additionalProperties: true
@@ -135,7 +135,7 @@ class departmentLanesRoute
* Query parameters:
* - department (int, required)
* - lane (int, required)
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start"
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker"
* - current_step (int >= 0, optional) → current click/step indicator
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
@@ -12,6 +12,7 @@ use objects\departments_o;
use objects\logs_o;
use objects\order_bookings_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
use traits\route_t;
@@ -265,6 +266,7 @@ class orderBookingRoute
$po = self::getTargetPo(false); // String | Null
$pickup = self::getTargetPickup(false); // Bool | Null
$items = self::getTargetItems(false); // Array of order_items_o objects
$order_id_was_set = self::isParametersSet(['order_id']);
$order_id = self::getTargetOrderId(false); // Int | Null
/** Authentication */
$auth = new authentication();
@@ -293,6 +295,7 @@ class orderBookingRoute
/**
* Update the object
*/
$previous_order_id = (int)($object->order_id->value() ?? 0);
$data = [
...(self::hasPermission($permission_other) && !empty($customer_number) ? [
'customer_number' => (int)$customer_number->customer_number->value(),
@@ -307,9 +310,12 @@ class orderBookingRoute
...(isset($po) ? ['po' => $po] : []),
...(isset($pickup) ? ['pickup' => $pickup] : []),
...(isset($items) ? ['items' => $items] : []),
...(isset($order_id) ? ['order_id' => $order_id] : []),
...($order_id_was_set ? ['order_id' => $order_id] : []),
];
$object->update($data);
if ($order_id_was_set && $order_id === null) {
self::clearMatchingOrderBookingLink($object, $previous_order_id);
}
/**
* Return the object
*/
@@ -529,7 +535,7 @@ class orderBookingRoute
return $value;
}
private function getTargetOrderId(bool $required = true): int|string|null
private function getTargetOrderId(bool $required = true): int|null
{
global $response;
$parameter = 'order_id';
@@ -541,7 +547,7 @@ class orderBookingRoute
} else {
self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
// Check if the value is null
if ($this->getParameter($parameter) === null) return "null";
if ($this->getParameter($parameter) === null) return null;
}
self::requireMinLength($parameter, 1);
self::requireMaxLength($parameter, 9);
@@ -551,6 +557,28 @@ class orderBookingRoute
return $value;
}
/**
* @throws Exception
*/
private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void
{
if ($previous_order_id <= 0) {
return;
}
$previous_order = (new orders_o())->select($previous_order_id);
if (!$previous_order->exists()) {
return;
}
if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) {
return;
}
$previous_order->booking_id->set(null);
$previous_order->objectChanged();
}
private function getTargetCustomer(bool $required = true): users_o|null
{
global $response;
@@ -45,6 +45,21 @@ class releaseManagerRoute
}
});
$this->post('/release/gate/test-runs', function () {
global $response;
$manager = new release_manager();
if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) {
$response->error(['message' => 'Invalid release gate token.'], 401);
return;
}
try {
$response->success($manager->runReleaseTest($this->requestPayload(), null), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
});
$this->get('/superuser/releases', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
@@ -61,6 +76,34 @@ class releaseManagerRoute
'superuser_release_manager_view' => 'View Release Manager source configuration',
]);
$this->get('/superuser/releases/operations', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
$response->success((new release_manager())->listOperations($this->getParametersAsArray()));
}, [
'superuser_release_manager_view' => 'View release operation runs',
]);
$this->get('/superuser/releases/operations/{id}', function () {
global $response;
$this->requirePermission('superuser_release_manager_view');
try {
$response->success((new release_manager())->operationDetail($this->routeId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_release_manager_view' => 'Inspect release operation diagnostics',
]);
$this->post('/superuser/releases/test-runs', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$response->success((new release_manager())->runReleaseTest($this->requestPayload(), $this->actorUserId()), 202);
}, [
'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics',
]);
$this->post('/superuser/releases/config', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
@@ -161,6 +204,18 @@ class releaseManagerRoute
'superuser_release_manager_rollback' => 'Rollback an active release channel',
]);
$this->post('/superuser/releases/channels/{id}/sync', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->syncChannel($this->routeId(), $this->actorUserId()), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_release_manager_deploy' => 'Sync the latest frontend and API branch commits into a release channel',
]);
$this->post('/superuser/releases/channels/{id}/bundle', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
@@ -380,6 +435,14 @@ class releaseManagerRoute
'superuser_release_manager_deploy' => 'Promote a deployment to its release channel',
]);
$this->post('/superuser/releases/issues/actions', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$response->success((new release_manager())->runIssueAction($this->requestPayload(), $this->actorUserId()));
}, [
'superuser_release_manager_deploy' => 'Run a safe Release Manager issue resolution action',
]);
$this->post('/superuser/releases/replay-targets', function () {
global $response;
$this->requirePermission('superuser_release_manager_replay');
@@ -451,4 +514,20 @@ class releaseManagerRoute
return $payload;
}
private function releaseGateToken(): string
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$authorization = (string)($headers['Authorization'] ?? $headers['authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches) === 1) {
return trim($matches[1]);
}
return trim((string)(
$headers['X-Release-Gate-Token']
?? $headers['x-release-gate-token']
?? $_SERVER['HTTP_X_RELEASE_GATE_TOKEN']
?? ''
));
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('detaches an order booking and clears the matching order link', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Detached Booking Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Detached Booking Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
$booking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'order_id' => $order['id'],
]);
api_test_runtime()->db()->query(
'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id']
);
$session = api_fixtures()->createUserSession([
'edit_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->put('/order-bookings', [
'id' => $booking['id'],
'order_id' => null,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toHaveKey('order_id')
->and($response->data()['order_id'])
->toBeNull();
$result = api_test_runtime()->db()->query(
'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' .
'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' .
'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1'
);
expect($result)->not->toBeFalse();
$row = $result->fetch_assoc();
expect($row)->toBeArray();
expect($row['booking_order_id'] ?? null)->toBeNull();
expect($row['order_booking_id'] ?? null)->toBeNull();
});
it('keeps the linked order when order_id is omitted from an order booking update', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
]);
$booking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'order_id' => $order['id'],
'reference' => 'ORIGINAL-BOOKING-REF',
]);
api_test_runtime()->db()->query(
'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id']
);
$session = api_fixtures()->createUserSession([
'edit_bookings',
'department_access_' . $department['id'],
]);
$response = api_client()->put('/order-bookings', [
'id' => $booking['id'],
'reference' => 'UPDATED-BOOKING-REF',
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($response->data()['order_id'] ?? 0))->toBe($order['id']);
expect($response->data()['reference'] ?? null)->toBe('UPDATED-BOOKING-REF');
$result = api_test_runtime()->db()->query(
'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' .
'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' .
'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1'
);
expect($result)->not->toBeFalse();
$row = $result->fetch_assoc();
expect($row)->toBeArray();
expect((int)($row['booking_order_id'] ?? 0))->toBe($order['id']);
expect((int)($row['order_booking_id'] ?? 0))->toBe($booking['id']);
});
@@ -38,8 +38,9 @@ namespace {
expect($content)->toContain("switch (\$dynamic_image_id)");
});
it('accepts ordered dynamic image button tokens including reset start and zero', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput('["reset",0,2,"start",5]'))->toBe([
it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([
'program_picker',
'reset',
0,
2,
@@ -50,7 +51,7 @@ namespace {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('ordered highlighted button IDs');
expect($route)->toContain('"reset", or "start"');
expect($route)->toContain('"reset", "start", or "program_picker"');
});
it('renders machine one dynamic image steps from the ordered button payload', function (): void {
@@ -60,13 +61,14 @@ namespace {
expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();');
expect($content)->toContain('getOrderedHighlightedButtonTokens');
expect($content)->toContain('normalizeHighlightedButtonToken');
expect($content)->toContain("const BUTTON_PROGRAM_PICKER = 'program_picker'");
expect($content)->toContain('getProgramPickerStepCoordinates');
expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)');
expect($content)->toContain('self::BUTTON_PROGRAM_PICKER');
$thumbOffset = strpos($content, 'public function drawStepThumb');
expect($thumbOffset)->not->toBeFalse();
$thumbBody = substr($content, (int)$thumbOffset, 1800);
expect($thumbBody)->toContain('self::IMAGE_BUTTON_HIGHLIGHTED_BLUE');
expect($thumbBody)->not->toContain('drawStepCounterOnButton');
expect($thumbBody)->not->toContain('only_generate_current_step');
$setupOffset = strpos($content, 'public function setup');
expect($setupOffset)->not->toBeFalse();
$setupBody = substr($content, (int)$setupOffset, 1000);
expect($setupBody)->not->toContain('drawStepThumb();');
});
}
@@ -123,7 +123,59 @@ it('marks a channel ready when all release services are healthy', function (): v
->and($channel['services'])->toHaveCount(5);
});
it('reports non-default channels missing bundles, versions, and URLs as blocking missing values', function (): void {
it('does not block channels on unhealthy data targets when data services are production shared', function (): void {
$database = releaseStatusReadyService('database', 71);
$database['deployment_status'] = 'reconcile_failed';
$database['availability_state'] = 'degraded';
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://beta.example.test',
'api_base_url' => 'https://api-beta.example.test',
'versions' => releaseStatusReadyVersions(8),
],
],
'deployment_targets' => [
['id' => 51, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'],
['id' => 52, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'],
],
'service_sets' => [
[
'id' => 53,
'channel_id' => 2,
'mode' => 'attach_existing',
'stack' => [
'database' => $database,
'redis' => releaseStatusReadyService('redis', 75),
'minio' => releaseStatusReadyService('minio', 76),
],
],
],
'deployments' => [
['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['issues'])->toBe([])
->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([
'status' => 'production_shared',
'state' => 'ready',
'severity' => 'ok',
'issue_type' => null,
]);
});
it('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
@@ -150,11 +202,13 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
$channel = releaseStatusChannelBySlug($overview, 'beta');
$missingLabels = array_column($channel['missing_values'], 'label');
$missingKeys = array_column($channel['missing_values'], 'key');
expect($overview['state'])->toBe('blocked')
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(5)
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4)
->and($channel['readiness'])->toBe('blocked')
->and($missingLabels)->toContain('release bundle')
->and($missingKeys)->not->toContain('release_bundle')
->and($missingLabels)->not->toContain('release bundle')
->and($missingLabels)->toContain('frontend version')
->and($missingLabels)->toContain('frontend URL')
->and($missingLabels)->toContain('API version')
@@ -166,6 +220,150 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
]);
});
it('marks a branch-based channel ready without a release bundle when app versions and URLs exist', function (): void {
$versions = releaseStatusReadyVersions(0);
unset($versions['bundle_id'], $versions['bundle_label']);
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://api-v2.truckwash.io/beta/frontend',
'api_base_url' => 'https://api-v2.truckwash.io/beta/api',
'versions' => $versions,
],
],
'deployment_targets' => [
['id' => 21, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'],
['id' => 22, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'],
],
'deployments' => [
['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'],
['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($overview['state'])->toBe('ready')
->and($channel['readiness'])->toBe('ready')
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'status' => 'ready',
])
->and($channel['issues'])->toBe([])
->and($channel['missing_values'])->toBe([]);
});
it('does not report frontend or API URLs missing when target auto endpoints are resolvable', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 2,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'versions' => releaseStatusReadyVersions(),
],
],
'deployment_targets' => [
[
'id' => 21,
'channel_id' => 2,
'channel_slug' => 'beta',
'app' => 'frontend',
'repository' => 'truckwash/front-end-vue',
'branch' => 'master',
'coolify_service_uuid' => 'frontend-beta-service',
'deploy_context' => [
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
],
],
[
'id' => 22,
'channel_id' => 2,
'channel_slug' => 'beta',
'app' => 'api',
'repository' => 'truckwash/backend-php',
'branch' => 'master',
'coolify_service_uuid' => 'api-beta-service',
'deploy_context' => [
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
],
],
],
'service_sets' => [
[
'id' => 31,
'channel_id' => 2,
'mode' => 'isolated_stack',
'stack' => [
'database' => releaseStatusReadyService('database', 41),
'redis' => releaseStatusReadyService('redis', 42),
'minio' => releaseStatusReadyService('minio', 43),
],
],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
$missingKeys = array_column($channel['missing_values'], 'key');
expect($missingKeys)->not->toContain('frontend_base_url')
->and($missingKeys)->not->toContain('api_base_url')
->and($channel['availability'])->toMatchArray([
'configured' => true,
'frontend_base_url' => 'https://gateway.example.test/beta/frontend',
'api_base_url' => 'https://gateway.example.test/beta/api',
])
->and($channel['readiness'])->toBe('ready');
});
it('does not surface legacy release bundle actions as readiness blockers', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 20,
'slug' => 'beta',
'name' => 'Beta',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://beta.example.test',
'api_base_url' => 'https://api-beta.example.test',
'versions' => [],
'availability' => [
'configured' => false,
'status' => 'unconfigured',
'missing' => ['release_bundle'],
],
],
],
'bundles' => [
['id' => 91, 'channel_id' => 20, 'status' => 'deployed', 'version_label' => 'beta-bundle'],
['id' => 92, 'channel_id' => 20, 'status' => 'failed', 'version_label' => 'failed-bundle'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'beta');
expect($channel['readiness'])->toBe('ready')
->and($channel['availability'])->toMatchArray([
'configured' => true,
'missing' => [],
'status' => 'ready',
])
->and($channel['issues'])->toBe([])
->and($channel['missing_values'])->toBe([]);
});
it('surfaces failed latest deployments as critical promotion blockers', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
@@ -232,6 +430,73 @@ it('surfaces failed latest deployments as critical promotion blockers', function
'type' => 'failed_deployment',
'deployment_id' => 57,
'next_action' => 'Open the deployment logs and fix composer dependencies.',
])
->and($channel['issues'][0]['key'])->toBe('failed_deployment:3:api::57:')
->and($channel['issues'][0]['impact'])->toContain('cannot be promoted')
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('retry_deployment');
});
it('offers application target preparation for path routed Coolify failures', function (): void {
$overview = releaseStatusOverviewForTest([
'channels' => [
[
'id' => 30,
'slug' => 'internal',
'name' => 'Internal',
'default_channel' => false,
'enabled' => true,
'frontend_base_url' => 'https://internal.example.test',
'api_base_url' => 'https://api-internal.example.test',
'versions' => releaseStatusReadyVersions(19),
'availability' => [
'configured' => true,
'status' => 'ready',
'missing' => [],
],
],
],
'deployment_targets' => [
['id' => 81, 'channel_id' => 30, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-internal'],
['id' => 82, 'channel_id' => 30, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'legacy-api-service'],
],
'service_sets' => [
[
'id' => 83,
'channel_id' => 30,
'mode' => 'isolated_stack',
'stack' => [
'database' => releaseStatusReadyService('database', 84),
'redis' => releaseStatusReadyService('redis', 85),
'minio' => releaseStatusReadyService('minio', 86),
],
],
],
'deployments' => [
[
'id' => 87,
'channel_id' => 30,
'app' => 'api',
'status' => 'failed',
'failure_summary' => [
'root_cause' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.',
'next_action' => 'Set coolify_resource_type=application or migrate this target before deploying.',
],
],
['id' => 88, 'channel_id' => 30, 'app' => 'frontend', 'status' => 'deployed'],
],
]);
$channel = releaseStatusChannelBySlug($overview, 'internal');
$actions = $channel['issues'][0]['actions'];
$actionIds = array_column($actions, 'id');
$prepareAction = $actions[array_search('prepare_application_target', $actionIds, true)];
expect($actionIds)->toContain('retry_deployment')
->and($actionIds)->toContain('prepare_application_target')
->and($prepareAction)->toMatchArray([
'requires_confirmation' => true,
'requires_input' => false,
'disabled_reason' => '',
]);
});
@@ -289,7 +554,8 @@ it('flags isolated stacks that are missing database Redis and MinIO services', f
'severity' => 'critical',
'issue_type' => 'missing_value',
'missing_key' => 'minio_service',
]);
])
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('complete_data_services');
});
it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void {
@@ -346,5 +612,8 @@ it('maps degraded Coolify targets and reconcile failures to release service issu
'severity' => 'critical',
'type' => 'service_unhealthy',
'service_key' => 'database',
]);
])
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('reconcile_coolify_target')
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('redeploy_coolify_target')
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('restart_coolify_target');
});
@@ -39,6 +39,29 @@ it('verifies GitHub sha256 webhook signatures', function (): void {
expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse();
});
it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void {
$previous = getenv('RELEASE_MANAGER_GATE_TOKEN');
try {
putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret');
$_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret';
$manager = new release_manager();
expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue();
expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse();
expect($manager->verifyReleaseGateToken(''))->toBeFalse();
} finally {
if ($previous === false) {
putenv('RELEASE_MANAGER_GATE_TOKEN');
unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']);
} else {
putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous);
$_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous;
}
}
});
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
@@ -433,6 +456,8 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions');
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events');
@@ -443,9 +468,13 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'");
expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'");
expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'");
expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'");
expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'");
expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'");
expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'");
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'");
expect($schema)->toContain("['stable', 'Stable'");
@@ -457,12 +486,18 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/release/runtime');
expect($route)->toContain('/release/timeline/events');
expect($route)->toContain('/release/github/webhook');
expect($route)->toContain('/release/gate/test-runs');
expect($route)->toContain('releaseGateToken');
expect($route)->toContain('/superuser/releases/config');
expect($route)->toContain('/superuser/releases/github/repositories');
expect($route)->toContain('/superuser/releases/github/branches');
expect($route)->toContain('/superuser/releases/github/commits');
expect($route)->toContain('/superuser/releases/github/test');
expect($route)->toContain('/superuser/releases/channels');
expect($route)->toContain('/superuser/releases/channels/{id}/sync');
expect($route)->toContain('/superuser/releases/test-runs');
expect($route)->toContain('/superuser/releases/operations');
expect($route)->toContain('/superuser/releases/operations/{id}');
expect($route)->toContain('/superuser/releases/channels/{id}/bundle');
expect($route)->toContain('/superuser/releases/assignment-subjects');
expect($route)->toContain('/superuser/releases/assignments');
@@ -473,6 +508,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/superuser/releases/bundles/{id}/deploy');
expect($route)->toContain('/superuser/releases/bundles/{id}/promote');
expect($route)->toContain('/superuser/releases/deployments');
expect($route)->toContain('/superuser/releases/issues/actions');
expect($route)->toContain('/superuser/releases/replay-targets');
expect($route)->toContain('/superuser/releases/timeline/sessions');
expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}');
@@ -484,6 +520,14 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain("requirePermission('superuser_release_manager_replay')");
expect($manager)->toContain('verifyGithubSignature');
expect($manager)->toContain('verifyReleaseGateToken');
expect($manager)->toContain('normalizeReleaseGateInput');
expect($manager)->toContain('release-manifest.json');
expect($manager)->toContain('static_artifact');
expect($manager)->toContain('api_gateway');
expect($manager)->toContain('assertReleaseGatePassedForPromotion');
expect($manager)->toContain('release_gate_required_for_promotion');
expect($manager)->toContain("'public_smoke_required' => true");
expect($manager)->toContain('normalizeGithubRepositoryName');
expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'");
expect($manager)->toContain('releaseConfig');
@@ -512,6 +556,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('searchAssignmentSubjects');
expect($manager)->toContain('publicAssignmentSubjectSuggestion');
expect($manager)->toContain('available_channels');
expect($manager)->toContain("'source' => 'deployment'");
expect($manager)->toContain('chooseRuntimeChannel');
expect($manager)->toContain('requestedRuntimeChannelSlug');
expect($manager)->toContain("status = 'superseded'");
@@ -531,6 +576,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain("'replica_failover' => false");
expect($manager)->toContain('deploymentCanBePromoted');
expect($manager)->toContain('deploymentFailureSummary');
expect($manager)->toContain('runIssueAction');
expect($manager)->toContain('release_issue_action_attempted');
expect($manager)->toContain('prepareReleaseIssueApplicationTarget');
expect($manager)->toContain('coolifyProjectSuggestions');
expect($manager)->toContain('releaseCoolifyServerUuid');
expect($manager)->toContain('coolify_project_uuid');
@@ -575,6 +623,13 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($index)->toContain('X-Release-Trace');
expect($manager)->toContain('X-Release-Channel');
expect($manager)->toContain('normalizeReleaseApiIngressPath');
expect($manager)->toContain('routeSlugForChannel');
expect($manager)->toContain('channelSlugForRoute');
expect($manager)->toContain('syncChannel');
expect($manager)->toContain('runReleaseTest');
expect($manager)->toContain('release_operation_runs');
expect($manager)->toContain('active_channel_app_key');
expect($manager)->toContain('production_shared');
expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels');
});
@@ -657,77 +712,80 @@ it('normalizes channel-prefixed API ingress paths before route dispatch', functi
}
});
it('allows explicit runtime selection of any enabled release channel', function (): void {
$db = $GLOBALS['db'] ?? null;
it('maps the public master API prefix to the stable release channel', function (): void {
$server = $_SERVER;
$get = $_GET;
$context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;
try {
$GLOBALS['db'] = new class {
public function prepare(string $sql): object
{
return new class {
/** @var array<int,mixed> */
private array $params = [];
$_SERVER['REQUEST_URI'] = '/master/api/release/runtime';
$_GET = [];
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
'trace_id' => 'trace-master',
'requested_channel' => '',
'frontend_version' => '',
'backend_version' => 'test',
'request_started_at' => date('c'),
'original_request_uri' => '/master/api/release/runtime',
'normalized_request_uri' => '',
'ingress_prefix_stripped' => false,
];
public function bind_param(string $types, mixed &...$params): void
{
$this->params = $params;
}
$normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']);
public function execute(): void
{
}
public function get_result(): object
{
$slug = (string)($this->params[0] ?? '');
return new class($slug) {
public function __construct(private readonly string $slug)
{
}
public function fetch_all(int $mode): array
{
if ($this->slug !== 'internal') {
return [];
}
return [[
'id' => 4,
'slug' => 'internal',
'name' => 'Internal',
'enabled' => 1,
'default_channel' => 0,
'deleted_at' => null,
]];
}
};
}
};
}
};
$manager = new release_manager();
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$chooseRuntimeChannel->setAccessible(true);
$channel = $chooseRuntimeChannel->invoke($manager, [
'id' => 1,
'slug' => 'stable',
'enabled' => 1,
'default_channel' => 1,
], [], 'internal');
expect($channel['slug'])->toBe('internal');
expect($normalized)->toMatchArray([
'route_slug' => 'master',
'channel_slug' => 'stable',
'normalized_request_uri' => '/release/runtime',
'normalized_path' => '/release/runtime',
]);
expect($_GET['release_channel'])->toBe('stable');
expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([
'requested_channel' => 'stable',
'release_route_slug' => 'master',
'ingress_prefix_stripped' => true,
]);
} finally {
if ($db === null) {
unset($GLOBALS['db']);
$_SERVER = $server;
$_GET = $get;
if ($context === null) {
unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
} else {
$GLOBALS['db'] = $db;
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
}
}
});
it('uses master as the public route slug for the stable release channel', function (): void {
expect(release_manager::routeSlugForChannel('stable'))->toBe('master');
expect(release_manager::routeSlugForChannel('beta'))->toBe('beta');
expect(release_manager::channelSlugForRoute('master'))->toBe('stable');
expect(release_manager::channelSlugForRoute('internal'))->toBe('internal');
});
it('ignores explicit runtime selection for channels outside the principal channel set', function (): void {
$manager = new release_manager();
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$chooseRuntimeChannel->setAccessible(true);
$stable = [
'id' => 1,
'slug' => 'stable',
'enabled' => 1,
'default_channel' => 1,
];
$internal = [
'id' => 4,
'slug' => 'internal',
'name' => 'Internal',
'enabled' => 1,
'default_channel' => 0,
];
expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable);
expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal);
});
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
$manager = new release_manager();
$availability = new ReflectionMethod(release_manager::class, 'channelAvailability');
@@ -842,11 +900,103 @@ it('requires non-default release channel runtime URLs and preserves load balance
expect($methodSource)->toContain('frontend_base_url');
expect($methodSource)->toContain('api_base_url');
expect($methodSource)->toContain('release_bundle');
expect($methodSource)->not->toContain("missing[] = 'release_bundle'");
expect($methodSource)->toContain('frontend_version');
expect($methodSource)->toContain('api_version');
});
it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void {
$manager = new release_manager();
$endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint');
$endpoint->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
$manual = $endpoint->invoke($manager, [
'app' => 'api',
'channel_slug' => 'beta',
'deploy_context' => [
'endpoint_mode' => 'manual',
'manual_endpoint_host' => 'manual-api.example.test',
'manual_endpoint_port' => '8443',
],
]);
expect($manual)->toMatchArray([
'mode' => 'manual',
'status' => 'resolved',
'host' => 'manual-api.example.test',
'port' => 8443,
'url' => 'https://manual-api.example.test:8443',
'source' => 'manual',
]);
expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [
'endpoint_mode' => 'manual',
'manual_endpoint_host' => 'manual-api.example.test',
'manual_endpoint_port' => '8443',
]))->toBe('https://manual-api.example.test:8443');
$fromPublicUrl = $endpoint->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'internal',
'deploy_context' => [
'endpoint_mode' => 'auto',
'coolify_public_url' => 'https://api-v2.truckwash.io',
],
]);
expect($fromPublicUrl)->toMatchArray([
'mode' => 'auto',
'status' => 'resolved',
'host' => 'api-v2.truckwash.io',
'port' => 443,
'url' => 'https://api-v2.truckwash.io/internal/frontend',
'source' => 'coolify_public_url',
]);
$fromHealth = $endpoint->invoke($manager, [
'app' => 'api',
'channel_slug' => 'canary',
'health_url' => 'https://health.example.test/canary/api/ping',
'deploy_context' => [
'endpoint_mode' => 'auto',
'coolify_domain' => 'domain.example.test',
],
]);
expect($fromHealth)->toMatchArray([
'status' => 'resolved',
'host' => 'health.example.test',
'url' => 'https://health.example.test/canary/api',
'source' => 'health_url',
]);
$gateway = $endpoint->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
]),
]);
expect($gateway)->toMatchArray([
'mode' => 'auto',
'status' => 'pending',
'host' => 'gateway.example.test',
'port' => 443,
'url' => 'https://gateway.example.test/beta/frontend',
'source' => 'auto_gateway',
]);
expect($targetPublicBaseUrl->invoke($manager, [
'app' => 'frontend',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
]),
]))->toBe('https://gateway.example.test/beta/frontend');
});
it('exposes release version git commit metadata for runtime channel cards', function (): void {
$manager = new release_manager();
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
@@ -882,8 +1032,7 @@ it('exposes release version git commit metadata for runtime channel cards', func
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
});
it('chooses requested runtime channels from available channels or enabled channel slugs', function (): void {
$db = $GLOBALS['db'] ?? null;
it('only chooses requested runtime channels from channels available to the principal', function (): void {
$manager = new release_manager();
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$choose->setAccessible(true);
@@ -900,43 +1049,20 @@ it('chooses requested runtime channels from available channels or enabled channe
'name' => 'Canary',
'default_channel' => 0,
];
$internal = [
'id' => 3,
'slug' => 'internal',
'name' => 'Internal',
'default_channel' => 0,
'enabled' => 1,
];
try {
$GLOBALS['db'] = new class {
public function prepare(string $sql): object
{
return new class {
public function bind_param(string $types, mixed &...$params): void
{
}
public function execute(): void
{
}
public function get_result(): object
{
return new class {
public function fetch_all(int $mode): array
{
return [];
}
};
}
};
}
};
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
} finally {
if ($db === null) {
unset($GLOBALS['db']);
} else {
$GLOBALS['db'] = $db;
}
}
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable);
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
expect($internal['enabled'])->toBe(1);
});
it('normalizes release assignment subject suggestions without leaking private fields', function (): void {
@@ -551,8 +551,9 @@ it('wires MinIO replication through routes and bootstrap snapshots', function ()
expect($manager)->toContain('shouldAdvanceActiveProvisionDuringRefresh');
expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])');
expect($manager)->toContain('stale targets do not keep a healthy current target below 100%');
expect($manager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
expect($manager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
$normalizedManager = str_replace("\r\n", "\n", $manager);
expect($normalizedManager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
expect($normalizedManager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
expect($manager)->toContain('private function minioBucketStats(');
expect($manager)->toContain("'minio' => [");
expect($routes)->toContain("/superuser/replication/minio");
@@ -68,10 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
expect($composeSource)->toContain('version: "2.4"');
expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("minio:\n condition: service_started");
expect($composeSource)->toContain("mariadb:\n condition: service_started");
$normalizedComposeSource = str_replace("\r\n", "\n", $composeSource);
expect($normalizedComposeSource)->toContain('version: "2.4"');
expect($normalizedComposeSource)->toContain('condition: service_healthy');
expect($normalizedComposeSource)->toContain("minio:\n condition: service_started");
expect($normalizedComposeSource)->toContain("mariadb:\n condition: service_started");
expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
@@ -623,12 +623,266 @@ it('builds guided simulator debug payload with blockers and canvas annotations',
->and($debug['questions'][0]['state'])->toBe('missing')
->and($debug['questions'][0]['answer_source'])->toBe('override')
->and($debug['tasks'][0]['state'])->toBe('blocked')
->and($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.')
->and($debug['tasks'][0]['causes'][0])->toMatchArray([
'kind' => 'question',
'id' => 11,
'label' => 'Are mirrors folded?',
'expected' => true,
'actual' => null,
])
->and($debug['dynamic_image_buttons'][0])->toMatchArray([
'kind' => 'dynamic_image_button',
'label' => '1',
'state' => 'hidden',
'task_id' => 41,
])
->and(array_column($debug['decisions'], 'kind'))->toContain('question')
->and(array_column($debug['decisions'], 'kind'))->toContain('task')
->and(array_column($debug['decisions'], 'kind'))->toContain('signal')
->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button')
->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions')
->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay')
->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning')
->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
});
it('adds program picker before mapped machine buttons in simulator debug decisions', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => [
'id' => 7,
'department' => 6,
'name' => 'Lane 7',
'relay_machine_id' => 'M-7',
'relay_machine_program_picker_id' => 'PICKER-7',
],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 4,
'answers' => [],
'questions' => [],
'tasks' => [
['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
],
'allowed_services' => ['PROGRAM_PICKER', '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' => [],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
],
],
'debug_candidates' => [
'questions' => [],
'conditions' => [],
'rules' => [],
'tasks' => [
['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
],
],
], [
'lookups' => [
'labels' => [
'departments' => ['6' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'vehicle_types' => ['4' => 'Program 4'],
'machine_types' => ['1001' => 'Portal'],
'tasks' => ['41' => 'Choose program', '42' => 'Press first button'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
],
],
],
],
]);
$dynamicImageDecisions = array_values(array_filter(
$debug['decisions'],
static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button'
));
expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0])
->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0'])
->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']);
});
it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => [
'id' => 7,
'department' => 6,
'name' => 'Lane 7',
'relay_machine_id' => 'M-7',
],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 2,
'answers' => [11 => false],
'answer_sources' => [11 => 'override'],
'questions' => [],
'tasks' => [],
'allowed_services' => [],
'machine_available' => true,
'all_visible_questions_answered' => true,
'allowed' => false,
'config_version_id' => 90,
'config_source' => 'draft',
'evaluation_trace' => [
'visible_question_ids' => [11],
'visibility_condition_results' => [21 => false],
'condition_results' => [21 => false],
'visibility_expression_traces' => [
21 => [
'type' => 'condition',
'condition_id' => 21,
'result' => false,
'expression' => [
'type' => 'group',
'operator' => 'ALL',
'result' => false,
'children' => [
[
'type' => 'predicate',
'subject_type' => 'question',
'subject_id' => 11,
'operator' => 'IS_TRUE',
'actual_value' => false,
'result' => false,
'reason' => 'Predicate did not pass.',
],
],
'reason' => 'Group did not pass.',
],
],
],
'condition_expression_traces' => [
21 => [
'type' => 'condition',
'condition_id' => 21,
'result' => false,
'expression' => [
'type' => 'predicate',
'subject_type' => 'question',
'subject_id' => 11,
'operator' => 'IS_TRUE',
'actual_value' => false,
'result' => false,
'reason' => 'Predicate did not pass.',
],
],
],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false],
],
],
'debug_candidates' => [
'questions' => [
['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1],
['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2],
],
'conditions' => [
['id' => 21, 'name' => 'Trailer present', 'condition_id' => null],
],
'rules' => [
['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'],
],
'tasks' => [
['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => ['start'], '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,
],
],
'visible_answers' => [11 => false],
],
], [
'lookups' => [
'labels' => [
'departments' => ['6' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'vehicle_types' => ['2' => 'Forvogn'],
'machine_types' => ['1001' => 'Portal'],
'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'],
'conditions' => ['21' => 'Trailer present'],
'tasks' => ['41' => 'Fold mirrors'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
],
],
],
],
]);
$hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? [];
$taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? [];
$actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? [];
$signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? [];
$buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? [];
expect($hiddenQuestion['state'])->toBe('hidden')
->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.')
->and($debug['conditions'][0]['causes'][0])->toMatchArray([
'kind' => 'question',
'id' => 11,
'label' => 'Are mirrors folded?',
'expected' => true,
'actual' => false,
])
->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.')
->and($actionDecision['state'])->toBe('skipped')
->and($actionDecision['causes'][0])->toMatchArray([
'kind' => 'condition',
'id' => 21,
'label' => 'Trailer present',
'expected' => true,
'actual' => false,
])
->and($signalDecision['causes'][0]['reason'])->toContain('blocked')
->and($buttonDecision['state'])->toBe('hidden')
->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors');
});
it('projects visible question answer paths into grouped task service and signal outcomes', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array {