fix(ops): connect ControlTowerPollCommand directly to agy wrapper via stdin and add mock tests
This commit is contained in:
parent
a159c034f8
commit
24825cbb2c
|
|
@ -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';
|
||||
|
|
|
|||
172
tests/Feature/ControlTowerPollCommandTest.php
Normal file
172
tests/Feature/ControlTowerPollCommandTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user