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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user