netgescon-day0/app/Console/Commands/ControlTowerPollCommand.php

387 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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
}
}
}