feat(ai-edits): add output storage backfill flow and coverage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-07 10:10:45 +01:00
parent fb45d1f6ab
commit 8cc0918881
18 changed files with 1610 additions and 18 deletions

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditOutputStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsBackfillStorageCommand extends Command
{
protected $signature = 'ai-edits:backfill-storage
{--request-id= : Restrict backfill to one AI edit request id}
{--limit=200 : Maximum outputs to process}
{--pretend : Dry run without writing changes}';
protected $description = 'Backfill local storage paths for AI outputs that only have provider URLs.';
public function __construct(private readonly AiEditOutputStorageService $outputStorage)
{
parent::__construct();
}
public function handle(): int
{
$limit = max(1, (int) $this->option('limit'));
$requestId = $this->normalizeRequestId($this->option('request-id'));
$pretend = (bool) $this->option('pretend');
$query = AiEditOutput::query()
->with('request')
->whereNotNull('provider_url')
->where(function (Builder $builder): void {
$builder
->whereNull('storage_path')
->orWhere('storage_path', '');
})
->orderBy('id');
if ($requestId !== null) {
$query->where('request_id', $requestId);
}
$candidateCount = (clone $query)->count();
$outputs = $query->limit($limit)->get();
if ($outputs->isEmpty()) {
$this->info('No AI outputs require storage backfill.');
return self::SUCCESS;
}
$this->line(sprintf(
'AI output backfill candidates: %d (processing up to %d).',
$candidateCount,
$limit
));
if ($pretend) {
$this->table(
['Output ID', 'Request ID', 'Provider URL'],
$outputs->map(static fn (AiEditOutput $output): array => [
(string) $output->id,
(string) $output->request_id,
(string) $output->provider_url,
])->all()
);
$this->info('Pretend mode enabled. No records were changed.');
return self::SUCCESS;
}
$processed = 0;
$stored = 0;
$failed = 0;
foreach ($outputs as $output) {
$processed++;
$request = $output->request;
if (! $request instanceof AiEditRequest) {
$failed++;
$this->warn(sprintf('Output %d skipped: missing request relation.', $output->id));
continue;
}
$persisted = $this->outputStorage->persist($request, [
'provider_url' => $output->provider_url,
'provider_asset_id' => $output->provider_asset_id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'bytes' => $output->bytes,
'checksum' => $output->checksum,
'metadata' => $output->metadata,
]);
$output->forceFill([
'provider_url' => $persisted['provider_url'] ?? $output->provider_url,
'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk,
'storage_path' => $persisted['storage_path'] ?? $output->storage_path,
'mime_type' => $persisted['mime_type'] ?? $output->mime_type,
'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width,
'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height,
'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes,
'checksum' => $persisted['checksum'] ?? $output->checksum,
'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata,
])->save();
$storagePath = trim((string) ($output->storage_path ?? ''));
if ($storagePath !== '') {
$stored++;
} else {
$failed++;
$this->warn(sprintf('Output %d could not be persisted locally.', $output->id));
}
}
$this->info(sprintf(
'AI output backfill complete: processed=%d stored=%d failed=%d.',
$processed,
$stored,
$failed
));
return self::SUCCESS;
}
private function normalizeRequestId(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$requestId = (int) $value;
return $requestId > 0 ? $requestId : null;
}
}