nis2-agile/scripts/feedback-worker.php
DevEnv nis2-agile fc4fbda732 [FIX] feedback-worker.php: correggi */ in docblock PHP
Il pattern */30 chiudeva prematuramente il docblock /** causando
parse error. Sostituito con spazio per chiarezza.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:16:09 +01:00

270 lines
8.9 KiB
PHP

#!/usr/bin/env php
<?php
/**
* NIS2 Agile — Feedback Worker
*
* Worker cron per la risoluzione autonoma dei ticket di segnalazione.
* Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
*
* Ciclo (ogni 30 min):
* 1. Acquisisce lock — previene run paralleli
* 2. Recupera ticket status='in_lavorazione'
* 3. Per ciascuno: invoca Claude Code CLI nel container devenv
* 4. Se exit_code=0: marca ticket come risolto + broadcast email
* 5. Logga risultati in FEEDBACK_WORKER_LOG
*
* Crontab (root):
* * /30 * * * * root /usr/bin/php8.4 /var/www/nis2-agile/scripts/feedback-worker.php
* (rimuovi lo spazio dopo * per avere: asterisco-slash-30)
*
* Variabili .env necessarie:
* FEEDBACK_RESOLVE_PASSWORD=... (password per POST /api/feedback/{id}/resolve)
* FEEDBACK_WORKER_ADMIN_EMAIL=...
* FEEDBACK_WORKER_ADMIN_PASS=...
*/
declare(strict_types=1);
// Carica ambiente
define('BASE_PATH', dirname(__DIR__));
define('APP_PATH', BASE_PATH . '/application');
define('PUBLIC_PATH', BASE_PATH . '/public');
define('UPLOAD_PATH', PUBLIC_PATH . '/uploads');
define('DATA_PATH', APP_PATH . '/data');
require_once APP_PATH . '/config/env.php';
require_once APP_PATH . '/config/config.php';
require_once APP_PATH . '/config/database.php';
// ── Config worker ─────────────────────────────────────────────────────────
const LOCK_FILE = '/tmp/nis2-feedback-worker.lock';
const CLAUDE_TIMEOUT = 1500; // secondi (25 min, < cron interval 30 min)
const DEVENV_CONTAINER = 'nis2-agile-devenv';
const APP_URL_INTERNAL = 'http://localhost:8080'; // URL interno per chiamate PHP curl
$logFile = defined('FEEDBACK_WORKER_LOG') ? FEEDBACK_WORKER_LOG : '/tmp/nis2-feedback-worker.log';
// ── Lock ──────────────────────────────────────────────────────────────────
if (file_exists(LOCK_FILE)) {
$lockAge = time() - filemtime(LOCK_FILE);
if ($lockAge < 1800) {
_log($logFile, 'Worker già in esecuzione (lock attivo, età: ' . $lockAge . 's). Uscita.');
exit(0);
}
_log($logFile, 'Lock obsoleto trovato (' . $lockAge . 's), rimuovo e procedo.');
unlink(LOCK_FILE);
}
file_put_contents(LOCK_FILE, posix_getpid());
register_shutdown_function(function () {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// ── Main ──────────────────────────────────────────────────────────────────
_log($logFile, '=== Feedback Worker avviato ===');
try {
$tickets = Database::fetchAll(
"SELECT id, tipo, descrizione, ai_suggerimento, ai_risposta, organization_id
FROM feedback_reports
WHERE status = 'in_lavorazione'
ORDER BY created_at ASC
LIMIT 10"
);
if (empty($tickets)) {
_log($logFile, 'Nessun ticket in lavorazione. Worker terminato.');
exit(0);
}
_log($logFile, count($tickets) . ' ticket da processare.');
$resolvePassword = defined('FEEDBACK_RESOLVE_PASSWORD') ? FEEDBACK_RESOLVE_PASSWORD : '';
if (empty($resolvePassword)) {
_log($logFile, 'FEEDBACK_RESOLVE_PASSWORD non configurata. Worker non può risolvere ticket.');
exit(1);
}
foreach ($tickets as $ticket) {
_processTicket($ticket, $resolvePassword, $logFile);
}
} catch (\Throwable $e) {
_log($logFile, 'ERRORE FATALE: ' . $e->getMessage());
exit(1);
}
_log($logFile, '=== Worker completato ===');
exit(0);
// ── Funzioni ──────────────────────────────────────────────────────────────
function _processTicket(array $ticket, string $password, string $logFile): void
{
$id = $ticket['id'];
_log($logFile, "Processing ticket #{$id} [{$ticket['tipo']}]: " . substr($ticket['descrizione'], 0, 80) . '…');
// Costruisci prompt per Claude Code
$prompt = _buildPrompt($ticket);
// Scrivi prompt su file temporaneo
$promptFile = "/tmp/nis2-feedback-prompt-{$id}.txt";
file_put_contents($promptFile, $prompt);
// Esegui Claude Code nel container devenv
$dockerCmd = sprintf(
'docker exec -u developer %s bash -c %s',
escapeshellarg(DEVENV_CONTAINER),
escapeshellarg(
'cd /projects/nis2-agile && ' .
'timeout ' . CLAUDE_TIMEOUT . ' ' .
'claude --dangerously-skip-permissions --output-format stream-json ' .
'-p "$(cat ' . $promptFile . ')" 2>&1'
)
);
$output = [];
$exitCode = 0;
exec($dockerCmd, $output, $exitCode);
@unlink($promptFile);
$outputText = implode("\n", $output);
_log($logFile, "Ticket #{$id} — Claude exit_code={$exitCode}, output=" . substr($outputText, 0, 200));
if ($exitCode !== 0) {
_log($logFile, "Ticket #{$id} — risoluzione fallita (exit {$exitCode}).");
// Rimane in in_lavorazione per il prossimo ciclo
return;
}
// Chiama API interna per marcare come risolto
$resolved = _resolveViaApi($id, $password, $logFile);
if ($resolved) {
_log($logFile, "Ticket #{$id} — risolto e broadcast inviato.");
}
}
function _buildPrompt(array $ticket): string
{
$suggerimento = $ticket['ai_suggerimento'] ?? 'Nessun suggerimento AI disponibile.';
return <<<PROMPT
Sei un developer senior che lavora su NIS2 Agile, una piattaforma PHP 8.4 per compliance NIS2.
## Ticket da risolvere
ID: {$ticket['id']}
Tipo: {$ticket['tipo']}
Descrizione: {$ticket['descrizione']}
## Suggerimento AI (classificazione automatica)
{$suggerimento}
## Istruzioni
1. Analizza il problema descritto
2. Identifica i file rilevanti nel progetto (/projects/nis2-agile/)
3. Applica la correzione minimale necessaria
4. Non modificare test o documentazione
5. Assicurati che il codice PHP sia valido (sintassi corretta)
6. Se il problema non è risolvibile in modo sicuro, esci con exit code 1
Risolvi il problema e poi esci. Non avviare server, non fare git commit.
PROMPT;
}
function _resolveViaApi(int $reportId, string $password, string $logFile): bool
{
$url = APP_URL_INTERNAL . "/api/feedback/{$reportId}/resolve";
$payload = json_encode(['password' => $password]);
// Ottieni un token JWT admin per la chiamata interna
$token = _getAdminToken($logFile);
if (!$token) {
_log($logFile, "Ticket #{$reportId} — impossibile ottenere token admin per risoluzione.");
return false;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $token,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode < 200 || $httpCode >= 300) {
_log($logFile, "Ticket #{$reportId} — API resolve fallita [{$httpCode}]: {$curlError}");
return false;
}
$data = json_decode($response, true);
return $data['success'] ?? false;
}
function _getAdminToken(string $logFile): ?string
{
static $cachedToken = null;
if ($cachedToken !== null) return $cachedToken;
$adminEmail = Env::get('FEEDBACK_WORKER_ADMIN_EMAIL', '');
$adminPass = Env::get('FEEDBACK_WORKER_ADMIN_PASS', '');
if (!$adminEmail || !$adminPass) {
_log($logFile, 'FEEDBACK_WORKER_ADMIN_EMAIL / FEEDBACK_WORKER_ADMIN_PASS non configurate.');
return null;
}
$ch = curl_init(APP_URL_INTERNAL . '/api/auth/login');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['email' => $adminEmail, 'password' => $adminPass]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
_log($logFile, "Login admin fallito [{$httpCode}].");
return null;
}
$data = json_decode($response, true);
$cachedToken = $data['data']['access_token'] ?? null;
return $cachedToken;
}
function _log(string $logFile, string $message): void
{
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;
$dir = dirname($logFile);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
echo $line;
}