Add department_selfserve_path_confirmations table and enhance PingApiTest
Introduce a new database table `department_selfserve_path_confirmations` to store path confirmations related to department configurations. Update `PingApiTest` to verify additional keys, ensuring `backend_version` and `api_commit_sha` are checked in the response.
This commit is contained in:
@@ -253,3 +253,27 @@ jobs:
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
curl --fail --show-error --silent \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -132,6 +132,7 @@ class release_manager
|
||||
];
|
||||
|
||||
private bool $schemaEnsured = false;
|
||||
private array $inProcessPassedReleaseGates = [];
|
||||
|
||||
public static function initializeRequestContext(): array
|
||||
{
|
||||
@@ -813,6 +814,22 @@ class release_manager
|
||||
]);
|
||||
$statuses[] = 'passed';
|
||||
|
||||
if ($this->releaseGateAutoSyncRequested($gateInput)) {
|
||||
foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) {
|
||||
$this->recordOperationStep(
|
||||
$operationId,
|
||||
(string)$autoSyncStep['step_key'],
|
||||
(string)$autoSyncStep['label'],
|
||||
(string)$autoSyncStep['status'],
|
||||
$autoSyncStep['message'] ?? null,
|
||||
$autoSyncStep['diagnostic'] ?? null,
|
||||
$autoSyncStep['solution_hint'] ?? null,
|
||||
is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : []
|
||||
);
|
||||
$statuses[] = (string)$autoSyncStep['status'];
|
||||
}
|
||||
}
|
||||
|
||||
if (($gateInput['required_checks'] ?? []) !== []) {
|
||||
$this->recordOperationStep(
|
||||
$operationId,
|
||||
@@ -905,6 +922,45 @@ class release_manager
|
||||
$finalStatus = in_array('failed', $statuses, true)
|
||||
? 'failed'
|
||||
: (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
|
||||
|
||||
if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) {
|
||||
try {
|
||||
$this->inProcessPassedReleaseGates[$operationId] = [
|
||||
'channel_id' => $channel !== null ? (int)$channel['id'] : null,
|
||||
'release_gate' => $gateInput,
|
||||
];
|
||||
$autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId);
|
||||
$autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed');
|
||||
$this->recordOperationStep(
|
||||
$operationId,
|
||||
'auto_sync',
|
||||
'Automatic container update',
|
||||
$autoSyncStepStatus,
|
||||
(string)($autoSyncResult['message'] ?? 'Automatic container update completed.'),
|
||||
$autoSyncResult['diagnostic'] ?? null,
|
||||
$autoSyncResult['solution_hint'] ?? null,
|
||||
$autoSyncResult
|
||||
);
|
||||
$statuses[] = $autoSyncStepStatus;
|
||||
} catch (Throwable $throwable) {
|
||||
$this->recordOperationStep(
|
||||
$operationId,
|
||||
'auto_sync',
|
||||
'Automatic container update',
|
||||
'failed',
|
||||
'Automatic container update failed after the release gate passed.',
|
||||
$throwable->getMessage(),
|
||||
'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.',
|
||||
$gateInput
|
||||
);
|
||||
$statuses[] = 'failed';
|
||||
}
|
||||
|
||||
$finalStatus = in_array('failed', $statuses, true)
|
||||
? 'failed'
|
||||
: (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
|
||||
}
|
||||
|
||||
$this->completeOperationRun(
|
||||
$operationId,
|
||||
$finalStatus,
|
||||
@@ -945,11 +1001,27 @@ class release_manager
|
||||
$apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io'));
|
||||
$requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl);
|
||||
$routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master';
|
||||
$app = '';
|
||||
if (trim((string)($input['app'] ?? '')) !== '') {
|
||||
try {
|
||||
$app = $this->normalizeApp((string)$input['app']);
|
||||
} catch (Throwable) {
|
||||
$app = '';
|
||||
}
|
||||
}
|
||||
$repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? ''));
|
||||
$branch = trim((string)($input['branch'] ?? ''));
|
||||
$workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? ''));
|
||||
|
||||
return [
|
||||
'environment_url' => $environmentUrl,
|
||||
'channel_slug' => $channelSlug,
|
||||
'route_slug' => $routeSlug,
|
||||
'app' => $app,
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
|
||||
'workflow_url' => $workflowUrl,
|
||||
'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128),
|
||||
'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128),
|
||||
'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))),
|
||||
@@ -984,6 +1056,70 @@ class release_manager
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function releaseGateAutoSyncRequested(array $gateInput): bool
|
||||
{
|
||||
return (bool)($gateInput['auto_sync'] ?? false);
|
||||
}
|
||||
|
||||
private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
|
||||
{
|
||||
$steps = [];
|
||||
$context = [
|
||||
'channel_slug' => $gateInput['channel_slug'] ?? null,
|
||||
'app' => $gateInput['app'] ?? null,
|
||||
'repository' => $gateInput['repository'] ?? null,
|
||||
'branch' => $gateInput['branch'] ?? null,
|
||||
'expected_commit' => $gateInput['expected_commit'] ?? null,
|
||||
'workflow_url' => $gateInput['workflow_url'] ?? null,
|
||||
];
|
||||
|
||||
if ($channel === null) {
|
||||
$steps[] = [
|
||||
'step_key' => 'auto_sync_channel',
|
||||
'label' => 'Automatic update channel',
|
||||
'status' => 'failed',
|
||||
'message' => 'Automatic container updates require a release channel.',
|
||||
'diagnostic' => 'The gate payload did not resolve to a configured release channel.',
|
||||
'solution_hint' => 'Pass channel_slug from CI, for example stable for master.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
if (trim((string)($gateInput['app'] ?? '')) === '') {
|
||||
$steps[] = [
|
||||
'step_key' => 'auto_sync_app',
|
||||
'label' => 'Automatic update app',
|
||||
'status' => 'failed',
|
||||
'message' => 'Automatic container updates require an app.',
|
||||
'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.',
|
||||
'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
if (trim((string)($gateInput['expected_commit'] ?? '')) === '') {
|
||||
$steps[] = [
|
||||
'step_key' => 'auto_sync_commit',
|
||||
'label' => 'Automatic update commit',
|
||||
'status' => 'failed',
|
||||
'message' => 'Automatic container updates require the CI-verified commit SHA.',
|
||||
'diagnostic' => 'expected_commit was empty.',
|
||||
'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
if ($steps === []) {
|
||||
$steps[] = [
|
||||
'step_key' => 'auto_sync_inputs',
|
||||
'label' => 'Automatic update inputs',
|
||||
'status' => 'passed',
|
||||
'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.',
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
return $steps;
|
||||
}
|
||||
|
||||
private function releaseGateStringArray(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
@@ -1040,7 +1176,7 @@ class release_manager
|
||||
return $steps;
|
||||
}
|
||||
|
||||
private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null): void
|
||||
private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void
|
||||
{
|
||||
if (!$this->releaseGateRequiredForPromotion()) {
|
||||
return;
|
||||
@@ -1048,6 +1184,24 @@ class release_manager
|
||||
|
||||
$expectedCommit = trim((string)$expectedCommit);
|
||||
$buildId = trim((string)$buildId);
|
||||
$app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : '';
|
||||
foreach ($this->inProcessPassedReleaseGates as $inProcessGate) {
|
||||
if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) {
|
||||
continue;
|
||||
}
|
||||
$gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : [];
|
||||
if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
|
||||
continue;
|
||||
}
|
||||
if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
|
||||
continue;
|
||||
}
|
||||
if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) {
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $this->selectRows(
|
||||
"SELECT id, context_json, completed_at
|
||||
FROM release_operation_runs
|
||||
@@ -1064,6 +1218,9 @@ class release_manager
|
||||
foreach ($rows as $row) {
|
||||
$context = json_decode((string)($row['context_json'] ?? ''), true);
|
||||
$gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : [];
|
||||
if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
|
||||
continue;
|
||||
}
|
||||
if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
|
||||
continue;
|
||||
}
|
||||
@@ -1077,6 +1234,35 @@ class release_manager
|
||||
throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.');
|
||||
}
|
||||
|
||||
private function releaseGateAppMatches(array $gate, string $app): bool
|
||||
{
|
||||
$app = $this->normalizeApp($app);
|
||||
$gateApp = trim((string)($gate['app'] ?? ''));
|
||||
if ($gateApp !== '') {
|
||||
try {
|
||||
return $this->normalizeApp($gateApp) === $app;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$gateApps = $this->releaseGateStringArray($gate['apps'] ?? []);
|
||||
if ($gateApps !== []) {
|
||||
foreach ($gateApps as $value) {
|
||||
try {
|
||||
if ($this->normalizeApp($value) === $app) {
|
||||
return true;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gates recorded before app-specific payloads existed were frontend release gates.
|
||||
return $app === 'frontend';
|
||||
}
|
||||
|
||||
private function releaseGateRequiredForPromotion(): bool
|
||||
{
|
||||
$envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? '')));
|
||||
@@ -1362,14 +1548,92 @@ class release_manager
|
||||
|
||||
private function releaseGateCommitMatches(string $actual, string $expected): bool
|
||||
{
|
||||
$expected = trim($expected);
|
||||
$expected = strtolower(trim($expected));
|
||||
if ($expected === '') {
|
||||
return true;
|
||||
}
|
||||
$actual = trim($actual);
|
||||
$actual = strtolower(trim($actual));
|
||||
return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected));
|
||||
}
|
||||
|
||||
private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array
|
||||
{
|
||||
$app = $this->normalizeApp($app);
|
||||
$baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app)
|
||||
?? $this->releaseTargetPublicBaseUrl($target);
|
||||
if ($baseUrl === null || $baseUrl === '') {
|
||||
throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app)));
|
||||
}
|
||||
|
||||
$timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300)));
|
||||
$pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10)));
|
||||
$deadline = time() + $timeout;
|
||||
$attempts = 0;
|
||||
$lastMessage = 'Readiness verification did not run.';
|
||||
|
||||
do {
|
||||
$attempts++;
|
||||
try {
|
||||
if ($app === 'frontend') {
|
||||
$context = $this->releaseStaticArtifactAttempt([
|
||||
'environment_url' => $baseUrl,
|
||||
'expected_commit' => $expectedCommit,
|
||||
'build_id' => (string)($options['build_id'] ?? ''),
|
||||
'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']),
|
||||
]);
|
||||
$context['app'] = $app;
|
||||
$context['base_url'] = $baseUrl;
|
||||
$context['attempts'] = $attempts;
|
||||
return $context;
|
||||
}
|
||||
|
||||
$json = $this->releaseGateFetchJson($baseUrl, 'ping');
|
||||
$payload = $json['json'];
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
throw new RuntimeException('API ping returned success=false.');
|
||||
}
|
||||
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
|
||||
$actualCommit = (string)(
|
||||
$data['api_commit_sha']
|
||||
?? $data['backend_commit_sha']
|
||||
?? $data['commit_sha']
|
||||
?? $data['backend_version']
|
||||
?? ''
|
||||
);
|
||||
if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'API ping commit %s did not match expected commit %s.',
|
||||
$actualCommit !== '' ? $actualCommit : '(missing)',
|
||||
$expectedCommit
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
'app' => $app,
|
||||
'base_url' => $baseUrl,
|
||||
'path' => 'ping',
|
||||
'status' => $json['status'] ?? null,
|
||||
'commit_sha' => $actualCommit,
|
||||
'attempts' => $attempts,
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
$lastMessage = $throwable->getMessage();
|
||||
if (time() >= $deadline) {
|
||||
break;
|
||||
}
|
||||
sleep($pollInterval);
|
||||
}
|
||||
} while (time() <= $deadline);
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'%s container readiness did not match commit %s after %d attempts: %s',
|
||||
strtoupper($app),
|
||||
$expectedCommit,
|
||||
$attempts,
|
||||
$lastMessage
|
||||
));
|
||||
}
|
||||
|
||||
private function releaseGateRejectsHtml(string $assetUrl): bool
|
||||
{
|
||||
return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1;
|
||||
@@ -1401,8 +1665,11 @@ class release_manager
|
||||
$requestedApp = $this->normalizeApp((string)$options['app']);
|
||||
}
|
||||
$apps = $requestedApp !== '' ? [$requestedApp] : self::APPS;
|
||||
$branch = self::releaseBranchForChannel($channel);
|
||||
$branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
|
||||
$routeSlug = self::routeSlugForChannel((string)$channel['slug']);
|
||||
$requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? ''));
|
||||
$commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest';
|
||||
$requireReadiness = $this->toBool($options['require_readiness'] ?? false);
|
||||
|
||||
$operationId = $this->createOperationRun('channel_sync', [
|
||||
'subject_type' => 'channel',
|
||||
@@ -1417,6 +1684,13 @@ class release_manager
|
||||
'branch' => $branch,
|
||||
'apps' => $apps,
|
||||
'source' => $options['source'] ?? 'manual',
|
||||
'repository' => $options['repository'] ?? null,
|
||||
'commit_mode' => $commitMode,
|
||||
'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null,
|
||||
'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null,
|
||||
'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
|
||||
'auto_sync_event' => $options['auto_sync_event'] ?? null,
|
||||
'gate_operation_id' => $options['gate_operation_id'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -1512,13 +1786,18 @@ class release_manager
|
||||
}
|
||||
|
||||
$repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
|
||||
$requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? ''));
|
||||
if ($requestedRepository !== '') {
|
||||
$repository = $requestedRepository;
|
||||
}
|
||||
if ($repository === '') {
|
||||
$repository = self::defaultRepositoryForApp($app);
|
||||
}
|
||||
$access = $this->githubRepositoryAccess([
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'commit_mode' => 'latest',
|
||||
'commit_sha' => $requestedCommitSha,
|
||||
'commit_mode' => $commitMode,
|
||||
]);
|
||||
if (!($access['ok'] ?? false)) {
|
||||
$this->recordOperationStep(
|
||||
@@ -1567,20 +1846,37 @@ class release_manager
|
||||
'app' => $app,
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'commit_mode' => 'latest',
|
||||
'commit_mode' => $commitMode,
|
||||
'commit_sha' => $commitSha,
|
||||
'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'),
|
||||
'build_url' => $options['build_url'] ?? null,
|
||||
'metadata' => [
|
||||
'release_operation_id' => $operationId,
|
||||
'sync_source' => $options['source'] ?? 'manual',
|
||||
'webhook_commit_sha' => $options['commit_sha'] ?? null,
|
||||
'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
|
||||
'gate_operation_id' => $options['gate_operation_id'] ?? null,
|
||||
'workflow_url' => $options['workflow_url'] ?? null,
|
||||
],
|
||||
], $actorUserId);
|
||||
$deployments[] = $deployment;
|
||||
if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) {
|
||||
if ($requireReadiness) {
|
||||
$readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options);
|
||||
$this->recordOperationStep(
|
||||
$operationId,
|
||||
$app . '_readiness',
|
||||
strtoupper($app) . ' container readiness',
|
||||
'passed',
|
||||
sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
||||
null,
|
||||
null,
|
||||
$readiness
|
||||
);
|
||||
}
|
||||
$promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId);
|
||||
$deployment = $promoted['deployment'] ?? $deployment;
|
||||
}
|
||||
$deployments[] = $deployment;
|
||||
$this->recordOperationStep(
|
||||
$operationId,
|
||||
$app . '_deploy',
|
||||
@@ -1627,6 +1923,315 @@ class release_manager
|
||||
return $operation;
|
||||
}
|
||||
|
||||
private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array
|
||||
{
|
||||
if ($channel === null) {
|
||||
throw new RuntimeException('Automatic container update requires a release channel.');
|
||||
}
|
||||
|
||||
$channelId = (int)$channel['id'];
|
||||
$app = $this->normalizeApp((string)($gateInput['app'] ?? ''));
|
||||
$commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
|
||||
if ($commitSha === '') {
|
||||
throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.');
|
||||
}
|
||||
|
||||
$branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
|
||||
$repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? ''));
|
||||
if ($repository === '') {
|
||||
$repository = self::defaultRepositoryForApp($app);
|
||||
}
|
||||
|
||||
$target = $this->deploymentTargetForChannelApp($channelId, $app);
|
||||
if ($target === null) {
|
||||
throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug']));
|
||||
}
|
||||
if (!$this->toBool($target['auto_deploy'] ?? false)) {
|
||||
throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug']));
|
||||
}
|
||||
|
||||
$targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
|
||||
$targetBranch = trim((string)($target['branch'] ?? ''));
|
||||
if ($targetRepository !== '' && $repository !== $targetRepository) {
|
||||
throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository));
|
||||
}
|
||||
if ($targetBranch !== '' && $branch !== $targetBranch) {
|
||||
throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch));
|
||||
}
|
||||
|
||||
$event = $this->upsertReleaseAutoSyncEvent([
|
||||
'channel_id' => $channelId,
|
||||
'app' => $app,
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'commit_sha' => $commitSha,
|
||||
'status' => 'gate_passed',
|
||||
'source' => 'release_gate',
|
||||
'workflow_url' => $gateInput['workflow_url'] ?? null,
|
||||
'gate_operation_id' => $gateOperationId,
|
||||
'metadata' => [
|
||||
'release_gate' => $gateInput,
|
||||
],
|
||||
]);
|
||||
|
||||
$eventId = (int)$event['id'];
|
||||
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => 'Automatic container update is already being processed for this commit.',
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$event = $this->releaseAutoSyncEventById($eventId) ?? $event;
|
||||
if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) {
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => 'Automatic container update was already completed for this commit.',
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
];
|
||||
}
|
||||
|
||||
$current = $this->currentDeploymentForChannelApp($channelId, $app);
|
||||
if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) {
|
||||
$event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
|
||||
'deployment_id' => (int)$current['id'],
|
||||
'metadata' => ['already_current' => true],
|
||||
]);
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
||||
'deployment' => $this->publicDeployment($current),
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
];
|
||||
}
|
||||
|
||||
$event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing');
|
||||
$operation = $this->syncChannel($channelId, $actorUserId, [
|
||||
'app' => $app,
|
||||
'source' => 'release_gate',
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'commit_mode' => 'specific',
|
||||
'commit_sha' => $commitSha,
|
||||
'build_url' => $gateInput['workflow_url'] ?? null,
|
||||
'workflow_url' => $gateInput['workflow_url'] ?? null,
|
||||
'gate_operation_id' => $gateOperationId,
|
||||
'auto_sync_event_id' => $eventId,
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
'require_readiness' => true,
|
||||
'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300,
|
||||
'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10,
|
||||
'build_id' => $gateInput['build_id'] ?? '',
|
||||
'shell_paths' => $gateInput['shell_paths'] ?? [],
|
||||
]);
|
||||
|
||||
if ((string)($operation['status'] ?? '') !== 'passed') {
|
||||
throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.'));
|
||||
}
|
||||
|
||||
$deployment = $this->currentDeploymentForChannelApp($channelId, $app);
|
||||
if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
|
||||
throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug']));
|
||||
}
|
||||
|
||||
$event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
|
||||
'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null,
|
||||
'deployment_id' => (int)$deployment['id'],
|
||||
'metadata' => [
|
||||
'sync_operation_id' => $operation['id'] ?? null,
|
||||
'deployment_id' => $deployment['id'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
||||
'sync_operation' => $operation,
|
||||
'deployment' => $this->publicDeployment($deployment),
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
$this->updateReleaseAutoSyncEvent($eventId, 'failed', [
|
||||
'error_message' => $throwable->getMessage(),
|
||||
]);
|
||||
throw $throwable;
|
||||
} finally {
|
||||
$this->releaseReleaseAutoSyncLock($eventId);
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertReleaseAutoSyncEvent(array $input): array
|
||||
{
|
||||
$channelId = (int)$input['channel_id'];
|
||||
$app = $this->normalizeApp((string)$input['app']);
|
||||
$repository = self::normalizeGithubRepositoryName((string)$input['repository']);
|
||||
$branch = trim((string)$input['branch']);
|
||||
$commitSha = self::normalizeCommitSha((string)$input['commit_sha']);
|
||||
$status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending';
|
||||
$source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null;
|
||||
$workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512);
|
||||
$gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null);
|
||||
$metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : [];
|
||||
|
||||
if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') {
|
||||
throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.');
|
||||
}
|
||||
|
||||
$existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha);
|
||||
if ($existing === null) {
|
||||
$this->execute(
|
||||
"INSERT INTO release_auto_sync_events (
|
||||
channel_id, app, repository, branch, commit_sha, status, source,
|
||||
workflow_url, gate_operation_id, metadata_json, gate_passed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)",
|
||||
'isssssssiss',
|
||||
[
|
||||
$channelId,
|
||||
$app,
|
||||
$repository,
|
||||
$branch,
|
||||
$commitSha,
|
||||
$status,
|
||||
$source,
|
||||
$workflowUrl,
|
||||
$gateOperationId,
|
||||
self::jsonEncode(self::redactPayload($metadata)),
|
||||
$status,
|
||||
]
|
||||
);
|
||||
return $this->releaseAutoSyncEventById($this->insertId()) ?? [];
|
||||
}
|
||||
|
||||
$existingStatus = (string)($existing['status'] ?? 'pending');
|
||||
if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) {
|
||||
$status = $existingStatus;
|
||||
}
|
||||
if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) {
|
||||
$status = $existingStatus;
|
||||
}
|
||||
|
||||
$this->execute(
|
||||
"UPDATE release_auto_sync_events
|
||||
SET status = ?,
|
||||
source = COALESCE(?, source),
|
||||
workflow_url = COALESCE(?, workflow_url),
|
||||
gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id),
|
||||
error_message = NULL,
|
||||
metadata_json = ?,
|
||||
gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?",
|
||||
'sssissi',
|
||||
[
|
||||
$status,
|
||||
$source,
|
||||
$workflowUrl,
|
||||
$gateOperationId ?? 0,
|
||||
self::jsonEncode(self::redactPayload($metadata)),
|
||||
$status,
|
||||
(int)$existing['id'],
|
||||
]
|
||||
);
|
||||
|
||||
return $this->releaseAutoSyncEventById((int)$existing['id']) ?? [];
|
||||
}
|
||||
|
||||
private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array
|
||||
{
|
||||
$status = self::safeIdentifier($status, 32) ?: 'pending';
|
||||
$syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null);
|
||||
$deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null);
|
||||
$errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null;
|
||||
$metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null;
|
||||
$this->execute(
|
||||
"UPDATE release_auto_sync_events
|
||||
SET status = ?,
|
||||
sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id),
|
||||
deployment_id = COALESCE(NULLIF(?, 0), deployment_id),
|
||||
error_message = ?,
|
||||
metadata_json = COALESCE(?, metadata_json),
|
||||
synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END,
|
||||
promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END,
|
||||
failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?",
|
||||
'siisssssi',
|
||||
[$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id]
|
||||
);
|
||||
|
||||
return $this->releaseAutoSyncEventById($id) ?? [];
|
||||
}
|
||||
|
||||
private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array
|
||||
{
|
||||
return $this->selectOne(
|
||||
"SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
|
||||
FROM release_auto_sync_events e
|
||||
INNER JOIN release_channels c ON c.id = e.channel_id
|
||||
WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ?
|
||||
LIMIT 1",
|
||||
'issss',
|
||||
[$channelId, $app, $repository, $branch, $commitSha]
|
||||
);
|
||||
}
|
||||
|
||||
private function releaseAutoSyncEventById(int $id): ?array
|
||||
{
|
||||
return $this->selectOne(
|
||||
"SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
|
||||
FROM release_auto_sync_events e
|
||||
INNER JOIN release_channels c ON c.id = e.channel_id
|
||||
WHERE e.id = ?
|
||||
LIMIT 1",
|
||||
'i',
|
||||
[$id]
|
||||
);
|
||||
}
|
||||
|
||||
private function publicReleaseAutoSyncEvent(array $event): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)($event['id'] ?? 0),
|
||||
'channel_id' => (int)($event['channel_id'] ?? 0),
|
||||
'channel_slug' => $event['channel_slug'] ?? null,
|
||||
'channel_name' => $event['channel_name'] ?? null,
|
||||
'app' => (string)($event['app'] ?? ''),
|
||||
'repository' => (string)($event['repository'] ?? ''),
|
||||
'branch' => (string)($event['branch'] ?? ''),
|
||||
'commit_sha' => (string)($event['commit_sha'] ?? ''),
|
||||
'status' => (string)($event['status'] ?? 'unknown'),
|
||||
'source' => $event['source'] ?? null,
|
||||
'workflow_url' => $event['workflow_url'] ?? null,
|
||||
'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null,
|
||||
'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null,
|
||||
'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null,
|
||||
'error_message' => $event['error_message'] ?? null,
|
||||
'metadata' => self::jsonDecode($event['metadata_json'] ?? null),
|
||||
'received_at' => $event['received_at'] ?? null,
|
||||
'gate_passed_at' => $event['gate_passed_at'] ?? null,
|
||||
'synced_at' => $event['synced_at'] ?? null,
|
||||
'promoted_at' => $event['promoted_at'] ?? null,
|
||||
'failed_at' => $event['failed_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function acquireReleaseAutoSyncLock(int $eventId): bool
|
||||
{
|
||||
$lockName = 'release_auto_sync:' . $eventId;
|
||||
$row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]);
|
||||
return (int)($row['acquired'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
private function releaseReleaseAutoSyncLock(int $eventId): void
|
||||
{
|
||||
try {
|
||||
$this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public function runIssueAction(array $input, ?int $actorUserId = null): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
@@ -4111,8 +4716,17 @@ class release_manager
|
||||
$this->assertReleaseGatePassedForPromotion(
|
||||
$channelId,
|
||||
(string)($bundle['frontend_commit_sha'] ?? ''),
|
||||
null
|
||||
null,
|
||||
'frontend'
|
||||
);
|
||||
if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') {
|
||||
$this->assertReleaseGatePassedForPromotion(
|
||||
$channelId,
|
||||
(string)$bundle['api_commit_sha'],
|
||||
null,
|
||||
'api'
|
||||
);
|
||||
}
|
||||
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
|
||||
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
|
||||
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
|
||||
@@ -4423,8 +5037,9 @@ class release_manager
|
||||
}
|
||||
$this->assertReleaseGatePassedForPromotion(
|
||||
$channelId,
|
||||
(string)($deployment['app'] ?? '') === 'frontend' ? (string)($deployment['commit_sha'] ?? '') : '',
|
||||
null
|
||||
(string)($deployment['commit_sha'] ?? ''),
|
||||
null,
|
||||
(string)($deployment['app'] ?? '')
|
||||
);
|
||||
$current = $this->currentChannelVersionRow($channelId);
|
||||
$frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null;
|
||||
@@ -4851,43 +5466,29 @@ class release_manager
|
||||
[$repository, $branch]
|
||||
);
|
||||
|
||||
$deployments = [];
|
||||
$operations = [];
|
||||
if ($mappedChannel !== null && $mappedApp !== '') {
|
||||
$operations[] = $this->syncChannel((int)$mappedChannel['id'], null, [
|
||||
'app' => $mappedApp,
|
||||
'source' => 'github_webhook',
|
||||
'repository' => $repository,
|
||||
'commit_sha' => $commitSha,
|
||||
'build_url' => (string)($payload['compare'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
$autoSyncEvents = [];
|
||||
foreach ($targets as $target) {
|
||||
if ($mappedChannel !== null && $mappedApp !== '') {
|
||||
continue;
|
||||
}
|
||||
$deployments[] = $this->startDeployment([
|
||||
'target_id' => (int)$target['id'],
|
||||
$autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([
|
||||
'channel_id' => (int)$target['channel_id'],
|
||||
'app' => (string)$target['app'],
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'commit_sha' => $commitSha,
|
||||
'version_label' => substr($commitSha, 0, 12),
|
||||
'build_url' => (string)($payload['compare'] ?? ''),
|
||||
'status' => 'pending',
|
||||
'source' => 'github_webhook',
|
||||
'workflow_url' => (string)($payload['compare'] ?? ''),
|
||||
'metadata' => [
|
||||
'github_event' => $event,
|
||||
'head_commit' => self::redactPayload($payload['head_commit'] ?? []),
|
||||
],
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
$this->audit(null, null, 'github_webhook_processed', null, 'info', [
|
||||
'repository' => $repository,
|
||||
'branch' => $branch,
|
||||
'commit_sha' => $commitSha,
|
||||
'deployment_count' => count($deployments),
|
||||
'auto_sync_event_count' => count($autoSyncEvents),
|
||||
]);
|
||||
|
||||
return [
|
||||
@@ -4897,8 +5498,9 @@ class release_manager
|
||||
'commit_sha' => $commitSha,
|
||||
'mapped_channel_slug' => $mappedChannelSlug,
|
||||
'mapped_app' => $mappedApp !== '' ? $mappedApp : null,
|
||||
'operations' => $operations,
|
||||
'deployments' => $deployments,
|
||||
'auto_sync_events' => $autoSyncEvents,
|
||||
'operations' => [],
|
||||
'deployments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7596,6 +8198,16 @@ class release_manager
|
||||
$commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : [];
|
||||
$commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha));
|
||||
$commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl);
|
||||
if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) {
|
||||
$comparison = $this->githubRequest(
|
||||
'GET',
|
||||
'/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha)
|
||||
);
|
||||
$comparisonStatus = (string)($comparison['status'] ?? '');
|
||||
if (!in_array($comparisonStatus, ['behind', 'identical'], true)) {
|
||||
throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
|
||||
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
app VARCHAR(16) NOT NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(128) NOT NULL,
|
||||
commit_sha VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
source VARCHAR(64) NULL,
|
||||
workflow_url VARCHAR(512) NULL,
|
||||
gate_operation_id BIGINT UNSIGNED NULL,
|
||||
sync_operation_id BIGINT UNSIGNED NULL,
|
||||
deployment_id BIGINT UNSIGNED NULL,
|
||||
error_message TEXT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gate_passed_at DATETIME NULL,
|
||||
synced_at DATETIME NULL,
|
||||
promoted_at DATETIME NULL,
|
||||
failed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
|
||||
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
|
||||
INDEX idx_release_auto_sync_gate (gate_operation_id),
|
||||
INDEX idx_release_auto_sync_sync (sync_operation_id),
|
||||
INDEX idx_release_auto_sync_deployment (deployment_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_service_sets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
@@ -408,6 +437,7 @@ class release_manager_schema_bootstrap
|
||||
'release_versions',
|
||||
'release_channel_versions',
|
||||
'release_assignments',
|
||||
'release_auto_sync_events',
|
||||
'release_service_sets',
|
||||
'release_deployments',
|
||||
'release_bundles',
|
||||
|
||||
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
|
||||
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
lane_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
config_version_id INT NULL,
|
||||
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
path_signature VARCHAR(128) NOT NULL,
|
||||
result_signature VARCHAR(128) NOT NULL,
|
||||
answers_json JSON NOT NULL,
|
||||
result_json JSON NOT NULL,
|
||||
scope_json JSON NULL,
|
||||
confirmed_by INT NULL,
|
||||
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
stale_reason VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
|
||||
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5460,7 +5460,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Bulk save all-in-one self-serve studio graph changes
|
||||
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
|
||||
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
|
||||
operationId: saveSelfserveStudioGraph
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -5617,6 +5617,29 @@ paths:
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/path-confirmations:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Confirm or reset a projected self-serve studio path
|
||||
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
|
||||
operationId: confirmSelfserveStudioPath
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Path confirmation updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/publish:
|
||||
post:
|
||||
tags:
|
||||
@@ -17898,10 +17921,10 @@ components:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [create, update, delete, connect, disconnect, reorder]
|
||||
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
|
||||
entity:
|
||||
type: string
|
||||
enum: [question, condition, task]
|
||||
enum: [question, condition, task, action, path]
|
||||
description: Standalone rule operations are not accepted for schema_version 2 drafts.
|
||||
id:
|
||||
type: integer
|
||||
@@ -18288,6 +18311,8 @@ components:
|
||||
items: { type: integer }
|
||||
max_states: { type: integer }
|
||||
path_sample_count: { type: integer }
|
||||
confirmations:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
|
||||
outcomes:
|
||||
type: array
|
||||
items:
|
||||
@@ -18303,6 +18328,15 @@ components:
|
||||
type: boolean
|
||||
progress:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathProgress'
|
||||
confirmations:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
|
||||
removed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
|
||||
|
||||
SelfserveStudioPathProgress:
|
||||
type: object
|
||||
@@ -18388,6 +18422,95 @@ components:
|
||||
node_ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
path_signature: { type: string }
|
||||
result_signature: { type: string }
|
||||
confirmation_status:
|
||||
type: string
|
||||
enum: [unconfirmed, confirmed, stale]
|
||||
confirmed_at:
|
||||
type: string
|
||||
nullable: true
|
||||
confirmed_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
stale_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
SelfserveStudioPathConfirmationSummary:
|
||||
type: object
|
||||
properties:
|
||||
confirmed: { type: integer }
|
||||
unconfirmed: { type: integer }
|
||||
stale: { type: integer }
|
||||
removed: { type: integer }
|
||||
total: { type: integer }
|
||||
|
||||
SelfserveStudioPathConfirmationRequest:
|
||||
type: object
|
||||
required: [department, path_signature]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
action:
|
||||
type: string
|
||||
enum: [confirm, reset, delete, clear]
|
||||
default: confirm
|
||||
path_signature: { type: string }
|
||||
result_signature:
|
||||
type: string
|
||||
description: Required when action is confirm.
|
||||
scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
answers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SelfserveStudioPathConfirmation:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
department_id: { type: integer }
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
config_version_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
config_source: { type: string }
|
||||
path_signature: { type: string }
|
||||
result_signature: { type: string }
|
||||
confirmation_status:
|
||||
type: string
|
||||
enum: [unconfirmed, confirmed, stale]
|
||||
answers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
confirmed_at:
|
||||
type: string
|
||||
nullable: true
|
||||
confirmed_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
stale_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
SelfserveStudioPathTask:
|
||||
type: object
|
||||
|
||||
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
|
||||
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'path_signature']);
|
||||
$departmentId = (int)self::getParameter('department');
|
||||
$this->assertDepartmentAccess($user, $departmentId);
|
||||
|
||||
try {
|
||||
$payload = self::getParametersAsArray();
|
||||
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
|
||||
$service = new selfserve_studio_graph();
|
||||
$result = in_array($action, ['delete', 'reset', 'clear'], true)
|
||||
? $service->resetPathConfirmation($departmentId, $payload)
|
||||
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
|
||||
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
|
||||
$response->success($result);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$response->error($exception->getMessage(), 422);
|
||||
}
|
||||
}, [
|
||||
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/publish', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\release_manager;
|
||||
use traits\route_t;
|
||||
|
||||
class pingRoute
|
||||
@@ -15,6 +16,8 @@ class pingRoute
|
||||
$response->success([
|
||||
'message' => 'pong',
|
||||
'time' => date('c'),
|
||||
'backend_version' => release_manager::backendVersion(),
|
||||
'api_commit_sha' => release_manager::backendCommitSha(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@ it('returns the ping contract', function (): void {
|
||||
expect($response->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('message', 'pong')
|
||||
->toHaveKey('time');
|
||||
->toHaveKey('time')
|
||||
->toHaveKey('backend_version')
|
||||
->toHaveKey('api_commit_sha');
|
||||
});
|
||||
|
||||
@@ -62,6 +62,40 @@ it('verifies CI release gate bearer tokens from dedicated release credentials',
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes app-specific release gate auto-sync metadata', function (): void {
|
||||
$manager = new release_manager();
|
||||
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
|
||||
$normalizeGate->setAccessible(true);
|
||||
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
|
||||
$appMatches->setAccessible(true);
|
||||
|
||||
$gate = $normalizeGate->invoke($manager, [
|
||||
'channel_slug' => 'stable',
|
||||
'app' => 'api',
|
||||
'repository' => 'https://github.com/copenhagentruckwash/api.git',
|
||||
'branch' => 'master',
|
||||
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
|
||||
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
|
||||
'auto_sync' => true,
|
||||
], ['slug' => 'stable']);
|
||||
|
||||
expect($gate)->toMatchArray([
|
||||
'channel_slug' => 'stable',
|
||||
'route_slug' => 'master',
|
||||
'app' => 'api',
|
||||
'repository' => 'copenhagentruckwash/api',
|
||||
'branch' => 'master',
|
||||
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
|
||||
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
|
||||
'auto_sync' => true,
|
||||
]);
|
||||
|
||||
expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
|
||||
expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
|
||||
expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
|
||||
expect($appMatches->invoke($manager, [], 'api'))->toBeFalse();
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -727,6 +761,8 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events');
|
||||
expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event');
|
||||
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');
|
||||
@@ -796,6 +832,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($manager)->toContain('verifyGithubSignature');
|
||||
expect($manager)->toContain('verifyReleaseGateToken');
|
||||
expect($manager)->toContain('normalizeReleaseGateInput');
|
||||
expect($manager)->toContain('processReleaseGateAutoSync');
|
||||
expect($manager)->toContain('release_auto_sync_events');
|
||||
expect($manager)->toContain('require_readiness');
|
||||
expect($manager)->toContain('release-manifest.json');
|
||||
expect($manager)->toContain('static_artifact');
|
||||
expect($manager)->toContain('api_gateway');
|
||||
|
||||
@@ -76,6 +76,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
|
||||
expect($content)->toContain('/department/selfserve/studio/simulate:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-outcomes:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-confirmations:');
|
||||
expect($content)->toContain('/department/selfserve/studio/publish:');
|
||||
expect($content)->toContain('/department/selfserve/studio/rollback:');
|
||||
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
|
||||
@@ -86,8 +87,11 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
|
||||
expect($content)->toContain('SelfserveStudioPathOutcomesResponse:');
|
||||
expect($content)->toContain('SelfserveStudioPathResult:');
|
||||
expect($content)->toContain('SelfserveStudioPathProgress:');
|
||||
expect($content)->toContain('SelfserveStudioPathConfirmationRequest:');
|
||||
expect($content)->toContain('SelfserveStudioPathConfirmation:');
|
||||
expect($content)->toContain('projectSelfserveStudioPathOutcomes');
|
||||
expect($content)->toContain('streamSelfserveStudioPathOutcomes');
|
||||
expect($content)->toContain('confirmSelfserveStudioPath');
|
||||
expect($content)->toContain('runSelfserveStudioGatewayAction');
|
||||
});
|
||||
|
||||
|
||||
@@ -108,16 +108,19 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations');
|
||||
expect($studioRoute)->toContain("ini_set('display_errors', '0')");
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/publish');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/rollback');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action');
|
||||
expect($studioRoute)->toContain('projectPathOutcomes');
|
||||
expect($studioRoute)->toContain('confirmPathOutcome');
|
||||
expect($studioRoute)->toContain('modules_shelly_config');
|
||||
expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop');
|
||||
|
||||
expect($studioGraph)->not->toBeFalse();
|
||||
expect($studioGraph)->toContain('department_selfserve_studio_layouts');
|
||||
expect($studioGraph)->toContain('department_selfserve_path_confirmations');
|
||||
expect($studioGraph)->toContain('buildGatewayWorkspace');
|
||||
expect($studioGraph)->toContain('runGatewayAction');
|
||||
expect($studioGraph)->toContain('layout_affects_runtime');
|
||||
|
||||
@@ -50,3 +50,13 @@ it('uses mysql-safe identifiers for self-serve studio virtual hardware storage',
|
||||
expect(strlen($identifier))->toBeLessThanOrEqual(64);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates self-serve studio path confirmation storage', function (): void {
|
||||
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
||||
|
||||
expect($bootstrapContent)->not->toBeFalse();
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations');
|
||||
expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature');
|
||||
});
|
||||
|
||||
@@ -370,6 +370,168 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed
|
||||
expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
|
||||
expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
|
||||
expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
|
||||
expect($studioGraphSource)->toContain('upsert_path');
|
||||
});
|
||||
|
||||
it('upserts path editor answers into generated condition and task config rows', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
|
||||
$method->setAccessible(true);
|
||||
$config = [
|
||||
'schema_version' => 2,
|
||||
'questions' => [
|
||||
['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1],
|
||||
['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2],
|
||||
],
|
||||
'conditions' => [],
|
||||
'rules' => [],
|
||||
'tasks' => [],
|
||||
'actions' => [],
|
||||
'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]],
|
||||
];
|
||||
|
||||
$method->invokeArgs($service, [6, &$config, [
|
||||
'action' => 'upsert_path',
|
||||
'entity' => 'path',
|
||||
'data' => [
|
||||
'path_key' => 'allowed_front',
|
||||
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
|
||||
'answers' => [
|
||||
['question_id' => 11, 'value' => true],
|
||||
['question_id' => 12, 'value' => false],
|
||||
],
|
||||
'result' => [
|
||||
'machine_allowed' => true,
|
||||
'task' => 'Set program',
|
||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
||||
'buttons' => ['program_picker', 'reset', 2, 'start'],
|
||||
'dynamic_images_vehicle_type' => 3,
|
||||
'tasks' => [
|
||||
[
|
||||
'task' => 'Set program',
|
||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
||||
'buttons' => ['program_picker'],
|
||||
'dynamic_images_vehicle_type' => 3,
|
||||
],
|
||||
[
|
||||
'task' => 'Press reset',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['reset'],
|
||||
],
|
||||
[
|
||||
'task' => 'Press machine button 2',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => [2],
|
||||
],
|
||||
[
|
||||
'task' => 'Press start',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['start'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
$condition = $config['conditions'][0] ?? [];
|
||||
$tasks = array_values($config['tasks'] ?? []);
|
||||
$pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
|
||||
$validation = (new class extends selfserve_config_versioning {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
})->validateConfig($config);
|
||||
|
||||
expect($condition['generated_by'])->toBe('path_editor')
|
||||
->and($condition['path_key'])->toBe('allowed_front')
|
||||
->and($condition['lane'])->toBe(7)
|
||||
->and($condition['product'])->toBe(2)
|
||||
->and($condition['machine_type_id'])->toBe(1001)
|
||||
->and($condition['expression']['children'])->toHaveCount(2)
|
||||
->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE')
|
||||
->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE')
|
||||
->and($tasks)->toHaveCount(4)
|
||||
->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start'])
|
||||
->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40])
|
||||
->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']])
|
||||
->and($tasks[0]['generated_by'])->toBe('path_editor')
|
||||
->and($tasks[0]['gate_type'])->toBe('CONDITION')
|
||||
->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER'])
|
||||
->and($tasks[0]['buttons'])->toBe(['program_picker'])
|
||||
->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3)
|
||||
->and($tasks[1]['buttons'])->toBe(['reset'])
|
||||
->and($tasks[2]['buttons'])->toBe([2])
|
||||
->and($tasks[3]['buttons'])->toBe(['start'])
|
||||
->and($pathMeta['condition_id'])->toBe((int)$condition['id'])
|
||||
->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id'])
|
||||
->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks))
|
||||
->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start'])
|
||||
->and($pathMeta['path_signature'])->not->toBe('')
|
||||
->and($validation['valid'])->toBeTrue();
|
||||
|
||||
$method->invokeArgs($service, [6, &$config, [
|
||||
'action' => 'upsert_path',
|
||||
'entity' => 'path',
|
||||
'data' => [
|
||||
'path_key' => 'allowed_front',
|
||||
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
|
||||
'answers' => [
|
||||
['question_id' => 11, 'value' => true],
|
||||
['question_id' => 12, 'value' => false],
|
||||
],
|
||||
'result' => [
|
||||
'machine_allowed' => true,
|
||||
'task' => 'Set program',
|
||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
||||
'buttons' => ['program_picker', 'start'],
|
||||
'dynamic_images_vehicle_type' => 3,
|
||||
'tasks' => [
|
||||
['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3],
|
||||
['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
||||
],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
$updatedTasks = array_values(array_filter(
|
||||
$config['tasks'] ?? [],
|
||||
static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front'
|
||||
));
|
||||
$updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
|
||||
|
||||
expect($updatedTasks)->toHaveCount(2)
|
||||
->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']])
|
||||
->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start'])
|
||||
->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]);
|
||||
|
||||
$method->invokeArgs($service, [6, &$config, [
|
||||
'action' => 'upsert_path',
|
||||
'entity' => 'path',
|
||||
'data' => [
|
||||
'path_key' => 'legacy_front',
|
||||
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
|
||||
'answers' => [
|
||||
['question_id' => 11, 'value' => false],
|
||||
],
|
||||
'result' => [
|
||||
'machine_allowed' => true,
|
||||
'task' => 'Legacy start',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['start'],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
$legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? [];
|
||||
$legacyTask = array_values(array_filter(
|
||||
$config['tasks'] ?? [],
|
||||
static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front'
|
||||
))[0] ?? [];
|
||||
|
||||
expect($legacyTask['task'])->toBe('Legacy start')
|
||||
->and($legacyTask['buttons'])->toBe(['start'])
|
||||
->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id'])
|
||||
->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]);
|
||||
});
|
||||
|
||||
it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void {
|
||||
@@ -1016,10 +1178,93 @@ it('projects visible question answer paths into grouped task service and signal
|
||||
->and($allowedPath['answers'])->toHaveCount(2)
|
||||
->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?')
|
||||
->and($allowedPath['services'])->toBe(['MACHINE'])
|
||||
->and($allowedPath['path_signature'])->not->toBe('')
|
||||
->and($allowedPath['result_signature'])->not->toBe('')
|
||||
->and($allowedPath['confirmation_status'])->toBe('unconfirmed')
|
||||
->and($allowedPath['node_ids'])->toContain('question:11')
|
||||
->and($allowedPath['node_ids'])->toContain('task:41');
|
||||
});
|
||||
|
||||
it('marks projected path confirmations confirmed or stale by stable signatures', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = static function (string $button): callable {
|
||||
return static function (array $overrides) use ($button): array {
|
||||
$answers = [];
|
||||
foreach ($overrides as $entry) {
|
||||
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
||||
}
|
||||
$allowed = ($answers[11] ?? null) === true;
|
||||
|
||||
return [
|
||||
'allowed' => $allowed,
|
||||
'questions' => [
|
||||
['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null],
|
||||
],
|
||||
'tasks' => $allowed ? [
|
||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]],
|
||||
] : [],
|
||||
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
||||
'debug' => [
|
||||
'questions' => [
|
||||
['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null],
|
||||
],
|
||||
'tasks' => [
|
||||
['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1],
|
||||
],
|
||||
'signal_timeline' => [],
|
||||
],
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
$scope = [
|
||||
'department_id' => 6,
|
||||
'lane_id' => 7,
|
||||
'vehicle_type_id' => 2,
|
||||
'config_source' => 'draft',
|
||||
'config_version_id' => 90,
|
||||
'hardware_mode' => 'studio',
|
||||
];
|
||||
$initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]);
|
||||
$allowedPath = array_values(array_filter(
|
||||
$initial['paths'],
|
||||
static fn(array $path): bool => ($path['allowed'] ?? false) === true
|
||||
))[0] ?? [];
|
||||
|
||||
$rows = [[
|
||||
'path_signature' => $allowedPath['path_signature'],
|
||||
'result_signature' => $allowedPath['result_signature'],
|
||||
'answers' => $allowedPath['answers'],
|
||||
'result' => ['allowed' => true],
|
||||
'scope' => $scope,
|
||||
'confirmed_at' => '2026-05-27 10:00:00',
|
||||
'confirmed_by' => 9,
|
||||
]];
|
||||
$confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [
|
||||
'scope' => $scope,
|
||||
'confirmation_rows' => $rows,
|
||||
]);
|
||||
$changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [
|
||||
'scope' => $scope,
|
||||
'confirmation_rows' => $rows,
|
||||
]);
|
||||
|
||||
$confirmedAllowed = array_values(array_filter(
|
||||
$confirmed['paths'],
|
||||
static fn(array $path): bool => ($path['allowed'] ?? false) === true
|
||||
))[0] ?? [];
|
||||
$staleAllowed = array_values(array_filter(
|
||||
$changed['paths'],
|
||||
static fn(array $path): bool => ($path['allowed'] ?? false) === true
|
||||
))[0] ?? [];
|
||||
|
||||
expect($confirmedAllowed['confirmation_status'])->toBe('confirmed')
|
||||
->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1)
|
||||
->and($staleAllowed['confirmation_status'])->toBe('stale')
|
||||
->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.')
|
||||
->and($changed['summary']['confirmations']['stale'])->toBe(1);
|
||||
});
|
||||
|
||||
it('truncates path outcome projection when the state cap is reached', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = function (array $overrides): array {
|
||||
|
||||
Reference in New Issue
Block a user