From 2cf8c449ad61e6f456e11231fa4b40bb5a85fe4a Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Fri, 6 Mar 2026 10:22:42 -0700 Subject: [PATCH] feat: add reindex:all and daemon:install commands - `reindex:all` incrementally re-indexes and vectorizes all git repos in a configurable base path (default ~/projects). Supports --kind, --skip-vectorize filters. - `daemon:install` generates and installs systemd timer units with the current user/paths. Three timers: enhance (15min), sync (30min), reindex (6h). Supports --status and --uninstall. No bash scripts, no hardcoded paths. Portable across any systemd host. Co-Authored-By: Claude Opus 4.6 --- app/Commands/DaemonInstallCommand.php | 214 ++++++++++++++++++ app/Commands/ReindexAllCommand.php | 121 ++++++++++ .../Commands/DaemonInstallCommandTest.php | 40 ++++ .../Commands/ReindexAllCommandTest.php | 107 +++++++++ 4 files changed, 482 insertions(+) create mode 100644 app/Commands/DaemonInstallCommand.php create mode 100644 app/Commands/ReindexAllCommand.php create mode 100644 tests/Feature/Commands/DaemonInstallCommandTest.php create mode 100644 tests/Feature/Commands/ReindexAllCommandTest.php diff --git a/app/Commands/DaemonInstallCommand.php b/app/Commands/DaemonInstallCommand.php new file mode 100644 index 0000000..f0758c1 --- /dev/null +++ b/app/Commands/DaemonInstallCommand.php @@ -0,0 +1,214 @@ + */ + private const UNITS = [ + 'knowledge-enhance' => [ + 'description' => 'Knowledge enhancement worker (Ollama auto-tagging)', + 'command' => 'enhance:worker', + 'interval' => '15min', + 'boot_delay' => '2min', + 'timeout' => 30, + 'start_timeout' => 300, + ], + 'knowledge-sync' => [ + 'description' => 'Knowledge remote sync (push and pull)', + 'command' => 'sync:remote', + 'interval' => '30min', + 'boot_delay' => '5min', + 'timeout' => 60, + 'start_timeout' => 300, + ], + 'knowledge-reindex' => [ + 'description' => 'Knowledge code re-indexing and vectorization', + 'command' => 'reindex:all', + 'interval' => '6h', + 'boot_delay' => '10min', + 'timeout' => 120, + 'start_timeout' => 1800, + ], + ]; + + public function handle(): int + { + if ((bool) $this->option('status')) { + return $this->showStatus(); + } + + if ((bool) $this->option('uninstall')) { + return $this->uninstall(); + } + + return $this->install(); + } + + private function install(): int + { + $user = get_current_user(); + $home = getenv('HOME') !== false ? (string) getenv('HOME') : '/tmp'; + $workDir = base_path(); + $php = PHP_BINARY; + + info("Installing knowledge systemd units for {$user}..."); + + foreach (self::UNITS as $name => $unit) { + $service = $this->buildService($name, $unit, $user, $home, $workDir, $php); + $timer = $this->buildTimer($name, $unit); + + $tmpService = tempnam(sys_get_temp_dir(), 'kd_'); + $tmpTimer = tempnam(sys_get_temp_dir(), 'kd_'); + + if ($tmpService === false || $tmpTimer === false) { + error('Failed to create temp files'); + + return self::FAILURE; + } + + file_put_contents($tmpService, $service); + file_put_contents($tmpTimer, $timer); + + $servicePath = "/etc/systemd/system/{$name}.service"; + $timerPath = "/etc/systemd/system/{$name}.timer"; + + $result = Process::run("sudo cp {$tmpService} {$servicePath} && sudo cp {$tmpTimer} {$timerPath}"); + + @unlink($tmpService); + @unlink($tmpTimer); + + if (! $result->successful()) { + error("Failed to install {$name}: ".$result->errorOutput()); + + return self::FAILURE; + } + } + + Process::run('sudo systemctl daemon-reload'); + + foreach (array_keys(self::UNITS) as $name) { + Process::run("sudo systemctl enable {$name}.timer"); + Process::run("sudo systemctl start {$name}.timer"); + note(" Started {$name}.timer"); + } + + info('All timers installed and started.'); + + return $this->showStatus(); + } + + private function uninstall(): int + { + info('Removing knowledge systemd units...'); + + foreach (array_keys(self::UNITS) as $name) { + Process::run("sudo systemctl stop {$name}.timer"); + Process::run("sudo systemctl disable {$name}.timer"); + Process::run("sudo rm -f /etc/systemd/system/{$name}.service /etc/systemd/system/{$name}.timer"); + note(" Removed {$name}"); + } + + Process::run('sudo systemctl daemon-reload'); + info('All knowledge timers removed.'); + + return self::SUCCESS; + } + + private function showStatus(): int + { + $result = Process::run('systemctl list-timers --no-pager 2>/dev/null'); + + if (! $result->successful()) { + warning('Could not query systemd timers (not on a systemd system?).'); + + return self::SUCCESS; + } + + $lines = explode("\n", $result->output()); + $relevant = array_filter( + $lines, + fn (string $line): bool => str_contains($line, 'knowledge') + || str_contains($line, 'NEXT') + || str_contains($line, '---'), + ); + + if ($relevant === []) { + warning('No knowledge timers found. Run `know daemon:install` to set them up.'); + + return self::SUCCESS; + } + + info('Knowledge timers:'); + foreach ($relevant as $line) { + $this->line($line); + } + + return self::SUCCESS; + } + + /** + * @param array{description: string, command: string, interval: string, boot_delay: string, timeout: int, start_timeout: int} $unit + */ + private function buildService( + string $name, + array $unit, + string $user, + string $home, + string $workDir, + string $php, + ): string { + return <<option('path'); + if (! is_string($basePath) || $basePath === '') { + $home = getenv('HOME') !== false ? (string) getenv('HOME') : '/tmp'; + $basePath = "{$home}/projects"; + } + + if (! is_dir($basePath)) { + error("Directory not found: {$basePath}"); + + return self::FAILURE; + } + + $dirs = glob("{$basePath}/*/", GLOB_ONLYDIR); + if ($dirs === false || $dirs === []) { + warning("No subdirectories found in {$basePath}"); + + return self::SUCCESS; + } + + $repos = array_filter($dirs, fn (string $dir): bool => is_dir("{$dir}.git")); + if ($repos === []) { + warning("No git repositories found in {$basePath}"); + + return self::SUCCESS; + } + + info('Found '.count($repos).' git repositories'); + + $skipVectorize = (bool) $this->option('skip-vectorize'); + + /** @var array $kinds */ + $kinds = $this->option('kind'); + if ($kinds === []) { + $kinds = ['class']; + } + + $indexed = 0; + $vectorized = 0; + $errors = 0; + + foreach ($repos as $dir) { + $name = basename(rtrim($dir, '/')); + $repo = "local/{$name}"; + + note("Indexing {$repo}..."); + $result = $symbolIndex->indexFolder($dir, incremental: true); + + if (! ($result['success'] ?? false)) { + warning(' Failed: '.($result['error'] ?? 'unknown error')); + $errors++; + + continue; + } + + $indexed++; + $symbolCount = $result['symbol_count'] ?? 0; + note(" {$symbolCount} symbols indexed"); + + if ($skipVectorize) { + continue; + } + + $home = getenv('HOME') !== false ? (string) getenv('HOME') : '/tmp'; + $indexPath = "{$home}/.code-index/".str_replace('/', '-', $repo).'.json'; + + if (! file_exists($indexPath)) { + warning(' Index file not found, skipping vectorization'); + + continue; + } + + if (! $codeIndexer->ensureCollection()) { + warning(' Failed to ensure Qdrant collection'); + + continue; + } + + $vResult = $codeIndexer->vectorizeFromIndex( + $indexPath, + $repo, + $symbolIndex, + $kinds, + ); + + $vectorized++; + note(" Vectorized: {$vResult['success']}/{$vResult['total']} ({$vResult['failed']} failed)"); + } + + info("Done: {$indexed} indexed, {$vectorized} vectorized, {$errors} errors"); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/tests/Feature/Commands/DaemonInstallCommandTest.php b/tests/Feature/Commands/DaemonInstallCommandTest.php new file mode 100644 index 0000000..c4b195e --- /dev/null +++ b/tests/Feature/Commands/DaemonInstallCommandTest.php @@ -0,0 +1,40 @@ +/dev/null' => Process::result(output: '', exitCode: 1), + ]); + + $this->artisan('daemon:install', ['--status' => true]) + ->assertSuccessful(); + }); + + it('shows knowledge timers when present', function (): void { + Process::fake([ + 'systemctl list-timers --no-pager 2>/dev/null' => Process::result( + output: "NEXT LEFT LAST PASSED UNIT ACTIVATES\nThu 2026-03-06 18:00:00 UTC 2h left Thu 2026-03-06 12:00:00 UTC 3h ago knowledge-reindex.timer knowledge-reindex.service\n", + exitCode: 0, + ), + ]); + + $this->artisan('daemon:install', ['--status' => true]) + ->assertSuccessful(); + }); + + it('shows warning when no timers found', function (): void { + Process::fake([ + 'systemctl list-timers --no-pager 2>/dev/null' => Process::result( + output: "NEXT LEFT LAST PASSED UNIT ACTIVATES\n0 timers listed.\n", + exitCode: 0, + ), + ]); + + $this->artisan('daemon:install', ['--status' => true]) + ->assertSuccessful(); + }); +}); diff --git a/tests/Feature/Commands/ReindexAllCommandTest.php b/tests/Feature/Commands/ReindexAllCommandTest.php new file mode 100644 index 0000000..a75243f --- /dev/null +++ b/tests/Feature/Commands/ReindexAllCommandTest.php @@ -0,0 +1,107 @@ +symbolIndexMock = Mockery::mock(SymbolIndexService::class); + $this->codeIndexerMock = Mockery::mock(CodeIndexerService::class); + $this->app->instance(SymbolIndexService::class, $this->symbolIndexMock); + $this->app->instance(CodeIndexerService::class, $this->codeIndexerMock); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +describe('reindex:all command', function (): void { + it('fails with invalid base path', function (): void { + $this->artisan('reindex:all', ['--path' => '/nonexistent/base']) + ->assertFailed(); + }); + + it('warns when no subdirectories found', function (): void { + $tempDir = sys_get_temp_dir().'/reindex-test-'.uniqid(); + mkdir($tempDir, 0755, true); + + $this->artisan('reindex:all', ['--path' => $tempDir]) + ->assertSuccessful(); + + rmdir($tempDir); + }); + + it('warns when no git repos found', function (): void { + $tempDir = sys_get_temp_dir().'/reindex-test-'.uniqid(); + mkdir($tempDir.'/somedir', 0755, true); + + $this->artisan('reindex:all', ['--path' => $tempDir]) + ->assertSuccessful(); + + rmdir($tempDir.'/somedir'); + rmdir($tempDir); + }); + + it('indexes git repos incrementally', function (): void { + $tempDir = sys_get_temp_dir().'/reindex-test-'.uniqid(); + $repoDir = $tempDir.'/myrepo'; + mkdir($repoDir.'/.git', 0755, true); + + $this->symbolIndexMock->shouldReceive('indexFolder') + ->once() + ->with($repoDir.'/', true) + ->andReturn([ + 'success' => true, + 'symbol_count' => 42, + ]); + + $home = getenv('HOME') !== false ? (string) getenv('HOME') : '/tmp'; + $indexPath = "{$home}/.code-index/local-myrepo.json"; + + // Skip vectorization if index file doesn't exist + $this->artisan('reindex:all', ['--path' => $tempDir, '--skip-vectorize' => true]) + ->assertSuccessful(); + + rmdir($repoDir.'/.git'); + rmdir($repoDir); + rmdir($tempDir); + }); + + it('handles indexing failures gracefully', function (): void { + $tempDir = sys_get_temp_dir().'/reindex-test-'.uniqid(); + $repoDir = $tempDir.'/badrepo'; + mkdir($repoDir.'/.git', 0755, true); + + $this->symbolIndexMock->shouldReceive('indexFolder') + ->once() + ->andReturn(['success' => false, 'error' => 'No source files']); + + $this->artisan('reindex:all', ['--path' => $tempDir]) + ->assertFailed(); + + rmdir($repoDir.'/.git'); + rmdir($repoDir); + rmdir($tempDir); + }); + + it('skips vectorization with --skip-vectorize flag', function (): void { + $tempDir = sys_get_temp_dir().'/reindex-test-'.uniqid(); + $repoDir = $tempDir.'/myrepo'; + mkdir($repoDir.'/.git', 0755, true); + + $this->symbolIndexMock->shouldReceive('indexFolder') + ->once() + ->andReturn(['success' => true, 'symbol_count' => 10]); + + $this->codeIndexerMock->shouldNotReceive('ensureCollection'); + $this->codeIndexerMock->shouldNotReceive('vectorizeFromIndex'); + + $this->artisan('reindex:all', ['--path' => $tempDir, '--skip-vectorize' => true]) + ->assertSuccessful(); + + rmdir($repoDir.'/.git'); + rmdir($repoDir); + rmdir($tempDir); + }); +});