## Summary - add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories - persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract - add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering ## Validation - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` ## Notes - verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape - excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #193
154 lines
4.8 KiB
PHP
154 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use Illuminate\Console\Command;
|
|
|
|
class PurgeLegacyBaselineGapRuns extends Command
|
|
{
|
|
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
|
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
|
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
|
{--workspace=* : Limit cleanup to workspace ids}
|
|
{--limit=500 : Maximum candidate runs to inspect}
|
|
{--force : Actually delete matched legacy runs}';
|
|
|
|
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
|
|
|
public function handle(): int
|
|
{
|
|
if (! app()->environment(['local', 'testing'])) {
|
|
$this->error('This cleanup command is limited to local and testing environments.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$types = $this->normalizedTypes();
|
|
$workspaceIds = array_values(array_filter(
|
|
array_map(
|
|
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
|
(array) $this->option('workspace'),
|
|
),
|
|
static fn (int $workspaceId): bool => $workspaceId > 0,
|
|
));
|
|
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
|
$limit = max(1, (int) $this->option('limit'));
|
|
$dryRun = ! (bool) $this->option('force');
|
|
|
|
$query = OperationRun::query()
|
|
->whereIn('type', $types)
|
|
->orderBy('id')
|
|
->limit($limit);
|
|
|
|
if ($workspaceIds !== []) {
|
|
$query->whereIn('workspace_id', $workspaceIds);
|
|
}
|
|
|
|
if ($tenantIds !== []) {
|
|
$query->whereIn('tenant_id', $tenantIds);
|
|
}
|
|
|
|
$candidates = $query->get();
|
|
$matched = $candidates
|
|
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
|
->values();
|
|
|
|
if ($matched->isEmpty()) {
|
|
$this->info('No legacy baseline gap runs matched the current filters.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->table(
|
|
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
|
$matched
|
|
->map(fn (OperationRun $run): array => [
|
|
'Run' => (string) $run->getKey(),
|
|
'Type' => (string) $run->type,
|
|
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
|
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
|
'Legacy signal' => $this->legacySignal($run),
|
|
])
|
|
->all(),
|
|
);
|
|
|
|
if ($dryRun) {
|
|
$this->warn(sprintf(
|
|
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
|
$matched->count(),
|
|
));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
OperationRun::query()
|
|
->whereKey($matched->modelKeys())
|
|
->delete();
|
|
|
|
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizedTypes(): array
|
|
{
|
|
$types = array_values(array_unique(array_filter(
|
|
array_map(
|
|
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
|
(array) $this->option('type'),
|
|
),
|
|
)));
|
|
|
|
if ($types === []) {
|
|
return ['baseline_compare', 'baseline_capture'];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$types,
|
|
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $tenantIdentifiers
|
|
* @return array<int, int>
|
|
*/
|
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
|
{
|
|
if ($tenantIdentifiers === []) {
|
|
return [];
|
|
}
|
|
|
|
$tenantIds = [];
|
|
|
|
foreach ($tenantIdentifiers as $identifier) {
|
|
$tenant = Tenant::query()->forTenant($identifier)->first();
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
$tenantIds[] = (int) $tenant->getKey();
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($tenantIds));
|
|
}
|
|
|
|
private function legacySignal(OperationRun $run): string
|
|
{
|
|
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
|
$byReason = is_array($byReason) ? $byReason : [];
|
|
|
|
if (array_key_exists('policy_not_found', $byReason)) {
|
|
return 'legacy_reason_code';
|
|
}
|
|
|
|
return 'legacy_subject_shape';
|
|
}
|
|
}
|