first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user