diff --git a/app/Console/Commands/ControlTowerPollCommand.php b/app/Console/Commands/ControlTowerPollCommand.php index 1d09b1f..6119b46 100644 --- a/app/Console/Commands/ControlTowerPollCommand.php +++ b/app/Console/Commands/ControlTowerPollCommand.php @@ -13,9 +13,10 @@ class ControlTowerPollCommand extends Command {--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 stdin | agy -p -, parses runner output, publishes reports, and marks tasks as done.'; + 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 { @@ -28,7 +29,6 @@ public function handle(): int $this->info("🤖 Control Tower Poller (.205) -> {$towerBase}"); - // 1. Fetch task $task = null; if ($taskIdOpt) { $task = ['id' => $taskIdOpt, 'title' => "Task {$taskIdOpt}", 'description' => "Esecuzione manuale task {$taskIdOpt}"]; @@ -44,7 +44,6 @@ public function handle(): int $desc = $task['description'] ?? 'Esecuzione automatica via Control Tower Poller'; $meta = $task['metadata'] ?? []; - // 1. Idempotency Check: if task status is already done or blocked, skip 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; @@ -61,32 +60,81 @@ public function handle(): int return self::SUCCESS; } - // 2. Build full prompt from task details (title, description, metadata) - $promptContext = implode("\n", [ + $promptInput = implode("\n", [ "TASK_ID: {$taskId}", "TITOLO: {$title}", "DESCRIZIONE:\n{$desc}", - "METADATA:\n" . json_encode($meta, JSON_PRETTY_PRINT), - "WHAT_EXPECTED: " . ($meta['whatExpected'] ?? ($meta['risultatoAtteso'] ?? 'Verifica audit dati')), - "CONSTRAINTS: " . ($meta['constraints'] ?? ($meta['vincoli'] ?? 'Nessun fallback')), - "REAL_CASES: " . ($meta['realCases'] ?? ($meta['casiReali'] ?? 'Stabile 0021')), + "METADATA:\n" . json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), ]); - // 3. Dynamic Task-Specific Report Processing - $taskReport = $this->buildTaskSpecificReport($taskId, $title, $desc, $meta, $promptContext, $currentBranch, $currentCommit); + $wrapperScript = (string) ($this->option('wrapper-script') ?: base_path('scripts/ops/antigravity-cli/run_205_followup_via_agy.sh')); - $rawOutput = $taskReport['raw_text']; - $parsed = $taskReport['parsed']; + if (! file_exists($wrapperScript)) { + $this->error("❌ Script wrapper non trovato: {$wrapperScript}"); + return self::FAILURE; + } - // 4. Validate mandatory fields for task execution - if ($taskReport['esito_205'] === 'bloccato' || ($parsed['blocco_contratto'] ?? 'no') === 'si') { - $this->error("❌ TASK BLOCCATO: Mancano campi obbligatori specifici richiesti dal task."); + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($wrapperScript, $descriptors, $pipes, base_path()); + 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); + + if ($exitCode !== 0) { + $this->error("❌ WRAPPER AGY FALLITO (Exit Code: {$exitCode}): {$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; } - // 5. Post report to Control Tower (REQUIRES HTTP 201 TO PROCEED) $reportRes = $this->publishReport($towerBase, $machineId, $token, $taskId, $rawOutput, $parsed, $currentBranch, $currentCommit); if (! $reportRes['success']) { @@ -96,133 +144,19 @@ public function handle(): int $reportId = $reportRes['id']; - // 6. Update task status in Control Tower to 'done' (ONLY AFTER HTTP 201) $this->updateTaskStatus($towerBase, $machineId, $token, $taskId, 'done'); - // 7. Close associated command if present $commandId = $task['command_id'] ?? ($task['metadata']['commandId'] ?? null); if ($commandId) { $this->updateCommandStatus($towerBase, $machineId, $token, $commandId, 'done'); } - // 8. Send heartbeat update $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 buildTaskSpecificReport( - string $taskId, - string $title, - string $description, - array $metadata, - string $promptContext, - string $branch, - string $commit - ): array { - $fullText = strtolower($title . ' ' . $description . ' ' . json_encode($metadata)); - - $requiresAliasFields = str_contains($fullText, '5bdee6bff2') - || str_contains($fullText, '1749') - || str_contains($fullText, '0021-a-220') - || str_contains($fullText, 'ui_checks'); - - $requiresAuditFields = str_contains($fullText, 'subalterno') - || str_contains($fullText, 'millesimi') - || str_contains($fullText, 'piano') - || str_contains($fullText, 'acan12') - || str_contains($fullText, 'simulato'); - - $baseDir = base_path('storage/app/amministratori/HWFGITXK/legacy/0021'); - $generaleMdb = "{$baseDir}/generale_stabile.mdb"; - - $mdbFiles = []; - if (file_exists($generaleMdb)) { - $mdbFiles[] = $generaleMdb; - } - foreach (['0001', '0003', '0004'] as $d) { - $f = "{$baseDir}/{$d}/singolo_anno.mdb"; - if (file_exists($f)) { - $mdbFiles[] = $f; - } - } - - $parsedData = [ - 'task_id' => $taskId, - 'esito_205' => 'riuscito', - 'repository' => 'ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git', - 'branch' => $branch, - 'commit' => $commit, - 'consolidated_unit_id' => $requiresAliasFields ? '1749' : '1741', - 'dates_used' => ['06/08/26 00:00:00 (08/06/2026)', '06/09/26 00:00:00 (09/06/2026)'], - 'absorbed_legacy_fragments' => ['id_cond=12 (ATER A/11)', 'id_cond=220 (BENEDETTO DANIELA A/11)', 'id_cond=46/217 (ATER A/CAN11)', 'id_cond=221 (BENEDETTO DANIELA A/CAN11)'], - 'open_legacy_fragments' => [], - 'test_eseguiti' => "php artisan netgescon:control-tower-poll --task-id={$taskId}", - 'blocco_dati' => 'no', - 'blocco_contratto' => 'no', - 'mdb_files_read' => $mdbFiles, - 'years_checked' => ['2024 (0001)', '2025 (0003)', '2026 (0004)'], - ]; - - if ($requiresAliasFields) { - $parsedData['unita_id_a11'] = '1749'; - $parsedData['codice_before'] = '0021-A-220'; - $parsedData['codice_after'] = '0021-A-11'; - $parsedData['db_proof_before_after'] = 'PRIMA: unita_id=1749, codice_unita=0021-A-220 | DOPO: unita_id=1749, codice_unita=0021-A-11'; - $parsedData['timeline_status'] = 'invariata (ATER fino 08/06/2026, BENEDETTO DANIELA / FERRANTE BIAGIO dal 09/06/2026)'; - $parsedData['catasto_piano_millesimi_status'] = 'null'; - $parsedData['ui_checks'] = ['unita-immobiliari', 'nominativi', 'rubrica/93', 'unita_id=1749', 'unita_id=1669']; - $parsedData['note'] = 'Verifica task 5bdee6bff2 completata con successo: unita_id=1749 codice_after=0021-A-11.'; - } elseif ($requiresAuditFields) { - $parsedData['subalterno'] = 'sub 12 (A/12), sub CAN/12 (CAN/12), sub CAN/11 (CAN/11)'; - $parsedData['millesimi'] = 'millesimi_proprieta (da tabella condomin)'; - $parsedData['piano'] = 'piano (da tabella condomin)'; - $parsedData['stato_attivo_soppresso'] = 'attivo fino al 06/08/26 (ATER) / attivo dal 06/09/26 (BENEDETTO DANIELA)'; - $parsedData['acan12'] = 'id_cond=47 (cod_cond=46/48/51, CAN/12 ATER / Pallotta Maria Luisa)'; - $parsedData['source_file'] = 'singolo_anno.mdb'; - $parsedData['source_table'] = 'condomin'; - $parsedData['source_year'] = '0001, 0003, 0004'; - $parsedData['source_field'] = 'id_cond, cod_cond, scala, int, nom_cond, subentrato_dal, attivo_fino_al'; - $parsedData['note'] = 'Audit task-specifico completato con verifica di subalterno, millesimi, piano, stato attivo/soppresso, A/CAN12 e sorgenti MDB.'; - } else { - $parsedData['note'] = 'Esecuzione task completata via Control Tower Poller.'; - } - - // Check validation: fail if required fields are missing - if ($requiresAliasFields) { - $aliasRequiredKeys = ['unita_id_a11', 'codice_before', 'codice_after', 'db_proof_before_after', 'timeline_status', 'catasto_piano_millesimi_status', 'ui_checks']; - foreach ($aliasRequiredKeys as $k) { - if (! isset($parsedData[$k]) || (is_array($parsedData[$k]) ? empty($parsedData[$k]) : $parsedData[$k] === '')) { - $parsedData['esito_205'] = 'bloccato'; - $parsedData['blocco_contratto'] = 'si'; - break; - } - } - } elseif ($requiresAuditFields) { - $auditRequiredKeys = ['subalterno', 'millesimi', 'piano', 'stato_attivo_soppresso', 'acan12', 'source_file', 'source_table', 'source_year', 'source_field']; - foreach ($auditRequiredKeys as $k) { - if (! isset($parsedData[$k]) || $parsedData[$k] === '') { - $parsedData['esito_205'] = 'bloccato'; - $parsedData['blocco_contratto'] = 'si'; - break; - } - } - } - - $rawTextLines = []; - foreach ($parsedData as $k => $v) { - $valStr = is_array($v) ? implode(', ', $v) : (string) $v; - $rawTextLines[] = strtoupper($k) . ': ' . $valStr; - } - - return [ - 'esito_205' => $parsedData['esito_205'], - 'raw_text' => implode("\n", $rawTextLines), - 'parsed' => $parsedData, - ]; - } - private function loadMachineEnv(): array { $home = getenv('HOME') ?: '/home/michele'; diff --git a/tests/Feature/ControlTowerPollCommandTest.php b/tests/Feature/ControlTowerPollCommandTest.php new file mode 100644 index 0000000..8f0f650 --- /dev/null +++ b/tests/Feature/ControlTowerPollCommandTest.php @@ -0,0 +1,172 @@ + Http::response(['task' => ['id' => $taskId, 'title' => 'Test Task', 'description' => 'Test Desc']], 200), + 'http://192.168.0.53:4174/api/reports' => Http::response(['id' => 'rep-123'], 201), + 'http://192.168.0.53:4174/api/tasks/status' => Http::response(['status' => 'done'], 200), + 'http://192.168.0.53:4174/api/machines/heartbeat' => Http::response(['status' => 'online'], 200), + ]); + + $mockScript = tempnam(sys_get_temp_dir(), 'mock_wrapper_') . '.sh'; + $jsonOutput = json_encode([ + 'ok' => true, + 'parsed' => [ + 'task_id' => $taskId, + 'esito_205' => 'riuscito', + 'repository' => 'ssh://git@192.168.0.53:2222/michele/netgescon-day0.git', + 'branch' => 'stabilization/205-zero', + 'commit' => 'a159c03799bd942bb6c8a74e50eb9ddf9a74aefb', + 'consolidated_unit_id' => '1545', + 'blocco_dati' => 'no', + 'dates_used' => '2026-08-10', + 'test_eseguiti' => 'NETTOWER_AGY_ULTRA_WRAPPER_OK', + 'note' => 'Test OK', + 'absorbed_legacy_fragments' => 'id_cond=12', + 'raw_text' => 'NETTOWER_AGY_ULTRA_WRAPPER_OK', + ], + ], JSON_UNESCAPED_SLASHES); + + file_put_contents($mockScript, "#!/usr/bin/env bash\ncat - >/dev/null\necho '{$jsonOutput}'\nexit 0\n"); + chmod($mockScript, 0755); + + $exitCode = $this->artisan('netgescon:control-tower-poll', [ + '--task-id' => $taskId, + '--wrapper-script' => $mockScript, + ])->run(); + + @unlink($mockScript); + + expect($exitCode)->toBe(Command::SUCCESS); + + Http::assertSent(function ($request) use ($taskId) { + return str_contains($request->url(), '/api/reports') && + ($request['task_id'] ?? null) === $taskId && + str_contains($request['raw_text'] ?? '', 'NETTOWER_AGY_ULTRA_WRAPPER_OK'); + }); +}); + +it('fails when wrapper returns old A/11 template non-JSON', function () { + $taskId = 'task-test-001'; + + Http::fake([ + 'http://192.168.0.53:4174/api/tasks/next*' => Http::response(['task' => ['id' => $taskId]], 200), + ]); + + $mockScript = tempnam(sys_get_temp_dir(), 'mock_wrapper_') . '.sh'; + $oldA11Report = "ESITO_205: riuscito\nTASK_ID: {$taskId}\nUNITA_ID_A11: 1749\nCODICE_BEFORE: 0021-A-220\nCODICE_AFTER: 0021-A-11"; + + file_put_contents($mockScript, "#!/usr/bin/env bash\ncat - >/dev/null\necho '{$oldA11Report}'\nexit 0\n"); + chmod($mockScript, 0755); + + $exitCode = $this->artisan('netgescon:control-tower-poll', [ + '--task-id' => $taskId, + '--wrapper-script' => $mockScript, + ])->run(); + + @unlink($mockScript); + + expect($exitCode)->toBe(Command::FAILURE); + + Http::assertNotSent(function ($request) { + return str_contains($request->url(), '/api/reports'); + }); +}); + +it('fails and does not publish report when wrapper exits with code 1', function () { + $taskId = 'task-test-001'; + + Http::fake([ + 'http://192.168.0.53:4174/api/tasks/next*' => Http::response(['task' => ['id' => $taskId]], 200), + ]); + + $mockScript = tempnam(sys_get_temp_dir(), 'mock_wrapper_') . '.sh'; + file_put_contents($mockScript, "#!/usr/bin/env bash\necho 'ERROR: CLI failed' >&2\nexit 1\n"); + chmod($mockScript, 0755); + + $exitCode = $this->artisan('netgescon:control-tower-poll', [ + '--task-id' => $taskId, + '--wrapper-script' => $mockScript, + ])->run(); + + @unlink($mockScript); + + expect($exitCode)->toBe(Command::FAILURE); + + Http::assertNotSent(function ($request) { + return str_contains($request->url(), '/api/reports'); + }); +}); + +it('fails and does not publish report when parsed task_id differs from current task', function () { + $taskId = 'task-test-001'; + + Http::fake([ + 'http://192.168.0.53:4174/api/tasks/next*' => Http::response(['task' => ['id' => $taskId]], 200), + ]); + + $mockScript = tempnam(sys_get_temp_dir(), 'mock_wrapper_') . '.sh'; + $jsonOutput = json_encode([ + 'ok' => true, + 'parsed' => [ + 'task_id' => 'DIFFERENT_TASK_999', + 'esito_205' => 'riuscito', + ], + ], JSON_UNESCAPED_SLASHES); + + file_put_contents($mockScript, "#!/usr/bin/env bash\necho '{$jsonOutput}'\nexit 0\n"); + chmod($mockScript, 0755); + + $exitCode = $this->artisan('netgescon:control-tower-poll', [ + '--task-id' => $taskId, + '--wrapper-script' => $mockScript, + ])->run(); + + @unlink($mockScript); + + expect($exitCode)->toBe(Command::FAILURE); + + Http::assertNotSent(function ($request) { + return str_contains($request->url(), '/api/reports'); + }); +}); + +it('fails and does not publish report when JSON ok=false', function () { + $taskId = 'task-test-001'; + + Http::fake([ + 'http://192.168.0.53:4174/api/tasks/next*' => Http::response(['task' => ['id' => $taskId]], 200), + ]); + + $mockScript = tempnam(sys_get_temp_dir(), 'mock_wrapper_') . '.sh'; + $jsonOutput = json_encode([ + 'ok' => false, + 'missing' => ['task_id', 'esito_205'], + 'parsed' => [], + ], JSON_UNESCAPED_SLASHES); + + file_put_contents($mockScript, "#!/usr/bin/env bash\necho '{$jsonOutput}'\nexit 0\n"); + chmod($mockScript, 0755); + + $exitCode = $this->artisan('netgescon:control-tower-poll', [ + '--task-id' => $taskId, + '--wrapper-script' => $mockScript, + ])->run(); + + @unlink($mockScript); + + expect($exitCode)->toBe(Command::FAILURE); + + Http::assertNotSent(function ($request) { + return str_contains($request->url(), '/api/reports'); + }); +});