Files
breadcrumb-the-shire/core/Service/Scheduler/ScheduledJobRegistry.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

230 lines
7.9 KiB
PHP

<?php
namespace MintyPHP\Service\Scheduler;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
use MintyPHP\Service\User\UserLifecycleService;
use RuntimeException;
class ScheduledJobRegistry
{
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
/** @var array<string, ScheduledJobHandlerInterface> */
private array $handlers;
/**
* @var array<string, array{
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* }>
*/
private array $moduleJobs = [];
public function __construct(
UserLifecycleService $userLifecycleService,
private readonly AppContainer $container
) {
$this->handlers = [
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
];
$this->loadModuleJobs();
}
/**
* Returns all job definitions keyed by job_key.
* Consumed by ScheduledJobService->ensureSystemJobs() to sync registry with the database.
*/
public function definitions(): array
{
$result = [];
foreach ($this->handlers as $jobKey => $handler) {
$result[$jobKey] = $handler->definition();
}
foreach ($this->moduleJobs as $jobKey => $definition) {
$result[$jobKey] = $this->stripHandlerFromDefinition($definition);
}
return $result;
}
/**
* Returns the definition for a single job key, or null if not registered.
*/
public function get(string $jobKey): ?array
{
$jobKey = trim($jobKey);
if ($jobKey === '') {
return null;
}
if (isset($this->handlers[$jobKey])) {
return $this->handlers[$jobKey]->definition();
}
if (isset($this->moduleJobs[$jobKey])) {
return $this->stripHandlerFromDefinition($this->moduleJobs[$jobKey]);
}
return null;
}
/**
* Dispatches execution to the registered handler for the given job key and returns
* a normalized result envelope. Unknown job keys return status 'failed' with
* error_code 'job_not_supported' rather than throwing an exception.
*
* @param string $jobKey Registered job identifier (see class constants).
* @param int|null $actorUserId Null for scheduler-triggered runs; user ID for manual runs.
* @return array{status:string,error_code:?string,error_message:?string,result:array}
*/
public function execute(string $jobKey, ?int $actorUserId): array
{
if (isset($this->handlers[$jobKey])) {
return $this->handlers[$jobKey]->execute($actorUserId);
}
$moduleDefinition = $this->moduleJobs[$jobKey] ?? null;
if (!is_array($moduleDefinition)) {
return [
'status' => 'failed',
'error_code' => 'job_not_supported',
'error_message' => null,
'result' => [],
];
}
$handlerClass = trim((string) $moduleDefinition['handler']);
if ($handlerClass === '') {
return [
'status' => 'failed',
'error_code' => 'job_handler_missing',
'error_message' => 'Scheduler job handler is not configured.',
'result' => [],
];
}
try {
$handler = $this->resolveHandler($handlerClass);
return $handler->execute($actorUserId);
} catch (\Throwable $exception) {
return [
'status' => 'failed',
'error_code' => 'job_handler_resolution_failed',
'error_message' => $exception->getMessage(),
'result' => [],
];
}
}
private function loadModuleJobs(): void
{
$registry = $this->resolveModuleRegistry();
if (!$registry instanceof ModuleRegistry) {
return;
}
foreach ($registry->getSchedulerJobs() as $jobDefinition) {
$jobKey = trim((string) $jobDefinition['job_key']);
if ($jobKey === '') {
continue;
}
if (isset($this->handlers[$jobKey])) {
throw new RuntimeException(
"Module scheduler job '{$jobKey}' collides with core scheduler job key."
);
}
if (isset($this->moduleJobs[$jobKey])) {
throw new RuntimeException(
"Duplicate module scheduler job key '{$jobKey}'."
);
}
$this->moduleJobs[$jobKey] = [
'handler' => trim((string) $jobDefinition['handler']),
'label' => trim((string) $jobDefinition['label']),
'description' => trim((string) $jobDefinition['description']),
'default_enabled' => (int) $jobDefinition['default_enabled'],
'default_timezone' => trim((string) $jobDefinition['default_timezone']),
'default_schedule_type' => trim((string) $jobDefinition['default_schedule_type']),
'default_schedule_interval' => (int) $jobDefinition['default_schedule_interval'],
'default_schedule_time' => $jobDefinition['default_schedule_time'],
'default_schedule_weekdays_csv' => $jobDefinition['default_schedule_weekdays_csv'],
'default_catchup_once' => (int) $jobDefinition['default_catchup_once'],
'allowed_schedule_types' => array_values($jobDefinition['allowed_schedule_types']),
];
}
}
/**
* @param array{
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* } $definition
* @return array{
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* }
*/
private function stripHandlerFromDefinition(array $definition): array
{
unset($definition['handler']);
return $definition;
}
private function resolveModuleRegistry(): ?ModuleRegistry
{
try {
$registry = $this->container->get(ModuleRegistry::class);
} catch (\Throwable) {
return null;
}
return $registry instanceof ModuleRegistry ? $registry : null;
}
private function resolveHandler(string $handlerClass): ScheduledJobHandlerInterface
{
$handler = null;
if ($this->container->has($handlerClass)) {
$handler = $this->container->get($handlerClass);
} elseif (class_exists($handlerClass)) {
$handler = new $handlerClass();
}
if (!$handler instanceof ScheduledJobHandlerInterface) {
throw new RuntimeException(
"Scheduler job handler '{$handlerClass}' must implement " . ScheduledJobHandlerInterface::class . '.'
);
}
return $handler;
}
}