Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions app/Commands/DaemonInstallCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

declare(strict_types=1);

namespace App\Commands;

use Illuminate\Support\Facades\Process;
use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\error;
use function Laravel\Prompts\info;
use function Laravel\Prompts\note;
use function Laravel\Prompts\warning;

class DaemonInstallCommand extends Command
{
protected $signature = 'daemon:install
{--uninstall : Remove all knowledge timers}
{--status : Show timer status}';

protected $description = 'Install or manage systemd timers for knowledge daemons';

/** @var array<string, array{description: string, command: string, interval: string, boot_delay: string, timeout: int, start_timeout: int}> */
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 <<<UNIT
[Unit]
Description={$unit['description']}
After=network.target

[Service]
Type=oneshot
User={$user}
WorkingDirectory={$workDir}
ExecStart={$php} know {$unit['command']}
TimeoutStopSec={$unit['timeout']}
TimeoutStartSec={$unit['start_timeout']}
Environment=HOME={$home}

[Install]
WantedBy=multi-user.target
UNIT;
}

/**
* @param array{description: string, command: string, interval: string, boot_delay: string, timeout: int, start_timeout: int} $unit
*/
private function buildTimer(string $name, array $unit): string
{
return <<<UNIT
[Unit]
Description=Run {$name} every {$unit['interval']}

[Timer]
OnBootSec={$unit['boot_delay']}
OnUnitActiveSec={$unit['interval']}
RandomizedDelaySec=60

[Install]
WantedBy=timers.target
UNIT;
}
}
121 changes: 121 additions & 0 deletions app/Commands/ReindexAllCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

namespace App\Commands;

use App\Services\CodeIndexerService;
use App\Services\SymbolIndexService;
use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\error;
use function Laravel\Prompts\info;
use function Laravel\Prompts\note;
use function Laravel\Prompts\warning;

class ReindexAllCommand extends Command
{
protected $signature = 'reindex:all
{--path= : Base directory containing git repos (default: ~/projects)}
{--kind=* : Symbol kinds to vectorize (default: class)}
{--skip-vectorize : Only re-index symbols, skip vectorization}';

protected $description = 'Incrementally re-index and vectorize all git repositories';

public function handle(
SymbolIndexService $symbolIndex,
CodeIndexerService $codeIndexer,
): int {
$basePath = $this->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<string> $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;
}
}
40 changes: 40 additions & 0 deletions tests/Feature/Commands/DaemonInstallCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Process;

describe('daemon:install command', function (): void {
it('shows status without error on non-systemd systems', function (): void {
Process::fake([
'systemctl list-timers --no-pager 2>/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();
});
});
Loading
Loading