Central history server + plugin write-through sync (epic hc-0p1)

Adds a PHP/SQLite history server in server/ and refactors the plugin to
write every session change through it. Healthcheck history now survives
plugin uninstall and groups across dev + live URLs for the same engagement
via an editable site_key (defaults to the normalised host).

Server (server/):
- Front controller + hand-rolled autoloader, no framework, no composer
- SQLite default DSN; swap to MySQL by changing config.php
- Schema: healthchecks (PK id, UNIQUE (site_key, started_at)) + step_updates
  (PK (healthcheck_id, step_id)) + request_log; auto-migration runner
- 8 endpoints: POST/GET/PUT healthchecks, PUT/GET step rows, GET step history
  with exclude_id, GET /sites (recent), GET /step-counts (badge data)
- Bearer auth via hash_equals; HTTPS expected (plugin enforces client-side)
- DEPLOY.md with Apache/nginx vhosts, Let's Encrypt, SQLite backup cron,
  and the /home/www/ perm gotcha
- dev-router.php works around PHP -S 405-ing dotted uniqid paths

Plugin:
- ATT_HC_Api HTTP client reads ATT_HC_API_URL/ATT_HC_API_KEY constants
  from wp-config.php; refuses non-HTTPS with a loopback dev exception
- ATT_HC_Session is now write-through: every start/update_step/finish/
  set_autocheck POSTs or PUTs to the server first, then updates the local
  WP option cache. No drift possible — failures throw ATT_HC_Api_Exception
- previous() now reads from /healthchecks?include=steps and reconstructs;
  the old att_hc_previous_session local option is gone
- ATT_HC_Session::resume(id) hydrates a server session into the local cache
- Start screen: editable site_key (defaults to normalise_site_url()),
  datalist of recent engagements, table of in-progress sessions for the
  chosen key with Resume buttons. Double-click guard on start + resume
  handlers short-circuits if a session is already active
- Per-step <details> disclosure shows "Previous notes (N)" badge from
  /step-counts; lazy-loads detail rows on first expand via admin-ajax,
  caches via data-loaded, resets on error so user can retry
- All admin handlers catch ATT_HC_Api_Exception and surface via
  att_hc_api_error transient → admin notice
- Hard config-error gate at the top of the admin page blocks the UI when
  ATT_HC_API_URL/ATT_HC_API_KEY are missing or malformed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 12:43:24 +01:00
parent 6dd050ea1d
commit 1782e52504
26 changed files with 1548 additions and 37 deletions

View File

@@ -2,11 +2,19 @@
if (!defined('ABSPATH')) exit;
/**
* Healthcheck session. Option-backed (single in-progress session per site).
* Healthcheck session — server is the source of truth.
*
* Per beads decision hc-5ix.4: option, not CPT. The plugin is installed per
* engagement, so per-site history living in the DB would die on uninstall.
* Reports are exported as Markdown instead — see report.php.
* The local WP option is a thin cache of the *active* session so page renders
* are fast and don't fetch on every request. Every write goes to the central
* history server first; only on a successful server response does the local
* cache get updated. If the server is unreachable or rejects the write, the
* caller sees an ATT_HC_Api_Exception and surfaces it via an admin notice —
* we never let the cache drift from the server.
*
* Cross-engagement history (previous-session diff, per-step prior notes) is
* fetched live from the server. The old local `att_hc_previous_session`
* option is gone — the server has that data, and the plugin gets uninstalled
* per engagement anyway.
*/
final class ATT_HC_Session {
@@ -36,38 +44,106 @@ final class ATT_HC_Session {
return new self($raw);
}
public static function start(int $technician_id): self {
/**
* Start a new session. POSTs to the central server first; on success
* caches the session locally so subsequent renders don't refetch.
*
* @param int $technician_id WP user id
* @param string|null $site_key logical engagement identifier. Falls back to
* normalised get_site_url() when null — the
* start-screen UI (hc-m1a) lets the tech edit it.
* @throws ATT_HC_Api_Exception when the server rejects or is unreachable.
*/
public static function start(int $technician_id, ?string $site_key = null): self {
$reporting_url = get_site_url();
$site_key = $site_key !== null && $site_key !== '' ? $site_key : self::normalise_site_url($reporting_url);
$data = [
'id' => uniqid('att_hc_', true),
'started_at' => time(),
'finished_at' => null,
'technician_id'=> $technician_id,
'site_url' => get_site_url(),
'wp_version' => get_bloginfo('version'),
'php_version' => PHP_VERSION,
'steps' => [], // keyed by step id → ['status' => ..., 'notes' => ..., 'updated_at' => ...]
'id' => uniqid('att_hc_', true),
'started_at' => time(),
'finished_at' => null,
'technician_id' => $technician_id,
'site_key' => $site_key,
'reporting_url' => $reporting_url,
// Kept as a display-only echo of where this install reports from.
// Pre-existing report templates still read `site_url` — keep populated.
'site_url' => $reporting_url,
'wp_version' => get_bloginfo('version'),
'php_version' => PHP_VERSION,
'steps' => [],
];
ATT_HC_Api::create_healthcheck([
'id' => $data['id'],
'site_key' => $data['site_key'],
'started_at' => $data['started_at'],
'reporting_url' => $data['reporting_url'],
'technician_id' => $data['technician_id'],
'wp_version' => $data['wp_version'],
'php_version' => $data['php_version'],
]);
update_option(ATT_HC_OPT_SESSION, $data, false);
return new self($data);
}
/**
* Resume an existing server-side session by id. Pulls the full state down
* (including step history) and writes it into the local cache as the active
* session. Used by the start-screen "Resume" button (hc-m1a).
*
* @throws ATT_HC_Api_Exception
*/
public static function resume(string $id): self {
$remote = ATT_HC_Api::get_healthcheck($id);
$data = self::hydrate_from_remote($remote);
update_option(ATT_HC_OPT_SESSION, $data, false);
return new self($data);
}
/** Drop the local cache. The server keeps the record — history is the point. */
public static function discard(): void {
delete_option(ATT_HC_OPT_SESSION);
}
/** The previous finished session (for diffing). Stored on finish(). */
public static function previous(): ?self {
$raw = get_option('att_hc_previous_session');
if (!is_array($raw) || empty($raw['id'])) return null;
return new self($raw);
/**
* The most recent FINISHED server-side session for this site, excluding the
* active one. Used for the existing diff/review-items panel.
*
* Returns null when there's no prior history (or when the server is
* unreachable — we degrade silently here because the diff is a nice-to-have,
* not a blocker for completing a healthcheck).
*/
public static function previous(?string $site_key = null, ?string $exclude_id = null): ?self {
if ($site_key === null) {
$current = self::current();
if (!$current) return null;
$site_key = $current->site_key();
$exclude_id = $exclude_id ?? $current->id();
}
try {
$response = ATT_HC_Api::list_healthchecks($site_key, include_steps: true, limit: 10);
} catch (ATT_HC_Api_Exception $e) {
return null;
}
foreach ($response['healthchecks'] ?? [] as $hc) {
if (!empty($hc['finished_at']) && ($exclude_id === null || $hc['id'] !== $exclude_id)) {
return new self(self::hydrate_from_remote($hc));
}
}
return null;
}
public function id(): string { return (string) $this->data['id']; }
public function started_at(): int { return (int) $this->data['started_at']; }
public function finished_at(): ?int { return isset($this->data['finished_at']) ? (int) $this->data['finished_at'] : null; }
public function is_finished(): bool { return $this->finished_at() !== null; }
public function technician_id(): int { return (int) $this->data['technician_id']; }
public function site_url(): string { return (string) ($this->data['site_url'] ?? get_site_url()); }
public function technician_id(): int { return (int) ($this->data['technician_id'] ?? 0); }
public function site_url(): string { return (string) ($this->data['site_url'] ?? $this->data['reporting_url'] ?? get_site_url()); }
public function site_key(): string { return (string) ($this->data['site_key'] ?? self::normalise_site_url(get_site_url())); }
public function reporting_url(): string { return (string) ($this->data['reporting_url'] ?? get_site_url()); }
public function wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); }
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
public function data(): array { return $this->data; }
@@ -80,10 +156,24 @@ final class ATT_HC_Session {
];
}
/**
* Write-through step update. Sends the full step state (status + notes +
* any prior autocheck) to the server first, then updates the local cache.
*
* @throws ATT_HC_Api_Exception
*/
public function update_step(string $step_id, string $status, string $notes): void {
if (!in_array($status, self::VALID_STATUSES, true)) {
$status = self::STATUS_NOT_STARTED;
}
$autocheck = $this->data['autocheck'][$step_id] ?? null;
ATT_HC_Api::upsert_step($this->id(), $step_id, [
'status' => $status,
'notes' => $notes,
'reporting_url' => $this->reporting_url(),
'autocheck' => $autocheck,
]);
$this->data['steps'][$step_id] = [
'status' => $status,
'notes' => $notes,
@@ -92,19 +182,37 @@ final class ATT_HC_Session {
update_option(ATT_HC_OPT_SESSION, $this->data, false);
}
/**
* Mark the session finished. Server PUT first, then cache update.
*
* @throws ATT_HC_Api_Exception
*/
public function finish(): void {
$this->data['finished_at'] = time();
$finished_at = time();
ATT_HC_Api::update_healthcheck($this->id(), ['finished_at' => $finished_at]);
$this->data['finished_at'] = $finished_at;
update_option(ATT_HC_OPT_SESSION, $this->data, false);
// Snapshot for next-session diff. One slot, overwritten each finish.
update_option('att_hc_previous_session', $this->data, false);
}
/** Store the result of running autocheck() on a step. */
/**
* Store the result of running autocheck() on a step. Write-through:
* we PUT the step row (preserving any existing status + notes) with the
* new autocheck blob attached.
*
* @throws ATT_HC_Api_Exception
*/
public function set_autocheck(string $step_id, array $findings): void {
$this->data['autocheck'][$step_id] = [
'checked_at' => time(),
'findings' => $findings,
];
$blob = ['checked_at' => time(), 'findings' => $findings];
$state = $this->step_state($step_id);
ATT_HC_Api::upsert_step($this->id(), $step_id, [
'status' => $state['status'],
'notes' => (string) $state['notes'],
'reporting_url' => $this->reporting_url(),
'autocheck' => $blob,
]);
$this->data['autocheck'][$step_id] = $blob;
update_option(ATT_HC_OPT_SESSION, $this->data, false);
}
@@ -125,4 +233,49 @@ final class ATT_HC_Session {
}
return ['done' => $done, 'total' => $total];
}
/**
* lowercase, strip scheme + leading www., trim trailing slash. The default
* site_key — the tech can override at start time (hc-m1a).
*/
public static function normalise_site_url(string $url): string {
$host = parse_url($url, PHP_URL_HOST) ?: $url;
$host = strtolower($host);
if (str_starts_with($host, 'www.')) $host = substr($host, 4);
$path = parse_url($url, PHP_URL_PATH) ?: '';
$path = rtrim($path, '/');
return $host . $path;
}
/**
* Turn a server `GET /healthchecks/{id}` (or a `?include=steps` list row)
* into the local cache shape.
*/
private static function hydrate_from_remote(array $remote): array {
$data = [
'id' => (string) $remote['id'],
'started_at' => (int) $remote['started_at'],
'finished_at' => isset($remote['finished_at']) && $remote['finished_at'] !== null ? (int) $remote['finished_at'] : null,
'technician_id' => isset($remote['technician_id']) ? (int) $remote['technician_id'] : 0,
'site_key' => (string) $remote['site_key'],
'reporting_url' => (string) $remote['reporting_url'],
'site_url' => (string) $remote['reporting_url'],
'wp_version' => (string) ($remote['wp_version'] ?? ''),
'php_version' => (string) ($remote['php_version'] ?? ''),
'steps' => [],
'autocheck' => [],
];
foreach ($remote['steps'] ?? [] as $step_row) {
$sid = (string) $step_row['step_id'];
$data['steps'][$sid] = [
'status' => (string) $step_row['status'],
'notes' => (string) ($step_row['notes'] ?? ''),
'updated_at' => (int) $step_row['updated_at'],
];
if (!empty($step_row['autocheck']) && is_array($step_row['autocheck'])) {
$data['autocheck'][$sid] = $step_row['autocheck'];
}
}
return $data;
}
}