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>
This commit is contained in:
2026-04-13 23:20:05 +02:00
parent 7c10fadcb9
commit 0e86925464
371 changed files with 191 additions and 192 deletions

View File

@@ -0,0 +1,270 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Domain\Taxonomy\ScheduledJobStatus;
use MintyPHP\Repository\Support\RepoQuery;
/** Persists scheduled jobs with cron expressions, next-run calculation, and metadata updates. */
class ScheduledJobRepository implements ScheduledJobRepositoryInterface
{
public function create(array $data): int|false
{
$id = DB::insert(
'insert into scheduled_jobs (
job_key, label, description, enabled, timezone,
schedule_type, schedule_interval, schedule_time, schedule_weekdays_csv,
catchup_once, next_run_at
) values (?,?,?,?,?,?,?,?,?,?,?)',
(string) ($data['job_key'] ?? ''),
(string) ($data['label'] ?? ''),
$data['description'] ?? null,
(string) ((int) ($data['enabled'] ?? 0)),
(string) ($data['timezone'] ?? ''),
(string) ($data['schedule_type'] ?? 'daily'),
(string) ((int) ($data['schedule_interval'] ?? 1)),
$data['schedule_time'] ?? null,
$data['schedule_weekdays_csv'] ?? null,
(string) ((int) ($data['catchup_once'] ?? 1)),
$data['next_run_at'] ?? null
);
return $id ? (int) $id : false;
}
public function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne('select * from scheduled_jobs where id = ? limit 1', (string) $id);
return $this->normalizeRow($row);
}
public function findByKey(string $jobKey): ?array
{
$jobKey = trim($jobKey);
if ($jobKey === '') {
return null;
}
$row = DB::selectOne('select * from scheduled_jobs where job_key = ? limit 1', $jobKey);
return $this->normalizeRow($row);
}
public function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$enabled = trim((string) ($filters['enabled'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'job_key', 'label', 'enabled', 'next_run_at', 'last_run_finished_at', 'last_run_status', 'updated_at'],
'job_key',
'asc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['job_key', 'label', 'description', 'last_error_code', 'last_error_message'],
$search
);
if (in_array($enabled, ['0', '1'], true)) {
$where[] = 'enabled = ?';
$params[] = $enabled;
}
if (ScheduledJobStatus::tryNormalize($status) !== null) {
$where[] = 'last_run_status = ?';
$params[] = $status;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$total = (int) (DB::selectValue('select count(*) from scheduled_jobs' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select * from scheduled_jobs' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public function listDueJobs(string $nowUtc, int $limit = 20): array
{
$limit = max(1, min(200, $limit));
$rows = DB::select(
'select * from scheduled_jobs
where enabled = 1
and next_run_at is not null
and next_run_at <= ?
order by next_run_at asc
limit ?',
$nowUtc,
(string) $limit
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return $normalized;
}
public function updateJobMeta(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update scheduled_jobs
set label = ?,
description = ?,
enabled = ?,
timezone = ?,
schedule_type = ?,
schedule_interval = ?,
schedule_time = ?,
schedule_weekdays_csv = ?,
catchup_once = ?,
next_run_at = ?
where id = ?',
(string) ($data['label'] ?? ''),
$data['description'] ?? null,
(string) ((int) ($data['enabled'] ?? 0)),
(string) ($data['timezone'] ?? ''),
(string) ($data['schedule_type'] ?? 'daily'),
(string) ((int) ($data['schedule_interval'] ?? 1)),
$data['schedule_time'] ?? null,
$data['schedule_weekdays_csv'] ?? null,
(string) ((int) ($data['catchup_once'] ?? 1)),
$data['next_run_at'] ?? null,
(string) $id
);
return $updated !== false;
}
/**
* Atomically marks a job as running if it is not already in a running state.
*
* Includes stale-running protection: if a job has been stuck in 'running' status
* for more than 2 hours, it is considered abandoned (e.g. the runner process died)
* and a new run is allowed to proceed. This prevents a crashed runner from blocking
* the job permanently.
*
* Returns true only when the UPDATE affected a row, i.e. the job was successfully
* claimed by this runner. Returns false if the job is already running (not stale).
*/
public function markRunning(int $id, string $startedAtUtc): bool
{
if ($id <= 0) {
return false;
}
// A job that has been 'running' for longer than this threshold is treated as stale.
$staleBefore = (new \DateTimeImmutable($startedAtUtc, new \DateTimeZone('UTC')))
->modify('-2 hours')
->format('Y-m-d H:i:s');
$updated = DB::update(
'update scheduled_jobs
set last_run_status = ?,
last_run_started_at = ?,
last_run_finished_at = null,
last_error_code = null,
last_error_message = null
where id = ?
and (last_run_status is null
or last_run_status <> ?
or (last_run_started_at is not null and last_run_started_at < ?))',
ScheduledJobStatus::Running->value,
$startedAtUtc,
(string) $id,
ScheduledJobStatus::Running->value,
$staleBefore
);
return (int) $updated > 0;
}
public function finishRun(
int $id,
string $status,
string $startedAtUtc,
string $finishedAtUtc,
?string $nextRunAtUtc,
?string $errorCode,
?string $errorMessage
): bool {
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update scheduled_jobs
set last_run_status = ?,
last_run_started_at = ?,
last_run_finished_at = ?,
next_run_at = ?,
last_error_code = ?,
last_error_message = ?
where id = ?',
$status,
$startedAtUtc,
$finishedAtUtc,
$nextRunAtUtc,
$errorCode,
$errorMessage,
(string) $id
);
return $updated !== false;
}
public function deleteOrphanedJobs(array $validKeys): int
{
$validKeys = array_filter(array_map('trim', $validKeys), static fn (string $k): bool => $k !== '');
if ($validKeys === []) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($validKeys), '?'));
$affected = DB::update(
"DELETE FROM scheduled_jobs WHERE job_key NOT IN ({$placeholders})",
...array_map('strval', $validKeys)
);
return (int) $affected;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['scheduled_jobs'] ?? $row;
if (!is_array($item) || !isset($item['id'])) {
return null;
}
return $item;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Repository\Scheduler;
/** Contract for scheduled job CRUD, due-job queries, and run status tracking. */
interface ScheduledJobRepositoryInterface
{
public function create(array $data): int|false;
public function find(int $id): ?array;
public function findByKey(string $jobKey): ?array;
public function listPaged(array $filters): array;
public function listDueJobs(string $nowUtc, int $limit = 20): array;
public function updateJobMeta(int $id, array $data): bool;
public function markRunning(int $id, string $startedAtUtc): bool;
public function finishRun(
int $id,
string $status,
string $startedAtUtc,
string $finishedAtUtc,
?string $nextRunAtUtc,
?string $errorCode,
?string $errorMessage
): bool;
/** Delete jobs whose job_key is not in the given list of valid keys. Returns affected row count. */
public function deleteOrphanedJobs(array $validKeys): int;
}

View File

@@ -0,0 +1,140 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
use MintyPHP\Repository\Support\RepoQuery;
/** Records individual job run results with duration, output, and retention-based purging. */
class ScheduledJobRunRepository implements ScheduledJobRunRepositoryInterface
{
public function create(array $data): int|false
{
$id = DB::insert(
'insert into scheduled_job_runs (
run_uuid, job_id, job_key, trigger_type, status, actor_user_id,
started_at, finished_at, duration_ms, error_code, error_message, result_json
) values (?,?,?,?,?,?,?,?,?,?,?,?)',
(string) ($data['run_uuid'] ?? ''),
(string) ((int) ($data['job_id'] ?? 0)),
(string) ($data['job_key'] ?? ''),
(string) ($data['trigger_type'] ?? ScheduledJobTriggerType::Scheduler->value),
(string) ($data['status'] ?? ScheduledJobRunStatus::Failed->value),
($data['actor_user_id'] ?? null) !== null ? (string) ((int) $data['actor_user_id']) : null,
(string) ($data['started_at'] ?? ''),
$data['finished_at'] ?? null,
($data['duration_ms'] ?? null) !== null ? (string) ((int) $data['duration_ms']) : null,
$data['error_code'] ?? null,
$data['error_message'] ?? null,
$data['result_json'] ?? null
);
return $id ? (int) $id : false;
}
public function listPagedByJobId(int $jobId, array $filters): array
{
if ($jobId <= 0) {
return ['total' => 0, 'rows' => []];
}
$search = trim((string) ($filters['search'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$triggerType = strtolower(trim((string) ($filters['trigger_type'] ?? '')));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'started_at', 'finished_at', 'duration_ms', 'status', 'trigger_type'],
'started_at',
'desc'
);
$where = ['scheduled_job_runs.job_id = ?'];
$params = [(string) $jobId];
RepoQuery::addLikeFilter(
$where,
$params,
['scheduled_job_runs.run_uuid', 'scheduled_job_runs.error_code', 'scheduled_job_runs.error_message'],
$search
);
if (ScheduledJobRunStatus::tryNormalize($status) !== null) {
$where[] = 'scheduled_job_runs.status = ?';
$params[] = $status;
}
if (ScheduledJobTriggerType::tryNormalize($triggerType) !== null) {
$where[] = 'scheduled_job_runs.trigger_type = ?';
$params[] = $triggerType;
}
$whereSql = ' where ' . implode(' and ', $where);
$fromSql = ' from scheduled_job_runs left join users on users.id = scheduled_job_runs.actor_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
scheduled_job_runs.id,
scheduled_job_runs.run_uuid,
scheduled_job_runs.job_id,
scheduled_job_runs.job_key,
scheduled_job_runs.trigger_type,
scheduled_job_runs.status,
scheduled_job_runs.actor_user_id,
scheduled_job_runs.started_at,
scheduled_job_runs.finished_at,
scheduled_job_runs.duration_ms,
scheduled_job_runs.error_code,
scheduled_job_runs.error_message,
scheduled_job_runs.result_json,
scheduled_job_runs.created_at,
users.id,
users.uuid,
users.display_name,
users.email
' . $fromSql . $whereSql .
sprintf(' order by scheduled_job_runs.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from scheduled_job_runs where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['scheduled_job_runs'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$item['actor_user_uuid'] = (string) ($user['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($user['display_name'] ?? '');
$item['actor_user_email'] = (string) ($user['email'] ?? '');
return $item;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace MintyPHP\Repository\Scheduler;
/** Contract for scheduled job run history: creation, pagination, and retention purge. */
interface ScheduledJobRunRepositoryInterface
{
public function create(array $data): int|false;
public function listPagedByJobId(int $jobId, array $filters): array;
public function purgeOlderThanDays(int $days): int;
}

View File

@@ -0,0 +1,51 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult;
/** Tracks scheduler daemon heartbeat, result codes, and error state. */
class SchedulerRuntimeRepository implements SchedulerRuntimeRepositoryInterface
{
public function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool
{
$heartbeatAtUtc = trim((string) ($heartbeatAtUtc ?? ''));
if ($heartbeatAtUtc === '') {
$heartbeatAtUtc = gmdate('Y-m-d H:i:s');
}
$result = SchedulerRuntimeResult::normalizeOr($result, SchedulerRuntimeResult::Ok)->value;
$errorCode = $this->nullableTrim($errorCode);
$updated = DB::update(
'insert into scheduler_runtime_status (id, last_heartbeat_at, last_result, last_error_code) ' .
'values (1, ?, ?, ?) ' .
'on duplicate key update ' .
'last_heartbeat_at = values(last_heartbeat_at), ' .
'last_result = values(last_result), ' .
'last_error_code = values(last_error_code)',
$heartbeatAtUtc,
$result,
$errorCode
);
return $updated !== false;
}
public function findStatus(): ?array
{
$row = DB::selectOne('select * from scheduler_runtime_status where id = 1 limit 1');
if (!is_array($row)) {
return null;
}
$item = $row['scheduler_runtime_status'] ?? $row;
return is_array($item) ? $item : null;
}
private function nullableTrim(?string $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace MintyPHP\Repository\Scheduler;
/** Contract for scheduler daemon heartbeat and runtime status. */
interface SchedulerRuntimeRepositoryInterface
{
public function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool;
public function findStatus(): ?array;
}