# status-mon

Background poller + state file + workspace banner for Statuspage.io-backed service status monitoring.

## What it does

Every 60 seconds, polls a configurable list of Statuspage.io `/api/v2/summary.json` endpoints and writes a normalised state file. The workspace server exposes that file at `/__api/status-mon`; every workspace page loads `/banner.js`, which reads the endpoint and renders a slim, severity-tinted banner as the first element in the viewport. Default sources ship as Anthropic + GitHub; any Statuspage.io-hosted status site works.

The banner is the only visible surface. Active incidents render as amber (minor), red (major/critical), or blue (maintenance) and are non-dismissable. A "recovered" row is green and dismissable per-incident via `localStorage`. When no source has anything to report, the banner is empty.

## Architecture

```
┌────────────────────────┐       ┌──────────────────────────┐       ┌──────────────────┐
│ Poller                 │ writes│ State file               │ reads │ Banner API       │
│ core/tools/status-mon/ ├──────▶│ .state/status-mon.json   │◀──────┤ /__api/status-mon│
│ 60s, server-side       │       │ (gitignored, per-host)   │       │                  │
└────────────────────────┘       └──────────────────────────┘       └─────────┬────────┘
                                                                              │ 30s client poll
                                                                              ▼
                                                                    ┌──────────────────┐
                                                                    │ Banner client    │
                                                                    │ /banner.js + CSS │
                                                                    │ inlined on pages │
                                                                    └──────────────────┘
```

The workspace server imports `startPoller({ repoRoot })` on boot. The returned handle exposes `stop()` for graceful shutdown. The poller writes the shared state file every 60s. A standalone `run.mjs` CLI is also provided for one-shot / scheduled use, but the server-side integration is the default because the workspace is launchd-supervised.

### Why server-side integration (not a separate scheduled task)

- The workspace server is supervised by launchd (label derived from `paName`, e.g. `com.<paName>.workspace`) — always on.
- A separate cron/launchd job would duplicate supervision, add localhost→localhost file-coordination risk, and widen the failure surface for no operational benefit.
- The module loads `poll.mjs` directly so the CLI (`run.mjs`) and the server share the exact same normalisation logic.

If an operator prefers a scheduled-task layout, `run.mjs` is a complete, supervision-ready CLI. See "Scheduled mode" below.

## Files

| File | Role |
|---|---|
| `poll.mjs` | Pure normalisation + poll logic. No I/O at import. |
| `poller.mjs` | Server-side lifecycle: interval loop, state-file IO, preferences resolution. |
| `run.mjs` | One-shot CLI wrapper. Writes `.state/status-mon.json`. |
| `poll.test.mjs` | Node tests for parsing + merging. |
| `poller.test.mjs` | Node tests for the poller lifecycle. |

## State file

Default location: `$DAVE_ROOT/.state/status-mon.json` (gitignored).

```jsonc
{
  "polledAt": "2026-04-20T12:00:00.000Z",
  "lastSuccessfulPollAt": "2026-04-20T12:00:00.000Z",
  "sources": [
    {
      "id": "anthropic",
      "name": "Anthropic",
      "sourceUrl": "https://status.anthropic.com/api/v2/summary.json",
      "overall": { "indicator": "minor", "description": "Partial service disruption" },
      "incidents": [
        {
          "id": "abc123",
          "name": "Elevated error rates on Messages API",
          "status": "monitoring",
          "impact": "minor",
          "startedAt": "2026-04-20T11:00:00Z",
          "updatedAt": "2026-04-20T11:45:00Z",
          "url": "https://stspg.io/abc123"
        }
      ],
      "recentlyResolved": []
    },
    {
      "id": "github",
      "name": "GitHub",
      "sourceUrl": "https://www.githubstatus.com/api/v2/summary.json",
      "overall": { "indicator": "none", "description": "All Systems Operational" },
      "incidents": [],
      "recentlyResolved": [
        {
          "id": "xyz789",
          "name": "Actions queue delays",
          "resolvedAt": "2026-04-20T07:15:00Z",
          "url": "https://www.githubstatus.com/incidents/xyz789"
        }
      ]
    }
  ]
}
```

### Graceful degradation

When a source fetch fails but the previous poll's slice for that source succeeded, the poller retains the prior slice, adds `"fetchError": "<reason>"` and `"stale": true`, and carries forward `lastSuccessfulPollAt` at the top level. Consumers can read this to decide whether to show a warning; the banner currently treats stale data exactly like live data since "the last thing we knew" is more useful than "status unknown".

If a source has *never* fetched successfully, its slice shows `indicator: 'none'` with the `fetchError` field — the banner silently omits it rather than rendering a spurious red row.

## Configuration

Override sources via `operator/config/preferences.json`:

```json
{
  "statusMonSources": [
    { "id": "anthropic", "name": "Anthropic", "url": "https://status.anthropic.com/api/v2/summary.json" },
    { "id": "openai", "name": "OpenAI", "url": "https://status.openai.com/api/v2/summary.json" }
  ]
}
```

Each entry must be a Statuspage.io `/api/v2/summary.json` endpoint. Non-Statuspage sources are out of scope for v1; the config shape is forward-compatible (a future `adapter` field could opt into alternative shapes).

If `statusMonSources` is absent or invalid, the tool falls back to the built-in defaults (Anthropic + GitHub).

## Server-side integration (default)

The workspace server imports and starts the poller on boot:

```javascript
// server.mjs (excerpt)
import { startPoller } from '../status-mon/poller.mjs';
const statusMonHandle = startPoller({ repoRoot: REPO_ROOT });
// shutdown hook calls statusMonHandle.stop()
```

Startup writes `.state/status-mon.json` out-of-band (non-blocking). Subsequent writes happen every 60s. The API endpoint `/__api/status-mon` reads the file verbatim — it never triggers a fetch, so page loads don't stall on upstream latency.

## Scheduled mode (alternative)

For deployments that prefer cron/launchd to the server-side loop, invoke the CLI:

```bash
node core/tools/status-mon/run.mjs            # production run
node core/tools/status-mon/run.mjs --dry-run  # prints state to stdout
node core/tools/status-mon/run.mjs --out /tmp/x.json  # redirect output
```

Example cron: `* * * * * cd $DAVE_ROOT && node core/tools/status-mon/run.mjs`

When both modes run simultaneously they're safe (last write wins; the file is tiny), but the practical recommendation is: server-side on a launchd-supervised workspace, CLI-scheduled on unsupervised hosts.

Exit codes: `0` ok, `1` usage error, `2` all sources failed and no cached state, `3` write failure.

## Banner client

`core/tools/workspace/app/banner.js` — included on every workspace HTML page via `<script src="/banner.js" defer></script>` and renders into a `<div id="global-banner"></div>` placed as the first child of `<body>`.

- Fetches `/__api/status-mon` on load.
- Polls every 30s while `document.visibilityState === 'visible'`; pauses when hidden.
- Severity ordering: `critical` > `major` > `minor` > `maintenance` > `recovered` > `none`. One row per source, rendered in source order.
- `localStorage.getItem('dave.statusmon.dismissed')` holds a JSON array of dismissed `recentlyResolved` IDs. Dismissals are GC'd on load — IDs no longer in the current state are dropped.
- Active-disruption rows link out to the incident's status page in a new tab. Green rows carry an × dismiss button.
- CSS is inlined by the script — no separate stylesheet request.

## Tests

- `node --test packages/workspace/test/status-mon/*.test.mjs` — pure logic + lifecycle (no network).
- `packages/workspace/e2e/specs/banner.spec.mjs` — Playwright coverage for the render matrix against fixture state files.

Node tests run in the `test` job; the e2e suite runs in the `workspace-e2e` job — both in `.github/workflows/ci.yml`.

## Extending

Add a non-Statuspage source type: extend `poll.mjs` with an `adapter` switch that dispatches on a per-source `adapter` field (default `'statuspage'`). Keep the normalised output shape identical so the banner and state file schema stay untouched.

Add a non-banner surface (e.g. desktop notification, slack webhook): write a consumer against `.state/status-mon.json`. The state file is the contract; the banner is one consumer.
