diff --git a/.horde.yml b/.horde.yml
index 49b4636c..a90976b7 100644
--- a/.horde.yml
+++ b/.horde.yml
@@ -23,7 +23,7 @@ license:
uri: http://www.horde.org/licenses/gpl
dependencies:
required:
- php: ^7.4 || ^8
+ php: ^8.1
composer:
horde/compress: ^3
horde/date: ^3
@@ -67,16 +67,6 @@ keywords:
- eas
- exchange
ci-platform:
- '8.0':
- - php: '>=8.1'
- - composer-plugin-api: ^2.2.0
- - ext-bcmath
- - ext-dom
- - ext-hash
- - ext-json
- - ext-pcre
- - ext-simplexml
- - ext-spl
'8.1':
- php: ^8.1
- composer-plugin-api: ^2.2.0
diff --git a/composer.json b/composer.json
index 66c6d5ee..2875b130 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,7 @@
"repositories": [],
"require": {
"horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2",
- "php": "^7.4 || ^8",
+ "php": "^8.1",
"horde/compress": "^3 || dev-FRAMEWORK_6_0",
"horde/date": "^3 || dev-FRAMEWORK_6_0",
"horde/exception": "^3 || dev-FRAMEWORK_6_0",
@@ -51,6 +51,9 @@
"autoload": {
"psr-0": {
"Horde_ActiveSync": "lib/"
+ },
+ "psr-4": {
+ "Horde\\ActiveSync\\": "src/"
}
},
"autoload-dev": {
diff --git a/doc/ops-monitor.md b/doc/ops-monitor.md
new file mode 100644
index 00000000..5af28c60
--- /dev/null
+++ b/doc/ops-monitor.md
@@ -0,0 +1,170 @@
+# ActiveSync operations monitor
+
+See the [operations monitor epic](https://github.com/horde/ActiveSync/issues/99).
+
+## Purpose and scope
+
+Version 1 provides a shared, read-only health model for answering:
+
+1. Which devices are active right now?
+2. Which sync keys or collections look stuck?
+3. Where are loop-guard or heartbeat problems concentrating?
+4. How can an operator jump from a bad device to its per-device log and
+ existing admin actions?
+
+The monitor does not provide historical metrics, alerting, or webhooks. It
+does not add HTTP 503 responses or `X-MS-Throttle`, replace
+`logging.type = perdevice`, mutate ActiveSync protocol behaviour, or expose
+wipe and block operations through the CLI.
+
+## Architecture
+
+| Piece | Package and location |
+|-------|----------------------|
+| Health heuristics and DTOs | `horde/activesync`, `src/Ops` |
+| Snapshot service | `horde/core` |
+| CLI `horde-activesync summary\|show\|top` | `horde/horde` (the `horde/base` repository) |
+| Admin health badges | `horde/horde` (the `horde/base` repository) |
+
+The evaluator consumes plain facts and has no storage dependency.
+`DeviceHealthFactory` is the compatibility boundary that converts legacy
+ActiveSync device, cache, and state rows into those facts. The snapshot
+service loads fleet state, and both operator interfaces consume its shared
+results.
+
+A device's collections are a `CollectionFactList`: an immutable map of
+`CollectionFacts`, keyed by collection id and readable with array access.
+Each fact copies `class`, `serverid`, `lastsynckey`, `backlog`,
+`backlogpings`, and `pingable` from the SyncCache row. Sync options such as
+window size, filter type, and body preferences stay on the cache row.
+
+## Health model
+
+Device and collection status is one of `ok`, `warn`, or `critical`. The worst
+signal determines collection status, and the worst device or collection
+status determines device status.
+
+A device is active when a heartbeat is inferred to be in flight, or when its
+latest SyncCache or last-sync timestamp is no older than `activeWithin`.
+A device is stuck when it has `hb_stuck`, `hb_missing_end`, `fsr_critical`, or
+`backlog_stuck` on either the device or one of its collections.
+
+| Tuning option | Default | Meaning |
+|---------------|---------|---------|
+| `now` | current time | Evaluation timestamp; fixed values make tests and snapshots deterministic |
+| `activeWithin` | 300 seconds | Maximum state age counted as active |
+| `hbStuckAfter` | derived | Explicit heartbeat-stuck threshold, when set |
+| `hbDefaultInterval` | 900 seconds | Heartbeat interval used when the cache has none |
+| `hbSlack` | 60 seconds | Allowance added to the heartbeat interval |
+| `hbAbandonedAfter` | 2 × heartbeat-stuck threshold | Age after which an unfinished heartbeat is treated as abandoned rather than stuck |
+| `fsrWarnAt` | 3 | FolderSync-required warning threshold |
+| `fsrCriticalAt` | 5 | FolderSync-required critical threshold |
+| `backlogGrace` | 60 seconds | Minimum backlog age before a pending warning |
+| `backlogTriggerMax` | 3 | Backlog recovery count considered stuck |
+| `backlogAbandonedAfter` | 3600 seconds | Age after which a capped backlog is treated as abandoned rather than stuck |
+
+When `hbStuckAfter` is unset, the threshold is the cached heartbeat interval
+(or `hbDefaultInterval`) plus `hbSlack`.
+
+## Reason codes
+
+| Code | Condition | Severity | What to do |
+|------|-----------|----------|------------|
+| `hb_in_flight` | Heartbeat start is newer than its end, or no end is recorded, and its age is at most the stuck threshold | `ok` | No action; this is activity context |
+| `hb_stuck` | Heartbeat start is newer than a recorded end and its age is between the stuck threshold and the abandoned threshold | `warn` | Check whether the client recently disconnected, suspended radio activity, rebooted, or updated; inspect the device log if the signal persists |
+| `hb_missing_end` | A heartbeat start has no recorded end and its age is between the stuck threshold and the abandoned threshold | `warn` | Check for a transient client disconnect; inspect the device log if the signal persists |
+| `hb_abandoned` | An unfinished heartbeat is older than the abandoned threshold | `ok` | Nothing — common after client disconnects; if it repeats for an otherwise active device, check the device log |
+| `fsr_warn` | FolderSync-required count is at least 3 and below 5 by default | `warn` | Check whether the client completes a FolderSync cycle |
+| `fsr_critical` | FolderSync-required count is at least 5 by default | `critical` | Inspect hierarchy state and the device log. The counter clears on FolderSync, or on a later successful PING/SYNC that does not request FolderSync (after ghost-collection heal) |
+| `blocked` | Device properties mark the device blocked | `warn` | Confirm the block is intentional in the admin device page |
+| `wipe_pending` | Full or account-only remote wipe is pending | `warn` | Confirm provisioning reaches the device |
+| `wipe_complete` | Full or account-only remote wipe is recorded complete | `warn` | Confirm the completed state is expected |
+| `backlog_pending` | Backlog age reached the grace period and recovery count remains below the trigger maximum | `warn` | Check whether the client drains subsequent Sync windows |
+| `backlog_stuck` | A backlog exists, recovery count reached the trigger maximum, and the MOREAVAILABLE is still newer than `backlogAbandonedAfter` | `critical` | Inspect collection sync keys and the per-device log |
+
+The numeric conditions use the configured thresholds rather than fixed
+defaults when options are overridden.
+
+## Honest limits
+
+Version 1 is derived from currently persisted device and SyncCache state; it
+cannot prove that a PHP request is still running. An unfinished heartbeat is
+therefore a warning rather than critical: a radio gap, client update, reboot,
+or suspended app can leave the same persisted timestamps as a server-side
+problem. An old unfinished heartbeat is treated as abandoned, not stuck,
+because a PHP request cannot plausibly remain active beyond that window.
+The initial fleet snapshot costs one
+SyncCache load per device; it is not a constant-cost aggregate query.
+Per-device protocol logs remain the deep-dive source.
+
+## Cookbook (CLI + admin)
+
+### Triage active devices
+
+Start with active devices ordered by health and keep the display refreshed:
+
+```sh
+horde-activesync top --watch --active-within=300 --sort=health
+```
+
+Narrow a large fleet with `--user`, `--device`, `--health`, or
+`--stuck-only`, and cap output with `--limit`. Use `--format` when another
+tool consumes the result. The snapshot performs one SyncCache read per device
+considered after filtering; it is not a constant-cost fleet aggregate.
+
+### Run a cron or Nagios check
+
+```sh
+horde-activesync summary --format=json
+```
+
+The command exits with `0` when no critical device is present, `1` when at
+least one critical device is present, and `2` when monitoring data is
+unavailable. Cron and Nagios wrappers should preserve that exit status and
+retain the JSON output for diagnosis.
+
+### Inspect one device
+
+Use the exact user and device identifiers shown by `top` or `summary`:
+
+```sh
+horde-activesync show alice@example.com DEVICE1
+```
+
+When `logging.type = perdevice`, the protocol log is:
+
+```text
+{configured logging path}/{UPPERCASE DEVICE ID}.txt
+```
+
+For example, device `DEVICE1` maps to `DEVICE1.txt` beneath the configured
+logging path. The path comes from the deployment configuration; do not embed
+credentials or deployment-specific paths in scripts or reports.
+
+### Confirm and act in the admin page
+
+Open **Administration → ActiveSync Devices**, find the same user and device,
+and verify the health signal against the device details and per-device log.
+Only then use the existing wipe, block, remove, or reset actions, with care:
+they change device state and can disrupt or erase client data. The
+`horde-activesync` CLI has no mutation commands.
+
+### Interpret common signals
+
+- `hb_stuck` and `hb_missing_end` are warnings for an unfinished heartbeat
+ still inside the plausible stuck window. Transient client disconnects,
+ radio gaps, reboots, and updates commonly produce this state; inspect the
+ request lifecycle and device log only when it persists or repeats.
+ `hb_abandoned` is older than that window and is intentionally `ok`
+ because the original request can no longer plausibly be running. Repeated
+ abandoned heartbeats on an otherwise active device still merit log review.
+- `backlog_stuck` means a collection has repeatedly failed to drain pending
+ changes *and* that MOREAVAILABLE is still recent. After
+ `backlogAbandonedAfter` (or after PING/looping-SYNC recovery hits its
+ trigger cap and clears the flag), an undrained folder is no longer
+ fleet-critical: leftover `sync_pending` waits for the next real SYNC, which
+ some clients never send for folders they only PING (for example iOS Mail
+ and Trash). Inspect sync keys and the log only while the signal is present.
+ `fsr_warn` / `fsr_critical` instead mean repeated FolderSync-required
+ recovery (FSR). After ghost collections are healed, a later successful
+ PING/SYNC clears that counter even if the client never FolderSyncs again.
diff --git a/doc/todo.md b/doc/todo.md
index b286928e..aa797414 100644
--- a/doc/todo.md
+++ b/doc/todo.md
@@ -216,6 +216,37 @@ when work proceeds on FRAMEWORK_6_1.
- `Horde_ActiveSync_Sync_Options` (and similar) for collection options /
body preferences instead of raw arrays.
- Collection object replacing the associative collection array in `Sync.php`.
+ The array carries ~34 keys across ~440 read sites (`Request/Sync.php` 181,
+ `SyncCache.php` 138, `Collections.php` 63) and mixes four concerns:
+ identity (`id`, `class`, `serverid`, `type`), persisted sync state
+ (`synckey`, `newsynckey`, `lastsynckey`, `getchanges`, `pingable`,
+ `backlog`, `backlogpings`), client-negotiated options (`windowsize`,
+ `filtertype`, `truncation`, `mimesupport`, `bodyprefs`, `conflict`,
+ `deletesasmoves`, `conversationmode`, …), and per-request scratch
+ (`clientids`, `fetchids`, `removes`, `modifiedids`, `importedchanges`,
+ `importfailures`, `atchash`, `instanceid_removes`, …). Target shape:
+ - `SyncCollection` for identity plus persisted sync state, with
+ `Horde_ActiveSync_SyncKey` as a first-class type (see *State, storage,
+ and identity* above); `Sync_Options` for the negotiated options; the
+ Changes object below for per-request scratch. Do not fold all four into
+ one interface.
+ - Storage stays an array. `SyncCache` hydrates rows into objects on load
+ and dehydrates on save; never `serialize()` the objects into
+ `cache_data`, which would change the blob format.
+ - `ArrayAccess` on the concrete `SyncCollection` class is a migration
+ shim so `$collection['class']` keeps working in `Sync.php`, ideally
+ logging deprecated access. Remove it once `Sync.php` is converted. Keep
+ it off the interface so the array API does not become permanent.
+ - Order: `SyncCache::addCollection()` first (it defines the persisted
+ fields), then `Collections.php`, then `Sync.php` (hot protocol path,
+ largest, riskiest).
+ - `Horde\ActiveSync\Ops\CollectionFacts` (health monitor, #100) is a
+ seven-field read-only projection built in
+ `DeviceHealthFactory::collectionFacts()`, the only place that converts
+ cache rows. Once `SyncCollection` exists, build the projection from it
+ or let `HealthEvaluator` consume `SyncCollection` directly; then drop
+ `CollectionFacts`. It is not the domain object and must not grow into
+ one.
- Changes object (array, `SplFixedArray`, or temp stream) to cap memory on
large initial mailbox syncs and to unify the `add` / `modify` / `delete`
shapes between email and PIM collections. Streaming v1 ([#83](https://github.com/horde/ActiveSync/issues/83))
diff --git a/lib/Horde/ActiveSync.php b/lib/Horde/ActiveSync.php
index 5c6cdb96..0c68db19 100644
--- a/lib/Horde/ActiveSync.php
+++ b/lib/Horde/ActiveSync.php
@@ -789,7 +789,7 @@ public function handleRequest($cmd, $devId)
// Autodiscovery handles authentication on it's own.
if ($cmd == 'Autodiscover') {
- $request = new Horde_ActiveSync_Request_Autodiscover($this, new Horde_ActiveSync_Device($this->_state));
+ $request = new Horde_ActiveSync_Request_Autodiscover($this);
if (!empty(self::$_logger)) {
$request->setLogger(self::$_logger);
diff --git a/lib/Horde/ActiveSync/Request/Base.php b/lib/Horde/ActiveSync/Request/Base.php
index 211200e8..2d7c38ac 100644
--- a/lib/Horde/ActiveSync/Request/Base.php
+++ b/lib/Horde/ActiveSync/Request/Base.php
@@ -106,8 +106,8 @@ abstract class Horde_ActiveSync_Request_Base
/**
* Const'r
*
- * @param Horde_ActiveSync $as The ActiveSync server.
- * @param Horde_ActiveSync_Device $device The device descriptor.
+ * @param Horde_ActiveSync $as The ActiveSync server. The device
+ * descriptor is read from the server.
*
* @return Horde_ActiveSync_Request_Base
*/
diff --git a/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php b/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php
index e20ee47f..773c0cf7 100644
--- a/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php
+++ b/migration/Horde/ActiveSync/20_horde_activesync_removesynccounters.php
@@ -4,7 +4,7 @@ class HordeActiveSyncRemoveSyncCounters extends Horde_Db_Migration_Base
{
public function up()
{
- $this->announce('Removing SyncKeyCounter data from cache.', 'cli.message');
+ $this->announce('Removing SyncKeyCounter data from cache.');
$sql = 'SELECT * FROM horde_activesync_cache';
$rows = $this->_connection->select($sql);
$insert_sql = 'UPDATE horde_activesync_cache SET cache_data = ? WHERE cache_devid = ? AND cache_user = ?';
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 3e12baf3..62d47947 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -15,6 +15,7 @@
lib
+ src
diff --git a/src/Ops/CollectionFactList.php b/src/Ops/CollectionFactList.php
new file mode 100644
index 00000000..dabf5a41
--- /dev/null
+++ b/src/Ops/CollectionFactList.php
@@ -0,0 +1,107 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use ArrayAccess;
+use ArrayIterator;
+use BadMethodCallException;
+use Countable;
+use InvalidArgumentException;
+use IteratorAggregate;
+use OutOfBoundsException;
+use Traversable;
+
+/**
+ * Immutable collection-id map of SyncCache health snapshots.
+ *
+ * @implements ArrayAccess
+ * @implements IteratorAggregate
+ */
+final class CollectionFactList implements ArrayAccess, Countable, IteratorAggregate
+{
+ /**
+ * @var array
+ */
+ private array $byId;
+
+ /**
+ * @param iterable $collections
+ */
+ public function __construct(iterable $collections = [])
+ {
+ $byId = [];
+ foreach ($collections as $collection) {
+ if (!$collection instanceof CollectionFacts) {
+ throw new InvalidArgumentException(
+ 'Collection fact lists accept CollectionFacts entries only.'
+ );
+ }
+ $byId[$collection->id] = $collection;
+ }
+ $this->byId = $byId;
+ }
+
+ public function get(string $id): ?CollectionFacts
+ {
+ return $this->byId[$id] ?? null;
+ }
+
+ /**
+ * @return list
+ */
+ public function all(): array
+ {
+ return array_values($this->byId);
+ }
+
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->all());
+ }
+
+ public function count(): int
+ {
+ return count($this->byId);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return is_string($offset) && isset($this->byId[$offset]);
+ }
+
+ public function offsetGet(mixed $offset): CollectionFacts
+ {
+ if (!is_string($offset) || !isset($this->byId[$offset])) {
+ throw new OutOfBoundsException(
+ 'Collection fact list has no entry for this id.'
+ );
+ }
+
+ return $this->byId[$offset];
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new BadMethodCallException('Collection fact lists are immutable.');
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new BadMethodCallException('Collection fact lists are immutable.');
+ }
+}
diff --git a/src/Ops/CollectionFacts.php b/src/Ops/CollectionFacts.php
new file mode 100644
index 00000000..66089586
--- /dev/null
+++ b/src/Ops/CollectionFacts.php
@@ -0,0 +1,50 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+/**
+ * Health inputs copied from one persisted SyncCache collection row.
+ *
+ * The cache stores collections as an associative array keyed by collection
+ * id. This snapshot keeps the fields the evaluator reads:
+ *
+ * - id: collection id, from the row key or the row's id value
+ * - class: EAS collection class (`class`)
+ * - serverid: backend folder id (`serverid`)
+ * - lastsynckey: persisted sync key (`lastsynckey`; the in-request
+ * `synckey` alias is ignored)
+ * - backlog: unix time when MOREAVAILABLE was recorded (`backlog`)
+ * - backlogpings: recovery attempts (`backlogpings`)
+ * - pingable: whether the collection is included in PING (`pingable`)
+ *
+ * Window size, filter type, truncation, body preferences, and conflict
+ * policy stay on the cache row.
+ */
+final class CollectionFacts
+{
+ public function __construct(
+ public readonly string $id,
+ public readonly ?string $class = null,
+ public readonly ?string $serverid = null,
+ public readonly ?string $lastsynckey = null,
+ public readonly ?int $backlog = null,
+ public readonly int $backlogpings = 0,
+ public readonly bool $pingable = false
+ ) {
+ }
+}
diff --git a/src/Ops/CollectionHealth.php b/src/Ops/CollectionHealth.php
new file mode 100644
index 00000000..349b7ec8
--- /dev/null
+++ b/src/Ops/CollectionHealth.php
@@ -0,0 +1,49 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+final class CollectionHealth
+{
+ /**
+ * @param HealthSignal[] $signals
+ */
+ public function __construct(
+ public readonly string $id,
+ public readonly ?string $class,
+ public readonly ?string $serverid,
+ public readonly ?string $lastsynckey,
+ public readonly string $status,
+ public readonly array $signals
+ ) {
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'class' => $this->class,
+ 'serverid' => $this->serverid,
+ 'lastsynckey' => $this->lastsynckey,
+ 'status' => $this->status,
+ 'signals' => array_map(
+ static fn (HealthSignal $signal): array => $signal->toArray(),
+ $this->signals
+ ),
+ ];
+ }
+}
diff --git a/src/Ops/DeviceFacts.php b/src/Ops/DeviceFacts.php
new file mode 100644
index 00000000..197b205d
--- /dev/null
+++ b/src/Ops/DeviceFacts.php
@@ -0,0 +1,42 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+final class DeviceFacts
+{
+ /**
+ * @param CollectionFactList $collections SyncCache snapshots keyed by collection id
+ */
+ public function __construct(
+ public readonly string $user,
+ public readonly string $deviceId,
+ public readonly string $deviceType,
+ public readonly ?string $version,
+ public readonly int $rwstatus,
+ public readonly int $accountOnlyRwstatus,
+ public readonly bool $blocked,
+ public readonly ?int $lastSyncTs,
+ public readonly ?int $cacheTimestamp,
+ public readonly ?int $hbinterval,
+ public readonly ?int $lasthbsyncstarted,
+ public readonly ?int $lastsyncendnormal,
+ public readonly int $foldersyncrequired,
+ public readonly CollectionFactList $collections
+ ) {
+ }
+}
diff --git a/src/Ops/DeviceHealth.php b/src/Ops/DeviceHealth.php
new file mode 100644
index 00000000..4677cc21
--- /dev/null
+++ b/src/Ops/DeviceHealth.php
@@ -0,0 +1,98 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+final class DeviceHealth
+{
+ /**
+ * @param HealthSignal[] $signals
+ * @param CollectionHealth[] $collections
+ */
+ public function __construct(
+ public readonly string $user,
+ public readonly string $deviceId,
+ public readonly string $deviceType,
+ public readonly ?string $version,
+ public readonly string $status,
+ public readonly bool $active,
+ public readonly ?int $ageSeconds,
+ public readonly ?int $hbinterval,
+ public readonly int $foldersyncrequired,
+ public readonly array $signals,
+ public readonly array $collections,
+ public ?string $logPath = null
+ ) {
+ }
+
+ public function withLogPath(?string $logPath): self
+ {
+ $clone = clone $this;
+ $clone->logPath = $logPath;
+
+ return $clone;
+ }
+
+ public function isStuck(): bool
+ {
+ return array_intersect($this->signalCodes(), [
+ SignalCode::HB_STUCK,
+ SignalCode::HB_MISSING_END,
+ SignalCode::FSR_CRITICAL,
+ SignalCode::BACKLOG_STUCK,
+ ]) !== [];
+ }
+
+ public function signalCodes(): array
+ {
+ $codes = array_map(
+ static fn (HealthSignal $signal): string => $signal->code,
+ $this->signals
+ );
+ foreach ($this->collections as $collection) {
+ foreach ($collection->signals as $signal) {
+ $codes[] = $signal->code;
+ }
+ }
+
+ return array_values(array_unique($codes));
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'user' => $this->user,
+ 'deviceId' => $this->deviceId,
+ 'deviceType' => $this->deviceType,
+ 'version' => $this->version,
+ 'status' => $this->status,
+ 'active' => $this->active,
+ 'ageSeconds' => $this->ageSeconds,
+ 'hbinterval' => $this->hbinterval,
+ 'foldersyncrequired' => $this->foldersyncrequired,
+ 'signals' => array_map(
+ static fn (HealthSignal $signal): array => $signal->toArray(),
+ $this->signals
+ ),
+ 'collections' => array_map(
+ static fn (CollectionHealth $collection): array => $collection->toArray(),
+ $this->collections
+ ),
+ 'logPath' => $this->logPath,
+ ];
+ }
+}
diff --git a/src/Ops/DeviceHealthFactory.php b/src/Ops/DeviceHealthFactory.php
new file mode 100644
index 00000000..02e900ed
--- /dev/null
+++ b/src/Ops/DeviceHealthFactory.php
@@ -0,0 +1,164 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use Horde_ActiveSync_Device;
+use Horde_ActiveSync_SyncCache;
+
+final class DeviceHealthFactory
+{
+ private readonly HealthEvaluator $evaluator;
+
+ public function __construct(?HealthEvaluator $evaluator = null)
+ {
+ $this->evaluator = $evaluator ?? new HealthEvaluator();
+ }
+
+ public function fromDevice(
+ Horde_ActiveSync_Device $device,
+ Horde_ActiveSync_SyncCache $cache,
+ ?int $lastSyncTs = null
+ ): DeviceHealth {
+ $properties = is_array($device->properties)
+ ? $device->properties
+ : [];
+ $hbinterval = self::nullableInt($cache->hbinterval);
+ $wait = self::nullableInt($cache->wait);
+ if ($hbinterval === null && $wait !== null) {
+ $hbinterval = $wait * 60;
+ }
+
+ return $this->evaluator->evaluate(new DeviceFacts(
+ user: (string) $device->user,
+ deviceId: (string) $device->id,
+ deviceType: (string) $device->deviceType,
+ version: self::nullableString($properties['version'] ?? null),
+ rwstatus: (int) $device->rwstatus,
+ accountOnlyRwstatus: (int) $device->accountOnlyRwstatus,
+ blocked: !empty($properties['blocked']),
+ lastSyncTs: $lastSyncTs,
+ cacheTimestamp: self::nullableInt($cache->timestamp),
+ hbinterval: $hbinterval,
+ lasthbsyncstarted: self::nullableInt($cache->lasthbsyncstarted),
+ lastsyncendnormal: self::nullableInt($cache->lastsyncendnormal),
+ foldersyncrequired: $cache->getFolderSyncRequiredIgnoredCount(),
+ collections: self::collectionFacts($cache->getCollections(false))
+ ));
+ }
+
+ public function fromRow(
+ array $deviceRow,
+ array $cacheData,
+ ?int $lastSyncTs = null
+ ): DeviceHealth {
+ $properties = self::properties($deviceRow['device_properties'] ?? []);
+ $hbinterval = self::nullableInt($cacheData['hbinterval'] ?? null);
+ $wait = self::nullableInt($cacheData['wait'] ?? null);
+ if ($hbinterval === null && $wait !== null) {
+ $hbinterval = $wait * 60;
+ }
+
+ return $this->evaluator->evaluate(new DeviceFacts(
+ user: (string) ($deviceRow['device_user'] ?? ''),
+ deviceId: (string) ($deviceRow['device_id'] ?? ''),
+ deviceType: (string) ($deviceRow['device_type'] ?? ''),
+ version: self::nullableString($properties['version'] ?? null),
+ rwstatus: (int) ($deviceRow['device_rwstatus'] ?? 0),
+ accountOnlyRwstatus:
+ (int) ($deviceRow['device_accountonly_rwstatus'] ?? 0),
+ blocked: !empty($properties['blocked']),
+ lastSyncTs: $lastSyncTs,
+ cacheTimestamp: self::nullableInt($cacheData['timestamp'] ?? null),
+ hbinterval: $hbinterval,
+ lasthbsyncstarted:
+ self::nullableInt($cacheData['lasthbsyncstarted'] ?? null),
+ lastsyncendnormal:
+ self::nullableInt($cacheData['lastsyncendnormal'] ?? null),
+ foldersyncrequired:
+ (int) ($cacheData['foldersyncrequired'] ?? 0),
+ collections: self::collectionFacts(
+ is_array($cacheData['collections'] ?? null)
+ ? $cacheData['collections']
+ : []
+ )
+ ));
+ }
+
+ private static function properties(mixed $properties): array
+ {
+ if (is_array($properties)) {
+ return $properties;
+ }
+ if (!is_string($properties) || $properties === '') {
+ return [];
+ }
+
+ $decoded = @unserialize(
+ $properties,
+ ['allowed_classes' => false]
+ );
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * Persisted SyncCache rows, keyed by collection id.
+ *
+ * Copied keys are id (or the row key), class, serverid, lastsynckey,
+ * backlog, backlogpings, and pingable.
+ *
+ * @param array> $collections
+ */
+ private static function collectionFacts(array $collections): CollectionFactList
+ {
+ $facts = [];
+ foreach ($collections as $id => $collection) {
+ if (!is_array($collection)) {
+ continue;
+ }
+ $collectionId = (string) ($collection['id'] ?? $id);
+ $facts[] = new CollectionFacts(
+ id: $collectionId,
+ class: self::nullableString($collection['class'] ?? null),
+ serverid:
+ self::nullableString($collection['serverid'] ?? null),
+ lastsynckey:
+ self::nullableString($collection['lastsynckey'] ?? null),
+ backlog: self::nullableInt($collection['backlog'] ?? null),
+ backlogpings: (int) ($collection['backlogpings'] ?? 0),
+ pingable: !empty($collection['pingable'])
+ );
+ }
+
+ return new CollectionFactList($facts);
+ }
+
+ private static function nullableInt(mixed $value): ?int
+ {
+ return $value === null || $value === false || $value === ''
+ ? null
+ : (int) $value;
+ }
+
+ private static function nullableString(mixed $value): ?string
+ {
+ return $value === null || $value === false || $value === ''
+ ? null
+ : (string) $value;
+ }
+}
diff --git a/src/Ops/FleetSummary.php b/src/Ops/FleetSummary.php
new file mode 100644
index 00000000..9fc67c82
--- /dev/null
+++ b/src/Ops/FleetSummary.php
@@ -0,0 +1,98 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+final class FleetSummary
+{
+ public function __construct(
+ public readonly int $devices,
+ public readonly int $active,
+ public readonly int $ok,
+ public readonly int $warn,
+ public readonly int $critical,
+ public readonly int $stuck,
+ public readonly int $wipePending,
+ public readonly int $blocked,
+ public readonly int $asOf
+ ) {
+ }
+
+ public static function fromDevices(iterable $deviceHealths, int $asOf): self
+ {
+ $counts = [
+ 'devices' => 0,
+ 'active' => 0,
+ 'ok' => 0,
+ 'warn' => 0,
+ 'critical' => 0,
+ 'stuck' => 0,
+ 'wipePending' => 0,
+ 'blocked' => 0,
+ ];
+
+ foreach ($deviceHealths as $device) {
+ ++$counts['devices'];
+ $counts['active'] += (int) $device->active;
+ ++$counts[$device->status];
+ $counts['stuck'] += (int) $device->isStuck();
+ $codes = $device->signalCodes();
+ $counts['wipePending'] += (int) in_array(
+ SignalCode::WIPE_PENDING,
+ $codes,
+ true
+ );
+ $counts['blocked'] += (int) in_array(
+ SignalCode::BLOCKED,
+ $codes,
+ true
+ );
+ }
+
+ return new self(
+ devices: $counts['devices'],
+ active: $counts['active'],
+ ok: $counts['ok'],
+ warn: $counts['warn'],
+ critical: $counts['critical'],
+ stuck: $counts['stuck'],
+ wipePending: $counts['wipePending'],
+ blocked: $counts['blocked'],
+ asOf: $asOf
+ );
+ }
+
+ public function hasCritical(): bool
+ {
+ return $this->critical > 0;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'devices' => $this->devices,
+ 'active' => $this->active,
+ 'ok' => $this->ok,
+ 'warn' => $this->warn,
+ 'critical' => $this->critical,
+ 'stuck' => $this->stuck,
+ 'wipePending' => $this->wipePending,
+ 'blocked' => $this->blocked,
+ 'asOf' => $this->asOf,
+ ];
+ }
+}
diff --git a/src/Ops/HealthEvaluator.php b/src/Ops/HealthEvaluator.php
new file mode 100644
index 00000000..42805500
--- /dev/null
+++ b/src/Ops/HealthEvaluator.php
@@ -0,0 +1,197 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use Horde_ActiveSync;
+
+final class HealthEvaluator
+{
+ private readonly HealthOptions $options;
+
+ public function __construct(?HealthOptions $options = null)
+ {
+ $this->options = $options ?? new HealthOptions();
+ }
+
+ public function evaluate(DeviceFacts $facts): DeviceHealth
+ {
+ $now = $this->options->now();
+ $stuckAfter = $this->options->hbStuckAfterFor($facts->hbinterval);
+ $abandonedAfter = $this->options->hbAbandonedAfterFor(
+ $facts->hbinterval
+ );
+ $signals = [];
+ $started = $facts->lasthbsyncstarted ?: null;
+ $ended = $facts->lastsyncendnormal ?: null;
+ $heartbeatInFlight = false;
+
+ if ($started !== null && ($ended === null || $started > $ended)) {
+ $heartbeatAge = $now - $started;
+ if ($heartbeatAge <= $stuckAfter) {
+ $signals[] = $this->signal(
+ SignalCode::HB_IN_FLIGHT,
+ HealthStatus::OK
+ );
+ $heartbeatInFlight = true;
+ } elseif ($heartbeatAge <= $abandonedAfter && $ended === null) {
+ $signals[] = $this->signal(
+ SignalCode::HB_MISSING_END,
+ HealthStatus::WARN
+ );
+ } elseif ($heartbeatAge <= $abandonedAfter) {
+ $signals[] = $this->signal(
+ SignalCode::HB_STUCK,
+ HealthStatus::WARN
+ );
+ } else {
+ $signals[] = $this->signal(
+ SignalCode::HB_ABANDONED,
+ HealthStatus::OK
+ );
+ }
+ }
+
+ if ($facts->foldersyncrequired >= $this->options->fsrCriticalAt) {
+ $signals[] = $this->signal(
+ SignalCode::FSR_CRITICAL,
+ HealthStatus::CRITICAL
+ );
+ } elseif ($facts->foldersyncrequired >= $this->options->fsrWarnAt) {
+ $signals[] = $this->signal(
+ SignalCode::FSR_WARN,
+ HealthStatus::WARN
+ );
+ }
+
+ if ($facts->blocked) {
+ $signals[] = $this->signal(SignalCode::BLOCKED, HealthStatus::WARN);
+ }
+
+ $pending = [
+ Horde_ActiveSync::RWSTATUS_PENDING,
+ Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING,
+ ];
+ $wiped = [
+ Horde_ActiveSync::RWSTATUS_WIPED,
+ Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED,
+ ];
+ if (in_array($facts->rwstatus, $pending, true)
+ || $facts->accountOnlyRwstatus === Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_PENDING) {
+ $signals[] = $this->signal(
+ SignalCode::WIPE_PENDING,
+ HealthStatus::WARN
+ );
+ }
+ if (in_array($facts->rwstatus, $wiped, true)
+ || $facts->accountOnlyRwstatus === Horde_ActiveSync::RWSTATUS_ACCOUNTONLY_WIPED) {
+ $signals[] = $this->signal(
+ SignalCode::WIPE_COMPLETE,
+ HealthStatus::WARN
+ );
+ }
+
+ $collections = [];
+ foreach ($facts->collections as $collection) {
+ $collections[] = $this->evaluateCollection($collection, $now);
+ }
+
+ $activityTimestamps = array_filter(
+ [$facts->cacheTimestamp, $facts->lastSyncTs],
+ static fn (?int $timestamp): bool => $timestamp !== null
+ );
+ $ageSeconds = $activityTimestamps === []
+ ? null
+ : $now - max($activityTimestamps);
+ $active = $heartbeatInFlight
+ || ($ageSeconds !== null
+ && $ageSeconds <= $this->options->activeWithin);
+
+ $statuses = array_map(
+ static fn (HealthSignal $signal): string => $signal->severity,
+ $signals
+ );
+ foreach ($collections as $collection) {
+ $statuses[] = $collection->status;
+ }
+
+ return new DeviceHealth(
+ user: $facts->user,
+ deviceId: $facts->deviceId,
+ deviceType: $facts->deviceType,
+ version: $facts->version,
+ status: HealthStatus::worst(...$statuses),
+ active: $active,
+ ageSeconds: $ageSeconds,
+ hbinterval: $facts->hbinterval,
+ foldersyncrequired: $facts->foldersyncrequired,
+ signals: $signals,
+ collections: $collections
+ );
+ }
+
+ private function evaluateCollection(
+ CollectionFacts $facts,
+ int $now
+ ): CollectionHealth {
+ $signals = [];
+ if ($facts->backlog !== null
+ && $facts->backlogpings >= $this->options->backlogTriggerMax
+ && ($now - $facts->backlog) < $this->options->backlogAbandonedAfter) {
+ $signals[] = $this->signal(
+ SignalCode::BACKLOG_STUCK,
+ HealthStatus::CRITICAL,
+ $facts->id
+ );
+ } elseif ($facts->backlog !== null
+ && ($now - $facts->backlog) >= $this->options->backlogGrace
+ && $facts->backlogpings < $this->options->backlogTriggerMax) {
+ $signals[] = $this->signal(
+ SignalCode::BACKLOG_PENDING,
+ HealthStatus::WARN,
+ $facts->id
+ );
+ }
+
+ $statuses = array_map(
+ static fn (HealthSignal $signal): string => $signal->severity,
+ $signals
+ );
+
+ return new CollectionHealth(
+ id: $facts->id,
+ class: $facts->class,
+ serverid: $facts->serverid,
+ lastsynckey: $facts->lastsynckey,
+ status: HealthStatus::worst(...$statuses),
+ signals: $signals
+ );
+ }
+
+ private function signal(
+ string $code,
+ string $severity,
+ ?string $collectionId = null
+ ): HealthSignal {
+ return new HealthSignal(
+ code: $code,
+ severity: $severity,
+ detail: SignalCode::describe($code),
+ collectionId: $collectionId
+ );
+ }
+}
diff --git a/src/Ops/HealthOptions.php b/src/Ops/HealthOptions.php
new file mode 100644
index 00000000..a6a5d142
--- /dev/null
+++ b/src/Ops/HealthOptions.php
@@ -0,0 +1,53 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+final class HealthOptions
+{
+ public function __construct(
+ private readonly ?int $now = null,
+ public readonly int $activeWithin = 300,
+ public readonly ?int $hbStuckAfter = null,
+ public readonly int $hbDefaultInterval = 900,
+ public readonly int $hbSlack = 60,
+ public readonly ?int $hbAbandonedAfter = null,
+ public readonly int $fsrWarnAt = 3,
+ public readonly int $fsrCriticalAt = 5,
+ public readonly int $backlogGrace = 60,
+ public readonly int $backlogTriggerMax = 3,
+ public readonly int $backlogAbandonedAfter = 3600
+ ) {
+ }
+
+ public function now(): int
+ {
+ return $this->now ?? time();
+ }
+
+ public function hbStuckAfterFor(?int $hbinterval): int
+ {
+ return $this->hbStuckAfter
+ ?? (($hbinterval ?: $this->hbDefaultInterval) + $this->hbSlack);
+ }
+
+ public function hbAbandonedAfterFor(?int $hbinterval): int
+ {
+ return $this->hbAbandonedAfter
+ ?? (2 * $this->hbStuckAfterFor($hbinterval));
+ }
+}
diff --git a/src/Ops/HealthSignal.php b/src/Ops/HealthSignal.php
new file mode 100644
index 00000000..1b648bff
--- /dev/null
+++ b/src/Ops/HealthSignal.php
@@ -0,0 +1,39 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+final class HealthSignal
+{
+ public function __construct(
+ public readonly string $code,
+ public readonly string $severity,
+ public readonly string $detail,
+ public readonly ?string $collectionId = null
+ ) {
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'code' => $this->code,
+ 'severity' => $this->severity,
+ 'detail' => $this->detail,
+ 'collectionId' => $this->collectionId,
+ ];
+ }
+}
diff --git a/src/Ops/HealthStatus.php b/src/Ops/HealthStatus.php
new file mode 100644
index 00000000..cf05e647
--- /dev/null
+++ b/src/Ops/HealthStatus.php
@@ -0,0 +1,56 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use InvalidArgumentException;
+
+final class HealthStatus
+{
+ public const OK = 'ok';
+ public const WARN = 'warn';
+ public const CRITICAL = 'critical';
+
+ public static function rank(string $status): int
+ {
+ return match ($status) {
+ self::OK => 0,
+ self::WARN => 1,
+ self::CRITICAL => 2,
+ default => throw new InvalidArgumentException(
+ sprintf('Unknown health status: %s', $status)
+ ),
+ };
+ }
+
+ public static function worst(string ...$statuses): string
+ {
+ $worst = self::OK;
+ foreach ($statuses as $status) {
+ if (self::rank($status) > self::rank($worst)) {
+ $worst = $status;
+ }
+ }
+
+ return $worst;
+ }
+
+ public static function isValid(string $status): bool
+ {
+ return in_array($status, [self::OK, self::WARN, self::CRITICAL], true);
+ }
+}
diff --git a/src/Ops/SignalCode.php b/src/Ops/SignalCode.php
new file mode 100644
index 00000000..5aebb7de
--- /dev/null
+++ b/src/Ops/SignalCode.php
@@ -0,0 +1,72 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use InvalidArgumentException;
+
+final class SignalCode
+{
+ public const HB_IN_FLIGHT = 'hb_in_flight';
+ public const HB_STUCK = 'hb_stuck';
+ public const HB_MISSING_END = 'hb_missing_end';
+ public const HB_ABANDONED = 'hb_abandoned';
+ public const FSR_WARN = 'fsr_warn';
+ public const FSR_CRITICAL = 'fsr_critical';
+ public const BLOCKED = 'blocked';
+ public const WIPE_PENDING = 'wipe_pending';
+ public const WIPE_COMPLETE = 'wipe_complete';
+ public const BACKLOG_PENDING = 'backlog_pending';
+ public const BACKLOG_STUCK = 'backlog_stuck';
+
+ public static function all(): array
+ {
+ return [
+ self::HB_IN_FLIGHT,
+ self::HB_STUCK,
+ self::HB_MISSING_END,
+ self::HB_ABANDONED,
+ self::FSR_WARN,
+ self::FSR_CRITICAL,
+ self::BLOCKED,
+ self::WIPE_PENDING,
+ self::WIPE_COMPLETE,
+ self::BACKLOG_PENDING,
+ self::BACKLOG_STUCK,
+ ];
+ }
+
+ public static function describe(string $code): string
+ {
+ return match ($code) {
+ self::HB_IN_FLIGHT => 'Heartbeat sync is currently in flight.',
+ self::HB_STUCK => 'Heartbeat sync has exceeded its expected duration.',
+ self::HB_MISSING_END => 'Heartbeat sync has no recorded normal end.',
+ self::HB_ABANDONED => 'Last heartbeat sync ended abnormally; the request is no longer running.',
+ self::FSR_WARN => 'Repeated FolderSync-required responses are approaching the limit.',
+ self::FSR_CRITICAL => 'Repeated FolderSync-required responses reached the limit.',
+ self::BLOCKED => 'Device is blocked.',
+ self::WIPE_PENDING => 'A remote wipe is pending.',
+ self::WIPE_COMPLETE => 'A remote wipe completed.',
+ self::BACKLOG_PENDING => 'Collection backlog is awaiting recovery.',
+ self::BACKLOG_STUCK => 'Collection backlog repeatedly triggered recovery.',
+ default => throw new InvalidArgumentException(
+ sprintf('Unknown health signal code: %s', $code)
+ ),
+ };
+ }
+}
diff --git a/test/unit/Horde/ActiveSync/Ops/CollectionFactListTest.php b/test/unit/Horde/ActiveSync/Ops/CollectionFactListTest.php
new file mode 100644
index 00000000..53819436
--- /dev/null
+++ b/test/unit/Horde/ActiveSync/Ops/CollectionFactListTest.php
@@ -0,0 +1,79 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+use OutOfBoundsException;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(CollectionFactList::class)]
+#[CoversClass(CollectionFacts::class)]
+class CollectionFactListTest extends TestCase
+{
+ public function testIndexesSnapshotsByCollectionId(): void
+ {
+ $mail = new CollectionFacts(id: 'mail', class: 'Email', serverid: 'INBOX');
+ $calendar = new CollectionFacts(id: 'calendar', class: 'Calendar');
+ $list = new CollectionFactList([$mail, $calendar]);
+
+ $this->assertCount(2, $list);
+ $this->assertSame($mail, $list->get('mail'));
+ $this->assertSame($calendar, $list['calendar']);
+ $this->assertNull($list->get('missing'));
+ $this->assertFalse(isset($list['missing']));
+ $this->assertSame(['mail', 'calendar'], array_map(
+ static fn (CollectionFacts $facts): string => $facts->id,
+ $list->all()
+ ));
+ }
+
+ public function testLaterSnapshotReplacesTheSameId(): void
+ {
+ $list = new CollectionFactList([
+ new CollectionFacts(id: 'mail', class: 'Email'),
+ new CollectionFacts(id: 'mail', class: 'Calendar'),
+ ]);
+
+ $this->assertCount(1, $list);
+ $this->assertSame('Calendar', $list['mail']->class);
+ }
+
+ public function testRejectsEntriesThatAreNotSnapshots(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new CollectionFactList([new CollectionFacts(id: 'mail'), 'mail']);
+ }
+
+ public function testMissingOffsetThrows(): void
+ {
+ $list = new CollectionFactList([new CollectionFacts(id: 'mail')]);
+
+ $this->expectException(OutOfBoundsException::class);
+ $list['calendar'];
+ }
+
+ public function testListIsImmutable(): void
+ {
+ $list = new CollectionFactList();
+
+ $this->expectException(BadMethodCallException::class);
+ $list['mail'] = new CollectionFacts(id: 'mail');
+ }
+}
diff --git a/test/unit/Horde/ActiveSync/Ops/DeviceHealthFactoryTest.php b/test/unit/Horde/ActiveSync/Ops/DeviceHealthFactoryTest.php
new file mode 100644
index 00000000..70b6190c
--- /dev/null
+++ b/test/unit/Horde/ActiveSync/Ops/DeviceHealthFactoryTest.php
@@ -0,0 +1,159 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use Horde_ActiveSync_Device;
+use Horde_ActiveSync_State_Sql;
+use Horde_ActiveSync_SyncCache;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(DeviceHealthFactory::class)]
+#[CoversClass(DeviceFacts::class)]
+#[CoversClass(CollectionFacts::class)]
+class DeviceHealthFactoryTest extends TestCase
+{
+ private const NOW = 1_700_000_000;
+
+ public function testFromRowDecodesPropertiesAndFalseValues(): void
+ {
+ $factory = $this->factory();
+ $health = $factory->fromRow(
+ [
+ 'device_id' => 'DEVICE1',
+ 'device_type' => 'phone',
+ 'device_user' => 'alice@example.com',
+ 'device_rwstatus' => 0,
+ 'device_accountonly_rwstatus' => 0,
+ 'device_properties' => serialize([
+ 'blocked' => true,
+ 'version' => '16.1',
+ ]),
+ ],
+ [
+ 'timestamp' => false,
+ 'hbinterval' => false,
+ 'wait' => 15,
+ 'lasthbsyncstarted' => false,
+ 'lastsyncendnormal' => false,
+ 'foldersyncrequired' => 3,
+ 'collections' => [
+ 'collection-1' => [
+ 'class' => 'Email',
+ 'serverid' => 'INBOX',
+ 'lastsynckey' => false,
+ 'backlog' => self::NOW - 60,
+ 'backlogpings' => 1,
+ 'pingable' => true,
+ ],
+ ],
+ ]
+ );
+
+ $this->assertSame('16.1', $health->version);
+ $this->assertSame(900, $health->hbinterval);
+ $this->assertNull($health->ageSeconds);
+ $this->assertSame(
+ [SignalCode::FSR_WARN, SignalCode::BLOCKED, SignalCode::BACKLOG_PENDING],
+ $health->signalCodes()
+ );
+ $this->assertSame('collection-1', $health->collections[0]->id);
+ $this->assertNull($health->collections[0]->lastsynckey);
+ }
+
+ public function testFromRowAcceptsArrayProperties(): void
+ {
+ $health = $this->factory()->fromRow(
+ [
+ 'device_id' => 'DEVICE1',
+ 'device_type' => 'phone',
+ 'device_user' => 'alice@example.com',
+ 'device_rwstatus' => 0,
+ 'device_accountonly_rwstatus' => 0,
+ 'device_properties' => ['version' => '14.1'],
+ ],
+ ['collections' => []],
+ self::NOW
+ );
+
+ $this->assertSame('14.1', $health->version);
+ $this->assertTrue($health->active);
+ }
+
+ public function testFromDeviceUsesLegacyObjectsAndIncludesKeylessCollections(): void
+ {
+ $state = $this->createMock(Horde_ActiveSync_State_Sql::class);
+ $state->method('getSyncCache')->willReturn([
+ 'timestamp' => self::NOW - 10,
+ 'hbinterval' => 900,
+ 'wait' => false,
+ 'lasthbsyncstarted' => false,
+ 'lastsyncendnormal' => false,
+ 'foldersyncrequired' => 0,
+ 'collections' => [
+ 'collection-1' => [
+ 'class' => 'Email',
+ 'serverid' => 'INBOX',
+ 'lastsynckey' => false,
+ 'backlog' => self::NOW - 60,
+ 'backlogpings' => 3,
+ 'pingable' => true,
+ ],
+ ],
+ ]);
+ $device = new Horde_ActiveSync_Device($state, [
+ 'id' => 'DEVICE1',
+ 'user' => 'alice@example.com',
+ 'deviceType' => 'phone',
+ 'rwstatus' => 0,
+ 'accountOnlyRwstatus' => 0,
+ 'properties' => [
+ 'blocked' => false,
+ 'version' => '16.1',
+ ],
+ ]);
+ $cache = new Horde_ActiveSync_SyncCache(
+ $state,
+ 'DEVICE1',
+ 'alice@example.com'
+ );
+
+ $health = $this->factory()->fromDevice(
+ $device,
+ $cache,
+ self::NOW - 20
+ );
+
+ $this->assertSame('DEVICE1', $health->deviceId);
+ $this->assertSame('16.1', $health->version);
+ $this->assertSame(10, $health->ageSeconds);
+ $this->assertTrue($health->active);
+ $this->assertSame(
+ [SignalCode::BACKLOG_STUCK],
+ $health->signalCodes()
+ );
+ $this->assertNull($health->collections[0]->lastsynckey);
+ }
+
+ private function factory(): DeviceHealthFactory
+ {
+ return new DeviceHealthFactory(new HealthEvaluator(
+ new HealthOptions(now: self::NOW)
+ ));
+ }
+}
diff --git a/test/unit/Horde/ActiveSync/Ops/HealthEvaluatorTest.php b/test/unit/Horde/ActiveSync/Ops/HealthEvaluatorTest.php
new file mode 100644
index 00000000..b7a6c10a
--- /dev/null
+++ b/test/unit/Horde/ActiveSync/Ops/HealthEvaluatorTest.php
@@ -0,0 +1,302 @@
+
+ * @category Horde
+ * @copyright 2026 The Horde Project
+ * @license http://www.horde.org/licenses/gpl GPLv2
+ * @package ActiveSync
+ */
+
+namespace Horde\ActiveSync\Ops;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(HealthEvaluator::class)]
+#[CoversClass(HealthOptions::class)]
+#[CoversClass(HealthStatus::class)]
+#[CoversClass(SignalCode::class)]
+#[CoversClass(HealthSignal::class)]
+#[CoversClass(CollectionHealth::class)]
+#[CoversClass(DeviceHealth::class)]
+#[CoversClass(FleetSummary::class)]
+class HealthEvaluatorTest extends TestCase
+{
+ private const NOW = 1_700_000_000;
+
+ /**
+ * @dataProvider folderSyncRequiredProvider
+ */
+ #[DataProvider('folderSyncRequiredProvider')]
+ public function testFolderSyncRequiredBoundaries(
+ int $count,
+ array $expectedCodes
+ ): void {
+ $health = $this->evaluate(['foldersyncrequired' => $count]);
+
+ $this->assertSame($expectedCodes, $health->signalCodes());
+ }
+
+ public static function folderSyncRequiredProvider(): array
+ {
+ return [
+ 'zero' => [0, []],
+ 'below warning' => [2, []],
+ 'warning' => [3, [SignalCode::FSR_WARN]],
+ 'critical' => [5, [SignalCode::FSR_CRITICAL]],
+ ];
+ }
+
+ public function testHeartbeatBoundariesAndMissingEnd(): void
+ {
+ $atBoundary = $this->evaluate([
+ 'lasthbsyncstarted' => self::NOW - 960,
+ 'lastsyncendnormal' => self::NOW - 1_000,
+ ]);
+ $this->assertSame(
+ [SignalCode::HB_IN_FLIGHT],
+ $atBoundary->signalCodes()
+ );
+ $this->assertTrue($atBoundary->active);
+ $this->assertSame(HealthStatus::OK, $atBoundary->status);
+
+ $stuck = $this->evaluate([
+ 'lasthbsyncstarted' => self::NOW - 961,
+ 'lastsyncendnormal' => self::NOW - 1_000,
+ ]);
+ $this->assertSame([SignalCode::HB_STUCK], $stuck->signalCodes());
+ $this->assertSame(HealthStatus::WARN, $stuck->status);
+ $this->assertTrue($stuck->isStuck());
+
+ $missingEnd = $this->evaluate([
+ 'lasthbsyncstarted' => self::NOW - 961,
+ 'lastsyncendnormal' => null,
+ ]);
+ $this->assertSame(
+ [SignalCode::HB_MISSING_END],
+ $missingEnd->signalCodes()
+ );
+ $this->assertSame(HealthStatus::WARN, $missingEnd->status);
+
+ $stuckAtAbandonedBoundary = $this->evaluate([
+ 'lasthbsyncstarted' => self::NOW - 1_920,
+ 'lastsyncendnormal' => self::NOW - 2_000,
+ ]);
+ $this->assertSame(
+ [SignalCode::HB_STUCK],
+ $stuckAtAbandonedBoundary->signalCodes()
+ );
+
+ $abandoned = $this->evaluate([
+ 'cacheTimestamp' => self::NOW - 301,
+ 'lasthbsyncstarted' => self::NOW - 1_921,
+ 'lastsyncendnormal' => self::NOW - 2_000,
+ ]);
+ $this->assertSame(
+ [SignalCode::HB_ABANDONED],
+ $abandoned->signalCodes()
+ );
+ $this->assertSame(HealthStatus::OK, $abandoned->status);
+ $this->assertFalse($abandoned->isStuck());
+ $this->assertFalse($abandoned->active);
+
+ $missingEndAtAbandonedBoundary = $this->evaluate([
+ 'lasthbsyncstarted' => self::NOW - 1_920,
+ 'lastsyncendnormal' => null,
+ ]);
+ $this->assertSame(
+ [SignalCode::HB_MISSING_END],
+ $missingEndAtAbandonedBoundary->signalCodes()
+ );
+
+ $abandonedMissingEnd = $this->evaluate([
+ 'cacheTimestamp' => self::NOW - 301,
+ 'lasthbsyncstarted' => self::NOW - 1_921,
+ 'lastsyncendnormal' => null,
+ ]);
+ $this->assertSame(
+ [SignalCode::HB_ABANDONED],
+ $abandonedMissingEnd->signalCodes()
+ );
+ $this->assertSame(HealthStatus::OK, $abandonedMissingEnd->status);
+ $this->assertFalse($abandonedMissingEnd->isStuck());
+ $this->assertFalse($abandonedMissingEnd->active);
+ }
+
+ public function testHeartbeatAbandonedThresholdOptions(): void
+ {
+ $defaults = new HealthOptions();
+ $this->assertSame(1_320, $defaults->hbAbandonedAfterFor(600));
+
+ $explicit = new HealthOptions(hbAbandonedAfter: 2_000);
+ $this->assertSame(2_000, $explicit->hbAbandonedAfterFor(600));
+
+ $this->assertSame(3600, $defaults->backlogAbandonedAfter);
+ }
+
+ public function testBlockedAndWipeSignals(): void
+ {
+ $pending = $this->evaluate([
+ 'blocked' => true,
+ 'rwstatus' => 2,
+ 'accountOnlyRwstatus' => 4,
+ ]);
+ $this->assertSame(
+ [SignalCode::BLOCKED, SignalCode::WIPE_PENDING],
+ $pending->signalCodes()
+ );
+
+ $complete = $this->evaluate([
+ 'rwstatus' => 3,
+ 'accountOnlyRwstatus' => 5,
+ ]);
+ $this->assertSame(
+ [SignalCode::WIPE_COMPLETE],
+ $complete->signalCodes()
+ );
+ }
+
+ public function testBacklogBoundariesAndWorstAggregation(): void
+ {
+ $health = $this->evaluate([
+ 'blocked' => true,
+ 'collections' => [
+ new CollectionFacts(
+ id: 'young',
+ backlog: self::NOW - 59,
+ backlogpings: 2
+ ),
+ new CollectionFacts(
+ id: 'pending',
+ backlog: self::NOW - 60,
+ backlogpings: 2
+ ),
+ new CollectionFacts(
+ id: 'stuck',
+ backlog: self::NOW,
+ backlogpings: 3
+ ),
+ new CollectionFacts(
+ id: 'abandoned',
+ backlog: self::NOW - 3600,
+ backlogpings: 3
+ ),
+ ],
+ ]);
+
+ $this->assertSame(HealthStatus::CRITICAL, $health->status);
+ $this->assertSame(HealthStatus::OK, $health->collections[0]->status);
+ $this->assertSame(HealthStatus::OK, $health->collections[3]->status);
+ $this->assertSame([], $health->collections[3]->signals);
+ $this->assertSame(
+ [SignalCode::BACKLOG_PENDING],
+ array_map(
+ static fn (HealthSignal $signal): string => $signal->code,
+ $health->collections[1]->signals
+ )
+ );
+ $this->assertSame(
+ [SignalCode::BLOCKED, SignalCode::BACKLOG_PENDING, SignalCode::BACKLOG_STUCK],
+ $health->signalCodes()
+ );
+ $this->assertTrue($health->isStuck());
+ }
+
+ public function testActiveWindowEdgesAndLatestTimestamp(): void
+ {
+ $atBoundary = $this->evaluate([
+ 'cacheTimestamp' => self::NOW - 300,
+ 'lastSyncTs' => self::NOW - 400,
+ ]);
+ $this->assertTrue($atBoundary->active);
+ $this->assertSame(300, $atBoundary->ageSeconds);
+
+ $outside = $this->evaluate([
+ 'cacheTimestamp' => self::NOW - 301,
+ ]);
+ $this->assertFalse($outside->active);
+ $this->assertSame(301, $outside->ageSeconds);
+
+ $unknown = $this->evaluate();
+ $this->assertNull($unknown->ageSeconds);
+ $this->assertFalse($unknown->active);
+ }
+
+ public function testFleetSummaryCountsAndSerialization(): void
+ {
+ $ok = $this->evaluate(['cacheTimestamp' => self::NOW]);
+ $warn = $this->evaluate(['blocked' => true]);
+ $critical = $this->evaluate(['foldersyncrequired' => 5]);
+ $pending = $this->evaluate(['rwstatus' => 2]);
+ $summary = FleetSummary::fromDevices(
+ [$ok, $warn, $critical, $pending],
+ self::NOW
+ );
+
+ $this->assertSame(4, $summary->devices);
+ $this->assertSame(1, $summary->active);
+ $this->assertSame(1, $summary->ok);
+ $this->assertSame(2, $summary->warn);
+ $this->assertSame(1, $summary->critical);
+ $this->assertSame(1, $summary->stuck);
+ $this->assertSame(1, $summary->wipePending);
+ $this->assertSame(1, $summary->blocked);
+ $this->assertTrue($summary->hasCritical());
+ $this->assertSame(self::NOW, $summary->toArray()['asOf']);
+
+ $withPath = $warn->withLogPath('/logs/DEVICE1.txt');
+ $this->assertNull($warn->logPath);
+ $this->assertSame('/logs/DEVICE1.txt', $withPath->toArray()['logPath']);
+ }
+
+ public function testSignalCatalogAndStatusHelpers(): void
+ {
+ $this->assertCount(11, SignalCode::all());
+ foreach (SignalCode::all() as $code) {
+ $this->assertNotSame('', SignalCode::describe($code));
+ }
+ $this->assertTrue(HealthStatus::isValid(HealthStatus::WARN));
+ $this->assertFalse(HealthStatus::isValid('unknown'));
+ $this->assertSame(
+ HealthStatus::CRITICAL,
+ HealthStatus::worst(
+ HealthStatus::OK,
+ HealthStatus::CRITICAL,
+ HealthStatus::WARN
+ )
+ );
+ }
+
+ private function evaluate(array $values = []): DeviceHealth
+ {
+ $facts = new DeviceFacts(
+ user: 'alice@example.com',
+ deviceId: 'DEVICE1',
+ deviceType: 'phone',
+ version: '16.1',
+ rwstatus: $values['rwstatus'] ?? 0,
+ accountOnlyRwstatus: $values['accountOnlyRwstatus'] ?? 0,
+ blocked: $values['blocked'] ?? false,
+ lastSyncTs: $values['lastSyncTs'] ?? null,
+ cacheTimestamp: $values['cacheTimestamp'] ?? null,
+ hbinterval: $values['hbinterval'] ?? 900,
+ lasthbsyncstarted: $values['lasthbsyncstarted'] ?? null,
+ lastsyncendnormal: $values['lastsyncendnormal'] ?? null,
+ foldersyncrequired: $values['foldersyncrequired'] ?? 0,
+ collections: new CollectionFactList($values['collections'] ?? [])
+ );
+
+ return (new HealthEvaluator(
+ new HealthOptions(now: self::NOW)
+ ))->evaluate($facts);
+ }
+}