Lines 100.00% 48 / 48
Functions and Methods 100.00% 11 / 11
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
AppPauseService 100.00% 48 / 48 100.00% 11 / 11 25 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 restoreStoredOverrides 100.00% 7 / 7 100.00% 1 / 1 2
 pauseApp 100.00% 1 / 1 100.00% 1 / 1 1
 resumeApp 100.00% 1 / 1 100.00% 1 / 1 1
 resetApp 100.00% 11 / 11 100.00% 1 / 1 5
 assertAppLoaded 100.00% 2 / 2 100.00% 1 / 1 2
 resetStoredOnlyApp 100.00% 5 / 5 100.00% 1 / 1 2
 overridePause 100.00% 8 / 8 100.00% 1 / 1 3
 createOverride 100.00% 7 / 7 100.00% 1 / 1 5
 createNotLoadedException 100.00% 4 / 4 100.00% 1 / 1 2
 broadcastPausedApps 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Broker;
6
7use Psr\Log\LoggerInterface;
8use Stewart\Contracts\App\AppId;
9use Stewart\Contracts\Exception\StoreException;
10use Stewart\Contracts\Time\Clock;
11use Stewart\Runtime\App\AppCatalog;
12use Stewart\Runtime\App\AppPauseOutcome;
13use Stewart\Runtime\App\AppPauseOverride;
14use Stewart\Runtime\App\AppPauseResetOutcome;
15use Stewart\Runtime\Exception\AppError;
16use Stewart\Runtime\Exception\AppException;
17use Stewart\Runtime\Ipc\Message\PausedAppsChanged;
18use Stewart\Runtime\Ipc\Wire\AppIdsFragment;
19use Stewart\Runtime\Lifecycle\AppPauseSource;
20use Stewart\Runtime\Lifecycle\AppStateAfterReset;
21
22final readonly class AppPauseService
23{
24    public function __construct(
25        private AppCatalog $apps,
26        private AppPauseRegistry $pausedApps,
27        private AppPauseOverrideStore $overrides,
28        private WorkerSlotRegistry $slots,
29        private Clock $clock,
30        private LoggerInterface $logger,
31    ) {}
32
33    /** @throws StoreException */
34    public function restoreStoredOverrides(): void
35    {
36        $stored = $this->overrides->loadOverrides();
37        $this->pausedApps->restoreOverrides($stored->filter(fn(AppPauseOverride $override): bool => $this->apps->enabled->find($override->appId) !== null));
38        $unknown = $stored->filter(fn(AppPauseOverride $override): bool => !$this->apps->knownIds->containsId($override->appId));
39
40        if ($unknown->count() > 0) {
41            $this->logger->warning('Stored pause overrides name apps that no longer exist; stewart app:reset <app> removes them', [
42                'apps' => implode(', ', $unknown->mapToList(static fn(AppPauseOverride $override): string => $override->appId->value)),
43            ]);
44        }
45    }
46
47    /** @throws AppException */
48    public function pauseApp(AppId $appId, AppPauseSource $source): AppPauseOutcome
49    {
50        return $this->overridePause($appId, true, $source);
51    }
52
53    /** @throws AppException */
54    public function resumeApp(AppId $appId, AppPauseSource $source): AppPauseOutcome
55    {
56        return $this->overridePause($appId, false, $source);
57    }
58
59    /** @throws AppException|StoreException */
60    public function resetApp(AppId $appId, AppPauseSource $source): AppPauseResetOutcome
61    {
62        if ($this->apps->enabled->find($appId) === null) {
63            return $this->resetStoredOnlyApp($appId, $source);
64        }
65
66        $wasPaused = $this->pausedApps->isPaused($appId);
67        $removed = $this->pausedApps->forgetOverride($appId);
68        $paused = $this->pausedApps->isPaused($appId);
69
70        if ($paused !== $wasPaused) {
71            $this->broadcastPausedApps();
72        }
73
74        if ($removed) {
75            $this->logger->info('App pause override removed', ['app' => $appId->value, 'source' => $source->value, 'paused' => $paused]);
76        }
77
78        $persistence = $this->overrides->removeOverride($appId);
79
80        return new AppPauseResetOutcome($removed, $paused ? AppStateAfterReset::PausedByConfig : AppStateAfterReset::Running, $persistence);
81    }
82
83    /** @throws AppException */
84    public function assertAppLoaded(AppId $appId): void
85    {
86        if ($this->apps->enabled->find($appId) === null) {
87            throw $this->createNotLoadedException($appId);
88        }
89    }
90
91    /** @throws AppException|StoreException */
92    private function resetStoredOnlyApp(AppId $appId, AppPauseSource $source): AppPauseResetOutcome
93    {
94        if (!$this->overrides->hasOverride($appId)) {
95            throw $this->createNotLoadedException($appId);
96        }
97
98        $persistence = $this->overrides->removeOverride($appId);
99        $this->logger->info('App pause override removed', ['app' => $appId->value, 'source' => $source->value]);
100
101        return new AppPauseResetOutcome(true, AppStateAfterReset::NotLoaded, $persistence);
102    }
103
104    /** @throws AppException */
105    private function overridePause(AppId $appId, bool $paused, AppPauseSource $source): AppPauseOutcome
106    {
107        $this->assertAppLoaded($appId);
108        $override = $this->createOverride($appId, $paused, $source);
109        $changed = $this->pausedApps->recordOverride($override);
110
111        if ($changed) {
112            $this->broadcastPausedApps();
113            $this->logger->info($paused ? 'App paused' : 'App resumed', ['app' => $appId->value, 'source' => $source->value]);
114        }
115
116        $persistence = $this->overrides->saveOverride($override);
117
118        return new AppPauseOutcome($changed, $persistence);
119    }
120
121    private function createOverride(AppId $appId, bool $paused, AppPauseSource $source): AppPauseOverride
122    {
123        $existing = $this->pausedApps->findOverride($appId);
124
125        if ($existing?->paused === $paused) {
126            return $existing;
127        }
128
129        // Pinning a config pause keeps its start and source, so status still says why the app is paused.
130        $configPause = $existing === null && $paused ? $this->pausedApps->findPause($appId) : null;
131
132        return $configPause === null
133            ? new AppPauseOverride($appId, $paused, $this->clock->getNow(), $source)
134            : new AppPauseOverride($appId, true, $configPause->since, $configPause->source);
135    }
136
137    private function createNotLoadedException(AppId $appId): AppException
138    {
139        return $this->apps->knownIds->containsId($appId) ? AppException::createForReason(
140            AppError::Disabled,
141            appId: $appId->value,
142        ) : AppException::unknown($appId);
143    }
144
145    private function broadcastPausedApps(): void
146    {
147        $this->slots->broadcast(new PausedAppsChanged(AppIdsFragment::fromCollection($this->pausedApps->listPausedAppIds())));
148    }
149}