387 lines
16 KiB
PHP
387 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Throwable;
|
||
|
||
class ControlTowerPollCommand extends Command
|
||
{
|
||
protected $signature = 'netgescon:control-tower-poll
|
||
{--tower= : URL base della Torre di Controllo}
|
||
{--machine= : ID macchina}
|
||
{--token=pending-205 : Token di autenticazione}
|
||
{--task-id= : ID task specifico da eseguire}
|
||
{--wrapper-script= : Percorso personalizzato per lo script wrapper agy}
|
||
{--dry-run : Esegue in modalita simulazione senza invocare agy o pubblicare report}';
|
||
|
||
protected $description = 'Polls Control Tower for tasks, executes prompt via wrapper script stdin, parses wrapper JSON output, publishes reports, and marks tasks as done.';
|
||
|
||
public function handle(): int
|
||
{
|
||
$envVars = $this->loadMachineEnv();
|
||
$towerBase = rtrim((string) ($this->option('tower') ?: ($envVars['TOWER_BASE'] ?? 'http://192.168.0.53:4174')), '/');
|
||
$machineId = (string) ($this->option('machine') ?: ($envVars['MACHINE_ID'] ?? 'machine-205-dev'));
|
||
$token = (string) ($this->option('token') !== 'pending-205' ? $this->option('token') : ($envVars['MACHINE_TOKEN'] ?? 'pending-205'));
|
||
$taskIdOpt = $this->option('task-id');
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
|
||
$this->info("🤖 Control Tower Poller (.205) -> {$towerBase}");
|
||
|
||
$task = null;
|
||
if ($taskIdOpt) {
|
||
$task = $this->fetchTaskById($towerBase, $taskIdOpt, $machineId, $token);
|
||
if (! $task) {
|
||
$this->error("❌ RECUPERO TASK REALE FALLITO: Task {$taskIdOpt} non trovato o errore HTTP. Nessun fallback sintetico ammesso.");
|
||
return self::FAILURE;
|
||
}
|
||
} else {
|
||
$task = $this->fetchNextTask($towerBase, $machineId, $token);
|
||
if (! $task) {
|
||
$this->warn('Nessun task in coda. Recupero task corrente dal pacchetto runtime...');
|
||
$task = $this->fetchCurrentTaskFromPacket($towerBase, $machineId, $token);
|
||
}
|
||
}
|
||
|
||
if (! $task || ! is_array($task)) {
|
||
$this->error('❌ TASK NON VALIDO O MANCANTE.');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$taskId = $task['id'] ?? null;
|
||
if (! $taskId) {
|
||
$this->error('❌ TASK NON VALIDO: Campo id mancante.');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
if ($taskIdOpt && $taskId !== $taskIdOpt) {
|
||
$this->error("❌ DISALLINEAMENTO TASK_ID: ID restituito '{$taskId}' non coincide con ID richiesto '{$taskIdOpt}'.");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$assignedMachine = $task['target_machine_id'] ?? $task['assigned_machine_id'] ?? $task['machine_id'] ?? null;
|
||
if ($assignedMachine !== null && $assignedMachine !== $machineId && $assignedMachine !== 'machine-205-dev') {
|
||
$this->error("❌ MACCHINA DISALLINEATA: Task {$taskId} e assegnato a '{$assignedMachine}', non a '{$machineId}'.");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$title = $task['title'] ?? null;
|
||
$desc = $task['description'] ?? null;
|
||
$meta = $task['metadata'] ?? null;
|
||
|
||
if ($title === null || $desc === null || $meta === null) {
|
||
$this->error("❌ TASK INCOMPLETO: title, description o metadata mancanti per task {$taskId}.");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
if (isset($task['status']) && in_array($task['status'], ['done', 'blocked'], true)) {
|
||
$this->info("ℹ️ Task {$taskId} e gia in stato '{$task['status']}'. Esecuzione ignorata (idempotenza).");
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
$this->line("📋 Task ID: <comment>{$taskId}</comment>");
|
||
$this->line("📝 Titolo: <comment>{$title}</comment>");
|
||
|
||
$currentBranch = trim(shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null') ?: 'stabilization/205-zero');
|
||
$currentCommit = trim(shell_exec('git rev-parse HEAD 2>/dev/null') ?: '');
|
||
|
||
if ($dryRun) {
|
||
$this->info('🧪 DRY RUN ATTIVO: simulazione completata senza inviare report.');
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
$expectedReport = is_array($meta) ? ($meta['expectedReport'] ?? $meta['expected_report'] ?? null) : null;
|
||
|
||
$promptParts = [
|
||
"TASK_ID: {$taskId}",
|
||
"TITOLO: {$title}",
|
||
"DESCRIZIONE:\n{$desc}",
|
||
"METADATA:\n" . (is_array($meta) ? json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : $meta),
|
||
];
|
||
|
||
if ($expectedReport !== null) {
|
||
$promptParts[] = "EXPECTED_REPORT:\n" . (is_array($expectedReport) || is_object($expectedReport) ? json_encode($expectedReport, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : $expectedReport);
|
||
}
|
||
|
||
$promptInput = implode("\n\n", $promptParts);
|
||
|
||
$defaultWrapperScript = '/home/michele/netgescon-day0-backup/scripts/ops/antigravity-cli/run_205_followup_via_agy.sh';
|
||
$wrapperScript = (string) ($this->option('wrapper-script') ?: $defaultWrapperScript);
|
||
|
||
if (! file_exists($wrapperScript)) {
|
||
$this->error("❌ Script wrapper non trovato: {$wrapperScript}");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$descriptors = [
|
||
0 => ['pipe', 'r'],
|
||
1 => ['pipe', 'w'],
|
||
2 => ['pipe', 'w'],
|
||
];
|
||
|
||
$env = [
|
||
'HOME' => getenv('HOME') ?: '/home/michele',
|
||
'USER' => getenv('USER') ?: 'michele',
|
||
'LOGNAME' => getenv('LOGNAME') ?: 'michele',
|
||
'PATH' => '/home/michele/.local/bin:/usr/local/bin:/usr/bin:/bin',
|
||
'AGY_MODE' => 'plan',
|
||
];
|
||
|
||
$process = proc_open($wrapperScript, $descriptors, $pipes, base_path(''), $env);
|
||
if (! is_resource($process)) {
|
||
$this->error('❌ Impossibile avviare lo script wrapper agy.');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
fwrite($pipes[0], $promptInput);
|
||
fclose($pipes[0]);
|
||
|
||
$stdout = stream_get_contents($pipes[1]);
|
||
fclose($pipes[1]);
|
||
|
||
$stderr = stream_get_contents($pipes[2]);
|
||
fclose($pipes[2]);
|
||
|
||
$exitCode = proc_close($process);
|
||
|
||
$stdinLength = strlen($promptInput);
|
||
$envKeys = implode(', ', array_keys($env));
|
||
|
||
if ($exitCode !== 0) {
|
||
$this->error('❌ WRAPPER AGY FALLITO');
|
||
$this->line("WRAPPER_PATH: {$wrapperScript}");
|
||
$this->line("STDIN_LENGTH: {$stdinLength}");
|
||
$this->line("ENV_KEYS: {$envKeys}");
|
||
$this->line("EXIT_CODE: {$exitCode}");
|
||
$this->line('STDOUT:');
|
||
if ($stdout !== '') {
|
||
$this->line(trim($stdout));
|
||
}
|
||
$this->line('STDERR:');
|
||
if ($stderr !== '') {
|
||
$this->line(trim($stderr));
|
||
}
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$jsonResult = json_decode($stdout, true);
|
||
if (! is_array($jsonResult)) {
|
||
$this->error("❌ OUTPUT WRAPPER NON VALIDO: Impossibile decodificare JSON: {$stdout}");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
if (! isset($jsonResult['ok']) || $jsonResult['ok'] !== true) {
|
||
$missingFields = implode(', ', $jsonResult['missing'] ?? []);
|
||
$this->error("❌ WRAPPER PARSER NON VALIDO (ok != true). Campi mancanti: {$missingFields}");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$parsed = $jsonResult['parsed'] ?? [];
|
||
if (! is_array($parsed)) {
|
||
$this->error("❌ WRAPPER PARSER NON VALIDO: 'parsed' mancante o non array.");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$parsedTaskId = $parsed['task_id'] ?? null;
|
||
if ($parsedTaskId !== $taskId) {
|
||
$this->error("❌ DISALLINEAMENTO TASK_ID: task_id parsed '{$parsedTaskId}' non coincide con task corrente '{$taskId}'. Report non pubblicato.");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$rawOutput = $parsed['raw_text'] ?? json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||
|
||
if (($parsed['esito_205'] ?? '') === 'bloccato' || ($parsed['blocco_contratto'] ?? 'no') === 'si') {
|
||
$this->error("❌ TASK BLOCCATO: Esito bloccato indicato nel report parsed.");
|
||
$this->publishReport($towerBase, $machineId, $token, $taskId, $rawOutput, $parsed, $currentBranch, $currentCommit);
|
||
$this->updateTaskStatus($towerBase, $machineId, $token, $taskId, 'blocked');
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$reportRes = $this->publishReport($towerBase, $machineId, $token, $taskId, $rawOutput, $parsed, $currentBranch, $currentCommit);
|
||
|
||
if (! $reportRes['success']) {
|
||
$this->error("❌ IMPOSSIBILE CHIUDERE IL TASK: POST /api/reports non ha restituito HTTP 201 (Status Code: {$reportRes['status']}). Task {$taskId} NON aggiornato a done.");
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$reportId = $reportRes['id'];
|
||
|
||
$this->updateTaskStatus($towerBase, $machineId, $token, $taskId, 'done');
|
||
|
||
$commandId = $task['command_id'] ?? ($task['metadata']['commandId'] ?? null);
|
||
if ($commandId) {
|
||
$this->updateCommandStatus($towerBase, $machineId, $token, $commandId, 'done');
|
||
}
|
||
|
||
$this->sendHeartbeat($towerBase, $machineId, $token, $taskId);
|
||
|
||
$this->info("✅ Task {$taskId} completato con successo: Report {$reportId} (HTTP 201) registrato e stato aggiornato a 'done'.");
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
private function loadMachineEnv(): array
|
||
{
|
||
$home = getenv('HOME') ?: '/home/michele';
|
||
$path = rtrim($home, '/') . '/.nettower/etc/machine.env';
|
||
if (! file_exists($path)) {
|
||
return [];
|
||
}
|
||
|
||
$vars = [];
|
||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
foreach ($lines as $line) {
|
||
$line = trim($line);
|
||
if ($line !== '' && ! str_starts_with($line, '#') && str_contains($line, '=')) {
|
||
[$k, $v] = explode('=', $line, 2);
|
||
$vars[trim($k)] = trim(trim($v), '"\'');
|
||
}
|
||
}
|
||
return $vars;
|
||
}
|
||
|
||
private function fetchTaskById(string $towerBase, string $taskId, string $machineId, string $token): ?array
|
||
{
|
||
try {
|
||
$response = Http::timeout(10)->get("{$towerBase}/api/tasks/{$taskId}", [
|
||
'machine_id' => $machineId,
|
||
'token' => $token,
|
||
]);
|
||
if ($response->successful()) {
|
||
$data = $response->json();
|
||
return $data['task'] ?? ($data['id'] ?? null ? $data : null);
|
||
} else {
|
||
$this->error("❌ HTTP {$response->status()} durante il recupero del task {$taskId}");
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->error("❌ Errore eccezione recupero task {$taskId}: {$e->getMessage()}");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private function fetchNextTask(string $towerBase, string $machineId, string $token): ?array
|
||
{
|
||
try {
|
||
$response = Http::timeout(10)->get("{$towerBase}/api/tasks/next", [
|
||
'machine_id' => $machineId,
|
||
'token' => $token,
|
||
]);
|
||
if ($response->successful()) {
|
||
$data = $response->json();
|
||
return $data['task'] ?? ($data['id'] ?? null ? $data : null);
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->warn("Impossibile contattare /api/tasks/next: {$e->getMessage()}");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private function fetchCurrentTaskFromPacket(string $towerBase, string $machineId, string $token): ?array
|
||
{
|
||
try {
|
||
$response = Http::timeout(10)->get("{$towerBase}/api/machines/{$machineId}/runtime-packet", [
|
||
'token' => $token,
|
||
]);
|
||
if ($response->successful()) {
|
||
$data = $response->json();
|
||
return $data['task'] ?? null;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->warn("Impossibile recuperare runtime-packet: {$e->getMessage()}");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private function publishReport(
|
||
string $towerBase,
|
||
string $machineId,
|
||
string $token,
|
||
string $taskId,
|
||
string $rawOutput,
|
||
array $parsed,
|
||
string $branch,
|
||
string $commit
|
||
): array {
|
||
try {
|
||
$response = Http::timeout(10)->post("{$towerBase}/api/reports", [
|
||
'machine_id' => $machineId,
|
||
'token' => $token,
|
||
'task_id' => $taskId,
|
||
'report_type' => 'execution-report',
|
||
'raw_text' => $rawOutput,
|
||
'parsed' => $parsed,
|
||
]);
|
||
|
||
$status = $response->status();
|
||
|
||
if ($status === 201 || $response->successful()) {
|
||
$reportId = (string) ($response->json('id') ?? 'N/A');
|
||
$this->info("✔️ Report inviato alla Torre di Controllo (HTTP {$status}, Report ID: {$reportId})");
|
||
return ['success' => true, 'status' => $status, 'id' => $reportId];
|
||
} else {
|
||
$this->warn("Risposta non 201 dall'API report: HTTP {$status} - " . substr(trim($response->body()), 0, 300));
|
||
return ['success' => false, 'status' => $status, 'id' => 'N/A'];
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->error("Errore eccezione invio report: {$e->getMessage()}");
|
||
return ['success' => false, 'status' => 500, 'id' => 'N/A'];
|
||
}
|
||
}
|
||
|
||
private function updateTaskStatus(string $towerBase, string $machineId, string $token, string $taskId, string $status = 'done'): void
|
||
{
|
||
try {
|
||
$response = Http::timeout(10)->post("{$towerBase}/api/tasks/status", [
|
||
'task_id' => $taskId,
|
||
'status' => $status,
|
||
'machine_id' => $machineId,
|
||
'token' => $token,
|
||
'progress_note' => "Task {$taskId} completato e report registrato con successo",
|
||
]);
|
||
|
||
if ($response->successful()) {
|
||
$this->info("✔️ Stato task {$taskId} aggiornato a '{$status}' su Torre di Controllo.");
|
||
} else {
|
||
$this->warn("Risposta non 20x aggiornamento stato task: HTTP " . $response->status());
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->error("Errore aggiornamento stato task: {$e->getMessage()}");
|
||
}
|
||
}
|
||
|
||
private function updateCommandStatus(string $towerBase, string $machineId, string $token, string $commandId, string $status = 'done'): void
|
||
{
|
||
try {
|
||
$response = Http::timeout(10)->post("{$towerBase}/api/commands/status", [
|
||
'command_id' => $commandId,
|
||
'status' => $status,
|
||
'machine_id' => $machineId,
|
||
'token' => $token,
|
||
'progress_note' => "Command {$commandId} completato da netgescon:control-tower-poll",
|
||
]);
|
||
|
||
if ($response->successful()) {
|
||
$this->info("✔️ Stato command {$commandId} aggiornato a '{$status}' su Torre di Controllo.");
|
||
} else {
|
||
$this->warn("Risposta non 20x aggiornamento stato command: HTTP " . $response->status());
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->error("Errore aggiornamento stato command: {$e->getMessage()}");
|
||
}
|
||
}
|
||
|
||
private function sendHeartbeat(string $towerBase, string $machineId, string $token, string $taskId): void
|
||
{
|
||
try {
|
||
Http::timeout(10)->post("{$towerBase}/api/machines/heartbeat", [
|
||
'machine_id' => $machineId,
|
||
'token' => $token,
|
||
'status' => 'online',
|
||
'progress_note' => "Task {$taskId} elaborato da netgescon:control-tower-poll",
|
||
]);
|
||
} catch (Throwable $e) {
|
||
// silent heartbeat catch
|
||
}
|
||
}
|
||
}
|