, 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|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|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> */ 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 */ 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 $row @return array */ 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'] ?? ''); } }