*/ public function state(): array { $release = Release::current(); $status = $this->readJson(self::STATUS); $lastRun = $this->readJson(self::LAST_RUN); $request = $this->pendingRequest(); $checkedAt = $this->timestamp($status['checked_at'] ?? null); $agentAlive = $this->agentIsAlive($status); // A figure from an agent that has since stopped is not current, and // "three updates behind" from last week reads exactly like now. $behind = $agentAlive && isset($status['behind']) ? (int) $status['behind'] : null; return [ 'version' => $release->version, 'commit' => $release->commit, 'source' => $release->source, 'deployed_at' => $release->deployedAt, // Null means "we do not know", which is not the same as "up to // date" and must not be shown as it. 'behind' => $behind, 'available' => $behind !== null && $behind > 0, 'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null, 'checked_at' => $checkedAt, 'agent_seen' => $agentAlive, 'running' => $agentAlive && ($request !== null || ($status['state'] ?? null) === 'running'), 'requested_at' => $request !== null ? $this->timestamp($request['requested_at'] ?? null) : null, 'requested_by' => $request['requested_by'] ?? null, 'last_state' => $lastRun['state'] ?? null, 'last_finished_at' => $this->timestamp($lastRun['finished_at'] ?? null), // The run's own failure first; a check-level problem (the // repository being unreachable) only when the last run was fine. 'last_error' => $this->errorMessage($lastRun) ?? $this->errorMessage($status), ]; } /** * Has the agent checked in recently enough to be considered alive? * * @param array $status */ private function agentIsAlive(array $status): bool { $checkedAt = $this->timestamp($status['checked_at'] ?? null); return $checkedAt !== null && $checkedAt->gt(Carbon::now()->subMinutes(self::AGENT_STALE_AFTER_MINUTES)); } /** Is the agent in the middle of a run right now? */ public function isRunning(): bool { $status = $this->readJson(self::STATUS); return ($status['state'] ?? null) === 'running' && $this->agentIsAlive($status); } /** Is an update run already asked for, and still current? */ public function pendingRequest(): ?array { $request = $this->readJson(self::REQUEST); if ($request === []) { return null; } $at = $this->timestamp($request['requested_at'] ?? null); if ($at === null || $at->lt(Carbon::now()->subMinutes(self::REQUEST_EXPIRES_MINUTES))) { return null; } return $request; } /** * Ask the agent to update. * * Returns false when a request is already outstanding — clicking twice must * not queue two runs, and the second click is much more likely to be * impatience than intent. */ public function request(string $by): bool { // Both conditions, not just the request file. The agent DELETES the // request before it starts — so between that moment and the end of the // run there is nothing pending, and a second click from a stale tab // would queue a whole redundant deployment for the next tick. if ($this->pendingRequest() !== null || $this->isRunning()) { return false; } $this->write(self::REQUEST, json_encode([ 'requested_at' => Carbon::now()->toIso8601String(), 'requested_by' => $by, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return true; } /** * The agent's failure, in the operator's language. * * The agent is a shell script and is not translated; it reports a code and * the translation happens here. Anything unrecognised is passed through * rather than swallowed — a message nobody has worded yet still beats * silence when an update is failing. */ private function errorMessage(array $status): ?string { $code = isset($status['error']) ? trim((string) $status['error']) : ''; if ($code === '') { return null; } $key = 'admin_settings.update_error.'.$code; if (! \Illuminate\Support\Facades\Lang::has($key)) { return $code; } return __($key, ['code' => (string) ($status['exit_code'] ?? '?')]); } /** The tail of the last run, for showing what went wrong. */ public function lastLog(int $lines = 40): ?string { $path = storage_path('app/'.self::LOG); try { if (! File::exists($path)) { return null; } $all = preg_split('/\R/', trim((string) File::get($path))) ?: []; return implode("\n", array_slice($all, -$lines)); } catch (Throwable) { return null; } } /** @return array */ private function readJson(string $relative): array { try { $path = storage_path('app/'.$relative); if (! File::exists($path)) { return []; } $decoded = json_decode((string) File::get($path), true); return is_array($decoded) ? $decoded : []; } catch (Throwable) { return []; } } private function write(string $relative, string $contents): void { $path = storage_path('app/'.$relative); File::ensureDirectoryExists(dirname($path)); File::put($path, $contents); } private function timestamp(mixed $value): ?Carbon { if (! is_string($value) || $value === '') { return null; } try { return Carbon::parse($value); } catch (Throwable) { return null; } } }