Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Quantum (Facade)

Published to `config/aether.php`. Key settings: `default` (driver name), `python_path` (Python executable), `drivers` (per-driver config with `synchronous_safe` flag).

Package-level settings are read through `Config\AetherConfig` (a container singleton), never via `config('aether.*')` directly: it owns every top-level default (`DEFAULT_DRIVER = 'local'`, poll interval, attempts, local task TTL...) and returns typed values. Jobs get it by method injection in `handle()`; constructors, `tries()` and drivers resolve it with `app(AetherConfig::class)`. Per-driver options (`aether.drivers.*`) are the driver's own business.

## Testing

`Quantum::fake()` replaces the manager with `QuantumFake` — same pattern as `Http::fake()`. Provides `assertCircuitRan()` and `assertEntropyGenerated()`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ use Aether\Facades\Quantum;

Quantum::extend('ionq', fn () => new IonqDriver(
Quantum::bridge(),
config('aether.drivers.ionq'),
app(\Aether\Config\AetherConfig::class)->driver('ionq'),
));
```

Expand Down
20 changes: 15 additions & 5 deletions src/AetherServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Aether;

use Aether\Config\AetherConfig;
use Aether\Contracts\QuantumDevice;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Support\ServiceProvider;

Expand All @@ -20,6 +22,10 @@ public function register(): void
'aether'
);

$this->app->singleton(AetherConfig::class, function ($app): AetherConfig {
return new AetherConfig($app->make(Repository::class));
});

$this->app->singleton(QuantumManager::class, function ($app): QuantumManager {
return new QuantumManager($app);
});
Expand Down Expand Up @@ -48,10 +54,14 @@ public function boot(): void
$this->commands([Commands\AetherInstallCommand::class]);
}

AboutCommand::add('Aether', fn (): array => [
'Default Driver' => config('aether.default', 'local'),
'Python Path' => config('aether.python_path', 'python3'),
'Process Timeout' => config('aether.process_timeout', 300).'s',
]);
AboutCommand::add('Aether', function (): array {
$config = $this->app->make(AetherConfig::class);

return [
'Default Driver' => $config->defaultDriver(),
'Python Path' => $config->pythonPath(),
'Process Timeout' => $config->processTimeout().'s',
];
});
}
}
5 changes: 3 additions & 2 deletions src/Commands/AetherInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Aether\Commands;

use Aether\Config\AetherConfig;
use Aether\QuantumManager;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
Expand Down Expand Up @@ -31,13 +32,13 @@ class AetherInstallCommand extends Command
/**
* Execute the console command.
*/
public function handle(QuantumManager $manager): int
public function handle(QuantumManager $manager, AetherConfig $config): int
{
$this->components->info('Installing Aether...');

$this->publishConfig();

$pythonPath = (string) config('aether.python_path', 'python3');
$pythonPath = $config->pythonPath();

$pythonOk = $this->checkPython($pythonPath);

Expand Down
162 changes: 162 additions & 0 deletions src/Config/AetherConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

declare(strict_types=1);

namespace Aether\Config;

use Illuminate\Contracts\Config\Repository;

/**
* Typed reader for the package-level `aether.*` settings.
*
* Every default lives here once, instead of being repeated at each
* config('aether.x', default) call site, and every caller gets a value of
* the documented type. Reads go to the config repository on each call, so a
* value changed at runtime (or in a test via config()->set()) is honoured by
* the next read; nothing is snapshotted.
*
* Per-driver options are not covered: they are typed by DriverConfig, built
* by the driver itself from the array driver() returns.
*/
final class AetherConfig
{
public const DEFAULT_DRIVER = 'local';

public const DEFAULT_PYTHON_PATH = 'python3';

public const DEFAULT_PROCESS_TIMEOUT = 300;

public const DEFAULT_POLL_INTERVAL = 5;

public const DEFAULT_MAX_POLL_ATTEMPTS = 720;

public const DEFAULT_LOCAL_TASK_TTL = 3600;

public function __construct(
private readonly Repository $config,
) {}

/**
* Name of the driver used when none is given (`aether.default`).
*
* A blank or non-string value falls back to the default, so a stray
* `AETHER_DRIVER=` never resolves to an empty driver name.
*/
public function defaultDriver(): string
{
return $this->string('aether.default') ?? self::DEFAULT_DRIVER;
}

/**
* Python executable used to run the bin/python scripts (`aether.python_path`).
*
* @deprecated Python support will be removed in the 100% PHP rewrite.
*/
public function pythonPath(): string
{
return $this->string('aether.python_path') ?? self::DEFAULT_PYTHON_PATH;
}

/**
* Seconds a Python subprocess may run before it is killed (`aether.process_timeout`).
*
* @deprecated Python support will be removed in the 100% PHP rewrite.
*/
public function processTimeout(): int
{
return $this->integer('aether.process_timeout', self::DEFAULT_PROCESS_TIMEOUT);
}

/**
* Queue the asynchronous jobs run on, or null for the default queue (`aether.queue`).
*/
public function queue(): ?string
{
return $this->string('aether.queue');
}

/**
* Seconds between two status checks of an asynchronous task (`aether.poll_interval`).
*/
public function pollInterval(): int
{
return $this->integer('aether.poll_interval', self::DEFAULT_POLL_INTERVAL);
}

/**
* Number of status checks before the polling job gives up (`aether.max_poll_attempts`).
*/
public function maxPollAttempts(): int
{
return $this->integer('aether.max_poll_attempts', self::DEFAULT_MAX_POLL_ATTEMPTS);
}

/**
* Whether asynchronous tasks are mirrored into the quantum_tasks table (`aether.persist_tasks`).
*
* Accepts real booleans and the string spellings env() produces; anything
* unrecognised counts as disabled, the safe default for a feature that
* needs a migration to have run.
*/
public function persistTasks(): bool
{
$value = $this->config->get('aether.persist_tasks', false);

return is_scalar($value) && filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) === true;
}

/**
* Seconds the local simulator keeps a dispatched result available to the
* polling job (`aether.local_task_ttl`).
*/
public function localTaskTtl(): int
{
return $this->integer('aether.local_task_ttl', self::DEFAULT_LOCAL_TASK_TTL);
}

/**
* Raw options for one driver (`aether.drivers.<name>`), for the driver to type.
*
* @return array<string, mixed>
*/
public function driver(string $name): array
{
$options = $this->config->get("aether.drivers.{$name}", []);

return is_array($options) ? $options : [];
}

/**
* Read a string option, trimmed, or null when blank or not a string.
*/
private function string(string $key): ?string
{
$value = $this->config->get($key);

if (! is_string($value)) {
return null;
}

$trimmed = trim($value);

return $trimmed === '' ? null : $trimmed;
}

/**
* Read an integer option, falling back to $default when blank or not numeric.
*/
private function integer(string $key, int $default): int
{
$value = $this->config->get($key);

if (is_int($value)) {
return $value;
}

$filtered = is_scalar($value) && ! is_bool($value)
? filter_var($value, FILTER_VALIDATE_INT)
: false;

return $filtered === false ? $default : $filtered;
}
}
3 changes: 2 additions & 1 deletion src/Drivers/LocalSimulatorDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Aether\Drivers;

use Aether\Circuit\CircuitBuilder;
use Aether\Config\AetherConfig;
use Aether\Contracts\AsynchronousDevice;
use Aether\Exceptions\QuantumExecutionException;
use Aether\Tasks\TaskSnapshot;
Expand Down Expand Up @@ -79,6 +80,6 @@ private function cacheKey(string $taskArn): string

private function taskTtl(): int
{
return (int) config('aether.local_task_ttl', 3600);
return app(AetherConfig::class)->localTaskTtl();
}
}
32 changes: 19 additions & 13 deletions src/Jobs/PollQuantumTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Aether\Jobs;

use Aether\Config\AetherConfig;
use Aether\Contracts\AsynchronousDevice;
use Aether\Contracts\QuantumDevice;
use Aether\Events\CircuitCompleted;
Expand Down Expand Up @@ -55,23 +56,28 @@ public function __construct(
public readonly array $circuit,
public readonly ?string $driver = null,
) {
$this->onQueue(config('aether.queue'));
// Constructors get no method injection, so the queue name is resolved
// from the container by hand; tries() below is in the same position.
$this->onQueue(app(AetherConfig::class)->queue());
}

/**
* Determine the number of times the job may be attempted.
*
* Called by the queue worker with no arguments, so the setting is read
* from the container rather than injected.
*/
public function tries(): int
{
return (int) config('aether.max_poll_attempts', 720);
return app(AetherConfig::class)->maxPollAttempts();
}

/**
* Execute the job.
*/
public function handle(QuantumManager $manager, Dispatcher $events): void
public function handle(QuantumManager $manager, Dispatcher $events, AetherConfig $config): void
{
$driverName = $this->driver ?? config('aether.default', 'local');
$driverName = $this->driver ?? $config->defaultDriver();
$device = $manager->driver($this->driver);

if (! $device instanceof AsynchronousDevice || ! $device instanceof QuantumDevice) {
Expand All @@ -81,23 +87,23 @@ public function handle(QuantumManager $manager, Dispatcher $events): void
$snapshot = $device->checkTask($this->taskArn);

if (! $snapshot->status->isTerminal()) {
$maxAttempts = $this->tries();
$maxAttempts = $config->maxPollAttempts();

if ($this->attempts() >= $maxAttempts) {
$e = QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts());
$this->persist($snapshot->status, null, $e->getMessage());
$this->persist($config, $snapshot->status, null, $e->getMessage());
throw $e;
}

$this->persist($snapshot->status);
$this->release((int) config('aether.poll_interval', 5));
$this->persist($config, $snapshot->status);
$this->release($config->pollInterval());

return;
}

if (! $snapshot->status->isSuccessful()) {
$e = TaskFailedException::forTask($this->taskArn, $snapshot->status);
$this->persist($snapshot->status, null, $e->getMessage());
$this->persist($config, $snapshot->status, null, $e->getMessage());
throw $e;
}

Expand All @@ -106,11 +112,11 @@ public function handle(QuantumManager $manager, Dispatcher $events): void
'checkTask',
"task [{$this->taskArn}] completed but returned no measurement counts."
);
$this->persist($snapshot->status, null, $e->getMessage());
$this->persist($config, $snapshot->status, null, $e->getMessage());
throw $e;
}

$this->persist($snapshot->status, $snapshot->counts);
$this->persist($config, $snapshot->status, $snapshot->counts);

$events->dispatch(new CircuitCompleted(
$driverName,
Expand All @@ -132,9 +138,9 @@ public function handle(QuantumManager $manager, Dispatcher $events): void
*
* @param array<string, int>|null $counts
*/
private function persist(TaskStatus $status, ?array $counts = null, ?string $error = null): void
private function persist(AetherConfig $config, TaskStatus $status, ?array $counts = null, ?string $error = null): void
{
if (! config('aether.persist_tasks', false)) {
if (! $config->persistTasks()) {
return;
}

Expand Down
Loading