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>
270 lines
8.9 KiB
PHP
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;
|
|
}
|