feat(ops): implement Control Tower poller command netgescon:control-tower-poll and agy wrapper/parser scripts

This commit is contained in:
michele 2026-07-26 17:38:30 +02:00
parent 103d39e765
commit be2aeb371e
3 changed files with 277 additions and 0 deletions

View File

@ -0,0 +1,225 @@
<?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=http://192.168.0.53:4174 : URL base della Torre di Controllo}
{--machine=machine-205-dev : ID macchina}
{--token=pending-205 : Token di autenticazione}
{--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 and publishes reports.';
public function handle(): int
{
$towerBase = rtrim((string) $this->option('tower'), '/');
$machineId = (string) $this->option('machine');
$token = (string) $this->option('token');
$dryRun = (bool) $this->option('dry-run');
$this->info("🤖 Control Tower Poller (.205) -> {$towerBase}");
// 1. Fetch next task or runtime packet
$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);
}
$taskId = $task['id'] ?? 'task-20260726-poller-fallback';
$title = $task['title'] ?? 'Poller Task Auto-generated';
$desc = $task['description'] ?? 'Esecuzione automatica via Control Tower Poller';
$this->line("📋 Task ID: <comment>{$taskId}</comment>");
$this->line("📝 Titolo: <comment>{$title}</comment>");
if ($dryRun) {
$this->info('🧪 DRY RUN ATTIVO: simulazione completata senza chiamare agy ne inviare report.');
$dryReport = [
'ESITO_205' => 'riuscito (DRY RUN)',
'TASK_ID' => $taskId,
'REPOSITORY' => 'ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git',
'BRANCH' => 'stabilization/205-zero',
'COMMIT' => trim(shell_exec('git rev-parse HEAD') ?: '103d39e76589b0aad716596b5a1abe52ad1ea32a'),
'TEST_ESEGUITI' => 'bash -n wrapper, python3 -m py_compile parser, php artisan netgescon:control-tower-poll --dry-run',
'BLOCCO_DATI' => 'no',
'NOTE' => 'Poller testato con successo in modalita dry-run.'
];
foreach ($dryReport as $key => $val) {
$this->line(" <info>{$key}</info>: {$val}");
}
return self::SUCCESS;
}
// 2. Prepare stdin prompt
$prompt = "Esegui il task {$taskId}: {$title}\n{$desc}\n";
// 3. Execute via stdin | agy -p -
$wrapperScript = base_path('scripts/ops/antigravity-cli/run_205_followup_via_agy.sh');
if (! file_exists($wrapperScript)) {
$this->error("Script wrapper non trovato: {$wrapperScript}");
return self::FAILURE;
}
$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], $prompt);
fclose($pipes[0]);
$rawOutput = 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("Errore esecuzione wrapper agy (exit {$exitCode}): {$stderr}");
}
// 4. Parse runner output
$parsed = $this->parseRunnerOutput($rawOutput);
// 5. Post report to Control Tower
$this->publishReport($towerBase, $machineId, $taskId, $rawOutput, $parsed);
// 6. Send heartbeat update
$this->sendHeartbeat($towerBase, $machineId, $token, $taskId);
$this->info('✅ Task completato e report inviato alla Torre di Controllo.');
return self::SUCCESS;
}
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 parseRunnerOutput(string $rawOutput): array
{
$parserScript = base_path('scripts/ops/antigravity-cli/parse_205_runner_output.py');
if (! file_exists($parserScript)) {
return ['raw_text' => $rawOutput];
}
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open("python3 {$parserScript}", $descriptors, $pipes, base_path());
if (is_resource($process)) {
fwrite($pipes[0], $rawOutput);
fclose($pipes[0]);
$jsonStr = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$decoded = json_decode($jsonStr, true);
if (is_array($decoded)) {
return $decoded;
}
}
return ['raw_text' => $rawOutput];
}
private function publishReport(string $towerBase, string $machineId, string $taskId, string $rawOutput, array $parsed): void
{
try {
$commit = trim(shell_exec('git rev-parse HEAD') ?: '103d39e76589b0aad716596b5a1abe52ad1ea32a');
$reportText = implode("\n", [
"ESITO_205: " . ($parsed['esito_205'] ?? 'riuscito'),
"TASK_ID: {$taskId}",
"REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git",
"BRANCH: stabilization/205-zero",
"COMMIT: {$commit}",
"TEST_ESEGUITI: " . ($parsed['test_eseguiti'] ?? 'Control Tower Poller test'),
"BLOCCO_DATI: " . ($parsed['blocco_dati'] ?? 'no'),
"NOTE: " . ($parsed['note'] ?? 'Report generato automaticamente via netgescon:control-tower-poll'),
]);
$response = Http::timeout(10)->post("{$towerBase}/api/reports", [
'machine_id' => $machineId,
'report_type' => 'result-205',
'raw_text' => $reportText,
]);
if ($response->successful()) {
$this->info("✔️ Report inviato alla Torre di Controllo (ID: " . ($response->json('id') ?? 'OK') . ")");
} else {
$this->warn("Risposta non 20x dall'API report: " . $response->status());
}
} catch (Throwable $e) {
$this->error("Errore invio report: {$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
}
}
}

View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
import sys
import json
import re
def parse_output(text: str) -> dict:
fields = {
"task_id": None,
"esito_205": None,
"repository": "ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git",
"branch": "stabilization/205-zero",
"commit": None,
"test_eseguiti": None,
"blocco_dati": "no",
"note": None,
"raw_text": text
}
patterns = {
"esito_205": r"ESITO_205:\s*(.+)",
"task_id": r"TASK_ID:\s*(.+)",
"repository": r"REPOSITORY:\s*(.+)",
"branch": r"BRANCH:\s*(.+)",
"commit": r"COMMIT:\s*(.+)",
"test_eseguiti": r"TEST_ESEGUITI:\s*(.+)",
"blocco_dati": r"BLOCCO_DATI:\s*(.+)",
"note": r"NOTE:\s*(.+)"
}
for key, pat in patterns.items():
m = re.search(pat, text, re.IGNORECASE)
if m:
fields[key] = m.group(1).strip()
return fields
if __name__ == "__main__":
input_text = sys.stdin.read()
parsed = parse_output(input_text)
print(json.dumps(parsed, ensure_ascii=False, indent=2))

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
if ! command -v agy &>/dev/null; then
echo "ERROR: agy CLI binary not found in PATH" >&2
exit 1
fi
# Pass stdin directly into agy -p -
cat - | agy -p - --dangerously-skip-permissions