first commit

This commit is contained in:
team 1
2026-04-20 16:36:28 +02:00
parent a0ec07a99c
commit 2587ac8b4b
41 changed files with 5126 additions and 2280 deletions

View File

@@ -1,6 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Command;
@@ -28,16 +27,15 @@ use Symfony\Component\Process\Process;
final class SystemRebuildCommand extends Command
{
public function __construct(
private readonly IngestJobService $jobService,
private readonly IngestOrchestrator $orchestrator,
private readonly TagNdjsonExporter $tagExporter,
private readonly TagVectorIndexBuilder $tagIndexBuilder,
private readonly IndexMetaManager $metaManager,
private readonly VectorIndexHealthService $health,
private readonly IngestJobService $jobService,
private readonly IngestOrchestrator $orchestrator,
private readonly TagNdjsonExporter $tagExporter,
private readonly TagVectorIndexBuilder $tagIndexBuilder,
private readonly IndexMetaManager $metaManager,
private readonly VectorIndexHealthService $health,
private readonly TagVectorIndexHealthService $tagHealth,
private readonly string $projectDir,
)
{
private readonly string $projectDir,
) {
parent::__construct();
}
@@ -58,16 +56,37 @@ final class SystemRebuildCommand extends Command
if (!$input->getOption('hard')) {
$io->error('Safety switch missing: you must pass --hard to run this command.');
$io->writeln('Example: bin/console mto:agent:system:rebuild --hard');
return Command::FAILURE;
}
$dryRun = (bool)$input->getOption('dry-run');
$dryRun = (bool) $input->getOption('dry-run');
$io->title('mto:agent:system:rebuild --hard');
// ---------------------------------------------------------
// 1) GLOBAL REINDEX (chunks rewrite + vector rebuild)
// ---------------------------------------------------------
if (!$this->runGlobalReindex($io, $dryRun)) {
return Command::FAILURE;
}
if (!$this->runTagRebuild($io, $input, $dryRun)) {
return Command::FAILURE;
}
if (!$this->runVectorServiceReload($io, $input, $dryRun)) {
return Command::FAILURE;
}
if (!$this->runHealthChecks($io, $input)) {
return Command::FAILURE;
}
$io->success('System rebuild finished.');
return Command::SUCCESS;
}
private function runGlobalReindex(SymfonyStyle $io, bool $dryRun): bool
{
$io->section('1/4 Global reindex (chunks + vector index)');
$job = $this->jobService->startJob(
@@ -82,141 +101,181 @@ final class SystemRebuildCommand extends Command
try {
$this->orchestrator->runExistingJob($job, $dryRun);
$io->success('Global reindex completed.');
return true;
} catch (\Throwable $e) {
$io->error('Global reindex failed: ' . $e->getMessage());
return Command::FAILURE;
return false;
}
}
// ---------------------------------------------------------
// 2) TAG REBUILD (tags.ndjson + vector_tags.index)
// ---------------------------------------------------------
if (!$input->getOption('no-tags')) {
$io->section('2/4 Tag rebuild (tags.ndjson + vector_tags.index)');
if ($dryRun) {
$io->note('dry-run enabled: tag rebuild skipped (would export + build tag index).');
} else {
try {
$export = $this->tagExporter->export();
$io->writeln('<info>Exported tags.ndjson</info>');
$io->writeln('Path: ' . $export['path']);
$io->writeln('Tags: ' . $export['tags']);
$io->writeln('Lines: ' . $export['lines']);
$io->writeln('Bytes: ' . $export['bytes']);
$this->tagIndexBuilder->build();
$io->writeln('<info>Built vector_tags.index</info>');
$this->metaManager->touchRuntime([
'last_tags_rebuild_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
]);
$io->success('Tag rebuild completed.');
} catch (\Throwable $e) {
$io->error('Tag rebuild failed: ' . $e->getMessage());
return Command::FAILURE;
}
}
} else {
private function runTagRebuild(SymfonyStyle $io, InputInterface $input, bool $dryRun): bool
{
if ((bool) $input->getOption('no-tags')) {
$io->section('2/4 Tag rebuild');
$io->note('Skipped due to --no-tags.');
return true;
}
// ---------------------------------------------------------
// 3) VECTOR SERVICE (install deps + start + reload)
// ---------------------------------------------------------
if (!$input->getOption('no-reload')) {
$io->section('3/4 Vector service reload (uvicorn)');
$io->section('2/4 Tag rebuild (tags.ndjson + vector_tags.index)');
if ($dryRun) {
$io->note('dry-run enabled: service reload skipped.');
} else {
$cmd = [
'.venv/bin/python',
'python/vector/vector_control.py',
'--install',
'--start',
'--reload',
'--port', '8090',
'--host', '0.0.0.0'
];
if ($dryRun) {
$io->note('dry-run enabled: tag rebuild skipped (would export + build tag index).');
$process = new Process($cmd, $this->projectDir);
$process->setTimeout(600);
$process->run();
return true;
}
$out = trim($process->getOutput());
$err = trim($process->getErrorOutput());
try {
$export = $this->tagExporter->export();
if ($out !== '') {
$io->writeln($out);
}
if ($err !== '') {
$io->writeln('<comment>' . $err . '</comment>');
}
$io->writeln('<info>Exported tags.ndjson</info>');
$io->writeln('Path: ' . (string) $export['path']);
$io->writeln('Tags: ' . (string) $export['tags']);
$io->writeln('Lines: ' . (string) $export['lines']);
$io->writeln('Bytes: ' . (string) $export['bytes']);
if (!$process->isSuccessful()) {
$io->error('Vector service reload failed (non-zero exit code).');
return Command::FAILURE;
}
$this->tagIndexBuilder->build();
$io->success('Vector service reloaded.');
}
} else {
$io->success('Tag rebuild completed.');
return true;
} catch (\Throwable $e) {
$io->error('Tag rebuild failed: ' . $e->getMessage());
return false;
}
}
private function runVectorServiceReload(SymfonyStyle $io, InputInterface $input, bool $dryRun): bool
{
if ((bool) $input->getOption('no-reload')) {
$io->section('3/4 Vector service reload');
$io->note('Skipped due to --no-reload.');
return true;
}
// ---------------------------------------------------------
// 4) HEALTH CHECK (NDJSON vs vector meta)
// ---------------------------------------------------------
if (!$input->getOption('no-health')) {
$io->section('4/4 Health check');
$io->section('3/4 Vector service reload (uvicorn)');
try {
$report = $this->health->check();
} catch (\Throwable $e) {
$io->error('Health check failed: ' . $e->getMessage());
return Command::FAILURE;
}
if ($dryRun) {
$io->note('dry-run enabled: service reload skipped.');
try {
$reportTag = $this->tagHealth->check();
} catch (\Throwable $e) {
$io->error('Tag health check failed: ' . $e->getMessage());
return Command::FAILURE;
}
return true;
}
$io->definitionList(
['ndjson_exists' => $report['ndjson_exists'] ? 'yes' : 'no'],
['ndjson_chunk_count' => (string)$report['ndjson_chunk_count']],
['vector_exists' => $report['vector_exists'] ? 'yes' : 'no'],
['meta_exists' => $report['meta_exists'] ? 'yes' : 'no'],
['vector_chunk_count' => (string)$report['vector_chunk_count']],
['status' => (string)$report['status']],
);
$cmd = [
'.venv/bin/python',
'python/vector/vector_control.py',
'--install',
'--start',
'--reload',
'--port', '8090',
'--host', '0.0.0.0',
];
$io->definitionList(
['tags_ndjson_exists' => $reportTag['tags_ndjson_exists'] ? 'yes' : 'no'],
['tags_ndjson_count' => (string)$reportTag['tags_ndjson_count']],
['tag_vector_exists' => $reportTag['vector_exists'] ? 'yes' : 'no'],
['tag_meta_exists' => $reportTag['meta_exists'] ? 'yes' : 'no'],
['vector_tag_count' => (string)$reportTag['vector_tag_count']],
['status' => (string)$reportTag['status']],
);
$process = new Process($cmd, $this->projectDir);
$process->setTimeout(600);
$process->run();
if (!in_array($report['status'], ['OK', 'OK_EMPTY'], true)) {
$io->error('Health check not OK: ' . $report['status']);
return Command::FAILURE;
}
$stdout = trim($process->getOutput());
$stderr = trim($process->getErrorOutput());
$io->success('Health check OK.');
} else {
if ($stdout !== '') {
$io->writeln($stdout);
}
if ($stderr !== '') {
$io->writeln('<comment>' . $stderr . '</comment>');
}
if (!$process->isSuccessful()) {
$io->error('Vector service reload failed (non-zero exit code).');
return false;
}
$io->success('Vector service reloaded.');
return true;
}
private function runHealthChecks(SymfonyStyle $io, InputInterface $input): bool
{
if ((bool) $input->getOption('no-health')) {
$io->section('4/4 Health check');
$io->note('Skipped due to --no-health.');
return true;
}
$io->success('System rebuild finished.');
return Command::SUCCESS;
$io->section('4/4 Health check');
try {
$chunkReport = $this->health->check();
} catch (\Throwable $e) {
$io->error('Health check failed: ' . $e->getMessage());
return false;
}
try {
$tagReport = $this->tagHealth->check();
} catch (\Throwable $e) {
$io->error('Tag health check failed: ' . $e->getMessage());
return false;
}
$this->renderChunkHealth($io, $chunkReport);
$this->renderTagHealth($io, $tagReport);
if (!$this->isHealthOk((string) ($chunkReport['status'] ?? 'UNKNOWN'))) {
$io->error('Chunk health check not OK: ' . (string) ($chunkReport['status'] ?? 'UNKNOWN'));
return false;
}
if (!$this->isHealthOk((string) ($tagReport['status'] ?? 'UNKNOWN'))) {
$io->error('Tag health check not OK: ' . (string) ($tagReport['status'] ?? 'UNKNOWN'));
return false;
}
$io->success('Health check OK.');
return true;
}
private function renderChunkHealth(SymfonyStyle $io, array $report): void
{
$io->definitionList(
['ndjson_exists' => !empty($report['ndjson_exists']) ? 'yes' : 'no'],
['ndjson_chunk_count' => (string) ($report['ndjson_chunk_count'] ?? 0)],
['vector_exists' => !empty($report['vector_exists']) ? 'yes' : 'no'],
['meta_exists' => !empty($report['meta_exists']) ? 'yes' : 'no'],
['vector_chunk_count' => (string) ($report['vector_chunk_count'] ?? 0)],
['status' => (string) ($report['status'] ?? 'UNKNOWN')],
);
}
private function renderTagHealth(SymfonyStyle $io, array $report): void
{
$io->definitionList(
['tags_ndjson_exists' => !empty($report['tags_ndjson_exists']) ? 'yes' : 'no'],
['tags_ndjson_count' => (string) ($report['tags_ndjson_count'] ?? 0)],
['tag_vector_exists' => !empty($report['vector_exists']) ? 'yes' : 'no'],
['tag_meta_exists' => !empty($report['meta_exists']) ? 'yes' : 'no'],
['vector_tag_count' => (string) ($report['vector_tag_count'] ?? 0)],
['tags_with_active_document_ids' => (string) ($report['tags_with_active_document_ids'] ?? 0)],
['meta_valid' => !empty($report['meta_valid']) ? 'yes' : 'no'],
['status' => (string) ($report['status'] ?? 'UNKNOWN')],
);
}
private function isHealthOk(string $status): bool
{
return in_array($status, ['OK', 'OK_EMPTY'], true);
}
}

View File

@@ -8,11 +8,13 @@ use App\Tag\TagVectorIndexHealthService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'mto:agent:tag:health',
description: 'Health-Check für TAG/FAISS Konsistenz'
description: 'Health-Check für Tag-/FAISS-Konsistenz'
)]
final class TagHealthCheckCommand extends Command
{
@@ -22,14 +24,87 @@ final class TagHealthCheckCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'summary',
null,
InputOption::VALUE_NONE,
'Gibt eine lesbare Zusammenfassung statt JSON aus.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$result = $this->health->check();
$status = trim((string) ($result['status'] ?? ''));
$output->writeln(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
if ($status === '') {
$status = 'UNKNOWN';
$result['status'] = $status;
$result['error'] = 'Health service returned no status.';
}
return str_starts_with($result['status'], 'OK')
if ((bool) $input->getOption('summary')) {
$this->renderSummary(new SymfonyStyle($input, $output), $result);
} else {
$this->renderJson($output, $result);
}
return $this->isHealthy($status)
? Command::SUCCESS
: Command::FAILURE;
}
}
/**
* @param array<string, mixed> $result
*/
private function renderSummary(SymfonyStyle $io, array $result): void
{
$io->title('Tag Vector Health');
$io->definitionList(
['status' => (string) ($result['status'] ?? 'UNKNOWN')],
['tags_ndjson_exists' => !empty($result['tags_ndjson_exists']) ? 'yes' : 'no'],
['tags_ndjson_count' => (string) ($result['tags_ndjson_count'] ?? 0)],
['vector_exists' => !empty($result['vector_exists']) ? 'yes' : 'no'],
['meta_exists' => !empty($result['meta_exists']) ? 'yes' : 'no'],
['vector_tag_count' => (string) ($result['vector_tag_count'] ?? 0)],
['meta_valid' => !empty($result['meta_valid']) ? 'yes' : 'no'],
['tags_with_active_document_ids' => (string) ($result['tags_with_active_document_ids'] ?? 0)],
);
if (!empty($result['error'])) {
$io->warning((string) $result['error']);
}
}
/**
* @param array<string, mixed> $result
*/
private function renderJson(OutputInterface $output, array $result): void
{
$json = json_encode(
$result,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
if (!is_string($json)) {
$json = json_encode([
'status' => 'UNKNOWN',
'error' => 'json_encode_failed',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($json)) {
$json = "{\"status\":\"UNKNOWN\",\"error\":\"json_encode_failed\"}";
}
}
$output->writeln($json);
}
private function isHealthy(string $status): bool
{
return in_array($status, ['OK', 'OK_EMPTY'], true);
}
}

View File

@@ -14,6 +14,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'mto:agent:tags:job:run',
@@ -39,112 +40,152 @@ final class TagRebuildRunJobCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$jobId = $input->getArgument('jobId');
$io = new SymfonyStyle($input, $output);
$jobId = trim((string) $input->getArgument('jobId'));
$create = (bool) $input->getOption('create');
if (!$create && !$jobId) {
$output->writeln('<error>You must provide either a jobId or use --create.</error>');
if (!$create && $jobId === '') {
$io->error('You must provide either a jobId or use --create.');
return Command::FAILURE;
}
if ($create && $jobId) {
$output->writeln('<error>Use either jobId OR --create, not both.</error>');
if ($create && $jobId !== '') {
$io->error('Use either jobId OR --create, not both.');
return Command::FAILURE;
}
if ($create) {
$job = new TagRebuildJob();
$this->em->persist($job);
$this->em->flush();
$jobId = $job->getId();
$output->writeln('<info>Created new TagRebuildJob: ' . $jobId . '</info>');
} else {
/** @var TagRebuildJob|null $job */
$job = $this->em->getRepository(TagRebuildJob::class)->find($jobId);
if (!$job instanceof TagRebuildJob) {
$output->writeln('<error>Job not found.</error>');
return Command::FAILURE;
}
}
$fh = null;
$job = null;
$lockHandle = null;
try {
// ---------------------------------------------------------
// LOCK INITIALIZATION
// ---------------------------------------------------------
$lockDir = \dirname($this->lockFilePath);
$job = $create ? $this->createJob($io) : $this->findJob($jobId);
$lockHandle = $this->acquireLock();
if (!\is_dir($lockDir) && !@\mkdir($lockDir, 0775, true) && !\is_dir($lockDir)) {
throw new \RuntimeException('Cannot create lock directory.');
}
$fh = @\fopen($this->lockFilePath, 'c+');
if (!$fh) {
throw new \RuntimeException('Cannot open lock file: ' . $this->lockFilePath);
}
if (!@\flock($fh, LOCK_EX | LOCK_NB)) {
throw new \RuntimeException('Another tag rebuild is currently running (lock busy).');
}
// ---------------------------------------------------------
// MARK RUNNING
// ---------------------------------------------------------
$job->markRunning();
$this->em->flush();
// ---------------------------------------------------------
// EXPORT TAGS (NDJSON)
// ---------------------------------------------------------
$export = $this->exporter->export();
$this->assertValidExport($export);
if (
!isset($export['path']) ||
!\is_string($export['path']) ||
!\file_exists($export['path'])
) {
throw new \RuntimeException('Export failed: NDJSON file missing.');
}
$io->writeln('<info>tags.ndjson exported</info>');
$io->writeln('Path: ' . (string) $export['path']);
$io->writeln('Tags: ' . (string) ($export['tags'] ?? 0));
$io->writeln('Lines: ' . (string) ($export['lines'] ?? 0));
$io->writeln('Bytes: ' . (string) ($export['bytes'] ?? 0));
if (isset($export['count']) && (int) $export['count'] === 0) {
throw new \RuntimeException('Export produced zero tags.');
}
// ---------------------------------------------------------
// BUILD VECTOR INDEX
// ---------------------------------------------------------
$this->builder->build();
// ---------------------------------------------------------
// MARK COMPLETED
// ---------------------------------------------------------
$job->markCompleted();
$this->em->flush();
$output->writeln('<info>Tag rebuild successful.</info>');
$output->writeln('NDJSON: ' . $export['path']);
$io->success('Tag rebuild successful.');
return Command::SUCCESS;
}
catch (\Throwable $e) {
if (isset($job)) {
$job->markFailed($e->getMessage());
} catch (\Throwable $e) {
if ($job instanceof TagRebuildJob) {
$job->markFailed($this->buildSafeErrorMessage($e));
$this->em->flush();
}
$output->writeln('<error>FAILED: ' . $e->getMessage() . '</error>');
$io->error('FAILED: ' . $e->getMessage());
return Command::FAILURE;
}
finally {
if ($fh) {
@\flock($fh, LOCK_UN);
@\fclose($fh);
}
} finally {
$this->releaseLock($lockHandle);
}
}
private function createJob(SymfonyStyle $io): TagRebuildJob
{
$job = new TagRebuildJob();
$this->em->persist($job);
$this->em->flush();
$io->writeln('<info>Created new TagRebuildJob: ' . (string) $job->getId() . '</info>');
return $job;
}
private function findJob(string $jobId): TagRebuildJob
{
/** @var TagRebuildJob|null $job */
$job = $this->em->getRepository(TagRebuildJob::class)->find($jobId);
if (!$job instanceof TagRebuildJob) {
throw new \RuntimeException('Job not found.');
}
return $job;
}
/**
* @return resource
*/
private function acquireLock()
{
$lockDir = \dirname($this->lockFilePath);
if (!\is_dir($lockDir) && !@\mkdir($lockDir, 0775, true) && !\is_dir($lockDir)) {
throw new \RuntimeException('Cannot create lock directory.');
}
$handle = @\fopen($this->lockFilePath, 'c+');
if ($handle === false) {
throw new \RuntimeException('Cannot open lock file: ' . $this->lockFilePath);
}
if (!@\flock($handle, LOCK_EX | LOCK_NB)) {
@\fclose($handle);
throw new \RuntimeException('Another tag rebuild is currently running (lock busy).');
}
return $handle;
}
/**
* @param resource|null $handle
*/
private function releaseLock($handle): void
{
if (!is_resource($handle)) {
return;
}
@\flock($handle, LOCK_UN);
@\fclose($handle);
}
/**
* @param array<string, mixed> $export
*/
private function assertValidExport(array $export): void
{
$path = trim((string) ($export['path'] ?? ''));
if ($path === '' || !\is_file($path)) {
throw new \RuntimeException('Export failed: NDJSON file missing.');
}
$tags = (int) ($export['tags'] ?? 0);
$lines = (int) ($export['lines'] ?? 0);
if ($tags < 0 || $lines < 0) {
throw new \RuntimeException('Export returned invalid statistics.');
}
}
private function buildSafeErrorMessage(\Throwable $e): string
{
$message = trim($e->getMessage());
if ($message === '') {
return 'Unknown tag rebuild failure.';
}
return mb_substr($message, 0, 4000);
}
}

View File

@@ -9,6 +9,7 @@ use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'mto:agent:tags:export',
@@ -17,26 +18,51 @@ use Symfony\Component\Console\Output\OutputInterface;
final class TagsExportCommand extends Command
{
public function __construct(
private TagNdjsonExporter $exporter,
private readonly TagNdjsonExporter $exporter,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->exporter->export();
$this->assertValidExport($result);
$io->writeln('<info>Tags NDJSON exported</info>');
$io->writeln('Path: ' . (string) ($result['path'] ?? ''));
$io->writeln('Tags: ' . (string) ($result['tags'] ?? 0));
$io->writeln('Lines: ' . (string) ($result['lines'] ?? 0));
$io->writeln('Bytes: ' . (string) ($result['bytes'] ?? 0));
$io->success('Tag export completed.');
return Command::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>ERROR: ' . $e->getMessage() . '</error>');
$io->error($e->getMessage());
return Command::FAILURE;
}
}
$output->writeln('<info>Tags NDJSON exported</info>');
$output->writeln('Path: ' . $result['path']);
$output->writeln('Tags: ' . $result['tags']);
$output->writeln('Lines: ' . $result['lines']);
$output->writeln('Bytes: ' . $result['bytes']);
/**
* @param array<string, mixed> $result
*/
private function assertValidExport(array $result): void
{
$path = trim((string) ($result['path'] ?? ''));
return Command::SUCCESS;
if ($path === '' || !is_file($path)) {
throw new \RuntimeException('Tag export failed: tags.ndjson is missing.');
}
$tags = (int) ($result['tags'] ?? 0);
$lines = (int) ($result['lines'] ?? 0);
$bytes = (int) ($result['bytes'] ?? 0);
if ($tags < 0 || $lines < 0 || $bytes < 0) {
throw new \RuntimeException('Tag export returned invalid statistics.');
}
}
}

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Command;
use App\Index\IndexMetaManager;
use App\Tag\TagNdjsonExporter;
use App\Tag\TagVectorIndexBuilder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'mto:agent:tags:rebuild',
@@ -21,45 +21,54 @@ final class TagsRebuildCommand extends Command
public function __construct(
private readonly TagNdjsonExporter $exporter,
private readonly TagVectorIndexBuilder $builder,
private readonly IndexMetaManager $metaManager,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
// -----------------------------------------
// 1) Export tags.ndjson
// -----------------------------------------
$export = $this->exporter->export();
$this->assertValidExport($export);
$output->writeln('<info>1/3 Exported tags.ndjson</info>');
$output->writeln('Path: ' . $export['path']);
$output->writeln('Tags: ' . $export['tags']);
$output->writeln('Lines: ' . $export['lines']);
$output->writeln('Bytes: ' . $export['bytes']);
$io->writeln('<info>1/2 Exported tags.ndjson</info>');
$io->writeln('Path: ' . (string) ($export['path'] ?? ''));
$io->writeln('Tags: ' . (string) ($export['tags'] ?? 0));
$io->writeln('Lines: ' . (string) ($export['lines'] ?? 0));
$io->writeln('Bytes: ' . (string) ($export['bytes'] ?? 0));
// -----------------------------------------
// 2) Build FAISS tag index
// -----------------------------------------
$this->builder->build();
$output->writeln('<info>2/3 Built vector_tags.index</info>');
$io->writeln('<info>2/2 Built vector_tags.index</info>');
$io->success('Tag rebuild completed.');
// -----------------------------------------
// 3) Enterprise Commit Marker
// -----------------------------------------
$this->metaManager->touchRuntime([
'last_tags_rebuild_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
]);
$output->writeln('<info>3/3 Runtime commit marker updated</info>');
return Command::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>ERROR: ' . $e->getMessage() . '</error>');
$io->error($e->getMessage());
return Command::FAILURE;
}
}
return Command::SUCCESS;
/**
* @param array<string, mixed> $export
*/
private function assertValidExport(array $export): void
{
$path = trim((string) ($export['path'] ?? ''));
if ($path === '' || !is_file($path)) {
throw new \RuntimeException('Tag export failed: tags.ndjson is missing.');
}
$tags = (int) ($export['tags'] ?? 0);
$lines = (int) ($export['lines'] ?? 0);
$bytes = (int) ($export['bytes'] ?? 0);
if ($tags < 0 || $lines < 0 || $bytes < 0) {
throw new \RuntimeException('Tag export returned invalid statistics.');
}
}
}