monitor/api/src/Repository/AgentRepository.php
fds2610-bot 79d97985f3 OPs-Agent repariert seine Praesenz nach dem naechsten Update selbst
Der OPs-Agent war seit seiner Registrierung am 31.08. nie praesent: seine
monitor-api.conf stammt von vor Issue #75 und kennt MONITOR_AGENT_ID nicht,
report_activity() in check_fds.sh meldete deshalb stillschweigend nichts -
waehrend die Systemmeldungen mit demselben Key weiter ankamen.

- API: jeder mit X-Api-Key authentifizierte Request frischt last_seen_at auf
  (AgentRepository::touch(), Zustand/Ticket bleiben). Greift auch, wenn auf dem
  Ping-Rechner noch ein aelteres check_fds.sh liegt.
- API: neuer Endpunkt GET /api/v1/agents/me liefert den Agenten zum Key.
- check_fds.sh: fehlt MONITOR_AGENT_ID, wird sie ueber /agents/me ermittelt
  und in monitor-api.conf nachgetragen (ohne Admin-Key).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5rnDxZCwLYiEVyfrAvsAA
2026-09-13 21:47:32 +02:00

196 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Auth\ApiKeyGenerator;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
final class AgentRepository
{
/** @var string[] */
public const ROLES = ['entwickler', 'product_owner', 'qa', 'ops', 'user'];
/**
* Praesenz-Timeout (Issue #17/#20): ein Agent gilt nur noch als anwesend, solange
* last_seen_at hoechstens so lange zurueckliegt. Der Server berechnet daraus das
* is_present-Feld selbst (gmdate()-basiert, also unabhaengig von der lokalen Zeitzone
* des Clients) - der Client macht dazu keine eigene Zeit-Arithmetik mehr.
*/
private const PRESENCE_TIMEOUT_SECONDS = 600;
public function __construct(private readonly Database $db, private readonly int $deskCount = 8)
{
}
/**
* Registriert einen neuen Agenten und weist ihm bei der Registrierung fest einen
* Schreibtisch zu (F1/F2: "Agenten erhalten bei der Registrierung eine feste Zuordnung").
* Die Rolle "user" bekommt nie einen Schreibtisch (kein fester Sitzplatz, F2). Sind alle
* Schreibtische belegt, registriert sich der Agent trotzdem, aber ohne Schreibtisch
* ("kommen durch die Tuer, machen ihre Aufgaben und gehen wieder").
*
* @return array{agent: array<string, mixed>, api_key: string}
*/
public function register(string $displayName, string $role): array
{
if (!in_array($role, self::ROLES, true)) {
throw new InvalidArgumentException('Unbekannte Rolle: ' . $role);
}
$deskIndex = $role === 'user' ? null : $this->findFreeDesk();
$apiKey = ApiKeyGenerator::generate();
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO agents (display_name, role, api_key_hash, desk_index, state, created_at, last_seen_at)
VALUES (:display_name, :role, :api_key_hash, :desk_index, :state, :created_at, :created_at)'
);
$stmt->execute([
'display_name' => $displayName,
'role' => $role,
'api_key_hash' => ApiKeyGenerator::hash($apiKey),
'desk_index' => $deskIndex,
'state' => 'idle',
'created_at' => $now,
]);
$agent = $this->findById((int) $this->db->pdo()->lastInsertId());
return ['agent' => $agent, 'api_key' => $apiKey];
}
private function findFreeDesk(): ?int
{
$stmt = $this->db->pdo()->query(
'SELECT desk_index FROM agents WHERE desk_index IS NOT NULL'
);
$occupied = array_map('intval', array_column($stmt->fetchAll(), 'desk_index'));
for ($i = 0; $i < $this->deskCount; $i++) {
if (!in_array($i, $occupied, true)) {
return $i;
}
}
return null;
}
/** @return array<string, mixed>|null */
public function findById(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $this->hidingKey($row);
}
/** @return array<string, mixed>|null */
public function findByApiKey(string $apiKey): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE api_key_hash = :hash');
$stmt->execute(['hash' => ApiKeyGenerator::hash($apiKey)]);
$row = $stmt->fetch();
return $row === false ? null : $this->hidingKey($row);
}
/** @return array<int, array<string, mixed>> */
public function all(): array
{
$stmt = $this->db->pdo()->query('SELECT * FROM agents ORDER BY id ASC');
return array_map(fn (array $row) => $this->hidingKey($row), $stmt->fetchAll());
}
/**
* Frischt nur last_seen_at auf, Zustand und Ticket bleiben unveraendert. Application ruft
* das bei jedem mit X-Api-Key authentifizierten Request auf: wer sich mit gueltigem Key
* meldet, ist offensichtlich da - auch wenn er (wie ein aelterer check_fds.sh ohne
* MONITOR_AGENT_ID) nie den Aktivitaets-Endpoint aufruft.
*/
public function touch(int $id): void
{
$stmt = $this->db->pdo()->prepare('UPDATE agents SET last_seen_at = :now WHERE id = :id');
$stmt->execute(['now' => $this->db->now(), 'id' => $id]);
}
/**
* Ein unbekanntes Ticket wuerde sonst erst in der FOREIGN-KEY-Constraint auflaufen und
* als ungefangene PDOException beim Client landen - deshalb hier pruefen und als
* regulaeren Validierungsfehler (422) melden.
*
* @return array<string, mixed>
*/
public function updateActivity(int $id, string $state, ?int $currentTicketId): array
{
if ($currentTicketId !== null && !$this->ticketExists($currentTicketId)) {
throw new InvalidArgumentException('Unbekanntes Ticket: ' . $currentTicketId);
}
$stmt = $this->db->pdo()->prepare(
'UPDATE agents SET state = :state, current_ticket_id = :ticket_id, last_seen_at = :now WHERE id = :id'
);
$stmt->execute([
'state' => $state,
'ticket_id' => $currentTicketId,
'now' => $this->db->now(),
'id' => $id,
]);
$agent = $this->findById($id);
if ($agent === null) {
throw new NotFoundException('Agent nicht gefunden: ' . $id);
}
return $agent;
}
private function ticketExists(int $ticketId): bool
{
$stmt = $this->db->pdo()->prepare('SELECT 1 FROM tickets WHERE id = :id');
$stmt->execute(['id' => $ticketId]);
return $stmt->fetchColumn() !== false;
}
/** @param array<string, mixed> $row @return array<string, mixed> */
private function hidingKey(array $row): array
{
unset($row['api_key_hash']);
$row['is_present'] = $this->isPresent($row['last_seen_at'] ?? null);
return $row;
}
/**
* Fehlt last_seen_at oder ist es nicht parsbar, gilt der Agent defensiv als anwesend,
* statt faelschlich einen leeren Platz zu melden.
*/
private function isPresent(?string $lastSeenAt): bool
{
if ($lastSeenAt === null) {
return true;
}
$lastSeenTimestamp = strtotime($lastSeenAt);
if ($lastSeenTimestamp === false) {
return true;
}
return (time() - $lastSeenTimestamp) <= self::PRESENCE_TIMEOUT_SECONDS;
}
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string
{
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(last_seen_at) AS m FROM agents')->fetch();
return $row['c'] . ':' . ($row['m'] ?? '');
}
}