fix(ops): connect ControlTowerPollCommand directly to agy wrapper via stdin and add mock tests

This commit is contained in:
michele 2026-08-10 00:55:01 +02:00
parent a159c034f8
commit 24825cbb2c
2 changed files with 237 additions and 131 deletions

View File

@ -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';

View File

@ -0,0 +1,172 @@
<?php
use Illuminate\Support\Facades\Http;
use Illuminate\Console\Command;
beforeEach(function () {
Http::preventStrayRequests();
});
it('publishes report when wrapper returns NETTOWER_AGY_ULTRA_WRAPPER_OK', function () {
$taskId = 'task-test-001';
Http::fake([
'http://192.168.0.53:4174/api/tasks/next*' => 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');
});
});