diff --git a/.changeset/notification-contract-outbox.md b/.changeset/notification-contract-outbox.md index bc1683e..9d9e472 100644 --- a/.changeset/notification-contract-outbox.md +++ b/.changeset/notification-contract-outbox.md @@ -2,10 +2,10 @@ "browserhive": minor --- -Notifications now follow what they announce, and the groundwork for sending them to your phone is in place. +Notifications now follow what they announce, and every notification is a versioned message that can be delivered reliably to other apps. - **A notification keeps its place as things change.** When you resolve an attention request or a vault confirmation, reject it, or it times out, its notification shows the outcome (**resolved**, **expired**) as a small pill in the bell and on the Notifications page, instead of staying as if it were still waiting. A toast still on screen for it closes. A recovered subsystem marks its degradation notification resolved the same way. - **Richer notification data in the API.** `GET /api/v1/notifications` and the `notifications` WebSocket topic add `kind`, `category`, `severity`, `state`, `revision` and `thread` to every notification. Existing fields are unchanged. - **Safer text.** An agent's attention reason and other text copied into a notification now go through the same redaction as the logs, and page addresses lose their query strings. -- **Foundations for Telegram, Discord and ntfy.** Every notification is also a versioned message document, whose JSON Schema is published in the reference docs. Delivery to chat apps goes through a new outbox in the database, with retries and a circuit breaker, so nothing is lost when a service is down. No channel can be configured yet; with none configured nothing changes and nothing extra runs. +- **Built for delivery to your phone.** Every notification is also a versioned message document, whose JSON Schema is published in the reference docs (the webhook channel sends it as is). Delivery to Telegram, Discord, ntfy and webhooks goes through a new outbox in the database, with retries and a circuit breaker, so nothing is lost when a service is down. With no channel configured nothing extra runs. - The database upgrades on start (schema v5: new columns on notifications and three new tables; a backup is written first). Older releases can still open it. Existing notifications are classified from what they already recorded; nothing is invented for them. diff --git a/.changeset/notification-digests.md b/.changeset/notification-digests.md new file mode 100644 index 0000000..28754f4 --- /dev/null +++ b/.changeset/notification-digests.md @@ -0,0 +1,14 @@ +--- +"browserhive": minor +--- + +A daily summary and a heads-up when something's off. + +- **Daily or weekly digest.** Give a channel a digest (every day at 09:00, optionally weekdays only with Monday covering the weekend, or every week on Friday at 17:00; day and time are yours to change) and it gets the period in numbers: sessions, tool calls and errors with the rate, attention requests and how fast they were answered, vault fills, blocked requests, the slowest tool against the period before, the top errors, open problems, a small chart of tool calls per hour and a table per harness, with a link to the Overview for exactly that period. The new **Daily digest** preset sets it up in one click. +- **In your time zone.** Each channel has a time zone, BrowserHive's own unless you pick another, and digests and quiet hours follow it through daylight saving time. +- **Nothing lost, nothing spammed.** If BrowserHive was off when a digest was due, the most recent one arrives when it starts again, marked late, with how many earlier ones were skipped. A day with no activity sends nothing (the delivery log says so). A digest due in quiet hours arrives silently. +- **Tell me when something looks off.** An hourly check that stays silent until a threshold is crossed: many tool calls failing, a request waiting too long, sessions at the limit, a spike in blocked requests, BrowserHive degraded. The alert updates itself and says **Back to normal** when things recover, without flapping. Thresholds can be tuned per channel. +- **Reports in BrowserHive.** Every digest and anomaly alert also lands in the dashboard once per period, however many channels it reached: in the bell and the inbox (a new **Reports** filter), quietly for digests (no pop-up, no badge) and like a System notification for anomaly alerts. The new **Notifications → Reports** tab keeps them for 90 days, even after you dismiss them, with filters and a page per report (its numbers, chart and tables, the channels it reached, and Open Overview for this period). It can also run a digest and anomaly alerts for the dashboard alone, with no channel at all. +- **Send a digest now.** Preview the real digest exactly as your phone will show it, then send it on demand, from the channel card or `POST /api/v1/channels/{id}/digest`. +- **Setup and terminal.** Startup channels take `digest=daily@09:00`, `digest=daily:weekdays` or `digest=weekly` (Friday 17:00; `weekly:mon@08:30` for another day), `tz=` and `anomaly=on` with `anomaly.*` thresholds; `browserhive channels list` shows each channel's next digest, and `channels preview --sample digest|anomaly` renders the samples. +- **Also:** the Allow this person button explains when you lack `channels:write`, a deleted channel's reply-topic cursor goes with it, the live delivery log no longer shows stale superseded rows, and the public address check reports a proxy's 5xx page as unreachable. The message contract gains the `digest.weekly` kind, a `chart` block and an optional `report` field (schema 1, additive); `GET /api/v1/notifications` takes a `category` filter; new `GET /api/v1/notifications/reports`, `GET /api/v1/notifications/reports/{id}` and `GET`/`PUT /api/v1/notifications/report-settings`; no database migration. diff --git a/README.md b/README.md index 31d1aa4..25ba747 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ -BrowserHive is a local [Model Context Protocol](https://modelcontextprotocol.io) server that gives any agent harness (Claude Code, Claude Desktop, Cursor, VS Code, your own) many parallel browser sessions. Each session is its own Chromium process with its own cookies, storage and service workers. Agents log in through your password manager without ever handling the password, hand the page to a human when they get stuck, and leave a replayable audit trail you can inspect on a built-in dashboard. +BrowserHive is a local [Model Context Protocol](https://modelcontextprotocol.io) server that gives any agent harness (Claude Code, Claude Desktop, Cursor, VS Code, your own) many parallel browser sessions. Each session is its own Chromium process with its own cookies, storage and service workers. Agents log in through your password manager without ever handling the password, hand the page to a human when they get stuck, and leave a replayable audit trail you can inspect on a built-in dashboard. When an agent needs you, BrowserHive can tell you on Telegram, Discord or ntfy, and you can answer from there. -Everything runs on your machine. Nothing leaves it unless you turn telemetry on. +Everything runs on your machine. Nothing leaves it unless you turn on telemetry or a notification channel, and then only to the endpoint or chat you chose. ## Why @@ -30,11 +30,13 @@ BrowserHive handles all of that behind 43 MCP tools and a best in class admin da ## Features - **Isolated sessions.** One Chromium process and context per session: separate cookies, local storage, IndexedDB and service workers. In-memory by default, persistent profiles on request, saved logins you can restore. +- **Your choice of browser, sandboxed.** Run sessions in the bundled Chromium or in the Google Chrome or Microsoft Edge installed on the machine (`browserhive init` shows what it found and lets you pick). Sessions run inside Chromium's sandbox wherever the machine allows it, and `--sandbox on` makes that a guarantee. - **Stealth, honestly scoped.** Full Chromium in new-headless mode, Patchright, automation flags removed, a coherent user agent and client hints derived from your real host, optional display fingerprint and human-like input. No invented OS, GPU or location, and [the limits are documented](docs/guide/stealth.md#ceilings). - **Vault credential injection the model never sees.** The agent names a Bitwarden entry and the form fields. BrowserHive checks the origin, session and principal, optionally asks you to confirm, types the credential and returns only a status. Tool results are redacted and Playwright traces exclude the keystrokes. - **Human takeover.** `request_attention` blocks the agent while you watch its browser live and drive it with your own mouse and keyboard, then resolve with a message back. - **Audit trail and trace replay.** Every tool call, navigation, vault access and blocked URL goes to SQLite, and every session can record a Playwright trace you open in the built-in Trace Viewer. -- **Operator dashboard.** Overview, sessions with live view and timeline, attention queue, visited websites, blocklist, vault policies and log, server logs, system status and effective configuration with provenance. +- **Notifications on your phone.** Your own Telegram bot, a Discord webhook or bot, an ntfy topic or a webhook of yours get a message when an agent needs you or something breaks. Approve, reject or resolve right in the chat, get a daily or weekly digest and anomaly alerts in your time zone, with optional screenshots, self-destructing messages and links that open on your phone. +- **Operator dashboard.** Overview, sessions with live view and timeline, attention queue, visited websites, blocklist, vault policies and log, notifications, channels and reports, server logs, system status and effective configuration with provenance. It recognises which agent (Claude Code, Codex, Cursor, …) ran each session and counts sessions and tool calls per agent. - **OpenTelemetry.** Opt-in OTLP export of traces (one per tool call), metrics and logs to Grafana, Jaeger, Honeycomb, Datadog or any collector, with deep links from the dashboard. - **One port.** MCP, REST API, WebSocket and dashboard share `127.0.0.1:9876`, with one bind rule and one authentication surface. Non-loopback binds require bearer tokens. - **Guardrails.** URL blocklist with hot reload, launch-argument deny-list, per-session ownership, configurable result recording and retention. @@ -96,7 +98,7 @@ Exposing BrowserHive beyond localhost, bearer tokens and client-specific setup a | | | |---|---| | **Start** | [Installation](docs/guide/installation.md) · [Quick start](docs/guide/quick-start.md) · [MCP clients](docs/guide/mcp-clients.md) | -| **Use** | [Dashboard](docs/guide/dashboard.md) · [Vault](docs/guide/vault.md) · [Human takeover](docs/guide/attention.md) · [Stealth](docs/guide/stealth.md) · [Telemetry](docs/guide/telemetry.md) | +| **Use** | [Dashboard](docs/guide/dashboard.md) · [Vault](docs/guide/vault.md) · [Human takeover](docs/guide/attention.md) · [Notifications](docs/guide/notifications.md) · [Stealth](docs/guide/stealth.md) · [Telemetry](docs/guide/telemetry.md) | | **Operate** | [Configuration](docs/guide/configuration.md) · [Security model](docs/guide/security.md) · [CLI](docs/guide/cli.md) · [Upgrading](docs/guide/upgrading.md) · [Troubleshooting](docs/guide/troubleshooting.md) · [FAQ](docs/guide/faq.md) | | **Embed** | [Programmatic API](docs/guide/programmatic-api.md) | | **Reference** | [Tools](docs/reference/tools.md) · [Configuration keys](docs/reference/configuration.md) · [Errors](docs/reference/errors.md) · [REST API](docs/reference/api.md) · [WebSocket](docs/reference/websocket.md) | @@ -105,7 +107,7 @@ Exposing BrowserHive beyond localhost, bearer tokens and client-specific setup a - **Bun ≥ 1.4.** Bun is the only supported runtime; installing with npm or pnpm is fine. - **macOS, Linux or Windows.** -- **Chromium**, installed by `browserhive init` (never during package install). +- **A browser:** the bundled Chromium, installed by `browserhive init` (never during package install), or the Google Chrome or Microsoft Edge installed on the machine. - **Bitwarden CLI** (`bw`), only for the vault. ## Contributing diff --git a/docs/README.md b/docs/README.md index 8fbf91a..643cd83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ BrowserHive is a local MCP server that gives AI agents isolated, stealthy Chromi - [Security model](guide/security.md): authentication, bind rules, the vault model, redaction, what is recorded - [Vault](guide/vault.md): Bitwarden setup, folder policies, bindings, confirmations - [Human takeover](guide/attention.md): `request_attention` and the live view -- [Notifications](guide/notifications.md): notifications on your phone through Telegram, Discord, ntfy or a webhook, with screenshots, self-destruct and a public address for links +- [Notifications](guide/notifications.md): notifications on your phone through Telegram, Discord, ntfy or a webhook, answering from the chat, daily digests and anomaly alerts, reports in the dashboard, screenshots, self-destruct and a public address for links - [Stealth](guide/stealth.md): what it does, what it does not, ceilings, proxies - [Telemetry](guide/telemetry.md): OpenTelemetry export and a local Grafana stack - [Command line](guide/cli.md): every command and exit code diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 25ee3e1..6e401c7 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -23,21 +23,22 @@ Help text is generated from the configuration schema, so `browserhive --help` al Resolves the configuration, opens storage (migrating the database if needed), starts the listener and prints the banner: ``` - BrowserHive 0.1.0 · bun 1.4.2 · patchright 1.63.0 + BrowserHive 0.2.0 · bun 1.4.2 · patchright 1.63.0 MCP http://127.0.0.1:9876/mcp (auth: token) Dashboard http://127.0.0.1:9876/ (admin) - Data dir /home/me/.local/share/browserhive (db v1, 12 MiB, 3 backups) + Data dir /home/me/.local/share/browserhive (db v6, 12 MiB, 3 backups) Config env:2 file:/home/me/browserhive.config.json:9 cli:1 Sessions cap 8 (derived from 12 GiB RAM) · lease 2h · persistence memory Stealth standard · patchright · humanize on · fingerprint off Telemetry otel → http://127.0.0.1:4318 (http/protobuf) Vault bitwarden (locked) + Notify 2 channels (1 from startup) · links → https://browserhive.example.net config: maxSessions=8 (cli) shadows config-file=4 Press Ctrl-C to stop. ``` -The first-run dashboard password and the first agent token are printed once in this banner and never logged. `Ctrl-C` (or `SIGTERM`) stops gracefully within `--shutdownTimeout`: listeners, then sessions (traces are finalized), then storage. A second `Ctrl-C` exits immediately with code 130. +The **Notify** row appears once a [notification channel](notifications.md#channels) or [`publicUrl`](notifications.md#public-address) is set. The first-run dashboard password and the first agent token are printed once in this banner and never logged. `Ctrl-C` (or `SIGTERM`) stops gracefully within `--shutdownTimeout`: listeners, then sessions (traces are finalized), then storage. A second `Ctrl-C` exits immediately with code 130. Output streams: under `--transport stdio`, stdout carries only MCP frames and everything else goes to stderr. Under HTTP, the banner and pretty logs go to stdout on a terminal, and JSON logs go to stderr, so `browserhive 2> logs.jsonl` captures them. Colour is on for terminals; `NO_COLOR` or `--color never` disables it. @@ -60,7 +61,7 @@ A list value joins its items with `+`; a value cannot contain `,` (write `%2C`). | `ntfy` | `name`, `topic` (a topic, or `env:NAME`), optional `server` (default `https://ntfy.sh`), `token=env:NAME`, `reply` (the [reply topic](notifications.md#ntfy-a-second-topic-for-answers) for act buttons, or `env:NAME`), `replyToken=env:NAME` | | `webhook` | `name`, `url` (a URL, or `env:NAME`), optional `secret=env:NAME` (the signing key) | -Rules, all optional: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info`, `warn`, `error`, `critical`), `sessions` (session name patterns such as `shop-*`), `harness`, `content` (`counts`, `titles`, `full`), `quiet=22:00-07:00` with `tz=Europe/Berlin`, `ttl.=2h` (Telegram at most `47h`), `deleteWhenResolved` (`true`, or a `+` list of categories), `images` (a `+` list of categories; needs `content=full`), `maskImages=true`, `actButtons=true` ([answer from your phone](notifications.md#answer-from-your-phone); Telegram, Discord bot mode, ntfy with `reply`, webhook) and `allow` (a `+` list of Telegram or Discord user ids allowed to press them; not for ntfy). +Rules, all optional: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info`, `warn`, `error`, `critical`), `sessions` (session name patterns such as `shop-*`), `harness`, `content` (`counts`, `titles`, `full`), `quiet=22:00-07:00`, `tz=Europe/Berlin` (the channel's time zone, for quiet hours and reports; default BrowserHive's), `digest` (`daily@09:00`, `daily:weekdays@09:00` for Monday to Friday, `weekly:fri@17:00`; the day and time may be left out: `daily` is every day at 09:00, `weekly` is Friday at 17:00), `anomaly=on` with `anomaly.errorRate` (percent or `off`), `anomaly.minCalls`, `anomaly.attention` (minutes or `off`), `anomaly.blocked` (the spike factor or `off`), `anomaly.blockedMin`, `anomaly.capacity` and `anomaly.degraded` (`on`/`off`) ([daily digests and anomaly alerts](notifications.md#daily-digests-and-anomaly-alerts)), `ttl.=2h` (Telegram at most `47h`), `deleteWhenResolved` (`true`, or a `+` list of categories), `images` (a `+` list of categories; needs `content=full`), `maskImages=true`, `actButtons=true` ([answer from your phone](notifications.md#answer-from-your-phone); Telegram, Discord bot mode, ntfy with `reply`, webhook) and `allow` (a `+` list of Telegram or Discord user ids allowed to press them; not for ntfy). ## `init` @@ -77,6 +78,7 @@ One-time setup, safe to re-run: |---|---| | `--browsers chromium` | Browsers to install (only `chromium` today; Chrome and Edge channels use the OS installation). | | `--force` | Re-download even if present. | +| `--skipBrowsers` | Skip the browser downloads (data directory and database only). | | `--dataDir `, `--config ` | Where state and configuration live. | | `--stealthDriver ` | `playwright` skips the Patchright download. | | `--writeSchema` | Write `browserhive.schema.json` next to a discovered config file. | @@ -139,16 +141,16 @@ Token commands work on the database directly when the server is stopped, or thro | Command | Meaning | |---|---| -| `channels list [--json]` | Every notification channel: platform (with the Discord mode), status (and "from startup"), where it sends, whether its variables are set, whether chat answers reach BrowserHive (`connected`, `reconnecting`, `offline (reason)`, or `—` when act buttons are off), the last delivery and the last 24 hours. | +| `channels list [--json]` | Every notification channel: platform (with the Discord mode), status (and "from startup"), where it sends, whether its variables are set, whether chat answers reach BrowserHive (`connected`, `reconnecting`, `offline (reason)`, or `—` when act buttons are off), the last delivery and the last 24 hours; under the table, each channel's next digest in its time zone and its anomaly state. | | `channels test [--json]` | Sends a real test message. Exit `0` when the platform accepted it, `1` with the reason when it did not. | -| `channels preview [--sample ] [--json]` | Prints the platform request a send would make (secrets shown as variable names); sends nothing. Samples: `attention` (default), `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`. | +| `channels preview [--sample ] [--json]` | Prints the platform request a send would make (secrets shown as variable names); sends nothing. Samples: `attention` (default), `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`, `digest`, `anomaly` (the report samples follow the channel's schedule, zone and content level). | They talk to the running server: `--url` defaults to the configured host and port, and `--token` (an operator API token) or `--cookie` authenticates. See [Notifications](notifications.md). ## `version` ``` -browserhive 0.1.0 (bun 1.4.2, sqlite 3.53.2, playwright 1.63.0, patchright 1.63.0) +browserhive 0.2.0 (bun 1.4.2, sqlite 3.53.2, playwright 1.63.0, patchright 1.63.0) ``` `--json` for scripts. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 68ea0dc..db85c17 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -159,6 +159,8 @@ Secrets stay hidden. For `authTokens` and `otelHeaders`, BrowserHive shows the v | [`defaultChannel`](../reference/configuration.md#defaultChannel) | `chromium` | the browser sessions use: the bundled `chromium`, or the installed `chrome` or `edge` ([choosing the browser](installation.md#choosing-the-browser)) | | [`sandbox`](../reference/configuration.md#sandbox) | `auto` | Chromium's sandbox: `auto` wherever the machine allows it, `on` required (refuses to start otherwise), `off` never ([security](security.md#the-browser-sandbox)) | | [`maxSessions`](../reference/configuration.md#maxSessions) | derived from RAM | concurrent session cap, or `unbounded` | +| [`publicUrl`](../reference/configuration.md#publicUrl) | unset | the address where you reach the dashboard from elsewhere (a Tailscale name, a reverse proxy, a tunnel); notification links use it ([public address](notifications.md#public-address)) | +| [`allowedHosts`](../reference/configuration.md#allowedHosts) | none | extra `Host` names to accept, such as the name a reverse proxy forwards | | [`sessionLease`](../reference/configuration.md#sessionLease) | `2h` | idle sessions are closed after this long | | [`blocklist`](../reference/configuration.md#blocklist) | unset | URL blocklist file | | [`dataDir`](../reference/configuration.md#dataDir) | OS default | where state lives | diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 85d3171..c52528d 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -87,4 +87,4 @@ Version, transport, uptime, bind address, sessions live versus the cap, open att Persisted notifications for attention requests, tool errors, crashed sessions and pending vault confirmations, grouped by day. Mark as read or dismiss, individually or all at once. Once an attention request or vault confirmation is settled (in the dashboard, by a timeout, or elsewhere), its row keeps its place and shows the outcome as a small pill (**resolved**, **expired**, or **closed** when the agent stopped waiting), and a toast still on screen for it closes. See [Notifications](notifications.md). -**Channels** (Notifications → Channels) sends notifications to your phone through Telegram, Discord (webhook or bot), ntfy or a webhook: one card per channel with its status, last delivery, 24-hour counts and, when act buttons are on, whether answers from the chat reach BrowserHive; and an **Add channel** wizard with a live preview and a test message. Its **What to send** step switches **Answer from the chat** on and edits who may answer. **Delivery log** lists every send, edit and delete, and says why anything was not sent. **Actions** lists every press of an act button: who, which button, from which chat, and what happened. See [Channels](notifications.md#channels) and [Answer from your phone](notifications.md#answer-from-your-phone). +**Channels** (Notifications → Channels) sends notifications to your phone through Telegram, Discord (webhook or bot), ntfy or a webhook: one card per channel with its status, last delivery, 24-hour counts and, when act buttons are on, whether answers from the chat reach BrowserHive; and an **Add channel** wizard with a live preview and a test message. Its **What to send** step switches **Answer from the chat** on and edits who may answer, and its **Reports** section sets a daily or weekly digest, the channel's time zone and the anomaly alerts; a card shows the next digest and **Send now** (a preview of the real digest, then a send on demand). The delivery log marks a digest sent after downtime as **late**. **Delivery log** lists every send, edit and delete, and says why anything was not sent. **Actions** lists every press of an act button: who, which button, from which chat, and what happened. **Reports** keeps every digest and anomaly alert once per period (the inbox's **Reports** chip shows the same), opens each as a page with its facts, chart and tables, and has **Reports in BrowserHive**: a digest and anomaly alerts for the dashboard itself, with no channel needed. See [Channels](notifications.md#channels), [Answer from your phone](notifications.md#answer-from-your-phone) and [Reports in BrowserHive](notifications.md#reports-in-browserhive). diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 19f3dc3..c2184bc 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -22,7 +22,7 @@ In your password manager. BrowserHive stores only bindings (which entry may be f Not through tool results while the redaction window is open, and never from BrowserHive's logs, database or traces. An agent with `evaluate` could read a field that still contains the value later; see [Security](security.md#redaction-and-its-limits) for the mitigations. **Does it phone home?** -No. Telemetry is opt-in and goes only to the OTLP endpoint you configure. +No. Telemetry is opt-in and goes only to the OTLP endpoint you configure. [Notification channels](notifications.md) are opt-in too: each sends only to the Telegram bot, Discord channel, ntfy topic or webhook you set up, at the content level you pick. BrowserHive runs no servers of its own. **Does it solve CAPTCHAs?** No. An agent can ask a human with `request_attention`; see [Human takeover](attention.md). diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 72c77c5..b9b5d78 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -113,12 +113,14 @@ browserhive doctor - Chromium's sandbox, per installed browser: each one is launched once to find out (under the default `--sandbox auto` a browser that falls back is reported, not counted as a warning). When the configured browser cannot sandbox, the guidance under the table says why and what to do. See [Security: the browser sandbox](security.md#the-browser-sandbox). - Whether BrowserHive runs as root or in a container, which rules out the sandbox. - The data directory exists, has owner-only permissions and has free disk space. -- The configuration is valid. It runs the full resolver and prints any shadow lines. +- The configuration is valid. It runs the full resolver and prints any shadow lines, counts the values that came from [references](configuration.md#references), and warns about each referenced variable that was not set. - The port is free on the configured host. - `bw` is on `PATH` when `vault=bitwarden`. - The database opens, with its schema version, pending migrations and last backup. - The OTLP endpoint is reachable when `otel=true` (a warning only). - `maxSessions` makes sense for the host's RAM. +- `publicUrl`, when set, reaches this BrowserHive ([public address](notifications.md#public-address)). +- Every `--notificationChannel` parses and every variable a notification channel names is set ([notifications](notifications.md#startup-channels)). - A config file that contains `authTokens` is not readable by other users. Exit code `0` means every check passed, `2` means warnings only, `1` means at least one check failed. `browserhive doctor --json` prints the same data as an array of `{ check, status, detail }`. `browserhive doctor --printApparmorProfile` prints an AppArmor profile that lets the configured browser sandbox on Ubuntu 23.10+; it installs nothing. diff --git a/docs/guide/mcp-clients.md b/docs/guide/mcp-clients.md index f81e5d9..ec6129c 100644 --- a/docs/guide/mcp-clients.md +++ b/docs/guide/mcp-clients.md @@ -240,7 +240,7 @@ Any other `X-BH-Meta-` header or `ai.browserhive/` `_meta` key is ke ### What is stored -Each connection's harness, model, workspace, client name and version, protocol version, User-Agent, IP address and labels are stored in the local database with the connection. A closed connection is deleted with the rest of the telemetry after `--retentionDays`, unless a stored session still refers to it. A session keeps its harness for as long as the session is kept. Nothing leaves the machine unless you turn [telemetry](telemetry.md) on; exported spans carry the harness and model, and metrics carry only the harness name. +Each connection's harness, model, workspace, client name and version, protocol version, User-Agent, IP address and labels are stored in the local database with the connection. A closed connection is deleted with the rest of the telemetry after `--retentionDays`, unless a stored session still refers to it. A session keeps its harness for as long as the session is kept. Nothing leaves the machine unless you turn on [telemetry](telemetry.md) (exported spans carry the harness and model, metrics only the harness name) or a [notification channel](notifications.md#what-leaves-your-machine), whose notifications and digests name the harness at the `titles` and `full` content levels. A W3C `traceparent` in a tool call's `_meta` becomes the parent of the tool span when [telemetry](telemetry.md) is on. diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 74024a9..ea9f008 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -6,6 +6,7 @@ BrowserHive can also put them on your phone: through your own Telegram bot, a Di - [Channels: set one up in two minutes](#channels) - [Answer from your phone](#answer-from-your-phone): act buttons on Telegram, Discord and ntfy +- [Daily digests and anomaly alerts](#daily-digests-and-anomaly-alerts): a summary on your schedule, and a heads-up when something is off - [Public address: links that open on your phone](#public-address) - [Screenshots](#screenshots), [self-destruct](#self-destruct), [startup channels](#startup-channels), [the delivery log](#delivery-log) - [What leaves your machine](#what-leaves-your-machine) @@ -21,6 +22,8 @@ BrowserHive can also put them on your phone: through your own Telegram bot, a Di | Tools failed (grouped per session: "shop · 12 tool errors") | `tool.errors` | problems | warn | | A subsystem is degraded (for example the retention sweep failed) | `system.degraded` | system | error | | A notification channel keeps failing | `channel.broken` | system | error | +| A channel's scheduled digest is due ([digests](#daily-digests-and-anomaly-alerts)) | `digest.daily`, `digest.weekly` | reports | info | +| A channel's hourly check found something off | `report.anomaly` | reports | warn (error while degraded or at capacity) | Routine events are deliberately silent: a session opening, a page visit, a clean close. Agents cannot send notifications themselves: every notification comes from something BrowserHive observed. The `request_attention` tool is how an agent reaches you. @@ -59,7 +62,7 @@ A channel is one place notifications go: a Telegram chat, a Discord channel, an 1. **Platform.** Telegram, Discord, ntfy or Webhook. 2. **Credentials.** Pick the name of an environment variable that will hold the token, such as `BH_TELEGRAM_TOKEN` (any name that does not start with `BROWSERHIVE_`, which configuration keys use). The wizard shows the exact line for how you run BrowserHive (a shell, systemd, Docker) and a live **set ✓ / missing ✗** check. Set the variable and restart BrowserHive; the wizard keeps your draft across the restart. 3. **Connect.** The platform-specific part, below. -4. **What to send.** Pick a preset (*Needs me now*, *Problems*, *Wrap-ups* or *Everything*) or open **Advanced** for minimum severity, session name patterns, harness, quiet hours with a time zone, the [content level](#what-leaves-your-machine), [screenshots](#screenshots) and [self-destruct](#self-destruct). +4. **What to send.** Pick a preset (*Needs me now*, *Problems*, *Wrap-ups*, *Everything* or *Daily digest*), set up [reports](#daily-digests-and-anomaly-alerts) (a digest, the time zone, anomaly alerts), or open **Advanced** for minimum severity, session name patterns, harness, quiet hours, the [content level](#what-leaves-your-machine), [screenshots](#screenshots) and [self-destruct](#self-destruct). 5. **Preview and test.** See the message exactly as it will look (drawn from the same code that sends it), save, then **Send test**. The test message has an **Open dashboard** button: tap it on your phone to check that links reach you ([public address](#public-address)). Each channel card shows its status, the last delivery and the last 24 hours (sent, failed, filtered). You can pause, resume, edit, duplicate, delete and test it there. There is no limit on channels; every matching channel gets its own copy. @@ -227,6 +230,72 @@ The host of `publicUrl` is trusted automatically: you do not need to add it to ` The final proof is the **Open dashboard** button of a test message, tapped on your phone. +## Daily digests and anomaly alerts + +A channel can also get **reports**: a summary on a schedule you pick, and a heads-up when something looks off. Set them in the wizard's **What to send** step, under **Reports**, or pick the **Daily digest** preset (a digest every morning at 09:00 and anomaly alerts, nothing instant). A channel receives its reports whatever its categories: the schedule is how you ask for them. BrowserHive itself keeps every report too, and can make them with no channel at all: see [Reports in BrowserHive](#reports-in-browserhive). + +### The digest + +**Every day** (09:00 unless you pick another time) or **every week** (Friday at 17:00 unless you pick another day and time), on the 24-hour clock. "Every day" includes the weekend; tick **Weekdays only** to get it Monday to Friday, and Monday's digest then covers the whole weekend. The digest covers the period that ends at its time: the last 24 hours, or the last seven days, weekend included. It says, in numbers: + +- sessions started and live now; tool calls, errors and the error rate (with the previous period's rate); +- attention requests: how many, how many were answered and the median wait, how many timed out, how many wait now; +- vault fills and how many failed; blocked requests and the most blocked pattern; +- the slowest tool (its p95, against the previous period), the top errors, and open problems; +- tool calls per hour as a small chart (text bars in chats; the numbers themselves in the webhook payload), and a table per harness; +- a link to the **Overview** for exactly that period. + +**Nothing happened, nothing sent.** A period with no session, tool call, attention request, vault fill, blocked request or open problem sends no digest. The delivery log still records it, as *not sent · nothing happened in the period*, so you can tell a quiet day from a broken channel. + +**Send a digest now.** The channel card (and the channel page) has **Send now**: a preview of the real digest for the period that ends now, drawn exactly as your phone will show it, and a button to send it on demand. The schedule is not touched. `POST /api/v1/channels/{id}/digest` does the same. + +### Time zones + +Each channel has a **time zone**, and it is BrowserHive's own zone unless you pick another (the picker is searchable: type a city). Digest times and [quiet hours](#channels) both follow it, so "09:00" means 09:00 on your wall clock, before and after daylight saving time. On the night the clocks go forward, a time that does not exist (02:30 on some nights) fires at the same wall time an hour later; on the night they go back, a time that happens twice fires once. The days around a change are 23 or 25 hours long, and the digest covers them whole. + +If you move BrowserHive to another machine, a channel without a zone of its own follows the new machine's zone. + +### Late digests + +BrowserHive is often a daemon on a laptop, so it may be off at 09:00. When it starts again, it sends the **most recent** digest it missed, marked **late** ("Sent late: BrowserHive was not running at 09:00"), and says how many earlier digests it skipped ("2 earlier digests were skipped while BrowserHive was off"). It sends only that one: a long weekend does not arrive as a burst of stale messages, and the Overview covers the rest. The delivery log shows a **late** pill on such a digest. Each window is sent once, even across crashes and restarts; changing a digest's time starts the new schedule from the change, so an edit never causes a late digest. + +That one late digest is the whole catch-up: the skipped periods are never sent later, and they are not folded into the next digest. + +**Quiet hours.** A digest is sent at the time you chose even if it falls inside the channel's quiet hours, but **silently** there (no sound or vibration). + +### Anomaly alerts + +**Tell me when something looks off** checks every hour, on the last hour of activity, and stays silent unless a check crosses its threshold: + +| Check | Alerts when (default) | Clears when | +|---|---|---| +| Many tool calls failing | 20 % or more of tool calls failed, with at least 20 calls | below 10 %, or under 10 calls | +| An attention request waiting too long | a request has waited 30 minutes | no request waits that long | +| Sessions at the limit | live sessions reach `maxSessions` | below 90 % of it (at least one below) | +| A spike in blocked requests | 3× the usual hourly count of the day before, and at least 50 | below half of both | +| BrowserHive itself degraded | an error-level problem is open on the System page | it is resolved | + +A crossing sends one alert listing every check that is off, the new ones first. When a check clears while others stay, the same message is updated silently; when everything is back under its threshold, it becomes **Back to normal**, silently by design: good news edits the alert in place and rings nothing. Because each check clears only well below where it fires, a value hovering around a threshold does not flap. During the channel's quiet hours no check runs; the first check after them tells you what is still off. Each threshold can be changed, or a check switched off, in **Advanced → Anomaly checks** (`anomaly.*` on a [startup channel](#startup-channels)). + +### What a report contains, per content level + +| Level | Digest | Anomaly alert | +|---|---|---| +| `counts` | Numbers only: sessions, calls, errors, attention, vault fills (with how many failed), blocked requests, open problems (how many), the chart | The checks, their values and thresholds | +| `titles` (default) | Adds tool names, error codes, harnesses, vault results, degradation codes, the top blocklist pattern and the tables | Adds the degradation codes and the sessions whose requests wait | +| `full` | Adds degradation messages and the most blocked domain | Adds the degradation messages | + +Everything in a report passes the same redaction as any notification. + +### Reports in BrowserHive + +Every report also lands in BrowserHive, **once per period**, in full (the dashboard is your own screen, so it is not cut to a channel's content level): + +- **In the bell and the inbox.** A digest arrives quietly: already read, so it never raises the badge, and it never pops up. An anomaly alert counts toward the badge and pops up like a **System** notification (switch System off in the toast preferences to keep them in the bell only); its "Back to normal" edit is silent and closes the pop-up. The inbox's **Reports** chip shows only digests and anomaly alerts; a report opens its report page. +- **Once, not once per channel.** Channels on the same schedule share one copy: same frequency, weekday, time and time zone, covering the same window. Two channels at 09:00 Berlin get one copy; a channel at 09:00 Berlin and one at 08:00 London get two, because each is written in its own zone. A digest you send with **Send now** has its own copy. An empty period has none. +- **Notifications → Reports** keeps the history: every digest and anomaly alert, even after you dismiss it from the inbox, for 90 days. Filter by kind, by where it went (a channel, or **BrowserHive only**) and by period. A report page draws the digest natively (facts, the chart, tables), says which window it covers and in which zone, marks it **late** or **on demand**, lists the channels it reached and how each delivery went, and has **Open Overview for this period**. +- **Without any channel.** At the top of the Reports tab, **Reports in BrowserHive** has its own digest (off by default; every day, optionally weekdays only, or every week; a time; a time zone, BrowserHive's own unless you pick one) and **Tell me when something looks off** (off by default, with the default thresholds). They follow the same rules as a channel's (late once, never empty) and share periods with channels on the same schedule. Changing them needs the `channels:write` permission. + ## Screenshots A notification can carry a screenshot of what the agent was looking at. Screenshots are **off** by default and switched on per channel and category (in the wizard's **Advanced** step). They are taken for three things only: @@ -258,19 +327,20 @@ browserhive --admin \ ``` - Secrets are always written `env:NAME`. A token typed into the flag is refused (exit 64), because other users of the machine can read process arguments. -- Rules use the same names as the dashboard: `categories`, `min`, `sessions`, `harness`, `content`, `quiet=22:00-07:00` with `tz`, `ttl.needs-you=2h`, `deleteWhenResolved`, `images`, `maskImages`, `actButtons=true` and `allow=123456+789012` (the Telegram or Discord user ids allowed to answer; a startup channel has no setup flow, so name them here). A Discord bot is `discord:name=ops,mode=bot,token=env:BH_DISCORD_BOT_TOKEN,channel=`; an ntfy reply topic is `reply=…`. Every parameter is listed in the [command-line guide](cli.md#notification-channels). +- Rules use the same names as the dashboard: `categories`, `min`, `sessions`, `harness`, `content`, `quiet=22:00-07:00`, `tz` (the channel's time zone), `digest=daily@09:00`, `digest=daily:weekdays` or `digest=weekly` (Friday at 17:00; `weekly:mon@08:30` for another day and time), `anomaly=on` with `anomaly.errorRate=10` and the other `anomaly.*` thresholds, `ttl.needs-you=2h`, `deleteWhenResolved`, `images`, `maskImages`, `actButtons=true` and `allow=123456+789012` (the Telegram or Discord user ids allowed to answer; a startup channel has no setup flow, so name them here). A Discord bot is `discord:name=ops,mode=bot,token=env:BH_DISCORD_BOT_TOKEN,channel=`; an ntfy reply topic is `reply=…`. Every parameter is listed in the [command-line guide](cli.md#notification-channels). - The flag has no environment-variable or config-file spelling. Startup channels appear in the dashboard with a **from startup** badge. You can pause them there, but you edit them by changing the flag and restarting. A startup channel whose name a dashboard channel already uses stops the start with an error, so neither silently wins. ## Delivery log -**Notifications → Delivery log** lists every send, edit and delete, live: time, channel, notification, revision, status, attempts and how long the platform took. Filter by channel, status, operation or kind. Open a row to see the notification's journey on every channel and the message exactly as that channel was shown it. Every row that was not delivered says why in a sentence: filtered by the channel's rules, quiet hours, the channel was paused, the platform refused the token, the message had been deleted in the chat, and so on. The same answers are in `GET /api/v1/channels/deliveries`. +**Notifications → Delivery log** lists every send, edit and delete, live: time, channel, notification, revision, status, attempts and how long the platform took. A digest or anomaly alert also shows the period it covers, and a **late** or **on demand** pill. Filter by channel, status, operation or kind. Open a row to see the notification's journey on every channel and the message exactly as that channel was shown it. Every row that was not delivered says why in a sentence: filtered by the channel's rules, quiet hours, the channel was paused, the platform refused the token, the message had been deleted in the chat, and so on. The same answers are in `GET /api/v1/channels/deliveries`. From a terminal: ```sh -browserhive channels list # status, target, variables, answers, last delivery, 24 h counts +browserhive channels list # status, target, variables, answers, next digest, last delivery, 24 h counts browserhive channels test phone # a real test message; exit 1 when the platform refused it browserhive channels preview phone --sample vault-confirm # the request a send would make; sends nothing +browserhive channels preview phone --sample digest # a sample digest at the channel's level and zone ``` These talk to the running server (`--url`, with `--token` for an operator API token or `--cookie`), like `browserhive admin tokens --url`. @@ -289,4 +359,4 @@ At every level, BrowserHive first removes registered secrets and credential-shap ## Retention -Read or dismissed notifications are kept for 30 days, others for 90. Delivery history is kept for 30 days. The act-button audit is kept like the other audit records (`auditRetentionDays`, 90 days by default); used or expired button tokens are removed a day after they expire. Configured channels are never pruned; `browserhive purge` lists them with everything else in the database. +Read or dismissed notifications are kept for 30 days, others for 90. Reports (digests and anomaly alerts) are kept for 90 days whether or not they were read or dismissed, so the Reports tab keeps its history. Delivery history is kept for 30 days. The act-button audit is kept for 90 days, like the other audit records; used or expired button tokens are removed a day after they expire. Configured channels are never pruned; `browserhive purge` lists them with everything else in the database. diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index b930f1b..3c72031 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -25,7 +25,7 @@ browserhive --admin The dashboard, REST API and WebSocket share the same port. On the first start the server prints a one-time password: ``` - BrowserHive 0.1.0 · bun 1.4.2 · patchright 1.63.0 + BrowserHive 0.2.0 · bun 1.4.2 · patchright 1.63.0 MCP http://127.0.0.1:9876/mcp (auth: off) Dashboard http://127.0.0.1:9876/ (admin) ... diff --git a/docs/guide/security.md b/docs/guide/security.md index ead0d34..4f4ebe5 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -85,6 +85,7 @@ Local-first means you own the records. With the defaults: | Navigations, blocked attempts, attention requests, vault access (without secrets) | Passwords, tokens, vault credentials, `Authorization` headers | | Screenshots taken by the agent; one frame per tool call with `--screenshotTrace` | Fields marked sensitive in the contracts, redacted before any sink | | A Playwright trace per session when `trace` is on (default with `--admin`) | Error messages are passed through the same redaction first | +| Notifications, their deliveries to channels and act-button presses; channel settings with the **names** of their token variables | Channel tokens, which stay in environment variables; act-button tokens are stored only as hashes | Stricter deployments can reduce what tool results store: @@ -148,6 +149,6 @@ During an open attention request, an operator's mouse and keyboard input goes st - Where the sandbox cannot run (see [above](#the-browser-sandbox)), a page that exploits a Chromium bug gets the privileges of the user running BrowserHive. Run it as a user with access only to what it needs, and keep BrowserHive (and an installed Chrome) updated: each BrowserHive version pins its Chromium build. - WebAuthn and passkeys cannot be replayed from saved state. -- Telemetry is off by default. Nothing leaves the host unless you set `--otel`, and then only to the endpoint you configure. +- Telemetry is off by default, and there is no notification channel until you add one. Nothing leaves the host unless you set `--otel` (then only to the endpoint you configure) or add a [notification channel](notifications.md#what-leaves-your-machine) (then only to the service you chose, at the channel's content level). Report vulnerabilities as described in `SECURITY.md` at the root of the repository. diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index 35f50be..c4cb4b6 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -1,6 +1,6 @@ # Telemetry (OpenTelemetry) -Telemetry is **off by default**, and nothing leaves your machine unless you turn it on. With `--otel`, BrowserHive exports traces, metrics and logs over OTLP/HTTP to any compatible backend: an OpenTelemetry Collector, Grafana Tempo/Loki/Mimir, Jaeger, Honeycomb, Datadog Agent and others. +Telemetry is **off by default**: nothing is exported unless you turn it on. With `--otel`, BrowserHive exports traces, metrics and logs over OTLP/HTTP to any compatible backend: an OpenTelemetry Collector, Grafana Tempo/Loki/Mimir, Jaeger, Honeycomb, Datadog Agent and others. ```bash browserhive --admin --otel --otelEndpoint http://127.0.0.1:4318 --otelServiceName browserhive-dev diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 25224f7..419c549 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -96,6 +96,20 @@ Check the session's Identity tab. Make sure `browserhive init` installed Patchri **Takeover input is ignored** — input is only accepted while an attention request is open for that session ([`INPUT_NOT_PERMITTED`](../reference/errors.md#INPUT_NOT_PERMITTED)). +## Notifications + +**A notification did not reach my phone** — open **Notifications → Delivery log**: every row that was not delivered says why in a sentence (filtered by the channel's rules, quiet hours, the channel was paused, the platform refused the token, …). `browserhive channels test ` sends a real test message and prints the platform's answer. See [the delivery log](notifications.md#delivery-log). + +**The channel shows a variable as missing, or `doctor` says it is not set** — a channel stores only the name of the environment variable that holds its token. Set it in the environment of the process that runs BrowserHive (the shell, a systemd `Environment=` line, `docker run -e`) and restart. See [Channels](notifications.md#channels). + +**A channel is marked broken** — five sends in a row failed, so its deliveries paused. Fix the cause shown on the card and in the delivery log, then resume the channel. + +**Links in a notification do not open on my phone** — they point at this computer until you set `publicUrl`. The System page and `browserhive doctor` check whether it reaches this BrowserHive. See [Public address](notifications.md#public-address). + +**Discord buttons say "This interaction failed", or the bot is offline** — see [Troubleshooting Discord bot mode](notifications.md#troubleshooting-discord-bot-mode). + +**No digest arrived** — a period with no activity sends nothing, and the delivery log says *nothing happened in the period*. If BrowserHive was off at the scheduled time, only the most recent missed digest is sent when it starts, marked late. See [Daily digests and anomaly alerts](notifications.md#daily-digests-and-anomaly-alerts). + ## MCP clients **401 on `/mcp`** — `--auth token` is on and the client sent no token or a revoked one. See [MCP clients](mcp-clients.md#authentication-tokens). diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index 68c73f6..3f8a810 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -25,15 +25,21 @@ browserhive db migrate --dryRun # list what would run browserhive db migrate ``` -**The browser sandbox is on by default since the release that added `--sandbox`.** Sessions now run inside Chromium's sandbox wherever the machine allows it (`sandbox=auto`). Where it cannot (Ubuntu 23.10+ with the bundled browser, root, Docker), sessions run as before and `browserhive doctor` explains why and how to fix it (still exit code 0). `--sandbox off` restores the previous behaviour exactly. See [Security: the browser sandbox](security.md#the-browser-sandbox). +Prereleases are published under the `next` tag: `bun add -g browserhive@next`. -**Each session records whether it ran sandboxed (schema v4).** From 0.2 on, the database stores each session's sandbox state and browser version when its browser launches, so closed sessions keep showing them. The migration only adds columns: an older release still opens the database. Sessions from before the upgrade show "not recorded"; nothing is guessed for them. +### From 0.1.x to 0.2 -**Answering from your phone (schema v6).** Three tables are added: the act-button tokens (hashes only), the audit of button presses, and where each chat connection resumes. Nothing is backfilled and an older release still opens the database. Telegram channels now send Rich Messages; messages sent before the upgrade keep being edited in their old format. See [Answer from your phone](notifications.md#answer-from-your-phone). +The first start of 0.2 migrates the database from schema v2 to v6, after writing the backup. Every step only adds tables or columns, so a 0.1.x release can still open the upgraded database, and nothing is invented for existing rows: -**Notifications gain a message contract (schema v5).** The database adds the notification contract's fields to every notification (kind, category, severity, state, revision, thread) and three tables for delivery to chat apps (channels, the delivery outbox and the sent-message index). Existing notifications are classified from what they already recorded; an attention request's notification reads as resolved or expired when the request was. The migration only adds columns and tables: an older release still opens the database. See [Notifications](notifications.md). +- **Harness identity (schema v3).** Sessions and MCP connections record which agent was connected ([harness identity](mcp-clients.md#harness-identity)). Sessions from before the upgrade read **Unknown**. +- **The browser and its sandbox per session (schema v4).** Each session stores whether it ran inside Chromium's sandbox and the browser's real version when its browser launches, so closed sessions keep showing them. Sessions from before the upgrade show "not recorded", which does not mean the sandbox was off. +- **Notifications gain a message contract (schema v5).** Every notification gets the contract's fields (kind, category, severity, state, revision, thread), and three tables hold delivery to chat apps (channels, the delivery outbox and the sent-message index). Existing notifications are classified from what they already recorded; an attention request's notification reads as resolved or expired when the request was. See [Notifications](notifications.md). +- **Answering from your phone (schema v6).** Three tables are added: the act-button tokens (hashes only), the audit of button presses, and where each chat connection resumes. See [Answer from your phone](notifications.md#answer-from-your-phone). Daily digests, anomaly alerts and the Reports tab need no further migration. -Prereleases are published under the `next` tag: `bun add -g browserhive@next`. +Two behaviour changes to know about: + +- **The browser sandbox is on by default.** Sessions now run inside Chromium's sandbox wherever the machine allows it (`--sandbox auto`). Where it cannot (Ubuntu 23.10+ with the bundled browser, root, Docker), sessions run as before and `browserhive doctor` explains why and how to fix it (still exit code 0). `--sandbox off` restores the previous behaviour exactly. See [Security: the browser sandbox](security.md#the-browser-sandbox). +- **Environment variables in the config file.** A string in `browserhive.config.json` that contains `{env:NAME}` is now read as a [reference](configuration.md#references) to that variable, and `${env:NAME}`, `{ENV:NAME}` or `{file:…}` stop startup with a hint. Other braces, such as `{trace_id}` in `otelTraceUrlTemplate`, are left alone; write `{{…}}` to keep text that looks like a reference literal. ## Downgrade diff --git a/docs/reference/api.md b/docs/reference/api.md index 2c66687..caab76d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -2,7 +2,7 @@ # REST API reference -The admin REST API served under `/api/v1` on the same port as MCP and the dashboard when `--admin` is on (106 operations), generated from `HTTP_ENDPOINTS` in `@browserhive/contracts/http`. Request and response schemas are in the OpenAPI 3.1 document the server serves at `/api/v1/openapi.json`, with an interactive reference UI at `/api/v1/docs`. +The admin REST API served under `/api/v1` on the same port as MCP and the dashboard when `--admin` is on (111 operations), generated from `HTTP_ENDPOINTS` in `@browserhive/contracts/http`. Request and response schemas are in the OpenAPI 3.1 document the server serves at `/api/v1/openapi.json`, with an interactive reference UI at `/api/v1/docs`. Summaries come from `packages/contracts/generated/openapi.json`. @@ -161,6 +161,10 @@ Summaries come from `packages/contracts/generated/openapi.json`. | POST | `/api/v1/notifications/read-all` | `markAllNotificationsRead` | `notifications:write` | cookie, bearer | Mark every notification read. | | DELETE | `/api/v1/notifications/{notification_id}` | `dismissNotification` | `notifications:write` | cookie, bearer | Dismiss one notification. | | POST | `/api/v1/notifications/dismiss-all` | `dismissAllNotifications` | `notifications:write` | cookie, bearer | Dismiss every notification. | +| GET | `/api/v1/notifications/reports` | `listReports` | `notifications:read` | cookie, bearer | Reports in BrowserHive: the in-app copies of digests and anomaly alerts, newest first. | +| GET | `/api/v1/notifications/reports/{notification_id}` | `getReport` | `notifications:read` | cookie, bearer | One report with its message and the channels it reached. | +| GET | `/api/v1/notifications/report-settings` | `getReportSettings` | `notifications:read` | cookie, bearer | The in-app reports: the digest schedule and the anomaly switch (D-45). | +| PUT | `/api/v1/notifications/report-settings` | `putReportSettings` | `channels:write` | cookie, bearer | Replaces the in-app reports settings; a changed schedule re-arms from now. | | GET | `/api/v1/me/preferences` | `getPreferences` | — | cookie, bearer | The caller's stored preferences (known keys only). | | PUT | `/api/v1/me/preferences` | `putPreferences` | `preferences:write` | cookie, bearer | Replace the preferences document (≤ 64 KiB; unknown keys rejected). | @@ -187,6 +191,7 @@ Summaries come from `packages/contracts/generated/openapi.json`. | POST | `/api/v1/channels/{channel_id}/pause` | `pauseChannel` | `channels:write` | cookie, bearer | Pause a channel; its pending deliveries are suppressed. | | POST | `/api/v1/channels/{channel_id}/resume` | `resumeChannel` | `channels:write` | cookie, bearer | Resume a paused or broken channel. | | POST | `/api/v1/channels/{channel_id}/test` | `testChannel` | `channels:write` | cookie, bearer | Send a real test message through the channel now; the result says why it failed. | +| POST | `/api/v1/channels/{channel_id}/digest` | `sendChannelDigest` | `channels:write` | cookie, bearer | Preview the channel's digest of the period that ends now, or also send it now (D-43). | ## Search diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 02a4a7f..b6ef22d 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -2,7 +2,7 @@ # Error reference -Every error code BrowserHive can produce (106 codes), generated from `ERROR_REGISTRY` in `@browserhive/contracts/errors`. Each code has a stable anchor: `errors.md#`, which is also the `type` URL of HTTP problem responses (`https://browserhive.ai/docs/errors#`). +Every error code BrowserHive can produce (107 codes), generated from `ERROR_REGISTRY` in `@browserhive/contracts/errors`. Each code has a stable anchor: `errors.md#`, which is also the `type` URL of HTTP problem responses (`https://browserhive.ai/docs/errors#`). ## How errors reach you @@ -80,6 +80,7 @@ Returned by tools and the REST API when a request cannot be served (unknown sess | [`CHANNEL_KIND_UNAVAILABLE`](#CHANNEL_KIND_UNAVAILABLE) | Platform not available yet | 400 | different_args | | [`CHANNEL_PLATFORM_ERROR`](#CHANNEL_PLATFORM_ERROR) | The platform refused the request | 502 | backoff | | [`DELIVERY_NOT_FOUND`](#DELIVERY_NOT_FOUND) | Delivery not found | 404 | never | +| [`REPORT_NOT_FOUND`](#REPORT_NOT_FOUND) | Report not found | 404 | never | | [`INTERNAL_ERROR`](#INTERNAL_ERROR) | Internal error | 500 | backoff | @@ -1378,6 +1379,30 @@ Details: |---|---|---|---| | `seq` | `number` | yes | — | + +### `REPORT_NOT_FOUND` + +| Property | Value | +|---|---| +| Title | Report not found | +| HTTP status | 404 | +| Category | `domain` | +| Retryable | `never` (do not retry; the request cannot succeed as sent) | + +Message: `Report {notification_id} does not exist.` + +Hint: Reports are kept for 90 days. + +Cause: The id is not an in-app report, or retention pruned it. + +Resolution: List the reports with GET /api/v1/notifications/reports. + +Details: + +| Field | Type | Required | Constraints | +|---|---|---|---| +| `notification_id` | `string` | yes | — | + ### `INTERNAL_ERROR` diff --git a/docs/reference/notification-message.schema.json b/docs/reference/notification-message.schema.json index 9fcde17..7243fdf 100644 --- a/docs/reference/notification-message.schema.json +++ b/docs/reference/notification-message.schema.json @@ -36,6 +36,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -1134,6 +1135,59 @@ "content" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "chart" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "values": { + "minItems": 1, + "maxItems": 48, + "type": "array", + "items": { + "type": "number", + "minimum": 0 + } + }, + "start": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "unit": { + "anyOf": [ + { + "type": "string", + "maxLength": 24 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "label", + "values", + "start", + "step_ms", + "unit" + ], + "additionalProperties": false } ] } @@ -1352,6 +1406,55 @@ "has_image" ], "additionalProperties": false + }, + "report": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "until": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "since", + "until" + ], + "additionalProperties": false + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ], + "additionalProperties": false } }, "required": [ diff --git a/docs/reference/websocket.md b/docs/reference/websocket.md index 04f706c..de83be1 100644 --- a/docs/reference/websocket.md +++ b/docs/reference/websocket.md @@ -341,7 +341,7 @@ Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`sessio | Field | Type | Required | Constraints | |---|---|---|---| -| `channel` | `object` | yes | keys `channel_id`, `name`, `kind`, `mode`, `source`, `status`, `target`, `target_hint`, `secret_refs`, `secrets`, `rules`, `capabilities`, `ready`, `problem`, `failure_count`, `last_error`, `last_ok_at`, `last_failure_at`, `created_at`, `updated_at`, `stats`, `connection` | +| `channel` | `object` | yes | keys `channel_id`, `name`, `kind`, `mode`, `source`, `status`, `target`, `target_hint`, `secret_refs`, `secrets`, `rules`, `capabilities`, `ready`, `problem`, `failure_count`, `last_error`, `last_ok_at`, `last_failure_at`, `created_at`, `updated_at`, `stats`, `connection`, `reports` | ### `channel.removed` @@ -355,7 +355,7 @@ Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`sessio | Field | Type | Required | Constraints | |---|---|---|---| -| `delivery` | `object` | yes | keys `seq`, `channel_id`, `channel_name`, `channel_kind`, `notification_id`, `notification_kind`, `notification_title`, `revision`, `op`, `status`, `reason`, `attempts`, `next_attempt_at`, `last_error`, `duration_ms`, `message_ref`, `created_at`, `updated_at` | +| `delivery` | `object` | yes | keys `seq`, `channel_id`, `channel_name`, `channel_kind`, `notification_id`, `notification_kind`, `notification_title`, `revision`, `op`, `status`, `reason`, `attempts`, `next_attempt_at`, `last_error`, `duration_ms`, `message_ref`, `created_at`, `updated_at`, `report` | ### `action.recorded` diff --git a/packages/browserhive/README.md b/packages/browserhive/README.md index 0515806..cde50e9 100644 --- a/packages/browserhive/README.md +++ b/packages/browserhive/README.md @@ -1,6 +1,6 @@ # browserhive -Isolated, stealthy Chromium sessions for AI agents over the Model Context Protocol, with vault-backed logins the model never sees, human takeover, an audit trail with trace replay, and an operator dashboard. Local-first: nothing leaves your machine unless you enable telemetry. +Isolated, stealthy Chromium sessions for AI agents over the Model Context Protocol, with vault-backed logins the model never sees, human takeover, an audit trail with trace replay, an operator dashboard, and notifications on your phone that you can answer from. Local-first: nothing leaves your machine unless you enable telemetry or a notification channel. ## Requirements @@ -44,11 +44,13 @@ Or run it over stdio: ## Features - One Chromium process and context per session; persistent profiles and saved logins +- The bundled Chromium or your installed Google Chrome or Microsoft Edge, inside Chromium's sandbox wherever the machine allows it - Stealth: full Chromium, Patchright, host-coherent identity, optional fingerprint and human-like input - `vault_fill`: Bitwarden credentials typed into the page after origin and policy checks, never returned to the model - `request_attention`: block the agent and take over its browser from the dashboard - SQLite audit trail and Playwright trace replay -- Dashboard, REST API and WebSocket on the same port as MCP +- Dashboard, REST API and WebSocket on the same port as MCP; sessions and tool calls per agent (Claude Code, Codex, Cursor, …) +- Notifications through your own Telegram bot, Discord webhook or bot, ntfy topic or webhook: answer from the chat, daily or weekly digests and anomaly alerts - Opt-in OpenTelemetry export of traces, metrics and logs ## Programmatic use @@ -69,6 +71,7 @@ await server.stop(); - [Connecting MCP clients](https://github.com/arg1998/BrowserHive/blob/main/docs/guide/mcp-clients.md) - [Security model](https://github.com/arg1998/BrowserHive/blob/main/docs/guide/security.md) - [Vault](https://github.com/arg1998/BrowserHive/blob/main/docs/guide/vault.md) +- [Notifications](https://github.com/arg1998/BrowserHive/blob/main/docs/guide/notifications.md) - [Configuration](https://github.com/arg1998/BrowserHive/blob/main/docs/guide/configuration.md) - [CLI](https://github.com/arg1998/BrowserHive/blob/main/docs/guide/cli.md) - [Tool reference](https://github.com/arg1998/BrowserHive/blob/main/docs/reference/tools.md) diff --git a/packages/browserhive/src/cli/commands/channels.ts b/packages/browserhive/src/cli/commands/channels.ts index 3352923..020a260 100644 --- a/packages/browserhive/src/cli/commands/channels.ts +++ b/packages/browserhive/src/cli/commands/channels.ts @@ -54,6 +54,51 @@ function answersText(channel: ChannelView): string { return c.state === 'offline' && c.detail !== null ? `offline (${c.detail})` : c.state; } +/** A time in a zone: `Wed 30 Sep 09:00`. */ +function inZone(at: number, zone: string): string { + const options: Intl.DateTimeFormatOptions = { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }; + let parts: Intl.DateTimeFormatPart[]; + try { + parts = new Intl.DateTimeFormat('en-US', { ...options, timeZone: zone }).formatToParts(at); + } catch { + parts = new Intl.DateTimeFormat('en-US', options).formatToParts(at); + } + const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value; + return `${get('weekday')} ${get('day')} ${get('month')} ${get('hour')}:${get('minute')}`; +} + +/** The scheduled reports of a channel (D-43, D-44), or `null` without any. */ +export function reportsText(channel: ChannelView): string | null { + const r = channel.reports; + const parts: string[] = []; + if (r.digest !== null) { + const every = + r.digest.every === 'week' + ? `weekly ${r.digest.day ?? 'fri'}` + : r.digest.weekdays_only + ? 'weekday' + : 'daily'; + parts.push( + `${every} digest ${r.digest.at} → next ${inZone(r.digest.next_at, r.time_zone)} ${r.time_zone}`, + ); + } + if (r.anomaly !== null) { + parts.push( + r.anomaly.active.length === 0 + ? 'anomaly alerts on' + : `something looks off: ${r.anomaly.active.map((a) => a.check.replace('_', ' ')).join(', ')}`, + ); + } + return parts.length === 0 ? null : parts.join(' · '); +} + function secretsText(channel: ChannelView): string { if (channel.secrets.length === 0) return '—'; return channel.secrets.map((s) => `${s.env} ${s.set ? '✓' : '✗'}`).join(', '); @@ -107,6 +152,8 @@ export async function runChannelsList( ]), ); for (const c of channels) { + const reports = reportsText(c); + if (reports !== null) out.line(` ${out.style.dim(`${c.name}:`)} ${reports}`); if (c.problem !== null) out.line(` ${out.style.dim(`${c.name}:`)} ${c.problem}`); } return EXIT.ok; diff --git a/packages/browserhive/src/cli/registry.ts b/packages/browserhive/src/cli/registry.ts index 6404aab..8abcbe1 100644 --- a/packages/browserhive/src/cli/registry.ts +++ b/packages/browserhive/src/cli/registry.ts @@ -360,7 +360,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [ kind: 'value', placeholder: '', describe: - 'Sample notification: attention, attention-resolved, vault-confirm, tool-errors, crash, degraded or test.', + 'Sample notification: attention, attention-resolved, vault-confirm, tool-errors, crash, degraded, test, digest or anomaly.', defaultText: 'attention', }, ...remoteFlags, diff --git a/packages/browserhive/src/composition/context.ts b/packages/browserhive/src/composition/context.ts index a3a020d..cde0b64 100644 --- a/packages/browserhive/src/composition/context.ts +++ b/packages/browserhive/src/composition/context.ts @@ -42,6 +42,9 @@ import type { PreferenceService, PublicUrlChecker, Recorder, + ReportScheduler, + ReportService, + ReportSettingsStore, RuntimeFacts, SessionService, SystemStatusService, @@ -120,6 +123,12 @@ export interface DomainPart { readonly channelService: ChannelService; /** Act-button press listeners (Telegram poller, Discord gateway, ntfy reply topic; D-41). */ readonly actionListeners: NotificationActionListeners; + /** Digests and anomaly alerts (D-43, D-44). */ + readonly reports: ReportScheduler; + /** The in-app report settings (D-45). */ + readonly reportSettings: ReportSettingsStore; + /** The Reports tab's reads and settings (D-45). */ + readonly reportService: ReportService; /** The `publicUrl` check (spec 08 §5.8). */ readonly publicUrl: PublicUrlChecker; /** Random per start; `GET /health` reports it (D-37). */ diff --git a/packages/browserhive/src/composition/phases/build-domain.ts b/packages/browserhive/src/composition/phases/build-domain.ts index 5c44f1f..69f08dc 100644 --- a/packages/browserhive/src/composition/phases/build-domain.ts +++ b/packages/browserhive/src/composition/phases/build-domain.ts @@ -208,6 +208,11 @@ async function buildDomain( actionCounter: telemetry.instruments.notificationActions, probe: createUrlProbe(), instanceId, + capacity: () => { + const status = sessions.serverStatus(); + return { live: status.count, max: status.limit ?? 0 }; + }, + reportCounter: telemetry.instruments.notificationReports, snapshots: createNotificationSnapshots({ sessions, screenshots: repos.screenshots, @@ -296,6 +301,9 @@ async function buildDomain( notificationOutbox: ops.notificationOutbox, channelService: ops.channelService, actionListeners: ops.actionListeners, + reports: ops.reports, + reportSettings: ops.reportSettings, + reportService: ops.reportService, publicUrl: ops.publicUrl, instanceId, preferences: ops.preferences, diff --git a/packages/browserhive/src/composition/phases/domain-ops.ts b/packages/browserhive/src/composition/phases/domain-ops.ts index 663466f..96176ee 100644 --- a/packages/browserhive/src/composition/phases/domain-ops.ts +++ b/packages/browserhive/src/composition/phases/domain-ops.ts @@ -37,7 +37,9 @@ import { type ChannelAdapterFactory, ChannelRegistry, ChannelService, + createReportFacts, type DeliveryCounter, + forgetChannelCursors, imageVariants, linkBuilderFor, NotificationActionListeners, @@ -47,6 +49,11 @@ import { PreferenceService, PublicUrlChecker, Recorder, + type ReportCounter, + ReportScheduler, + ReportService, + ReportSettingsStore, + runtimeZone, } from '@browserhive/core/server'; /** Inputs of {@link buildOps}. */ @@ -90,6 +97,12 @@ export interface OpsInput { readonly probe: UrlProbe; /** Random per start (`GET /health`). */ readonly instanceId: string; + /** Live sessions and `maxSessions` now (the anomaly check's capacity, D-44). */ + readonly capacity: () => { readonly live: number; readonly max: number }; + /** Counts scheduled report decisions (spec 10 §7). */ + readonly reportCounter?: ReportCounter; + /** The host's IANA zone (the default of every channel's reports); default the runtime's. */ + readonly hostZone?: () => string; } /** Built operations services (not started; `wire-observers` starts them). */ @@ -106,6 +119,12 @@ export interface OpsParts { readonly actions: NotificationActionService; /** The press listeners (started by `wire-observers`). */ readonly actionListeners: NotificationActionListeners; + /** Digests and anomaly alerts (started by `wire-observers`, D-43, D-44). */ + readonly reports: ReportScheduler; + /** The in-app report settings (loaded by `wire-observers` before the scheduler starts, D-45). */ + readonly reportSettings: ReportSettingsStore; + /** The Reports tab's reads and settings (D-45). */ + readonly reportService: ReportService; /** The `publicUrl` check (spec 08 §5.8). */ readonly publicUrl: PublicUrlChecker; readonly preferences: PreferenceService; @@ -126,6 +145,7 @@ export function buildOps(input: OpsInput): OpsParts { env: (name) => input.env[name], registerSecret: input.registerSecret, ...(input.channelFactories !== undefined && { factories: input.channelFactories }), + onRemoved: (channelId) => forgetChannelCursors(repos.notificationCursors, channelId), }); const links = linkBuilderFor(config.publicUrl, input.dashboardUrl); // The feed is late-bound: the channel service is built after the outbox that reports to it. @@ -162,6 +182,28 @@ export function buildOps(input: OpsInput): OpsParts { feed?.onDeliveryChange(notificationId, channelId), actions, }); + const hostZone = input.hostZone ?? runtimeZone; + const reportSettings = new ReportSettingsStore(repos.notificationCursors); + const reports = new ReportScheduler({ + settings: reportSettings, + // In-app report copies reach open dashboards like any produced notification (D-45). + inbox: (op, notification) => + op === 'created' + ? bus.publish('notification.created', { type: 'notification.created', notification }) + : bus.publish('notification.updated', { type: 'notification.updated', notification }), + registry: channels, + facts: createReportFacts({ analytics: input.analytics, repos, capacity: input.capacity }), + uow: input.uow, + repos, + outbox: notificationOutbox, + clock, + ids, + logger, + hostZone, + redactor: input.redactor, + ...(input.reportCounter !== undefined && { counter: input.reportCounter }), + onDeliveryChange: (notificationId) => feed?.onDeliveryChange(notificationId), + }); const channelService = new ChannelService({ repos, uow: input.uow, @@ -179,6 +221,9 @@ export function buildOps(input: OpsInput): OpsParts { ...(input.discord !== undefined && { discord: input.discord }), connection: (channelId) => actionListeners.status(channelId), actions, + reports, + cursors: repos.notificationCursors, + hostZone, }); feed = channelService; const publicUrl = new PublicUrlChecker({ @@ -226,6 +271,14 @@ export function buildOps(input: OpsInput): OpsParts { channelService, actions, actionListeners, + reports, + reportSettings, + reportService: new ReportService({ + repo: repos.notifications, + settings: reportSettings, + scheduler: reports, + clock, + }), publicUrl, preferences: new PreferenceService({ repo: repos.preferences, clock, logger }), retention: new RetentionScheduler({ diff --git a/packages/browserhive/src/composition/phases/listeners-http.ts b/packages/browserhive/src/composition/phases/listeners-http.ts index e67bd1e..934c204 100644 --- a/packages/browserhive/src/composition/phases/listeners-http.ts +++ b/packages/browserhive/src/composition/phases/listeners-http.ts @@ -233,6 +233,7 @@ export async function openHttpListener( auth: domain.auth, blocklist: blocklistPort(domain.blocklist, config.blocklist ?? null), notifications: notificationsPort(domain.notifications, storage.uow.repos.notifications), + reports: domain.reportService, preferences: preferencesPort(domain.preferences, () => ctx.clock.now()), logs: ring, logLevel: logLevelController(logger, LOG_MODULES), diff --git a/packages/browserhive/src/composition/phases/wire-observers.ts b/packages/browserhive/src/composition/phases/wire-observers.ts index 78b2196..05014aa 100644 --- a/packages/browserhive/src/composition/phases/wire-observers.ts +++ b/packages/browserhive/src/composition/phases/wire-observers.ts @@ -103,6 +103,11 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.notifications.start(); domain.notificationOutbox.start(); domain.actionListeners.start(); + void domain.reportSettings + .load() + .then(() => domain.reports.load()) + .catch((err: unknown) => logger.warn('report cursors failed', { err: serializeError(err) })) + .finally(() => domain.reports.start()); status.start(); domain.retention.start(); domain.outbox.start(); @@ -127,6 +132,7 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.outbox.stop(); domain.retention.stop(); status.stop(); + domain.reports.stop(); domain.actionListeners.stop(); domain.notificationOutbox.stop(); domain.notifications.stop(); diff --git a/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt b/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt index 7a79c67..0abb3a9 100644 --- a/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt +++ b/packages/browserhive/test/cli/__goldens__/help-channels-preview.txt @@ -13,7 +13,8 @@ ARGUMENTS FLAGS --json Print machine-readable JSON instead of text. --sample Sample notification: attention, attention-resolved, vault-confirm, - tool-errors, crash, degraded or test. default: attention + tool-errors, crash, degraded, test, digest or anomaly. + default: attention --url Talk to a running server over its REST API instead of opening the database (http://127.0.0.1:9876). --token Operator bearer token for --url (bh_operator_…). diff --git a/packages/browserhive/test/cli/__goldens__/help-channels.txt b/packages/browserhive/test/cli/__goldens__/help-channels.txt index aae7c32..4ba7c4c 100644 --- a/packages/browserhive/test/cli/__goldens__/help-channels.txt +++ b/packages/browserhive/test/cli/__goldens__/help-channels.txt @@ -40,7 +40,7 @@ FLAGS — test FLAGS — preview --json Print machine-readable JSON instead of text. --sample Sample notification: attention, attention-resolved, vault-confirm, tool-errors, - crash, degraded or test. default: attention + crash, degraded, test, digest or anomaly. default: attention --url Talk to a running server over its REST API instead of opening the database (http://127.0.0.1:9876). --token Operator bearer token for --url (bh_operator_…). diff --git a/packages/browserhive/test/cli/channels.test.ts b/packages/browserhive/test/cli/channels.test.ts index 43c97ee..3933eab 100644 --- a/packages/browserhive/test/cli/channels.test.ts +++ b/packages/browserhive/test/cli/channels.test.ts @@ -36,6 +36,7 @@ function channel(overrides: Partial): ChannelView { last_status: null, }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, ...overrides, }; } @@ -55,10 +56,32 @@ const CHANNELS: ChannelView[] = [ connection: { state: 'offline', since: 1, detail: 'Discord refused the bot token.' }, }), channel({ channel_id: 'nc-000000000003', name: 'pager', kind: 'ntfy', target_hint: 'ntfy.sh/x' }), + channel({ + channel_id: 'nc-000000000004', + name: 'morning', + rules: { digest: { every: 'day', at: '08:30' }, anomaly: {}, time_zone: 'Europe/Berlin' }, + reports: { + time_zone: 'Europe/Berlin', + host_zone: false, + // 2026-09-30 06:30 UTC = 08:30 in Berlin. + digest: { + every: 'day', + at: '08:30', + day: null, + weekdays_only: false, + next_at: 1_790_749_800_000, + last_until: null, + }, + anomaly: { + next_check_at: 1_790_748_000_000, + active: [{ check: 'error_rate', since: 1, value: 34, threshold: 20 }], + }, + }, + }), ]; const http: CliDeps['http'] = async () => { - const body = { data: CHANNELS, now: 2 }; + const body = { data: CHANNELS, now: 2, host_time_zone: 'Europe/Berlin' }; return { status: 200, json: async () => body, text: async () => JSON.stringify(body) }; }; @@ -73,6 +96,9 @@ describe('channels list', () => { expect(result.stdout).toContain('discord (bot)'); expect(result.stdout).toContain('connected'); expect(result.stdout).toContain('offline (Discord refused the bot token.)'); + expect(result.stdout).toContain( + 'daily digest 08:30 → next Wed 30 Sep 08:30 Europe/Berlin · something looks off: error rate', + ); const json = await cliHarness({ argv: ['channels', 'list', '--json', '--url', 'http://127.0.0.1:9876', '--token', 'x'], http, diff --git a/packages/contracts/generated/openapi.json b/packages/contracts/generated/openapi.json index a68d664..b7d3611 100644 --- a/packages/contracts/generated/openapi.json +++ b/packages/contracts/generated/openapi.json @@ -182,6 +182,7 @@ "CHANNEL_KIND_UNAVAILABLE", "CHANNEL_PLATFORM_ERROR", "DELIVERY_NOT_FOUND", + "REPORT_NOT_FOUND", "INTERNAL_ERROR", "ADMIN_REQUIRES_HTTP", "INSECURE_BIND_REFUSED", @@ -8740,7 +8741,7 @@ "latest_seq" ] }, - "NotificationsPage": { + "ReportsPage": { "type": "object", "properties": { "data": { @@ -8748,161 +8749,261 @@ "items": { "type": "object", "properties": { - "notification_id": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "principal_id": { - "type": [ - "string", - "null" - ] - }, - "type": { - "type": "string", - "enum": [ - "attention", - "error", - "vault", - "lifecycle", - "system" - ] - }, - "title": { - "type": "string" - }, - "body": { - "type": [ - "string", - "null" - ] - }, - "session_id": { - "type": [ - "string", - "null" - ], - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "session_slug": { - "type": [ - "string", - "null" - ] - }, - "target": { - "type": [ - "string", - "null" - ] - }, - "source_event_id": { - "type": [ - "string", - "null" + "notification": { + "type": "object", + "properties": { + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "principal_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": [ + "string", + "null" + ], + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "session_slug": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "source_event_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "read_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "dismissed_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string" + } + }, + "required": [ + "notification_id", + "principal_id", + "type", + "title", + "body", + "session_id", + "session_slug", + "target", + "source_event_id", + "created_at", + "updated_at", + "count", + "read_at", + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ] }, - "created_at": { - "type": "integer", - "minimum": 0 - }, - "updated_at": { - "type": "integer", - "minimum": 0 - }, - "count": { - "type": "integer", - "minimum": 1 - }, - "read_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "dismissed_at": { + "report": { "type": [ - "integer", + "object", "null" ], - "minimum": 0 - }, - "kind": { - "type": "string", - "enum": [ - "attention.requested", - "vault.confirm", - "vault.filled", - "session.finished", - "session.crashed", - "session.reaped", - "tool.errors", - "system.degraded", - "channel.broken", - "digest.daily", - "report.anomaly", - "test" - ] - }, - "category": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - }, - "severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] - }, - "state": { - "type": "string", - "enum": [ - "open", - "acted", - "resolved", - "expired", - "final" + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" ] }, - "revision": { - "type": "integer", - "minimum": 1 - }, - "thread": { - "type": "string" + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "channel_id", + "name", + "kind", + "status", + "reason" + ] + } } }, "required": [ - "notification_id", - "principal_id", - "type", - "title", - "body", - "session_id", - "session_slug", - "target", - "source_event_id", - "created_at", - "updated_at", - "count", - "read_at", - "dismissed_at", - "kind", - "category", - "severity", - "state", - "revision", - "thread" + "notification", + "report", + "channels" ] } }, @@ -9013,1481 +9114,6751 @@ "required": [ "now" ] - }, - "unread_count": { - "type": "integer", - "minimum": 0 } }, "required": [ "data", "page", "applied", - "meta", - "unread_count" - ] - }, - "NotificationsUpdatedResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [ - true - ] - }, - "updated": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "ok", - "updated" + "meta" ] }, - "PreferencesResponse": { + "ReportDetailResponse": { "type": "object", "properties": { - "preferences": { + "report": { "type": "object", "properties": { - "sidebar": { - "type": "string", - "enum": [ - "expanded", - "collapsed" + "notification": { + "type": "object", + "properties": { + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "principal_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": [ + "string", + "null" + ], + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "session_slug": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "source_event_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "read_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "dismissed_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string" + } + }, + "required": [ + "notification_id", + "principal_id", + "type", + "title", + "body", + "session_id", + "session_slug", + "target", + "source_event_id", + "created_at", + "updated_at", + "count", + "read_at", + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ] }, - "default_page_size": { - "type": "integer", - "minimum": 10, - "maximum": 500 + "report": { + "type": [ + "object", + "null" + ], + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] }, - "saved_views": { + "channels": { "type": "array", "items": { "type": "object", "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 64 + "channel_id": { + "type": "string" }, "name": { - "type": "string", - "minLength": 1, - "maxLength": 80 + "type": "string" }, - "route": { + "kind": { + "type": "string" + }, + "status": { "type": "string", - "minLength": 1, - "maxLength": 128 + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] }, - "search": { - "type": "object", - "additionalProperties": {} + "reason": { + "type": [ + "string", + "null" + ] } }, "required": [ - "id", + "channel_id", "name", - "route", - "search" - ], - "additionalProperties": false - }, - "maxItems": 100 - }, - "notifications": { - "type": "object", - "properties": { - "toasts": { - "type": "boolean" - }, - "types": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "attention", - "error", - "vault", - "lifecycle", - "system" - ] - } - } - }, - "additionalProperties": false - }, - "page_defaults": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": {} + "kind", + "status", + "reason" + ] } } }, - "additionalProperties": false - }, - "updated_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - } - }, - "required": [ - "preferences", - "updated_at" - ] - }, - "PutPreferencesResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [ - true + "required": [ + "notification", + "report", + "channels" ] }, - "updated_at": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "ok", - "updated_at" - ] - }, - "PutPreferencesRequest": { - "type": "object", - "properties": { - "preferences": { + "message": { "type": "object", "properties": { - "sidebar": { - "type": "string", + "schema": { + "type": "number", "enum": [ - "expanded", - "collapsed" + 1 ] }, - "default_page_size": { + "id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "revision": { "type": "integer", - "minimum": 10, - "maximum": 500 + "minimum": 1 }, - "saved_views": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 80 - }, - "route": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "search": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "id", - "name", - "route", - "search" - ], - "additionalProperties": false - }, - "maxItems": 100 + "thread": { + "type": "string", + "minLength": 1, + "maxLength": 160 }, - "notifications": { + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "alert": { + "type": "boolean" + }, + "at": { "type": "object", "properties": { - "toasts": { - "type": "boolean" + "created": { + "type": "integer", + "minimum": 0 }, - "types": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "attention", - "error", - "vault", - "lifecycle", - "system" - ] - } + "updated": { + "type": "integer", + "minimum": 0 } }, - "additionalProperties": false + "required": [ + "created", + "updated" + ] }, - "page_defaults": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": {} - } - } - }, - "additionalProperties": false - } - }, - "required": [ - "preferences" - ], - "additionalProperties": false - }, - "ChannelsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "channel_id": { - "type": "string", - "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" - }, - "name": { - "type": "string" - }, - "kind": { - "type": "string", - "enum": [ - "in-app", - "telegram", - "discord", - "ntfy", - "webhook", - "slack", - "pushover", - "teams", - "apprise", - "email" - ] - }, - "mode": { - "type": [ - "string", - "null" - ] - }, - "source": { - "type": "string", - "enum": [ - "db", - "startup" - ] - }, - "status": { - "type": "string", - "enum": [ - "active", - "paused", - "broken" - ] - }, - "target": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 2048 - } - }, - "target_hint": { - "type": "string" - }, - "secret_refs": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - } - }, - "secrets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "param": { - "type": "string" - }, - "env": { - "type": "string" + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "summary": { + "type": "string", + "maxLength": 240 + }, + "blocks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } }, - "set": { - "type": "boolean" - } - }, - "required": [ - "param", - "env", - "set" - ] - } - }, - "rules": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - } - }, - "min_severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" + "required": [ + "type", + "content" ] }, - "sessions": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - }, - "harness": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 32 - }, - "maxItems": 32 - }, - "quiet_hours": { + { "type": "object", "properties": { - "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "end": { + "type": { "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + "enum": [ + "heading" + ] }, - "time_zone": { + "text": { "type": "string", - "minLength": 1, - "maxLength": 64 + "maxLength": 4000 } }, "required": [ - "start", - "end" - ] - }, - "content": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" + "type", + "text" ] }, - "images": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } - }, - "mask_images": { - "type": "boolean" - }, - "ttl_ms": { + { "type": "object", "properties": { - "needs-you": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "problems": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "wrap-ups": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "reports": { - "type": "integer", - "exclusiveMinimum": 0 + "type": { + "type": "string", + "enum": [ + "fields" + ] }, - "system": { - "type": "integer", - "exclusiveMinimum": 0 + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "value": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "label", + "value" + ] + }, + "minItems": 1, + "maxItems": 12 } - } + }, + "required": [ + "type", + "items" + ] }, - "delete_when_resolved": { + { "type": "object", "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" + "type": { + "type": "string", + "enum": [ + "quote" + ] }, - "system": { - "type": "boolean" - } - } - }, - "act_buttons": { - "type": "boolean" - }, - "allow_list": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - } - } + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "collapsible": { + "type": "boolean" + } + }, + "required": [ + "type", + "content", + "collapsible" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "ordered": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "type", + "ordered", + "items" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "table" + ] + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "maxItems": 20 + } + }, + "required": [ + "type", + "columns", + "rows" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "alt": { + "type": "string", + "maxLength": 4000 + }, + "captured_at": { + "type": "integer", + "minimum": 0 + }, + "masked": { + "type": "boolean" + }, + "path": { + "type": [ + "string", + "null" + ], + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "ref", + "alt", + "captured_at", + "masked", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "language": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + } + }, + "required": [ + "type", + "text", + "language" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "divider" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "footer" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "chart" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "values": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 1, + "maxItems": 48 + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "step_ms": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "unit": { + "type": [ + "string", + "null" + ], + "maxLength": 24 + } + }, + "required": [ + "type", + "label", + "values", + "start", + "step_ms", + "unit" + ] + } + ] + }, + "maxItems": 50 + }, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "act" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "command": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "attention.resolve", + "vault.confirm.resolve", + "session.extend_lease", + "session.close" + ] + }, + "args": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "required": [ + "op", + "args" + ] + }, + "confirm": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "fallback": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "label", + "path" + ] + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "command", + "confirm", + "fallback" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "open" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "path" + ] + } + ] + }, + "maxItems": 5 + }, + "entities": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "maxLength": 128 + }, + "session_slug": { + "type": "string", + "maxLength": 64 + }, + "harness": { + "type": "string", + "maxLength": 64 + }, + "owner": { + "type": "string", + "maxLength": 128 + }, + "tool": { + "type": "string", + "maxLength": 64 + }, + "error_code": { + "type": "string", + "maxLength": 64 + }, + "domain": { + "type": "string", + "maxLength": 253 + }, + "request_id": { + "type": "string", + "maxLength": 64 + } + } + }, + "privacy": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "has_image": { + "type": "boolean" + } + }, + "required": [ + "level", + "has_image" + ] + }, + "report": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] + } + }, + "required": [ + "schema", + "id", + "revision", + "thread", + "kind", + "category", + "severity", + "state", + "alert", + "at", + "title", + "summary", + "blocks", + "actions", + "entities", + "privacy" + ] + } + }, + "required": [ + "report", + "message" + ] + }, + "ReportSettingsResponse": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "additionalProperties": false + }, + "host_time_zone": { + "type": "string" + }, + "reports": { + "type": "object", + "properties": { + "time_zone": { + "type": "string" + }, + "host_zone": { + "type": "boolean" + }, + "digest": { + "type": [ + "object", + "null" + ], + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string" + }, + "day": { + "type": [ + "string", + "null" + ], + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", + null + ] + }, + "weekdays_only": { + "type": "boolean" + }, + "next_at": { + "type": "integer", + "minimum": 0 + }, + "last_until": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "every", + "at", + "day", + "weekdays_only", + "next_at", + "last_until" + ] + }, + "anomaly": { + "type": [ + "object", + "null" + ], + "properties": { + "next_check_at": { + "type": "integer", + "minimum": 0 + }, + "active": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": { + "type": "string", + "enum": [ + "error_rate", + "attention", + "capacity", + "blocked", + "degraded" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "number" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "check", + "since", + "value", + "threshold" + ] + } + } + }, + "required": [ + "next_check_at", + "active" + ] + } + }, + "required": [ + "time_zone", + "host_zone", + "digest", + "anomaly" + ] + } + }, + "required": [ + "settings", + "host_time_zone", + "reports" + ] + }, + "PutReportSettingsRequest": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "additionalProperties": false + } + }, + "required": [ + "settings" + ], + "additionalProperties": false + }, + "NotificationsPage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "principal_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + }, + "title": { + "type": "string" + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": [ + "string", + "null" + ], + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "session_slug": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "source_event_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "read_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "dismissed_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string" + } + }, + "required": [ + "notification_id", + "principal_id", + "type", + "title", + "body", + "session_id", + "session_slug", + "target", + "source_event_id", + "created_at", + "updated_at", + "count", + "read_at", + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" + ] + } + }, + "page": { + "type": "object", + "properties": { + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "prev_cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "next_cursor", + "limit" + ] + }, + "facets": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "value", + "count" + ] + } + } + }, + "applied": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "additionalProperties": {} + }, + "sort": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "key", + "dir" + ] + } + }, + "required": [ + "filters", + "sort" + ] + }, + "meta": { + "type": "object", + "properties": { + "now": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "now" + ] + }, + "unread_count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "data", + "page", + "applied", + "meta", + "unread_count" + ] + }, + "NotificationsUpdatedResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "updated": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "ok", + "updated" + ] + }, + "PreferencesResponse": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "sidebar": { + "type": "string", + "enum": [ + "expanded", + "collapsed" + ] + }, + "default_page_size": { + "type": "integer", + "minimum": 10, + "maximum": 500 + }, + "saved_views": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "route": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "search": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "name", + "route", + "search" + ], + "additionalProperties": false + }, + "maxItems": 100 + }, + "notifications": { + "type": "object", + "properties": { + "toasts": { + "type": "boolean" + }, + "types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + } + } + }, + "additionalProperties": false + }, + "page_defaults": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + } + }, + "additionalProperties": false + }, + "updated_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "preferences", + "updated_at" + ] + }, + "PutPreferencesResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "updated_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "ok", + "updated_at" + ] + }, + "PutPreferencesRequest": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "sidebar": { + "type": "string", + "enum": [ + "expanded", + "collapsed" + ] + }, + "default_page_size": { + "type": "integer", + "minimum": 10, + "maximum": 500 + }, + "saved_views": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "route": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "search": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "id", + "name", + "route", + "search" + ], + "additionalProperties": false + }, + "maxItems": 100 + }, + "notifications": { + "type": "object", + "properties": { + "toasts": { + "type": "boolean" + }, + "types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] + } + } + }, + "additionalProperties": false + }, + "page_defaults": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "preferences" + ], + "additionalProperties": false + }, + "ChannelsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "in-app", + "telegram", + "discord", + "ntfy", + "webhook", + "slack", + "pushover", + "teams", + "apprise", + "email" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string", + "enum": [ + "db", + "startup" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "broken" + ] + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "target_hint": { + "type": "string" + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "param": { + "type": "string" + }, + "env": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "param", + "env", + "set" + ] + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + }, + "capabilities": { + "type": [ + "object", + "null" + ], + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "charts": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "charts", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "ready": { + "type": "boolean" + }, + "problem": { + "type": [ + "string", + "null" + ] + }, + "failure_count": { + "type": "integer", + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_ok_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_failure_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "stats": { + "type": "object", + "properties": { + "sent_24h": { + "type": "integer", + "minimum": 0 + }, + "failed_24h": { + "type": "integer", + "minimum": 0 + }, + "suppressed_24h": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "last_delivery_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded", + null + ] + } + }, + "required": [ + "sent_24h", + "failed_24h", + "suppressed_24h", + "pending", + "last_delivery_at", + "last_status" + ] + }, + "connection": { + "type": [ + "object", + "null" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "reconnecting", + "offline" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "since", + "detail" + ] + }, + "reports": { + "type": "object", + "properties": { + "time_zone": { + "type": "string" + }, + "host_zone": { + "type": "boolean" + }, + "digest": { + "type": [ + "object", + "null" + ], + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string" + }, + "day": { + "type": [ + "string", + "null" + ], + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", + null + ] + }, + "weekdays_only": { + "type": "boolean" + }, + "next_at": { + "type": "integer", + "minimum": 0 + }, + "last_until": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "every", + "at", + "day", + "weekdays_only", + "next_at", + "last_until" + ] + }, + "anomaly": { + "type": [ + "object", + "null" + ], + "properties": { + "next_check_at": { + "type": "integer", + "minimum": 0 + }, + "active": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": { + "type": "string", + "enum": [ + "error_rate", + "attention", + "capacity", + "blocked", + "degraded" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "number" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "check", + "since", + "value", + "threshold" + ] + } + } + }, + "required": [ + "next_check_at", + "active" + ] + } + }, + "required": [ + "time_zone", + "host_zone", + "digest", + "anomaly" + ] + } + }, + "required": [ + "channel_id", + "name", + "kind", + "mode", + "source", + "status", + "target", + "target_hint", + "secret_refs", + "secrets", + "rules", + "capabilities", + "ready", + "problem", + "failure_count", + "last_error", + "last_ok_at", + "last_failure_at", + "created_at", + "updated_at", + "stats", + "connection", + "reports" + ] + } + }, + "now": { + "type": "integer", + "minimum": 0 + }, + "host_time_zone": { + "type": "string" + } + }, + "required": [ + "data", + "now", + "host_time_zone" + ] + }, + "ChannelResponse": { + "type": "object", + "properties": { + "channel": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "in-app", + "telegram", + "discord", + "ntfy", + "webhook", + "slack", + "pushover", + "teams", + "apprise", + "email" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string", + "enum": [ + "db", + "startup" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "broken" + ] + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "target_hint": { + "type": "string" + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "param": { + "type": "string" + }, + "env": { + "type": "string" + }, + "set": { + "type": "boolean" + } + }, + "required": [ + "param", + "env", + "set" + ] + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + }, + "capabilities": { + "type": [ + "object", + "null" + ], + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "charts": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "charts", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "ready": { + "type": "boolean" + }, + "problem": { + "type": [ + "string", + "null" + ] + }, + "failure_count": { + "type": "integer", + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_ok_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_failure_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "stats": { + "type": "object", + "properties": { + "sent_24h": { + "type": "integer", + "minimum": 0 + }, + "failed_24h": { + "type": "integer", + "minimum": 0 + }, + "suppressed_24h": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "last_delivery_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_status": { + "type": [ + "string", + "null" + ], + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded", + null + ] + } + }, + "required": [ + "sent_24h", + "failed_24h", + "suppressed_24h", + "pending", + "last_delivery_at", + "last_status" + ] + }, + "connection": { + "type": [ + "object", + "null" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "connecting", + "connected", + "reconnecting", + "offline" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "detail": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "since", + "detail" + ] + }, + "reports": { + "type": "object", + "properties": { + "time_zone": { + "type": "string" + }, + "host_zone": { + "type": "boolean" + }, + "digest": { + "type": [ + "object", + "null" + ], + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string" + }, + "day": { + "type": [ + "string", + "null" + ], + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", + null + ] + }, + "weekdays_only": { + "type": "boolean" + }, + "next_at": { + "type": "integer", + "minimum": 0 + }, + "last_until": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "every", + "at", + "day", + "weekdays_only", + "next_at", + "last_until" + ] + }, + "anomaly": { + "type": [ + "object", + "null" + ], + "properties": { + "next_check_at": { + "type": "integer", + "minimum": 0 + }, + "active": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": { + "type": "string", + "enum": [ + "error_rate", + "attention", + "capacity", + "blocked", + "degraded" + ] + }, + "since": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "number" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "check", + "since", + "value", + "threshold" + ] + } + } + }, + "required": [ + "next_check_at", + "active" + ] + } + }, + "required": [ + "time_zone", + "host_zone", + "digest", + "anomaly" + ] + } + }, + "required": [ + "channel_id", + "name", + "kind", + "mode", + "source", + "status", + "target", + "target_hint", + "secret_refs", + "secrets", + "rules", + "capabilities", + "ready", + "problem", + "failure_count", + "last_error", + "last_ok_at", + "last_failure_at", + "created_at", + "updated_at", + "stats", + "connection", + "reports" + ] + } + }, + "required": [ + "channel" + ] + }, + "ChannelInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + }, + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + }, + "default": {} + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + }, + "default": {} + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + }, + "default": {} + } + }, + "required": [ + "name", + "kind" + ], + "additionalProperties": false + }, + "ChannelPreview": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "sample": { + "type": "string", + "enum": [ + "attention", + "attention-resolved", + "vault-confirm", + "tool-errors", + "crash", + "degraded", + "test", + "digest", + "anomaly" + ] + }, + "capabilities": { + "type": "object", + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "charts": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "charts", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "message": { + "type": "object", + "properties": { + "schema": { + "type": "number", + "enum": [ + 1 + ] + }, + "id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "alert": { + "type": "boolean" + }, + "at": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + } }, - "capabilities": { - "type": [ - "object", - "null" - ], - "properties": { - "rich_blocks": { - "type": "boolean" + "required": [ + "created", + "updated" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "summary": { + "type": "string", + "maxLength": 240 + }, + "blocks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] }, - "tables": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "heading" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] }, - "images": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "fields" + ] + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "value": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "label", + "value" + ] + }, + "minItems": 1, + "maxItems": 12 + } + }, + "required": [ + "type", + "items" + ] }, - "act_buttons": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "quote" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "collapsible": { + "type": "boolean" + } + }, + "required": [ + "type", + "content", + "collapsible" + ] }, - "open_links": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "ordered": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "type", + "ordered", + "items" + ] }, - "edit": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "table" + ] + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "maxItems": 20 + } + }, + "required": [ + "type", + "columns", + "rows" + ] }, - "delete": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "alt": { + "type": "string", + "maxLength": 4000 + }, + "captured_at": { + "type": "integer", + "minimum": 0 + }, + "masked": { + "type": "boolean" + }, + "path": { + "type": [ + "string", + "null" + ], + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "ref", + "alt", + "captured_at", + "masked", + "path" + ] }, - "replies": { - "type": "boolean" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "language": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + } + }, + "required": [ + "type", + "text", + "language" + ] }, - "delete_window_ms": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "divider" + ] + } + }, + "required": [ + "type" + ] }, - "max_title_chars": { - "type": "integer", - "minimum": 0 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "footer" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] }, - "max_text_chars": { - "type": "integer", - "minimum": 0 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "chart" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "values": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 1, + "maxItems": 48 + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "step_ms": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "unit": { + "type": [ + "string", + "null" + ], + "maxLength": 24 + } + }, + "required": [ + "type", + "label", + "values", + "start", + "step_ms", + "unit" + ] + } + ] + }, + "maxItems": 50 + }, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "act" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "command": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "attention.resolve", + "vault.confirm.resolve", + "session.extend_lease", + "session.close" + ] + }, + "args": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "required": [ + "op", + "args" + ] + }, + "confirm": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "fallback": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "label", + "path" + ] + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "command", + "confirm", + "fallback" + ] }, - "max_buttons": { - "type": "integer", - "minimum": 0 + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "open" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "path" + ] } + ] + }, + "maxItems": 5 + }, + "entities": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "maxLength": 128 + }, + "session_slug": { + "type": "string", + "maxLength": 64 + }, + "harness": { + "type": "string", + "maxLength": 64 + }, + "owner": { + "type": "string", + "maxLength": 128 + }, + "tool": { + "type": "string", + "maxLength": 64 + }, + "error_code": { + "type": "string", + "maxLength": 64 + }, + "domain": { + "type": "string", + "maxLength": 253 + }, + "request_id": { + "type": "string", + "maxLength": 64 + } + } + }, + "privacy": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "has_image": { + "type": "boolean" + } + }, + "required": [ + "level", + "has_image" + ] + }, + "report": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 }, - "required": [ - "rich_blocks", - "tables", - "images", - "act_buttons", - "open_links", - "edit", - "delete", - "replies", - "delete_window_ms", - "max_title_chars", - "max_text_chars", - "max_buttons" - ] - }, - "ready": { - "type": "boolean" + "manual": { + "type": "boolean" + } }, - "problem": { - "type": [ - "string", - "null" - ] + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] + } + }, + "required": [ + "schema", + "id", + "revision", + "thread", + "kind", + "category", + "severity", + "state", + "alert", + "at", + "title", + "summary", + "blocks", + "actions", + "entities", + "privacy" + ] + }, + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string" }, - "failure_count": { - "type": "integer", - "minimum": 0 + "path": { + "type": "string" }, - "last_error": { - "type": [ - "string", - "null" + "encoding": { + "type": "string", + "enum": [ + "json", + "multipart", + "binary" ] }, - "last_ok_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "last_failure_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "created_at": { - "type": "integer", - "minimum": 0 - }, - "updated_at": { - "type": "integer", - "minimum": 0 + "body": { + "type": "object", + "additionalProperties": {} }, - "stats": { + "headers": { "type": "object", - "properties": { - "sent_24h": { - "type": "integer", - "minimum": 0 - }, - "failed_24h": { - "type": "integer", - "minimum": 0 - }, - "suppressed_24h": { - "type": "integer", - "minimum": 0 - }, - "pending": { - "type": "integer", - "minimum": 0 - }, - "last_delivery_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "last_status": { - "type": [ - "string", - "null" - ], - "enum": [ - "pending", - "sending", - "sent", - "retrying", - "dead", - "suppressed", - "superseded", - null - ] - } - }, - "required": [ - "sent_24h", - "failed_24h", - "suppressed_24h", - "pending", - "last_delivery_at", - "last_status" - ] + "additionalProperties": { + "type": "string" + } }, - "connection": { + "file": { "type": [ "object", "null" ], "properties": { - "state": { - "type": "string", - "enum": [ - "connecting", - "connected", - "reconnecting", - "offline" - ] - }, - "since": { - "type": "integer", - "minimum": 0 + "name": { + "type": "string" }, - "detail": { - "type": [ - "string", - "null" - ] + "content_type": { + "type": "string" } }, "required": [ - "state", - "since", - "detail" + "name", + "content_type" ] } }, "required": [ - "channel_id", - "name", - "kind", - "mode", - "source", - "status", - "target", - "target_hint", - "secret_refs", - "secrets", - "rules", - "capabilities", - "ready", - "problem", - "failure_count", - "last_error", - "last_ok_at", - "last_failure_at", - "created_at", - "updated_at", - "stats", - "connection" + "method", + "path", + "encoding", + "body", + "headers", + "file" ] } }, - "now": { - "type": "integer", - "minimum": 0 + "local_links": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "data", - "now" + "kind", + "mode", + "sample", + "capabilities", + "message", + "requests", + "local_links", + "notes" ] }, - "ChannelResponse": { + "ChannelPreviewRequest": { "type": "object", "properties": { - "channel": { + "channel_id": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, + "rules": { "type": "object", "properties": { - "channel_id": { - "type": "string", - "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" - }, - "name": { - "type": "string" + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } }, - "kind": { + "min_severity": { "type": "string", "enum": [ - "in-app", - "telegram", - "discord", - "ntfy", - "webhook", - "slack", - "pushover", - "teams", - "apprise", - "email" + "info", + "warn", + "error", + "critical" ] }, - "mode": { - "type": [ - "string", - "null" - ] + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 }, - "source": { - "type": "string", - "enum": [ - "db", - "startup" + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" ] }, - "status": { + "time_zone": { "type": "string", - "enum": [ - "active", - "paused", - "broken" - ] + "minLength": 1, + "maxLength": 64 }, - "target": { + "digest": { "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 2048 - } - }, - "target_hint": { - "type": "string" + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] }, - "secret_refs": { + "anomaly": { "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - } - }, - "secrets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "param": { - "type": "string" - }, - "env": { - "type": "string" - }, - "set": { - "type": "boolean" - } + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 }, - "required": [ - "param", - "env", - "set" - ] + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } } }, - "rules": { + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { "type": "object", "properties": { - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - } + "needs-you": { + "type": "boolean" }, - "min_severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] + "problems": { + "type": "boolean" }, - "sessions": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 + "wrap-ups": { + "type": "boolean" }, - "harness": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 32 - }, - "maxItems": 32 + "reports": { + "type": "boolean" }, - "quiet_hours": { - "type": "object", - "properties": { - "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "end": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "time_zone": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "required": [ - "start", - "end" - ] + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 }, - "content": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" - ] + "problems": { + "type": "integer", + "exclusiveMinimum": 0 }, - "images": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 }, - "mask_images": { + "reports": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "delete_when_resolved": { + "type": "object", + "properties": { + "needs-you": { "type": "boolean" }, - "ttl_ms": { - "type": "object", - "properties": { - "needs-you": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "problems": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "wrap-ups": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "reports": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "system": { - "type": "integer", - "exclusiveMinimum": 0 - } - } + "problems": { + "type": "boolean" }, - "delete_when_resolved": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + }, + "sample": { + "type": "string", + "enum": [ + "attention", + "attention-resolved", + "vault-confirm", + "tool-errors", + "crash", + "degraded", + "test", + "digest", + "anomaly" + ], + "default": "attention" + } + }, + "additionalProperties": false + }, + "DeliveriesPage": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "channel_id": { + "type": "string" + }, + "channel_name": { + "type": [ + "string", + "null" + ] + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test", + null + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "next_attempt_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" }, - "reports": { - "type": "boolean" + { + "type": "number" + } + ] + } + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "report": { + "type": [ + "object", + "null" + ], + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } }, - "system": { - "type": "boolean" - } - } - }, - "act_buttons": { - "type": "boolean" - }, - "allow_list": { - "type": "array", - "items": { + "required": [ + "since", + "until" + ] + }, + "time_zone": { "type": "string", "minLength": 1, "maxLength": 64 }, - "maxItems": 32 - } - } - }, - "capabilities": { - "type": [ - "object", - "null" - ], - "properties": { - "rich_blocks": { - "type": "boolean" - }, - "tables": { - "type": "boolean" - }, - "images": { - "type": "boolean" - }, - "act_buttons": { - "type": "boolean" - }, - "open_links": { - "type": "boolean" - }, - "edit": { - "type": "boolean" - }, - "delete": { - "type": "boolean" - }, - "replies": { - "type": "boolean" - }, - "delete_window_ms": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "max_title_chars": { - "type": "integer", - "minimum": 0 - }, - "max_text_chars": { - "type": "integer", - "minimum": 0 + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } }, - "max_buttons": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "rich_blocks", - "tables", - "images", - "act_buttons", - "open_links", - "edit", - "delete", - "replies", - "delete_window_ms", - "max_title_chars", - "max_text_chars", - "max_buttons" - ] - }, - "ready": { - "type": "boolean" - }, - "problem": { - "type": [ - "string", - "null" - ] - }, - "failure_count": { - "type": "integer", - "minimum": 0 + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] + } }, - "last_error": { + "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at", + "report" + ] + } + }, + "page": { + "type": "object", + "properties": { + "next_cursor": { "type": [ "string", - "null" - ] - }, - "last_ok_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "last_failure_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "created_at": { - "type": "integer", - "minimum": 0 - }, - "updated_at": { - "type": "integer", - "minimum": 0 - }, - "stats": { - "type": "object", - "properties": { - "sent_24h": { - "type": "integer", - "minimum": 0 - }, - "failed_24h": { - "type": "integer", - "minimum": 0 - }, - "suppressed_24h": { - "type": "integer", - "minimum": 0 - }, - "pending": { - "type": "integer", - "minimum": 0 - }, - "last_delivery_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "last_status": { - "type": [ - "string", - "null" - ], - "enum": [ - "pending", - "sending", - "sent", - "retrying", - "dead", - "suppressed", - "superseded", - null - ] - } - }, - "required": [ - "sent_24h", - "failed_24h", - "suppressed_24h", - "pending", - "last_delivery_at", - "last_status" - ] - }, - "connection": { - "type": [ - "object", - "null" - ], - "properties": { - "state": { - "type": "string", - "enum": [ - "connecting", - "connected", - "reconnecting", - "offline" - ] - }, - "since": { - "type": "integer", - "minimum": 0 - }, - "detail": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "state", - "since", - "detail" + "null" + ] + }, + "prev_cursor": { + "type": [ + "string", + "null" ] + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 } }, "required": [ - "channel_id", - "name", - "kind", - "mode", - "source", - "status", - "target", - "target_hint", - "secret_refs", - "secrets", - "rules", - "capabilities", - "ready", - "problem", - "failure_count", - "last_error", - "last_ok_at", - "last_failure_at", - "created_at", - "updated_at", - "stats", - "connection" - ] - } - }, - "required": [ - "channel" - ] - }, - "ChannelInput": { - "type": "object", - "properties": { - "name": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" - }, - "kind": { - "type": "string", - "enum": [ - "telegram", - "discord", - "ntfy", - "webhook" + "next_cursor", + "limit" ] }, - "mode": { - "type": [ - "string", - "null" - ], - "maxLength": 32 - }, - "target": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 2048 - }, - "default": {} - }, - "secret_refs": { + "facets": { "type": "object", "additionalProperties": { - "type": "string", - "maxLength": 256 - }, - "default": {} - }, - "rules": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - } - }, - "min_severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] - }, - "sessions": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - }, - "harness": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 32 - }, - "maxItems": 32 - }, - "quiet_hours": { + "type": "array", + "items": { "type": "object", "properties": { - "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "end": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] }, - "time_zone": { - "type": "string", - "minLength": 1, - "maxLength": 64 + "count": { + "type": "integer", + "minimum": 0 } }, "required": [ - "start", - "end" - ] - }, - "content": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" + "value", + "count" ] - }, - "images": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } - }, - "mask_images": { - "type": "boolean" - }, - "ttl_ms": { + } + } + }, + "applied": { + "type": "object", + "properties": { + "filters": { "type": "object", - "properties": { - "needs-you": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "problems": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "wrap-ups": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "reports": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "system": { - "type": "integer", - "exclusiveMinimum": 0 - } - } + "additionalProperties": {} }, - "delete_when_resolved": { + "sort": { "type": "object", "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" + "key": { + "type": "string" }, - "system": { - "type": "boolean" - } - } - }, - "act_buttons": { - "type": "boolean" - }, - "allow_list": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 + "dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } }, - "maxItems": 32 + "required": [ + "key", + "dir" + ] } }, - "default": {} + "required": [ + "filters", + "sort" + ] + }, + "meta": { + "type": "object", + "properties": { + "now": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "now" + ] } }, "required": [ - "name", - "kind" - ], - "additionalProperties": false + "data", + "page", + "applied", + "meta" + ] }, - "ChannelPreview": { + "DeliveryDetailResponse": { "type": "object", "properties": { - "kind": { - "type": "string", - "enum": [ - "telegram", - "discord", - "ntfy", - "webhook" - ] - }, - "mode": { - "type": [ - "string", - "null" - ] - }, - "sample": { - "type": "string", - "enum": [ - "attention", - "attention-resolved", - "vault-confirm", - "tool-errors", - "crash", - "degraded", - "test" - ] - }, - "capabilities": { + "delivery": { "type": "object", "properties": { - "rich_blocks": { - "type": "boolean" + "seq": { + "type": "integer", + "exclusiveMinimum": 0 }, - "tables": { - "type": "boolean" + "channel_id": { + "type": "string" }, - "images": { - "type": "boolean" + "channel_name": { + "type": [ + "string", + "null" + ] }, - "act_buttons": { - "type": "boolean" + "channel_kind": { + "type": [ + "string", + "null" + ] }, - "open_links": { - "type": "boolean" + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" }, - "edit": { - "type": "boolean" + "notification_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test", + null + ] }, - "delete": { - "type": "boolean" + "notification_title": { + "type": [ + "string", + "null" + ] }, - "replies": { - "type": "boolean" + "revision": { + "type": "integer", + "minimum": 1 }, - "delete_window_ms": { + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "next_attempt_at": { "type": [ "integer", "null" ], "minimum": 0 }, - "max_title_chars": { - "type": "integer", + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], "minimum": 0 }, - "max_text_chars": { + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "created_at": { "type": "integer", "minimum": 0 }, - "max_buttons": { + "updated_at": { "type": "integer", "minimum": 0 + }, + "report": { + "type": [ + "object", + "null" + ], + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] } }, "required": [ - "rich_blocks", - "tables", - "images", - "act_buttons", - "open_links", - "edit", - "delete", - "replies", - "delete_window_ms", - "max_title_chars", - "max_text_chars", - "max_buttons" + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at", + "report" ] }, "message": { - "type": "object", + "type": [ + "object", + "null" + ], "properties": { "schema": { "type": "number", @@ -10521,6 +15892,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -11645,6 +17017,54 @@ "type", "content" ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "chart" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "values": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 1, + "maxItems": 48 + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "step_ms": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "unit": { + "type": [ + "string", + "null" + ], + "maxLength": 24 + } + }, + "required": [ + "type", + "label", + "values", + "start", + "step_ms", + "unit" + ] } ] }, @@ -11854,6 +17274,50 @@ "level", "has_image" ] + }, + "report": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] } }, "required": [ @@ -11874,295 +17338,359 @@ "entities", "privacy" ] - }, - "requests": { + } + }, + "required": [ + "delivery", + "message" + ] + }, + "ChannelEnvResponse": { + "type": "object", + "properties": { + "vars": { "type": "array", "items": { "type": "object", "properties": { - "method": { - "type": "string" - }, - "path": { + "name": { "type": "string" }, - "encoding": { - "type": "string", - "enum": [ - "json", - "multipart", - "binary" - ] - }, - "body": { - "type": "object", - "additionalProperties": {} - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "file": { - "type": [ - "object", - "null" - ], - "properties": { - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - } - }, - "required": [ - "name", - "content_type" - ] + "set": { + "type": "boolean" } }, "required": [ - "method", - "path", - "encoding", - "body", - "headers", - "file" + "name", + "set" ] } + } + }, + "required": [ + "vars" + ] + }, + "TelegramConnectResponse": { + "type": "object", + "properties": { + "connect_id": { + "type": "string" + }, + "bot_username": { + "type": "string" + }, + "link": { + "type": "string" + }, + "group_link": { + "type": "string" + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "connect_id", + "bot_username", + "link", + "group_link", + "expires_at" + ] + }, + "TelegramConnectRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "token_env" + ], + "additionalProperties": false + }, + "TelegramConnectStatus": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "waiting", + "connected", + "expired", + "failed" + ] + }, + "chat": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "thread_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "title", + "type", + "thread_id" + ] + }, + "user": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "status", + "chat", + "user", + "error", + "expires_at" + ] + }, + "DiscordBotInfo": { + "type": "object", + "properties": { + "application_id": { + "type": "string" }, - "local_links": { - "type": "boolean" + "bot_id": { + "type": "string" }, - "notes": { + "bot_username": { + "type": "string" + }, + "invite_url": { + "type": "string" + }, + "guilds": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] } } }, "required": [ - "kind", - "mode", - "sample", - "capabilities", - "message", - "requests", - "local_links", - "notes" + "application_id", + "bot_id", + "bot_username", + "invite_url", + "guilds" ] }, - "ChannelPreviewRequest": { + "DiscordBotRequest": { "type": "object", "properties": { - "channel_id": { - "type": "string", - "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" - }, - "kind": { + "token_env": { "type": "string", - "enum": [ - "telegram", - "discord", - "ntfy", - "webhook" - ] - }, - "mode": { - "type": [ - "string", - "null" - ], - "maxLength": 32 - }, - "target": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 2048 - } - }, - "secret_refs": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 256 - } - }, - "rules": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - } - }, - "min_severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] - }, - "sessions": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - }, - "harness": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 32 + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "token_env" + ], + "additionalProperties": false + }, + "DiscordChannelsResponse": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "maxItems": 32 - }, - "quiet_hours": { - "type": "object", - "properties": { - "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "end": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "time_zone": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } + "name": { + "type": "string" }, - "required": [ - "start", - "end" - ] - }, - "content": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" - ] - }, - "images": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } - }, - "mask_images": { - "type": "boolean" - }, - "ttl_ms": { - "type": "object", - "properties": { - "needs-you": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "problems": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "wrap-ups": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "reports": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "system": { - "type": "integer", - "exclusiveMinimum": 0 - } - } - }, - "delete_when_resolved": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } - }, - "act_buttons": { - "type": "boolean" - }, - "allow_list": { - "type": "array", - "items": { + "type": { "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - } + "enum": [ + "text", + "announcement" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "type", + "category" + ] } + } + }, + "required": [ + "channels" + ] + }, + "DiscordChannelsRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, - "sample": { + "guild_id": { + "type": "string", + "pattern": "^\\d{15,21}$" + } + }, + "required": [ + "token_env", + "guild_id" + ], + "additionalProperties": false + }, + "DiscordConnectResponse": { + "type": "object", + "properties": { + "connect_id": { + "type": "string" + }, + "expires_at": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "connect_id", + "expires_at" + ] + }, + "DiscordConnectRequest": { + "type": "object", + "properties": { + "token_env": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "channel_id": { + "type": "string", + "pattern": "^\\d{15,21}$" + } + }, + "required": [ + "token_env", + "channel_id" + ], + "additionalProperties": false + }, + "DiscordConnectStatus": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": [ - "attention", - "attention-resolved", - "vault-confirm", - "tool-errors", - "crash", - "degraded", - "test" + "waiting", + "connected", + "expired", + "failed" + ] + }, + "user": { + "type": [ + "object", + "null" ], - "default": "attention" + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "type": "integer", + "minimum": 0 } }, - "additionalProperties": false + "required": [ + "status", + "user", + "error", + "expires_at" + ] }, - "DeliveriesPage": { + "ActionsPage": { "type": "object", "properties": { "data": { @@ -12174,44 +17702,23 @@ "type": "integer", "exclusiveMinimum": 0 }, + "at": { + "type": "integer", + "minimum": 0 + }, "channel_id": { "type": "string" }, "channel_name": { - "type": [ - "string", - "null" - ] + "type": "string" }, "channel_kind": { - "type": [ - "string", - "null" - ] + "type": "string" }, "notification_id": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "notification_kind": { "type": [ "string", "null" - ], - "enum": [ - "attention.requested", - "vault.confirm", - "vault.filled", - "session.finished", - "session.crashed", - "session.reaped", - "tool.errors", - "system.degraded", - "channel.broken", - "digest.daily", - "report.anomaly", - "test", - null ] }, "notification_title": { @@ -12220,104 +17727,62 @@ "null" ] }, - "revision": { - "type": "integer", - "minimum": 1 - }, - "op": { - "type": "string", - "enum": [ - "send", - "edit", - "delete" - ] - }, - "status": { - "type": "string", - "enum": [ - "pending", - "sending", - "sent", - "retrying", - "dead", - "suppressed", - "superseded" - ] + "action_id": { + "type": "string" }, - "reason": { + "action_label": { "type": [ "string", "null" ] }, - "attempts": { - "type": "integer", - "minimum": 0 + "op": { + "type": "string" }, - "next_attempt_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "actor": { + "type": "string" }, - "last_error": { + "actor_name": { "type": [ "string", "null" ] }, - "duration_ms": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "outcome": { + "type": "string", + "enum": [ + "done", + "failed", + "not_allowed", + "used", + "expired", + "stale", + "wrong_channel", + "disabled" + ] }, - "message_ref": { + "detail": { "type": [ - "object", + "string", "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - } - }, - "created_at": { - "type": "integer", - "minimum": 0 - }, - "updated_at": { - "type": "integer", - "minimum": 0 + ] } }, "required": [ "seq", + "at", "channel_id", "channel_name", "channel_kind", "notification_id", - "notification_kind", - "notification_title", - "revision", - "op", - "status", - "reason", - "attempts", - "next_attempt_at", - "last_error", - "duration_ms", - "message_ref", - "created_at", - "updated_at" + "notification_title", + "action_id", + "action_label", + "op", + "actor", + "actor_name", + "outcome", + "detail" ] } }, @@ -12373,75 +17838,352 @@ } ] }, - "count": { + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "value", + "count" + ] + } + } + }, + "applied": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "additionalProperties": {} + }, + "sort": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "key", + "dir" + ] + } + }, + "required": [ + "filters", + "sort" + ] + }, + "meta": { + "type": "object", + "properties": { + "now": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "now" + ] + } + }, + "required": [ + "data", + "page", + "applied", + "meta" + ] + }, + "ChannelPatch": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + }, + "mode": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + }, + "target": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 2048 + } + }, + "secret_refs": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, + "rules": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + } + }, + "min_severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "sessions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + }, + "harness": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "maxItems": 32 + }, + "quiet_hours": { + "type": "object", + "properties": { + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "start", + "end" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ] + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "type": [ + "number", + "null" + ], + "minimum": 1, + "maximum": 100 + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 10080 + }, + "blocked_spike": { + "type": [ + "number", + "null" + ], + "minimum": 1.5, + "maximum": 1000 + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "content": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "images": { + "type": "object", + "properties": { + "needs-you": { + "type": "boolean" + }, + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" + } + } + }, + "mask_images": { + "type": "boolean" + }, + "ttl_ms": { + "type": "object", + "properties": { + "needs-you": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "problems": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "wrap-ups": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "reports": { "type": "integer", - "minimum": 0 + "exclusiveMinimum": 0 + }, + "system": { + "type": "integer", + "exclusiveMinimum": 0 } - }, - "required": [ - "value", - "count" - ] - } - } - }, - "applied": { - "type": "object", - "properties": { - "filters": { - "type": "object", - "additionalProperties": {} + } }, - "sort": { + "delete_when_resolved": { "type": "object", "properties": { - "key": { - "type": "string" + "needs-you": { + "type": "boolean" }, - "dir": { - "type": "string", - "enum": [ - "asc", - "desc" - ] + "problems": { + "type": "boolean" + }, + "wrap-ups": { + "type": "boolean" + }, + "reports": { + "type": "boolean" + }, + "system": { + "type": "boolean" } + } + }, + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 }, - "required": [ - "key", - "dir" - ] - } - }, - "required": [ - "filters", - "sort" - ] - }, - "meta": { - "type": "object", - "properties": { - "now": { - "type": "integer", - "minimum": 0 + "maxItems": 32 } - }, - "required": [ - "now" - ] + } } }, - "required": [ - "data", - "page", - "applied", - "meta" - ] + "additionalProperties": false }, - "DeliveryDetailResponse": { + "ChannelTestResponse": { "type": "object", "properties": { + "ok": { + "type": "boolean" + }, "delivery": { - "type": "object", + "type": [ + "object", + "null" + ], "properties": { "seq": { "type": "integer", @@ -12462,164 +18204,15 @@ "null" ] }, - "notification_id": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "notification_kind": { - "type": [ - "string", - "null" - ], - "enum": [ - "attention.requested", - "vault.confirm", - "vault.filled", - "session.finished", - "session.crashed", - "session.reaped", - "tool.errors", - "system.degraded", - "channel.broken", - "digest.daily", - "report.anomaly", - "test", - null - ] - }, - "notification_title": { - "type": [ - "string", - "null" - ] - }, - "revision": { - "type": "integer", - "minimum": 1 - }, - "op": { - "type": "string", - "enum": [ - "send", - "edit", - "delete" - ] - }, - "status": { - "type": "string", - "enum": [ - "pending", - "sending", - "sent", - "retrying", - "dead", - "suppressed", - "superseded" - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "attempts": { - "type": "integer", - "minimum": 0 - }, - "next_attempt_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "last_error": { - "type": [ - "string", - "null" - ] - }, - "duration_ms": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "message_ref": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - } - }, - "created_at": { - "type": "integer", - "minimum": 0 - }, - "updated_at": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "seq", - "channel_id", - "channel_name", - "channel_kind", - "notification_id", - "notification_kind", - "notification_title", - "revision", - "op", - "status", - "reason", - "attempts", - "next_attempt_at", - "last_error", - "duration_ms", - "message_ref", - "created_at", - "updated_at" - ] - }, - "message": { - "type": [ - "object", - "null" - ], - "properties": { - "schema": { - "type": "number", - "enum": [ - 1 - ] - }, - "id": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "revision": { - "type": "integer", - "minimum": 1 - }, - "thread": { - "type": "string", - "minLength": 1, - "maxLength": 160 - }, - "kind": { + "notification_id": { "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "type": [ + "string", + "null" + ], "enum": [ "attention.requested", "vault.confirm", @@ -12631,799 +18224,1322 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", - "test" + "test", + null ] }, - "category": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" + "notification_title": { + "type": [ + "string", + "null" ] }, - "severity": { + "revision": { + "type": "integer", + "minimum": 1 + }, + "op": { "type": "string", "enum": [ - "info", - "warn", - "error", - "critical" + "send", + "edit", + "delete" ] }, - "state": { + "status": { "type": "string", "enum": [ - "open", - "acted", - "resolved", - "expired", - "final" + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" ] }, - "alert": { - "type": "boolean" - }, - "at": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0 - }, - "updated": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "created", - "updated" + "reason": { + "type": [ + "string", + "null" ] }, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 120 + "attempts": { + "type": "integer", + "minimum": 0 }, - "summary": { - "type": "string", - "maxLength": 240 + "next_attempt_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "blocks": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "content": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bold" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "italic" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "code" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "link" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" - } - }, - "required": [ - "type", - "text", - "path" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "time" - ] - }, - "at": { - "type": "integer", - "minimum": 0 - }, - "style": { - "type": "string", - "enum": [ - "relative", - "absolute" - ] - } - }, - "required": [ - "type", - "at", - "style" - ] - } - ] - }, - "maxItems": 64 - } - }, - "required": [ - "type", - "content" - ] + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "heading" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } + "type": "number" + } + ] + } + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "report": { + "type": [ + "object", + "null" + ], + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 }, - "required": [ - "type", - "text" - ] + "until": { + "type": "integer", + "minimum": 0 + } }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "fields" - ] - }, - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "value": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bold" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "italic" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] + } + }, + "required": [ + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at", + "report" + ] + }, + "error": { + "type": [ + "object", + "null" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + } + }, + "required": [ + "ok", + "delivery", + "error" + ] + }, + "ChannelDigestResponse": { + "type": "object", + "properties": { + "preview": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "telegram", + "discord", + "ntfy", + "webhook" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "sample": { + "type": "string", + "enum": [ + "attention", + "attention-resolved", + "vault-confirm", + "tool-errors", + "crash", + "degraded", + "test", + "digest", + "anomaly" + ] + }, + "capabilities": { + "type": "object", + "properties": { + "rich_blocks": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "charts": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "act_buttons": { + "type": "boolean" + }, + "open_links": { + "type": "boolean" + }, + "edit": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "replies": { + "type": "boolean" + }, + "delete_window_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_title_chars": { + "type": "integer", + "minimum": 0 + }, + "max_text_chars": { + "type": "integer", + "minimum": 0 + }, + "max_buttons": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "rich_blocks", + "tables", + "charts", + "images", + "act_buttons", + "open_links", + "edit", + "delete", + "replies", + "delete_window_ms", + "max_title_chars", + "max_text_chars", + "max_buttons" + ] + }, + "message": { + "type": "object", + "properties": { + "schema": { + "type": "number", + "enum": [ + 1 + ] + }, + "id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "alert": { + "type": "boolean" + }, + "at": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "created", + "updated" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "summary": { + "type": "string", + "maxLength": 240 + }, + "blocks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] }, - "required": [ - "type", - "text" - ] + "text": { + "type": "string", + "maxLength": 4000 + } }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "code" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] }, - "required": [ - "type", - "text" - ] + "text": { + "type": "string", + "maxLength": 4000 + } }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "link" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" - } + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] }, - "required": [ - "type", - "text", - "path" - ] + "text": { + "type": "string", + "maxLength": 4000 + } }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "time" - ] - }, - "at": { - "type": "integer", - "minimum": 0 - }, - "style": { - "type": "string", - "enum": [ - "relative", - "absolute" - ] - } + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] }, - "required": [ - "type", - "at", - "style" - ] - } - ] - }, - "maxItems": 64 - } - }, - "required": [ - "label", - "value" - ] - }, - "minItems": 1, - "maxItems": 12 - } - }, - "required": [ - "type", - "items" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "quote" - ] - }, - "content": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bold" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" ] }, - "text": { - "type": "string", - "maxLength": 4000 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] } - }, - "required": [ - "type", - "text" ] }, - { + "maxItems": 64 + } + }, + "required": [ + "type", + "content" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "heading" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "fields" + ] + }, + "items": { + "type": "array", + "items": { "type": "object", "properties": { - "type": { + "label": { "type": "string", - "enum": [ - "italic" - ] + "minLength": 1, + "maxLength": 40 }, - "text": { - "type": "string", - "maxLength": 4000 + "value": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] + } + ] + }, + "maxItems": 64 } }, "required": [ - "type", - "text" + "label", + "value" ] }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "code" + "minItems": 1, + "maxItems": 12 + } + }, + "required": [ + "type", + "items" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "quote" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "link" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" - } - }, - "required": [ - "type", - "text", - "path" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "time" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "at": { - "type": "integer", - "minimum": 0 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] }, - "style": { - "type": "string", - "enum": [ - "relative", - "absolute" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" ] } - }, - "required": [ - "type", - "at", - "style" ] - } - ] + }, + "maxItems": 64 + }, + "collapsible": { + "type": "boolean" + } }, - "maxItems": 64 - }, - "collapsible": { - "type": "boolean" - } - }, - "required": [ - "type", - "content", - "collapsible" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "list" + "required": [ + "type", + "content", + "collapsible" ] }, - "ordered": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "type": "array", + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "ordered": { + "type": "boolean" + }, "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bold" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "italic" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "code" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "link" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" - } - }, - "required": [ - "type", - "text", - "path" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "time" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" ] }, - "at": { - "type": "integer", - "minimum": 0 - }, - "style": { - "type": "string", - "enum": [ - "relative", - "absolute" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" ] } - }, - "required": [ - "type", - "at", - "style" ] - } - ] - }, - "maxItems": 64 + }, + "maxItems": 64 + }, + "minItems": 1, + "maxItems": 20 + } }, - "minItems": 1, - "maxItems": 20 - } - }, - "required": [ - "type", - "ordered", - "items" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "table" + "required": [ + "type", + "ordered", + "items" ] }, - "columns": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "minItems": 1, - "maxItems": 8 - }, - "rows": { - "type": "array", - "items": { - "type": "array", - "items": { + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "table" + ] + }, + "columns": { "type": "array", "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bold" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bold" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" ] }, - "text": { - "type": "string", - "maxLength": 4000 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "italic" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ] } - }, - "required": [ - "type", - "text" ] }, + "maxItems": 64 + } + }, + "maxItems": 20 + } + }, + "required": [ + "type", + "columns", + "rows" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "alt": { + "type": "string", + "maxLength": 4000 + }, + "captured_at": { + "type": "integer", + "minimum": 0 + }, + "masked": { + "type": "boolean" + }, + "path": { + "type": [ + "string", + "null" + ], + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "ref", + "alt", + "captured_at", + "masked", + "path" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "language": { + "type": [ + "string", + "null" + ], + "maxLength": 32 + } + }, + "required": [ + "type", + "text", + "language" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "divider" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "footer" + ] + }, + "content": { + "type": "array", + "items": { + "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "enum": [ - "italic" + "text" ] }, "text": { @@ -13442,7 +19558,7 @@ "type": { "type": "string", "enum": [ - "code" + "bold" ] }, "text": { @@ -13461,23 +19577,17 @@ "type": { "type": "string", "enum": [ - "link" + "italic" ] }, "text": { "type": "string", "maxLength": 4000 - }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" } }, "required": [ "type", - "text", - "path" + "text" ] }, { @@ -13486,361 +19596,263 @@ "type": { "type": "string", "enum": [ - "time" + "code" ] }, - "at": { - "type": "integer", - "minimum": 0 - }, - "style": { + "text": { "type": "string", - "enum": [ - "relative", - "absolute" - ] + "maxLength": 4000 } }, "required": [ - "type", - "at", - "style" - ] - } - ] - }, - "maxItems": 64 - } - }, - "maxItems": 20 - } - }, - "required": [ - "type", - "columns", - "rows" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "image" - ] - }, - "ref": { - "type": "string", - "minLength": 1, - "maxLength": 512 - }, - "alt": { - "type": "string", - "maxLength": 4000 - }, - "captured_at": { - "type": "integer", - "minimum": 0 - }, - "masked": { - "type": "boolean" - }, - "path": { - "type": [ - "string", - "null" - ], - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" - } - }, - "required": [ - "type", - "ref", - "alt", - "captured_at", - "masked", - "path" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "code" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - }, - "language": { - "type": [ - "string", - "null" - ], - "maxLength": 32 - } - }, - "required": [ - "type", - "text", - "language" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "divider" - ] - } - }, - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "footer" - ] - }, - "content": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "bold" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "italic" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "code" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - } - }, - "required": [ - "type", - "text" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "link" - ] - }, - "text": { - "type": "string", - "maxLength": 4000 - }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" - } - }, - "required": [ - "type", - "text", - "path" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "time" + "type", + "text" ] }, - "at": { - "type": "integer", - "minimum": 0 + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ] }, - "style": { - "type": "string", - "enum": [ - "relative", - "absolute" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "time" + ] + }, + "at": { + "type": "integer", + "minimum": 0 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" ] } - }, - "required": [ - "type", - "at", - "style" ] - } - ] + }, + "maxItems": 64 + } }, - "maxItems": 64 - } - }, - "required": [ - "type", - "content" - ] - } - ] - }, - "maxItems": 50 - }, - "actions": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": [ - "act" + "required": [ + "type", + "content" ] }, - "id": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]{0,31}$", - "description": "Stable within the message (`resolve`, `open-session`)." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "style": { - "type": "string", - "enum": [ - "primary", - "danger", - "default" + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "chart" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "values": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + }, + "minItems": 1, + "maxItems": 48 + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "step_ms": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "unit": { + "type": [ + "string", + "null" + ], + "maxLength": 24 + } + }, + "required": [ + "type", + "label", + "values", + "start", + "step_ms", + "unit" ] - }, - "command": { + } + ] + }, + "maxItems": 50 + }, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { "type": "object", "properties": { - "op": { + "kind": { "type": "string", "enum": [ - "attention.resolve", - "vault.confirm.resolve", - "session.extend_lease", - "session.close" + "act" ] }, - "args": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, + "command": { "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "string", - "maxLength": 256 - }, - { - "type": "number" - }, - { - "type": "boolean" + "properties": { + "op": { + "type": "string", + "enum": [ + "attention.resolve", + "vault.confirm.resolve", + "session.extend_lease", + "session.close" + ] + }, + "args": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] } - ] - } + } + }, + "required": [ + "op", + "args" + ] + }, + "confirm": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "fallback": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "label", + "path" + ] } }, "required": [ - "op", - "args" + "kind", + "id", + "label", + "style", + "command", + "confirm", + "fallback" ] }, - "confirm": { - "type": [ - "string", - "null" - ], - "maxLength": 240 - }, - "fallback": { + { "type": "object", "properties": { + "kind": { + "type": "string", + "enum": [ + "open" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "description": "Stable within the message (`resolve`, `open-session`)." + }, "label": { "type": "string", "minLength": 1, "maxLength": 40 }, + "style": { + "type": "string", + "enum": [ + "primary", + "danger", + "default" + ] + }, "path": { "type": "string", "maxLength": 2048, @@ -13848,1186 +19860,1068 @@ } }, "required": [ + "kind", + "id", "label", + "style", "path" ] } + ] + }, + "maxItems": 5 + }, + "entities": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "maxLength": 128 + }, + "session_slug": { + "type": "string", + "maxLength": 64 + }, + "harness": { + "type": "string", + "maxLength": 64 + }, + "owner": { + "type": "string", + "maxLength": 128 + }, + "tool": { + "type": "string", + "maxLength": 64 + }, + "error_code": { + "type": "string", + "maxLength": 64 + }, + "domain": { + "type": "string", + "maxLength": 253 + }, + "request_id": { + "type": "string", + "maxLength": 64 + } + } + }, + "privacy": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "has_image": { + "type": "boolean" + } + }, + "required": [ + "level", + "has_image" + ] + }, + "report": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] }, - "required": [ - "kind", - "id", - "label", - "style", - "command", - "confirm", - "fallback" + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] + } + }, + "required": [ + "schema", + "id", + "revision", + "thread", + "kind", + "category", + "severity", + "state", + "alert", + "at", + "title", + "summary", + "blocks", + "actions", + "entities", + "privacy" + ] + }, + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string" + }, + "path": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "json", + "multipart", + "binary" ] }, - { + "body": { + "type": "object", + "additionalProperties": {} + }, + "headers": { "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "file": { + "type": [ + "object", + "null" + ], "properties": { - "kind": { - "type": "string", - "enum": [ - "open" - ] - }, - "id": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]{0,31}$", - "description": "Stable within the message (`resolve`, `open-session`)." - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 40 - }, - "style": { - "type": "string", - "enum": [ - "primary", - "danger", - "default" - ] + "name": { + "type": "string" }, - "path": { - "type": "string", - "maxLength": 2048, - "pattern": "^\\/(?!\\/)\\S*$" + "content_type": { + "type": "string" } }, "required": [ - "kind", - "id", - "label", - "style", - "path" + "name", + "content_type" ] } - ] - }, - "maxItems": 5 - }, - "entities": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "maxLength": 128 - }, - "session_slug": { - "type": "string", - "maxLength": 64 - }, - "harness": { - "type": "string", - "maxLength": 64 - }, - "owner": { - "type": "string", - "maxLength": 128 - }, - "tool": { - "type": "string", - "maxLength": 64 - }, - "error_code": { - "type": "string", - "maxLength": 64 - }, - "domain": { - "type": "string", - "maxLength": 253 - }, - "request_id": { - "type": "string", - "maxLength": 64 - } - } - }, - "privacy": { - "type": "object", - "properties": { - "level": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" - ] }, - "has_image": { - "type": "boolean" - } - }, - "required": [ - "level", - "has_image" - ] - } - }, - "required": [ - "schema", - "id", - "revision", - "thread", - "kind", - "category", - "severity", - "state", - "alert", - "at", - "title", - "summary", - "blocks", - "actions", - "entities", - "privacy" - ] - } - }, - "required": [ - "delivery", - "message" - ] - }, - "ChannelEnvResponse": { - "type": "object", - "properties": { - "vars": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "set": { - "type": "boolean" + "required": [ + "method", + "path", + "encoding", + "body", + "headers", + "file" + ] } }, - "required": [ - "name", - "set" - ] - } - } - }, - "required": [ - "vars" - ] - }, - "TelegramConnectResponse": { - "type": "object", - "properties": { - "connect_id": { - "type": "string" - }, - "bot_username": { - "type": "string" - }, - "link": { - "type": "string" - }, - "group_link": { - "type": "string" - }, - "expires_at": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "connect_id", - "bot_username", - "link", - "group_link", - "expires_at" - ] - }, - "TelegramConnectRequest": { - "type": "object", - "properties": { - "token_env": { - "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - } - }, - "required": [ - "token_env" - ], - "additionalProperties": false - }, - "TelegramConnectStatus": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "waiting", - "connected", - "expired", - "failed" + "local_links": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "kind", + "mode", + "sample", + "capabilities", + "message", + "requests", + "local_links", + "notes" ] }, - "chat": { - "type": [ - "object", - "null" - ], + "window": { + "type": "object", "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - }, - "type": { - "type": "string" + "since": { + "type": "integer", + "minimum": 0 }, - "thread_id": { - "type": [ - "string", - "null" - ] + "until": { + "type": "integer", + "minimum": 0 } }, "required": [ - "id", - "title", - "type", - "thread_id" + "since", + "until" ] }, - "user": { + "empty": { + "type": "boolean" + }, + "sent": { + "type": "boolean" + }, + "ok": { + "type": "boolean" + }, + "delivery": { "type": [ "object", "null" ], "properties": { - "id": { - "type": "string" + "seq": { + "type": "integer", + "exclusiveMinimum": 0 }, - "name": { + "channel_id": { "type": "string" + }, + "channel_name": { + "type": [ + "string", + "null" + ] + }, + "channel_kind": { + "type": [ + "string", + "null" + ] + }, + "notification_id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "notification_kind": { + "type": [ + "string", + "null" + ], + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "digest.weekly", + "report.anomaly", + "test", + null + ] + }, + "notification_title": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "op": { + "type": "string", + "enum": [ + "send", + "edit", + "delete" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sending", + "sent", + "retrying", + "dead", + "suppressed", + "superseded" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "next_attempt_at": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "duration_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "message_ref": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "created_at": { + "type": "integer", + "minimum": 0 + }, + "updated_at": { + "type": "integer", + "minimum": 0 + }, + "report": { + "type": [ + "object", + "null" + ], + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ] } }, "required": [ - "id", - "name" - ] - }, - "error": { - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "status", - "chat", - "user", - "error", - "expires_at" - ] - }, - "DiscordBotInfo": { - "type": "object", - "properties": { - "application_id": { - "type": "string" - }, - "bot_id": { - "type": "string" - }, - "bot_username": { - "type": "string" - }, - "invite_url": { - "type": "string" + "seq", + "channel_id", + "channel_name", + "channel_kind", + "notification_id", + "notification_kind", + "notification_title", + "revision", + "op", + "status", + "reason", + "attempts", + "next_attempt_at", + "last_error", + "duration_ms", + "message_ref", + "created_at", + "updated_at", + "report" + ] }, - "guilds": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } + "error": { + "type": [ + "object", + "null" + ], + "properties": { + "code": { + "type": "string" }, - "required": [ - "id", - "name" - ] - } + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] } }, "required": [ - "application_id", - "bot_id", - "bot_username", - "invite_url", - "guilds" + "preview", + "window", + "empty", + "sent", + "ok", + "delivery", + "error" ] }, - "DiscordBotRequest": { + "ChannelDigestRequest": { "type": "object", "properties": { - "token_env": { - "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + "send": { + "type": "boolean", + "default": false } }, - "required": [ - "token_env" - ], "additionalProperties": false }, - "DiscordChannelsResponse": { + "SearchResponse": { "type": "object", "properties": { - "channels": { + "sessions": { "type": "array", "items": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "type": { + "session_id": { "type": "string", - "enum": [ - "text", - "announcement" - ] + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "category": { - "type": [ - "string", - "null" - ] + "slug": { + "type": "string" } }, "required": [ - "id", - "name", - "type", - "category" + "session_id", + "slug" ] } + }, + "tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "vault_handles": { + "type": "array", + "items": { + "type": "string" + } + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ - "channels" + "sessions", + "tools", + "vault_handles", + "patterns" ] }, - "DiscordChannelsRequest": { + "ClientErrorReport": { "type": "object", "properties": { - "token_env": { + "message": { "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + "minLength": 1, + "maxLength": 2000 }, - "guild_id": { + "stack": { "type": "string", - "pattern": "^\\d{15,21}$" - } - }, - "required": [ - "token_env", - "guild_id" - ], - "additionalProperties": false - }, - "DiscordConnectResponse": { - "type": "object", - "properties": { - "connect_id": { - "type": "string" + "maxLength": 16000 }, - "expires_at": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "connect_id", - "expires_at" - ] - }, - "DiscordConnectRequest": { - "type": "object", - "properties": { - "token_env": { + "route": { "type": "string", - "maxLength": 128, - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + "maxLength": 512 }, - "channel_id": { + "user_agent": { "type": "string", - "pattern": "^\\d{15,21}$" + "maxLength": 512 + }, + "build": { + "type": "string", + "maxLength": 128 } }, "required": [ - "token_env", - "channel_id" + "message", + "route", + "user_agent", + "build" ], "additionalProperties": false - }, - "DiscordConnectStatus": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "waiting", - "connected", - "expired", - "failed" - ] + } + }, + "parameters": {} + }, + "paths": { + "/api/v1/health": { + "get": { + "operationId": "getHealth", + "tags": [ + "health" + ], + "summary": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "security": [], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } }, - "user": { - "type": [ - "object", - "null" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } - }, - "required": [ - "id", - "name" - ] + } }, - "error": { - "type": [ - "string", - "null" - ] + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "expires_at": { - "type": "integer", - "minimum": 0 + "503": { + "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } } - }, - "required": [ - "status", - "user", - "error", - "expires_at" - ] - }, - "ActionsPage": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "seq": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "at": { - "type": "integer", - "minimum": 0 - }, - "channel_id": { - "type": "string" - }, - "channel_name": { - "type": "string" - }, - "channel_kind": { - "type": "string" - }, - "notification_id": { - "type": [ - "string", - "null" - ] - }, - "notification_title": { - "type": [ - "string", - "null" - ] - }, - "action_id": { - "type": "string" - }, - "action_label": { - "type": [ - "string", - "null" - ] - }, - "op": { - "type": "string" - }, - "actor": { - "type": "string" - }, - "actor_name": { - "type": [ - "string", - "null" - ] - }, - "outcome": { + } + } + }, + "/api/v1/openapi.json": { + "get": { + "operationId": "getOpenApi", + "tags": [ + "meta" + ], + "summary": "This OpenAPI 3.1 document.", + "security": [], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "OpenAPI 3.1.", + "content": { + "application/json": { + "schema": { "type": "string", - "enum": [ - "done", - "failed", - "not_allowed", - "used", - "expired", - "stale", - "wrong_channel", - "disabled" - ] - }, - "detail": { - "type": [ - "string", - "null" - ] + "format": "binary" } - }, - "required": [ - "seq", - "at", - "channel_id", - "channel_name", - "channel_kind", - "notification_id", - "notification_title", - "action_id", - "action_label", - "op", - "actor", - "actor_name", - "outcome", - "detail" - ] + } } }, - "page": { - "type": "object", - "properties": { - "next_cursor": { - "type": [ - "string", - "null" - ] - }, - "prev_cursor": { - "type": [ - "string", - "null" - ] - }, - "limit": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "total": { - "type": "integer", - "minimum": 0 + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } - }, - "required": [ - "next_cursor", - "limit" - ] + } }, - "facets": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "count": { - "type": "integer", - "minimum": 0 - } - }, - "required": [ - "value", - "count" - ] + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } } - }, - "applied": { - "type": "object", - "properties": { - "filters": { - "type": "object", - "additionalProperties": {} - }, - "sort": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "dir": { - "type": "string", - "enum": [ - "asc", - "desc" - ] - } - }, - "required": [ - "key", - "dir" - ] + } + } + } + }, + "/api/v1/docs": { + "get": { + "operationId": "getDocs", + "tags": [ + "meta" + ], + "summary": "API reference UI (admin surface only).", + "security": [], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Reference UI.", + "content": { + "text/html": { + "schema": { + "type": "string", + "format": "binary" + } } - }, - "required": [ - "filters", - "sort" - ] + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "meta": { - "type": "object", - "properties": { - "now": { - "type": "integer", - "minimum": 0 + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } - }, - "required": [ - "now" - ] + } } - }, - "required": [ - "data", - "page", - "applied", - "meta" - ] - }, - "ChannelPatch": { - "type": "object", - "properties": { - "name": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" + } + } + }, + "/api/v1/ws": { + "get": { + "operationId": "wsUpgrade", + "tags": [ + "realtime" + ], + "summary": "Realtime WebSocket (`Sec-WebSocket-Protocol: browserhive.v1`). Auth failures upgrade then close 4401; see the WS protocol.", + "security": [ + { + "cookieAuth": [] }, - "mode": { - "type": [ - "string", - "null" - ], - "maxLength": 32 + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "101": { + "description": "Switching protocols." }, - "target": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 2048 + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "secret_refs": { - "type": "object", - "additionalProperties": { - "type": "string", - "maxLength": 256 + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "rules": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "needs-you", - "problems", - "wrap-ups", - "reports", - "system" - ] - } - }, - "min_severity": { - "type": "string", - "enum": [ - "info", - "warn", - "error", - "critical" - ] - }, - "sessions": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 - }, - "harness": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 32 - }, - "maxItems": 32 - }, - "quiet_hours": { - "type": "object", - "properties": { - "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "end": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" - }, - "time_zone": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "required": [ - "start", - "end" - ] - }, - "content": { - "type": "string", - "enum": [ - "counts", - "titles", - "full" - ] - }, - "images": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } - } - }, - "mask_images": { - "type": "boolean" - }, - "ttl_ms": { - "type": "object", - "properties": { - "needs-you": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "problems": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "wrap-ups": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "reports": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "system": { - "type": "integer", - "exclusiveMinimum": 0 - } + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } - }, - "delete_when_resolved": { - "type": "object", - "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" - }, - "reports": { - "type": "boolean" - }, - "system": { - "type": "boolean" - } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } - }, - "act_buttons": { - "type": "boolean" - }, - "allow_list": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "login", + "tags": [ + "auth" + ], + "summary": "Log in with the operator password; sets the session cookie.", + "security": [], + "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" } } } }, - "additionalProperties": false - }, - "ChannelTestResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" + "responses": { + "200": { + "description": "Log in with the operator password; sets the session cookie.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } }, - "delivery": { - "type": [ - "object", - "null" - ], - "properties": { - "seq": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "channel_id": { - "type": "string" - }, - "channel_name": { - "type": [ - "string", - "null" - ] - }, - "channel_kind": { - "type": [ - "string", - "null" - ] - }, - "notification_id": { - "type": "string", - "pattern": "^n-[A-Za-z0-9_-]{12}$" - }, - "notification_kind": { - "type": [ - "string", - "null" - ], - "enum": [ - "attention.requested", - "vault.confirm", - "vault.filled", - "session.finished", - "session.crashed", - "session.reaped", - "tool.errors", - "system.degraded", - "channel.broken", - "digest.daily", - "report.anomaly", - "test", - null - ] - }, - "notification_title": { - "type": [ - "string", - "null" - ] - }, - "revision": { - "type": "integer", - "minimum": 1 - }, - "op": { - "type": "string", - "enum": [ - "send", - "edit", - "delete" - ] - }, - "status": { - "type": "string", - "enum": [ - "pending", - "sending", - "sent", - "retrying", - "dead", - "suppressed", - "superseded" - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - }, - "attempts": { - "type": "integer", - "minimum": 0 - }, - "next_attempt_at": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "last_error": { - "type": [ - "string", - "null" - ] - }, - "duration_ms": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "message_ref": { - "type": [ - "object", - "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } - }, - "created_at": { - "type": "integer", - "minimum": 0 - }, - "updated_at": { - "type": "integer", - "minimum": 0 } - }, - "required": [ - "seq", - "channel_id", - "channel_name", - "channel_kind", - "notification_id", - "notification_kind", - "notification_title", - "revision", - "op", - "status", - "reason", - "attempts", - "next_attempt_at", - "last_error", - "duration_ms", - "message_ref", - "created_at", - "updated_at" - ] + } }, - "error": { - "type": [ - "object", - "null" - ], - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" + "401": { + "description": "INVALID_CREDENTIALS", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } } - }, - "required": [ - "code", - "message" - ] + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logout", + "tags": [ + "auth" + ], + "summary": "Destroy the current session and clear the cookie.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] } - }, - "required": [ - "ok", - "delivery", - "error" - ] - }, - "SearchResponse": { - "type": "object", - "properties": { - "sessions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "slug": { - "type": "string" + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "Destroy the current session and clear the cookie.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveSessionResponse" } - }, - "required": [ - "session_id", - "slug" - ] + } } }, - "tools": { - "type": "array", - "items": { - "type": "string" + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "vault_handles": { - "type": "array", - "items": { - "type": "string" + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } }, - "patterns": { - "type": "array", - "items": { - "type": "string" + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } } } - }, - "required": [ - "sessions", - "tools", - "vault_handles", - "patterns" - ] - }, - "ClientErrorReport": { - "type": "object", - "properties": { - "message": { - "type": "string", - "minLength": 1, - "maxLength": 2000 + } + } + }, + "/api/v1/auth/me": { + "get": { + "operationId": "getMe", + "tags": [ + "auth" + ], + "summary": "The authenticated principal.", + "security": [ + { + "cookieAuth": [] }, - "stack": { - "type": "string", - "maxLength": 16000 + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": null, + "responses": { + "200": { + "description": "The authenticated principal.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } }, - "route": { - "type": "string", - "maxLength": 512 + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "user_agent": { - "type": "string", - "maxLength": 512 + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - "build": { - "type": "string", - "maxLength": 128 + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } - }, - "required": [ - "message", - "route", - "user_agent", - "build" - ], - "additionalProperties": false + } } }, - "parameters": {} - }, - "paths": { - "/api/v1/health": { - "get": { - "operationId": "getHealth", + "/api/v1/auth/change-password": { + "post": { + "operationId": "changePassword", "tags": [ - "health" + "auth" + ], + "summary": "Change the operator password; revokes every other session.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], - "summary": "Liveness/readiness; 200 only when ready (also served at `/health`).", - "security": [], "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + } + }, "responses": { "200": { - "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "description": "Change the operator password; revokes every other session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HealthResponse" + "$ref": "#/components/schemas/ChangePasswordResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED, BAD_CURRENT_PASSWORD, WEAK_PASSWORD", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "429": { - "description": "RATE_LIMITED", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -15036,8 +20930,8 @@ } } }, - "500": { - "description": "INTERNAL_ERROR", + "429": { + "description": "RATE_LIMITED", "content": { "application/problem+json": { "schema": { @@ -15046,12 +20940,12 @@ } } }, - "503": { - "description": "Liveness/readiness; 200 only when ready (also served at `/health`).", + "500": { + "description": "INTERNAL_ERROR", "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/HealthResponse" + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15059,23 +20953,49 @@ } } }, - "/api/v1/openapi.json": { + "/api/v1/auth/sessions": { "get": { - "operationId": "getOpenApi", + "operationId": "listAuthSessions", "tags": [ - "meta" + "auth" + ], + "summary": "The caller's operator sessions.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], - "summary": "This OpenAPI 3.1 document.", - "security": [], "x-browserhive-scope": null, "responses": { "200": { - "description": "OpenAPI 3.1.", + "description": "The caller's operator sessions.", "content": { "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/AuthSessionList" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15103,23 +21023,81 @@ } } }, - "/api/v1/docs": { - "get": { - "operationId": "getDocs", + "/api/v1/auth/sessions/{id_prefix}": { + "delete": { + "operationId": "revokeAuthSession", "tags": [ - "meta" + "auth" + ], + "summary": "Revoke one operator session by id prefix.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], - "summary": "API reference UI (admin surface only).", - "security": [], "x-browserhive-scope": null, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 4, + "maxLength": 32 + }, + "required": true, + "name": "id_prefix", + "in": "path" + } + ], "responses": { "200": { - "description": "Reference UI.", + "description": "Revoke one operator session by id prefix.", "content": { - "text/html": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15147,13 +21125,13 @@ } } }, - "/api/v1/ws": { - "get": { - "operationId": "wsUpgrade", + "/api/v1/auth/sessions/revoke-all": { + "post": { + "operationId": "revokeAllAuthSessions", "tags": [ - "realtime" + "auth" ], - "summary": "Realtime WebSocket (`Sec-WebSocket-Protocol: browserhive.v1`). Auth failures upgrade then close 4401; see the WS protocol.", + "summary": "Revoke every session of the caller except the current one.", "security": [ { "cookieAuth": [] @@ -15164,8 +21142,15 @@ ], "x-browserhive-scope": null, "responses": { - "101": { - "description": "Switching protocols." + "200": { + "description": "Revoke every session of the caller except the current one.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeAllSessionsResponse" + } + } + } }, "401": { "description": "UNAUTHORIZED", @@ -15210,48 +21195,35 @@ } } }, - "/api/v1/auth/login": { - "post": { - "operationId": "login", + "/api/v1/auth/tokens": { + "get": { + "operationId": "listTokens", "tags": [ "auth" ], - "summary": "Log in with the operator password; sets the session cookie.", - "security": [], - "x-browserhive-scope": null, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } + "summary": "Issued API tokens (never the secret).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] } - }, + ], + "x-browserhive-scope": null, "responses": { "200": { - "description": "Log in with the operator password; sets the session cookie.", + "description": "Issued API tokens (never the secret).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ApiTokenList" } } } }, "401": { - "description": "INVALID_CREDENTIALS", + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -15260,8 +21232,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -15291,15 +21263,13 @@ } } } - } - }, - "/api/v1/auth/logout": { + }, "post": { - "operationId": "logout", + "operationId": "createToken", "tags": [ "auth" ], - "summary": "Destroy the current session and clear the cookie.", + "summary": "Issue an API token; the token is shown once.", "security": [ { "cookieAuth": [] @@ -15309,13 +21279,33 @@ } ], "x-browserhive-scope": null, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTokenRequest" + } + } + } + }, "responses": { "200": { - "description": "Destroy the current session and clear the cookie.", + "description": "Issue an API token; the token is shown once.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/CreateTokenResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15340,6 +21330,16 @@ } } }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -15363,13 +21363,13 @@ } } }, - "/api/v1/auth/me": { - "get": { - "operationId": "getMe", + "/api/v1/auth/tokens/{credential_id}": { + "delete": { + "operationId": "revokeToken", "tags": [ "auth" ], - "summary": "The authenticated principal.", + "summary": "Revoke an API token.", "security": [ { "cookieAuth": [] @@ -15379,13 +21379,35 @@ } ], "x-browserhive-scope": null, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": true, + "name": "credential_id", + "in": "path" + } + ], "responses": { "200": { - "description": "The authenticated principal.", + "description": "Revoke an API token.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MeResponse" + "$ref": "#/components/schemas/ArchiveSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15410,6 +21432,16 @@ } } }, + "404": { + "description": "NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -15433,13 +21465,13 @@ } } }, - "/api/v1/auth/change-password": { + "/api/v1/auth/grants": { "post": { - "operationId": "changePassword", + "operationId": "createGrant", "tags": [ "auth" ], - "summary": "Change the operator password; revokes every other session.", + "summary": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", "security": [ { "cookieAuth": [] @@ -15454,24 +21486,24 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" + "$ref": "#/components/schemas/CreateGrantRequest" } } } }, "responses": { "200": { - "description": "Change the operator password; revokes every other session.", + "description": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordResponse" + "$ref": "#/components/schemas/CreateGrantResponse" } } } }, "400": { - "description": "VALIDATION_FAILED, BAD_CURRENT_PASSWORD, WEAK_PASSWORD", + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -15533,29 +21565,261 @@ } } }, - "/api/v1/auth/sessions": { + "/api/v1/sessions": { "get": { - "operationId": "listAuthSessions", + "operationId": "listSessions", "tags": [ - "auth" + "sessions" ], - "summary": "The caller's operator sessions.", + "summary": "List sessions with facets; the live registry overlays stored rows.", "security": [ { - "cookieAuth": [] + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "created_at", + "slug", + "channel", + "last_activity_at", + "errors", + "lease_expires_at", + "closed_at", + "owner", + "persistence_mode", + "blocked", + "harness" + ], + "default": "created_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "reserved", + "launching", + "live", + "paused", + "draining", + "closed", + "crashed" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "state", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "all", + "live", + "closed", + "archived" + ], + "default": "all" + }, + "required": false, + "name": "view", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "exclude", + "include", + "only" + ], + "default": "exclude" + }, + "required": false, + "name": "archived", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "owner", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "chromium", + "chrome", + "edge" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "channel", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "memory", + "persistent", + "storage-state" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "persistence_mode", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "harness", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" }, { - "bearerAuth": [] + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" } ], - "x-browserhive-scope": null, "responses": { "200": { - "description": "The caller's operator sessions.", + "description": "List sessions with facets; the live registry overlays stored rows.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthSessionList" + "$ref": "#/components/schemas/SessionsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15603,13 +21867,13 @@ } } }, - "/api/v1/auth/sessions/{id_prefix}": { - "delete": { - "operationId": "revokeAuthSession", + "/api/v1/sessions/bulk": { + "post": { + "operationId": "bulkSessions", "tags": [ - "auth" + "sessions" ], - "summary": "Revoke one operator session by id prefix.", + "summary": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", "security": [ { "cookieAuth": [] @@ -15618,26 +21882,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:write", "parameters": [ { "schema": { "type": "string", - "minLength": 4, - "maxLength": 32 + "format": "uuid" }, "required": true, - "name": "id_prefix", - "in": "path" + "name": "idempotency-key", + "in": "header" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkSessionsRequest" + } + } + } + }, "responses": { "200": { - "description": "Revoke one operator session by id prefix.", + "description": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/BulkSessionsResponse" } } } @@ -15672,8 +21945,8 @@ } } }, - "404": { - "description": "NOT_FOUND", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -15705,13 +21978,13 @@ } } }, - "/api/v1/auth/sessions/revoke-all": { - "post": { - "operationId": "revokeAllAuthSessions", + "/api/v1/sessions/{session_id}": { + "get": { + "operationId": "getSession", "tags": [ - "auth" + "sessions" ], - "summary": "Revoke every session of the caller except the current one.", + "summary": "One session with trace/data-dir descriptors and counters (no embedded arrays).", "security": [ { "cookieAuth": [] @@ -15720,14 +21993,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], "responses": { "200": { - "description": "Revoke every session of the caller except the current one.", + "description": "One session with trace/data-dir descriptors and counters (no embedded arrays).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevokeAllSessionsResponse" + "$ref": "#/components/schemas/SessionDetail" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15752,6 +22046,16 @@ } } }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -15773,15 +22077,13 @@ } } } - } - }, - "/api/v1/auth/tokens": { - "get": { - "operationId": "listTokens", + }, + "delete": { + "operationId": "deleteSession", "tags": [ - "auth" + "sessions" ], - "summary": "Issued API tokens (never the secret).", + "summary": "Terminate if live, then delete rows and artifacts.", "security": [ { "cookieAuth": [] @@ -15790,14 +22092,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], "responses": { "200": { - "description": "Issued API tokens (never the secret).", + "description": "Terminate if live, then delete rows and artifacts.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiTokenList" + "$ref": "#/components/schemas/DeleteSessionResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -15822,6 +22145,16 @@ } } }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -15843,13 +22176,15 @@ } } } - }, + } + }, + "/api/v1/sessions/{session_id}/terminate": { "post": { - "operationId": "createToken", + "operationId": "terminateSession", "tags": [ - "auth" + "sessions" ], - "summary": "Issue an API token; the token is shown once.", + "summary": "Close a live session (operator reason).", "security": [ { "cookieAuth": [] @@ -15858,24 +22193,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTokenRequest" - } - } + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Issue an API token; the token is shown once.", + "description": "Close a live session (operator reason).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTokenResponse" + "$ref": "#/components/schemas/TerminateSessionResponse" } } } @@ -15910,8 +22246,18 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "SESSION_NOT_LIVE", "content": { "application/problem+json": { "schema": { @@ -15943,13 +22289,13 @@ } } }, - "/api/v1/auth/tokens/{credential_id}": { - "delete": { - "operationId": "revokeToken", + "/api/v1/sessions/{session_id}/archive": { + "post": { + "operationId": "archiveSession", "tags": [ - "auth" + "sessions" ], - "summary": "Revoke an API token.", + "summary": "Archive a finished session (exempt from retention).", "security": [ { "cookieAuth": [] @@ -15958,22 +22304,21 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, + "x-browserhive-scope": "sessions:write", "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 128 + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": true, - "name": "credential_id", + "name": "session_id", "in": "path" } ], "responses": { "200": { - "description": "Revoke an API token.", + "description": "Archive a finished session (exempt from retention).", "content": { "application/json": { "schema": { @@ -16012,8 +22357,18 @@ } } }, - "404": { - "description": "NOT_FOUND", + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "SESSION_LIVE", "content": { "application/problem+json": { "schema": { @@ -16045,13 +22400,13 @@ } } }, - "/api/v1/auth/grants": { + "/api/v1/sessions/{session_id}/unarchive": { "post": { - "operationId": "createGrant", + "operationId": "unarchiveSession", "tags": [ - "auth" + "sessions" ], - "summary": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", + "summary": "Unarchive a session.", "security": [ { "cookieAuth": [] @@ -16060,24 +22415,25 @@ "bearerAuth": [] } ], - "x-browserhive-scope": null, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateGrantRequest" - } - } + "x-browserhive-scope": "sessions:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" } - }, + ], "responses": { "200": { - "description": "Mint a single-use, 10-minute grant: `trace` → resource_id is the session id, `screenshot` → the event id.", + "description": "Unarchive a session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateGrantResponse" + "$ref": "#/components/schemas/ArchiveSessionResponse" } } } @@ -16112,8 +22468,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -16145,13 +22501,13 @@ } } }, - "/api/v1/sessions": { + "/api/v1/sessions/{session_id}/tool-calls": { "get": { - "operationId": "listSessions", + "operationId": "listSessionToolCalls", "tags": [ "sessions" ], - "summary": "List sessions with facets; the live registry overlays stored rows.", + "summary": "Tool calls of one session (`?expand=detail` adds args/result).", "security": [ { "cookieAuth": [] @@ -16162,6 +22518,15 @@ ], "x-browserhive-scope": "sessions:read", "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": true, + "name": "session_id", + "in": "path" + }, { "schema": { "type": "string", @@ -16210,19 +22575,10 @@ "schema": { "type": "string", "enum": [ - "created_at", - "slug", - "channel", - "last_activity_at", - "errors", - "lease_expires_at", - "closed_at", - "owner", - "persistence_mode", - "blocked", - "harness" + "ts", + "duration_ms" ], - "default": "created_at" + "default": "ts" }, "required": false, "name": "sort", @@ -16236,99 +22592,21 @@ ], "items": { "type": "string", - "enum": [ - "reserved", - "launching", - "live", - "paused", - "draining", - "closed", - "crashed" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "state", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "all", - "live", - "closed", - "archived" - ], - "default": "all" - }, - "required": false, - "name": "view", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "exclude", - "include", - "only" - ], - "default": "exclude" - }, - "required": false, - "name": "archived", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "owner", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "chromium", - "chrome", - "edge" - ] + "minLength": 1, + "maxLength": 64 }, "minItems": 1 }, "required": false, - "name": "channel", + "name": "tool", "in": "query" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "memory", - "persistent", - "storage-state" - ] - }, - "minItems": 1 + "type": "boolean" }, "required": false, - "name": "persistence_mode", + "name": "ok", "in": "query" }, { @@ -16345,7 +22623,7 @@ "minItems": 1 }, "required": false, - "name": "harness", + "name": "error_code", "in": "query" }, { @@ -16360,137 +22638,47 @@ }, { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "enum": [ + "detail" + ] }, "required": false, - "name": "since", + "name": "expand", "in": "query" }, { "schema": { "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - } - ], - "responses": { - "200": { - "description": "List sessions with facets; the live registry overlays stored rows.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionsPage" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/sessions/bulk": { - "post": { - "operationId": "bulkSessions", - "tags": [ - "sessions" - ], - "summary": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", - "security": [ - { - "cookieAuth": [] + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:write", - "parameters": [ { "schema": { - "type": "string", - "format": "uuid" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "idempotency-key", - "in": "header" + "required": false, + "name": "until", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkSessionsRequest" - } - } - } - }, "responses": { "200": { - "description": "Archive, unarchive, terminate or delete up to 100 sessions (per-item results).", + "description": "Tool calls of one session (`?expand=detail` adds args/result).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkSessionsResponse" + "$ref": "#/components/schemas/SessionToolCallsPage" } } } @@ -16525,8 +22713,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -16558,13 +22746,13 @@ } } }, - "/api/v1/sessions/{session_id}": { + "/api/v1/sessions/{session_id}/tool-calls/{event_id}": { "get": { - "operationId": "getSession", + "operationId": "getSessionToolCall", "tags": [ "sessions" ], - "summary": "One session with trace/data-dir descriptors and counters (no embedded arrays).", + "summary": "One tool call with args, result and its screenshot.", "security": [ { "cookieAuth": [] @@ -16583,15 +22771,24 @@ "required": true, "name": "session_id", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" + }, + "required": true, + "name": "event_id", + "in": "path" } ], "responses": { "200": { - "description": "One session with trace/data-dir descriptors and counters (no embedded arrays).", + "description": "One tool call with args, result and its screenshot.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionDetail" + "$ref": "#/components/schemas/ToolCallDetail" } } } @@ -16627,7 +22824,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "SESSION_NOT_FOUND, NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -16657,13 +22854,15 @@ } } } - }, - "delete": { - "operationId": "deleteSession", + } + }, + "/api/v1/sessions/{session_id}/pages": { + "get": { + "operationId": "listSessionPages", "tags": [ "sessions" ], - "summary": "Terminate if live, then delete rows and artifacts.", + "summary": "Pages visited by one session.", "security": [ { "cookieAuth": [] @@ -16672,7 +22871,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -16682,116 +22881,122 @@ "required": true, "name": "session_id", "in": "path" - } - ], - "responses": { - "200": { - "description": "Terminate if live, then delete rows and artifacts.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteSessionResponse" - } - } - } }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "ts" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/sessions/{session_id}/terminate": { - "post": { - "operationId": "terminateSession", - "tags": [ - "sessions" - ], - "summary": "Close a live session (operator reason).", - "security": [ { - "cookieAuth": [] + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "public", + "ip", + "local", + "ftp", + "other" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "category", + "in": "query" }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:write", - "parameters": [ + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "required": false, + "name": "domain", + "in": "query" + }, { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "pattern": "^t-[0-9a-z]{6}$" }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "tab_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" } ], "responses": { "200": { - "description": "Close a live session (operator reason).", + "description": "Pages visited by one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminateSessionResponse" + "$ref": "#/components/schemas/SessionPagesPage" } } } @@ -16836,16 +23041,6 @@ } } }, - "409": { - "description": "SESSION_NOT_LIVE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -16869,13 +23064,13 @@ } } }, - "/api/v1/sessions/{session_id}/archive": { - "post": { - "operationId": "archiveSession", + "/api/v1/sessions/{session_id}/attention": { + "get": { + "operationId": "listSessionAttention", "tags": [ "sessions" ], - "summary": "Archive a finished session (exempt from retention).", + "summary": "Attention requests of one session.", "security": [ { "cookieAuth": [] @@ -16884,7 +23079,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "attention:read", "parameters": [ { "schema": { @@ -16894,126 +23089,114 @@ "required": true, "name": "session_id", "in": "path" - } - ], - "responses": { - "200": { - "description": "Archive a finished session (exempt from retention).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" }, - "409": { - "description": "SESSION_LIVE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/sessions/{session_id}/unarchive": { - "post": { - "operationId": "unarchiveSession", - "tags": [ - "sessions" - ], - "summary": "Unarchive a session.", - "security": [ { - "cookieAuth": [] + "schema": { + "type": "string", + "enum": [ + "created_at", + "resolved_at", + "waited_ms" + ], + "default": "created_at" + }, + "required": false, + "name": "sort", + "in": "query" }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:write", - "parameters": [ + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "status", + "in": "query" + }, { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "takeover", + "notify" + ] + }, + "minItems": 1 }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "mode", + "in": "query" } ], "responses": { "200": { - "description": "Unarchive a session.", + "description": "Attention requests of one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/SessionAttentionPage" } } } @@ -17081,13 +23264,13 @@ } } }, - "/api/v1/sessions/{session_id}/tool-calls": { + "/api/v1/sessions/{session_id}/vault-access": { "get": { - "operationId": "listSessionToolCalls", + "operationId": "listSessionVaultAccess", "tags": [ "sessions" ], - "summary": "Tool calls of one session (`?expand=detail` adds args/result).", + "summary": "Vault access audit rows of one session.", "security": [ { "cookieAuth": [] @@ -17096,7 +23279,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "vault:read", "parameters": [ { "schema": { @@ -17156,7 +23339,9 @@ "type": "string", "enum": [ "ts", - "duration_ms" + "entry_name", + "result", + "session" ], "default": "ts" }, @@ -17172,38 +23357,59 @@ ], "items": { "type": "string", - "minLength": 1, - "maxLength": 64 + "enum": [ + "success", + "origin_mismatch", + "auth_failed", + "blocked", + "denied" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "result", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pass", + "fail", + "skipped" + ] }, "minItems": 1 }, "required": false, - "name": "tool", + "name": "origin_check", "in": "query" }, { "schema": { - "type": "boolean" + "type": "string", + "enum": [ + "on", + "off" + ] }, "required": false, - "name": "ok", + "name": "evaluate", "in": "query" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "error_code", + "name": "session_id", "in": "query" }, { @@ -17213,18 +23419,17 @@ "maxLength": 200 }, "required": false, - "name": "q", + "name": "entry_name", "in": "query" }, { "schema": { "type": "string", - "enum": [ - "detail" - ] + "minLength": 1, + "maxLength": 200 }, "required": false, - "name": "expand", + "name": "q", "in": "query" }, { @@ -17254,11 +23459,11 @@ ], "responses": { "200": { - "description": "Tool calls of one session (`?expand=detail` adds args/result).", + "description": "Vault access audit rows of one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionToolCallsPage" + "$ref": "#/components/schemas/VaultLogPage" } } } @@ -17326,123 +23531,13 @@ } } }, - "/api/v1/sessions/{session_id}/tool-calls/{event_id}": { - "get": { - "operationId": "getSessionToolCall", - "tags": [ - "sessions" - ], - "summary": "One tool call with args, result and its screenshot.", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" - }, - "required": true, - "name": "event_id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "One tool call with args, result and its screenshot.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolCallDetail" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "SESSION_NOT_FOUND, NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/sessions/{session_id}/pages": { + "/api/v1/sessions/{session_id}/blocked": { "get": { - "operationId": "listSessionPages", + "operationId": "listSessionBlocked", "tags": [ "sessions" ], - "summary": "Pages visited by one session.", + "summary": "Blocked requests of one session.", "security": [ { "cookieAuth": [] @@ -17451,7 +23546,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "blocklist:read", "parameters": [ { "schema": { @@ -17510,7 +23605,11 @@ "schema": { "type": "string", "enum": [ - "ts" + "ts", + "domain", + "pattern", + "session", + "source" ], "default": "ts" }, @@ -17518,6 +23617,35 @@ "name": "sort", "in": "query" }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "required": false, + "name": "pattern", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "required": false, + "name": "domain", + "in": "query" + }, { "schema": { "type": [ @@ -17527,56 +23655,58 @@ "items": { "type": "string", "enum": [ - "public", - "ip", - "local", - "ftp", - "other" + "tool", + "request" ] }, "minItems": 1 }, "required": false, - "name": "category", + "name": "source", "in": "query" }, { "schema": { "type": "string", "minLength": 1, - "maxLength": 253 + "maxLength": 200 }, "required": false, - "name": "domain", + "name": "q", "in": "query" }, { "schema": { - "type": "string", - "pattern": "^t-[0-9a-z]{6}$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "tab_id", + "name": "since", "in": "query" }, { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "q", + "name": "until", "in": "query" } ], "responses": { "200": { - "description": "Pages visited by one session.", + "description": "Blocked requests of one session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPagesPage" + "$ref": "#/components/schemas/BlockedAttemptsPage" } } } @@ -17644,13 +23774,13 @@ } } }, - "/api/v1/sessions/{session_id}/attention": { + "/api/v1/sessions/{session_id}/screenshots": { "get": { - "operationId": "listSessionAttention", + "operationId": "listSessionScreenshots", "tags": [ "sessions" ], - "summary": "Attention requests of one session.", + "summary": "Screenshots of one session (image URLs accept grants).", "security": [ { "cookieAuth": [] @@ -17659,7 +23789,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:read", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -17716,38 +23846,14 @@ }, { "schema": { - "type": "string", - "enum": [ - "created_at", - "resolved_at", - "waited_ms" - ], - "default": "created_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" + "type": "string", + "enum": [ + "ts" ], - "items": { - "type": "string", - "enum": [ - "pending", - "resolved", - "rejected", - "timeout", - "cancelled" - ] - }, - "minItems": 1 + "default": "ts" }, "required": false, - "name": "status", + "name": "sort", "in": "query" }, { @@ -17759,24 +23865,24 @@ "items": { "type": "string", "enum": [ - "takeover", - "notify" + "tool", + "trace" ] }, "minItems": 1 }, "required": false, - "name": "mode", + "name": "kind", "in": "query" } ], "responses": { "200": { - "description": "Attention requests of one session.", + "description": "Screenshots of one session (image URLs accept grants).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionAttentionPage" + "$ref": "#/components/schemas/ScreenshotsPage" } } } @@ -17844,13 +23950,13 @@ } } }, - "/api/v1/sessions/{session_id}/vault-access": { + "/api/v1/sessions/{session_id}/timeline": { "get": { - "operationId": "listSessionVaultAccess", + "operationId": "getSessionTimeline", "tags": [ "sessions" ], - "summary": "Vault access audit rows of one session.", + "summary": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", "security": [ { "cookieAuth": [] @@ -17859,7 +23965,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -17870,65 +23976,6 @@ "name": "session_id", "in": "path" }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "entry_name", - "result", - "session" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, { "schema": { "type": [ @@ -17938,112 +23985,192 @@ "items": { "type": "string", "enum": [ - "success", - "origin_mismatch", - "auth_failed", - "blocked", - "denied" + "tool", + "page", + "attention", + "vault", + "blocked" ] }, "minItems": 1 }, "required": false, - "name": "result", + "name": "kinds", "in": "query" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pass", - "fail", - "skipped" - ] - }, - "minItems": 1 + "type": "boolean", + "default": false }, "required": false, - "name": "origin_check", + "name": "errors_only", "in": "query" }, { "schema": { "type": "string", - "enum": [ - "on", - "off" - ] + "minLength": 1, + "maxLength": 200 }, "required": false, - "name": "evaluate", + "name": "q", "in": "query" }, { "schema": { "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" }, "required": false, - "name": "session_id", + "name": "cursor", "in": "query" }, { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 }, "required": false, - "name": "entry_name", + "name": "limit", "in": "query" + } + ], + "responses": { + "200": { + "description": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimelinePage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/sessions/{session_id}/screenshots/{event_id}": { + "get": { + "operationId": "getScreenshotImage", + "tags": [ + "sessions" + ], + "summary": "Screenshot bytes (cookie, bearer or `?grant=` for route `screenshot` = event id).", + "security": [ + { + "cookieAuth": [] }, + { + "bearerAuth": [] + }, + { + "grantAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 200 + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": false, - "name": "q", - "in": "query" + "required": true, + "name": "session_id", + "in": "path" }, { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "schema": { + "type": "string", + "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" }, - "required": false, - "name": "since", - "in": "query" + "required": true, + "name": "event_id", + "in": "path" }, { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "string", + "minLength": 1, + "maxLength": 256 }, "required": false, - "name": "until", + "name": "grant", "in": "query" } ], "responses": { "200": { - "description": "Vault access audit rows of one session.", + "description": "The image bytes.", "content": { - "application/json": { + "image/*": { "schema": { - "$ref": "#/components/schemas/VaultLogPage" + "type": "string", + "format": "binary" } } } @@ -18079,7 +24206,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "NOT_FOUND, SCREENSHOT_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -18111,22 +24238,25 @@ } } }, - "/api/v1/sessions/{session_id}/blocked": { + "/api/v1/sessions/{session_id}/trace.zip": { "get": { - "operationId": "listSessionBlocked", + "operationId": "getTraceZip", "tags": [ "sessions" ], - "summary": "Blocked requests of one session.", + "summary": "The session trace (single `Range` supported; `?grant=` for route `trace` = session id).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] + }, + { + "grantAuth": [] } ], - "x-browserhive-scope": "blocklist:read", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { @@ -18141,152 +24271,32 @@ "schema": { "type": "string", "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "domain", - "pattern", - "session", - "source" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 512 - }, - "required": false, - "name": "pattern", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253 - }, - "required": false, - "name": "domain", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "request" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "source", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "maxLength": 256 }, "required": false, - "name": "until", + "name": "grant", "in": "query" } ], "responses": { "200": { - "description": "Blocked requests of one session.", + "description": "Whole trace.", "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/BlockedAttemptsPage" + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "Requested byte range.", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" } } } @@ -18322,7 +24332,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -18331,6 +24341,17 @@ } } }, + "416": { + "description": "Range not satisfiable.", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -18352,117 +24373,54 @@ } } } - } - }, - "/api/v1/sessions/{session_id}/screenshots": { - "get": { - "operationId": "listSessionScreenshots", + }, + "head": { + "operationId": "headTraceZip", "tags": [ "sessions" ], - "summary": "Screenshots of one session (image URLs accept grants).", + "summary": "Trace size probe.", "security": [ { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": true, - "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" + "cookieAuth": [] }, { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" + "bearerAuth": [] }, + { + "grantAuth": [] + } + ], + "x-browserhive-scope": "sessions:read", + "parameters": [ { "schema": { "type": "string", - "enum": [ - "ts" - ], - "default": "ts" + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": false, - "name": "sort", - "in": "query" + "required": true, + "name": "session_id", + "in": "path" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "trace" - ] - }, - "minItems": 1 + "type": "string", + "minLength": 1, + "maxLength": 256 }, "required": false, - "name": "kind", + "name": "grant", "in": "query" } ], "responses": { "200": { - "description": "Screenshots of one session (image URLs accept grants).", + "description": "Headers only.", "content": { - "application/json": { + "application/zip": { "schema": { - "$ref": "#/components/schemas/ScreenshotsPage" + "type": "string", + "format": "binary" } } } @@ -18498,7 +24456,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND", + "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", "content": { "application/problem+json": { "schema": { @@ -18530,13 +24488,13 @@ } } }, - "/api/v1/sessions/{session_id}/timeline": { + "/api/v1/sessions/{session_id}/trace": { "get": { - "operationId": "getSessionTimeline", + "operationId": "getSessionTrace", "tags": [ "sessions" ], - "summary": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", + "summary": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", "security": [ { "cookieAuth": [] @@ -18555,78 +24513,15 @@ "required": true, "name": "session_id", "in": "path" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "page", - "attention", - "vault", - "blocked" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "kinds", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "errors_only", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" } ], "responses": { "200": { - "description": "Merged timeline of tool calls, pages, attention, vault and blocked rows.", + "description": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TimelinePage" + "$ref": "#/components/schemas/SessionTraceInfo" } } } @@ -18694,22 +24589,19 @@ } } }, - "/api/v1/sessions/{session_id}/screenshots/{event_id}": { - "get": { - "operationId": "getScreenshotImage", + "/api/v1/sessions/{session_id}/data-dir/reveal": { + "post": { + "operationId": "revealSessionDataDir", "tags": [ "sessions" ], - "summary": "Screenshot bytes (cookie, bearer or `?grant=` for route `screenshot` = event id).", + "summary": "Open the session's data directory in the host file manager (honest result).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] - }, - { - "grantAuth": [] } ], "x-browserhive-scope": "sessions:read", @@ -18722,35 +24614,15 @@ "required": true, "name": "session_id", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^e-[0-9A-HJKMNP-TV-Z]{26}$" - }, - "required": true, - "name": "event_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256 - }, - "required": false, - "name": "grant", - "in": "query" } ], "responses": { "200": { - "description": "The image bytes.", + "description": "Open the session's data directory in the host file manager (honest result).", "content": { - "image/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/RevealDataDirResponse" } } } @@ -18786,7 +24658,7 @@ } }, "404": { - "description": "NOT_FOUND, SCREENSHOT_UNAVAILABLE", + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -18818,22 +24690,19 @@ } } }, - "/api/v1/sessions/{session_id}/trace.zip": { + "/api/v1/sessions/{session_id}/export": { "get": { - "operationId": "getTraceZip", + "operationId": "exportSession", "tags": [ "sessions" ], - "summary": "The session trace (single `Range` supported; `?grant=` for route `trace` = session id).", + "summary": "Streamed timeline export (NDJSON or CSV by `Accept`), capped at 100k rows.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] - }, - { - "grantAuth": [] } ], "x-browserhive-scope": "sessions:read", @@ -18849,31 +24718,32 @@ }, { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tool", + "page", + "attention", + "vault", + "blocked" + ] + }, + "minItems": 1 }, "required": false, - "name": "grant", + "name": "kinds", "in": "query" } ], "responses": { "200": { - "description": "Whole trace.", - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "206": { - "description": "Requested byte range.", + "description": "kind,ts,id,data rows.", "content": { - "application/zip": { + "text/csv": { "schema": { "type": "string", "format": "binary" @@ -18912,7 +24782,7 @@ } }, "404": { - "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", + "description": "SESSION_NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -18921,13 +24791,12 @@ } } }, - "416": { - "description": "Range not satisfiable.", + "406": { + "description": "NOT_ACCEPTABLE", "content": { - "application/zip": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -18953,25 +24822,24 @@ } } } - }, - "head": { - "operationId": "headTraceZip", + } + }, + "/api/v1/sessions/{session_id}/viewport": { + "post": { + "operationId": "setSessionViewport", "tags": [ "sessions" ], - "summary": "Trace size probe.", + "summary": "Resize the active page viewport; not attention-gated (D-10).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] - }, - { - "grantAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "sessions:write", "parameters": [ { "schema": { @@ -18980,27 +24848,26 @@ }, "required": true, "name": "session_id", - "in": "path" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 256 - }, - "required": false, - "name": "grant", - "in": "query" + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetViewportRequest" + } + } + } + }, "responses": { "200": { - "description": "Headers only.", + "description": "Resize the active page viewport; not attention-gated (D-10).", "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/SetViewportResponse" } } } @@ -19036,7 +24903,27 @@ } }, "404": { - "description": "SESSION_NOT_FOUND, TRACE_UNAVAILABLE", + "description": "SESSION_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "SESSION_NOT_AVAILABLE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -19068,13 +24955,13 @@ } } }, - "/api/v1/sessions/{session_id}/trace": { - "get": { - "operationId": "getSessionTrace", + "/api/v1/sessions/{session_id}/input": { + "post": { + "operationId": "sendSessionInput", "tags": [ "sessions" ], - "summary": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", + "summary": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", "security": [ { "cookieAuth": [] @@ -19083,7 +24970,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "sessions:takeover", "parameters": [ { "schema": { @@ -19095,13 +24982,23 @@ "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionInputRequest" + } + } + } + }, "responses": { "200": { - "description": "Trace descriptor. `viewer_url` embeds the trace.zip URL; the client appends `?grant=` to that inner URL.", + "description": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionTraceInfo" + "$ref": "#/components/schemas/SessionInputResponse" } } } @@ -19146,6 +25043,26 @@ } } }, + "409": { + "description": "INPUT_NOT_PERMITTED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -19169,13 +25086,13 @@ } } }, - "/api/v1/sessions/{session_id}/data-dir/reveal": { - "post": { - "operationId": "revealSessionDataDir", + "/api/v1/tool-calls": { + "get": { + "operationId": "listToolCalls", "tags": [ - "sessions" + "activity" ], - "summary": "Open the session's data directory in the host file manager (honest result).", + "summary": "Tool calls across sessions (live feed seed, fleet error views).", "security": [ { "cookieAuth": [] @@ -19186,23 +25103,192 @@ ], "x-browserhive-scope": "sessions:read", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts", + "duration_ms" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "tool", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ok", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "error_code", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "detail" + ] + }, + "required": false, + "name": "expand", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" + }, { "schema": { "type": "string", "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "has_session", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "harness", + "in": "query" } ], "responses": { "200": { - "description": "Open the session's data directory in the host file manager (honest result).", + "description": "Tool calls across sessions (live feed seed, fleet error views).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevealDataDirResponse" + "$ref": "#/components/schemas/ToolCallsPage" } } } @@ -19237,16 +25323,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -19270,13 +25346,13 @@ } } }, - "/api/v1/sessions/{session_id}/export": { + "/api/v1/activity": { "get": { - "operationId": "exportSession", + "operationId": "getActivity", "tags": [ - "sessions" + "activity" ], - "summary": "Streamed timeline export (NDJSON or CSV by `Accept`), capped at 100k rows.", + "summary": "Gap-filled activity buckets (≤ 720) and headline counters.", "security": [ { "cookieAuth": [] @@ -19289,44 +25365,59 @@ "parameters": [ { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "session_id", - "in": "path" + "required": false, + "name": "since", + "in": "query" }, { "schema": { "type": [ - "array", + "integer", "null" ], - "items": { - "type": "string", - "enum": [ - "tool", - "page", - "attention", - "vault", - "blocked" - ] - }, - "minItems": 1 + "minimum": 0 }, "required": false, - "name": "kinds", + "name": "until", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 60000, + "maximum": 86400000 + }, + "required": false, + "name": "bucket_ms", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "tool", + "error_code", + "session" + ] + }, + "required": false, + "name": "group_by", "in": "query" } ], "responses": { "200": { - "description": "kind,ts,id,data rows.", + "description": "Gap-filled activity buckets (≤ 720) and headline counters.", "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ActivityResponse" } } } @@ -19361,26 +25452,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "406": { - "description": "NOT_ACCEPTABLE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -19404,13 +25475,13 @@ } } }, - "/api/v1/sessions/{session_id}/viewport": { - "post": { - "operationId": "setSessionViewport", + "/api/v1/metrics/tools": { + "get": { + "operationId": "getToolMetrics", "tags": [ - "sessions" + "activity" ], - "summary": "Resize the active page viewport; not attention-gated (D-10).", + "summary": "Per-tool call counts, error rate and latency percentiles.", "security": [ { "cookieAuth": [] @@ -19419,35 +25490,63 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:write", + "x-browserhive-scope": "sessions:read", "parameters": [ + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "tool", + "error_code", + "tool,error_code" + ], + "default": "tool" + }, + "required": false, + "name": "group_by", + "in": "query" + }, { "schema": { "type": "string", "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, - "required": true, + "required": false, "name": "session_id", - "in": "path" + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetViewportRequest" - } - } - } - }, "responses": { "200": { - "description": "Resize the active page viewport; not attention-gated (D-10).", + "description": "Per-tool call counts, error rate and latency percentiles.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetViewportResponse" + "$ref": "#/components/schemas/ToolMetricsResponse" } } } @@ -19482,36 +25581,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "SESSION_NOT_AVAILABLE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -19535,13 +25604,13 @@ } } }, - "/api/v1/sessions/{session_id}/input": { - "post": { - "operationId": "sendSessionInput", + "/api/v1/metrics/harnesses": { + "get": { + "operationId": "getHarnessMetrics", "tags": [ - "sessions" + "activity" ], - "summary": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", + "summary": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", "security": [ { "cookieAuth": [] @@ -19550,35 +25619,40 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:takeover", + "x-browserhive-scope": "sessions:read", "parameters": [ { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "session_id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionInputRequest" - } - } + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" } - }, + ], "responses": { "200": { - "description": "Operator takeover input; each input re-checks the open takeover attention request (per-item results).", + "description": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionInputResponse" + "$ref": "#/components/schemas/HarnessMetricsResponse" } } } @@ -19613,36 +25687,6 @@ } } }, - "404": { - "description": "SESSION_NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "INPUT_NOT_PERMITTED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -19666,13 +25710,13 @@ } } }, - "/api/v1/tool-calls": { + "/api/v1/pages": { "get": { - "operationId": "listToolCalls", + "operationId": "listPages", "tags": [ - "activity" + "pages" ], - "summary": "Tool calls across sessions (live feed seed, fleet error views).", + "summary": "Pages across sessions (navigation history) with category facets.", "security": [ { "cookieAuth": [] @@ -19732,7 +25776,9 @@ "type": "string", "enum": [ "ts", - "duration_ms" + "domain", + "category", + "session" ], "default": "ts" }, @@ -19748,38 +25794,37 @@ ], "items": { "type": "string", - "minLength": 1, - "maxLength": 64 + "enum": [ + "public", + "ip", + "local", + "ftp", + "other" + ] }, "minItems": 1 }, "required": false, - "name": "tool", + "name": "category", "in": "query" }, { "schema": { - "type": "boolean" + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "ok", + "name": "session_id", "in": "query" }, { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 + "type": "string", + "minLength": 1, + "maxLength": 253 }, "required": false, - "name": "error_code", + "name": "domain", "in": "query" }, { @@ -19792,212 +25837,38 @@ "name": "q", "in": "query" }, - { - "schema": { - "type": "string", - "enum": [ - "detail" - ] - }, - "required": false, - "name": "expand", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "has_session", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "harness", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Tool calls across sessions (live feed seed, fleet error views).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolCallsPage" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/activity": { - "get": { - "operationId": "getActivity", - "tags": [ - "activity" - ], - "summary": "Gap-filled activity buckets (≤ 720) and headline counters.", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, { "schema": { "type": [ "integer", "null" ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 60000, - "maximum": 86400000 + "minimum": 0 }, "required": false, - "name": "bucket_ms", + "name": "since", "in": "query" }, { "schema": { - "type": "string", - "enum": [ - "tool", - "error_code", - "session" - ] + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "group_by", + "name": "until", "in": "query" } ], "responses": { "200": { - "description": "Gap-filled activity buckets (≤ 720) and headline counters.", + "description": "Pages across sessions (navigation history) with category facets.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActivityResponse" + "$ref": "#/components/schemas/PagesPage" } } } @@ -20055,13 +25926,13 @@ } } }, - "/api/v1/metrics/tools": { + "/api/v1/pages/recent": { "get": { - "operationId": "getToolMetrics", + "operationId": "listRecentPages", "tags": [ - "activity" + "pages" ], - "summary": "Per-tool call counts, error rate and latency percentiles.", + "summary": "Most recent page visits across sessions.", "security": [ { "cookieAuth": [] @@ -20074,59 +25945,23 @@ "parameters": [ { "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "tool", - "error_code", - "tool,error_code" - ], - "default": "tool" - }, - "required": false, - "name": "group_by", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 15 }, "required": false, - "name": "session_id", + "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "Per-tool call counts, error rate and latency percentiles.", + "description": "Most recent page visits across sessions.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToolMetricsResponse" + "$ref": "#/components/schemas/RecentPagesResponse" } } } @@ -20184,13 +26019,13 @@ } } }, - "/api/v1/metrics/harnesses": { + "/api/v1/pages/domains": { "get": { - "operationId": "getHarnessMetrics", + "operationId": "listPageDomains", "tags": [ - "activity" + "pages" ], - "summary": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", + "summary": "Most visited domains (all-time when no window).", "security": [ { "cookieAuth": [] @@ -20224,15 +26059,26 @@ "required": false, "name": "until", "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 5 + }, + "required": false, + "name": "limit", + "in": "query" } ], "responses": { "200": { - "description": "Sessions and tool calls per agent harness over a window (self-reported identity, D-30).", + "description": "Most visited domains (all-time when no window).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HarnessMetricsResponse" + "$ref": "#/components/schemas/PageDomainsResponse" } } } @@ -20290,13 +26136,13 @@ } } }, - "/api/v1/pages": { + "/api/v1/attention": { "get": { - "operationId": "listPages", + "operationId": "listAttention", "tags": [ - "pages" + "attention" ], - "summary": "Pages across sessions (navigation history) with category facets.", + "summary": "Attention requests (open and history) with the live open count and status/mode facets.", "security": [ { "cookieAuth": [] @@ -20305,7 +26151,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "attention:read", "parameters": [ { "schema": { @@ -20355,12 +26201,11 @@ "schema": { "type": "string", "enum": [ - "ts", - "domain", - "category", - "session" + "created_at", + "resolved_at", + "waited_ms" ], - "default": "ts" + "default": "created_at" }, "required": false, "name": "sort", @@ -20375,36 +26220,45 @@ "items": { "type": "string", "enum": [ - "public", - "ip", - "local", - "ftp", - "other" + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" ] }, "minItems": 1 }, "required": false, - "name": "category", + "name": "status", "in": "query" }, { "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "takeover", + "notify" + ] + }, + "minItems": 1 }, "required": false, - "name": "session_id", + "name": "mode", "in": "query" }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 253 + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "domain", + "name": "session_id", "in": "query" }, { @@ -20444,11 +26298,11 @@ ], "responses": { "200": { - "description": "Pages across sessions (navigation history) with category facets.", + "description": "Attention requests (open and history) with the live open count and status/mode facets.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PagesPage" + "$ref": "#/components/schemas/AttentionPage" } } } @@ -20506,13 +26360,13 @@ } } }, - "/api/v1/pages/recent": { - "get": { - "operationId": "listRecentPages", + "/api/v1/attention/{request_id}/resolve": { + "post": { + "operationId": "resolveAttention", "tags": [ - "pages" + "attention" ], - "summary": "Most recent page visits across sessions.", + "summary": "Resolve or reject an open attention request.", "security": [ { "cookieAuth": [] @@ -20521,27 +26375,35 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "sessions:read", + "x-browserhive-scope": "attention:resolve", "parameters": [ { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 15 + "type": "string", + "pattern": "^a-[A-Za-z0-9_-]{12}$" }, - "required": false, - "name": "limit", - "in": "query" + "required": true, + "name": "request_id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveAttentionRequest" + } + } + } + }, "responses": { "200": { - "description": "Most recent page visits across sessions.", + "description": "Resolve or reject an open attention request.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecentPagesResponse" + "$ref": "#/components/schemas/ResolveRequestResponse" } } } @@ -20576,6 +26438,36 @@ } } }, + "404": { + "description": "NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "ATTENTION_NOT_OPEN", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -20599,66 +26491,50 @@ } } }, - "/api/v1/pages/domains": { - "get": { - "operationId": "listPageDomains", + "/api/v1/attention/bulk": { + "post": { + "operationId": "bulkAttention", "tags": [ - "pages" - ], - "summary": "Most visited domains (all-time when no window).", - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "sessions:read", - "parameters": [ - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, + "attention" + ], + "summary": "Resolve or reject several attention requests (per-item results).", + "security": [ { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" + "cookieAuth": [] }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "attention:resolve", + "parameters": [ { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 5 + "type": "string", + "format": "uuid" }, - "required": false, - "name": "limit", - "in": "query" + "required": true, + "name": "idempotency-key", + "in": "header" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAttentionRequest" + } + } + } + }, "responses": { "200": { - "description": "Most visited domains (all-time when no window).", + "description": "Resolve or reject several attention requests (per-item results).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PageDomainsResponse" + "$ref": "#/components/schemas/BulkRequestsResponse" } } } @@ -20693,6 +26569,16 @@ } } }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -20716,13 +26602,13 @@ } } }, - "/api/v1/attention": { + "/api/v1/vault/confirm": { "get": { - "operationId": "listAttention", + "operationId": "listVaultConfirm", "tags": [ - "attention" + "vault" ], - "summary": "Attention requests (open and history) with the live open count and status/mode facets.", + "summary": "Vault fill confirmations (open and history).", "security": [ { "cookieAuth": [] @@ -20731,7 +26617,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:read", + "x-browserhive-scope": "vault:read", "parameters": [ { "schema": { @@ -20782,8 +26668,7 @@ "type": "string", "enum": [ "created_at", - "resolved_at", - "waited_ms" + "resolved_at" ], "default": "created_at" }, @@ -20813,25 +26698,6 @@ "name": "status", "in": "query" }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "takeover", - "notify" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "mode", - "in": "query" - }, { "schema": { "type": "string", @@ -20840,49 +26706,15 @@ "required": false, "name": "session_id", "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" } ], "responses": { "200": { - "description": "Attention requests (open and history) with the live open count and status/mode facets.", + "description": "Vault fill confirmations (open and history).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AttentionPage" + "$ref": "#/components/schemas/VaultConfirmPage" } } } @@ -20917,6 +26749,16 @@ } } }, + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -20940,13 +26782,13 @@ } } }, - "/api/v1/attention/{request_id}/resolve": { + "/api/v1/vault/confirm/{request_id}/resolve": { "post": { - "operationId": "resolveAttention", + "operationId": "resolveVaultConfirm", "tags": [ - "attention" + "vault" ], - "summary": "Resolve or reject an open attention request.", + "summary": "Approve or deny a pending vault fill (`reason` is audit-only).", "security": [ { "cookieAuth": [] @@ -20955,7 +26797,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:resolve", + "x-browserhive-scope": "vault:confirm", "parameters": [ { "schema": { @@ -20972,14 +26814,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveAttentionRequest" + "$ref": "#/components/schemas/ResolveVaultConfirmRequest" } } } }, "responses": { "200": { - "description": "Resolve or reject an open attention request.", + "description": "Approve or deny a pending vault fill (`reason` is audit-only).", "content": { "application/json": { "schema": { @@ -21019,7 +26861,7 @@ } }, "404": { - "description": "NOT_FOUND", + "description": "VAULT_NOT_CONFIGURED, NOT_FOUND", "content": { "application/problem+json": { "schema": { @@ -21029,7 +26871,7 @@ } }, "409": { - "description": "ATTENTION_NOT_OPEN", + "description": "CONFIRM_NOT_OPEN", "content": { "application/problem+json": { "schema": { @@ -21071,13 +26913,13 @@ } } }, - "/api/v1/attention/bulk": { + "/api/v1/vault/confirm/bulk": { "post": { - "operationId": "bulkAttention", + "operationId": "bulkVaultConfirm", "tags": [ - "attention" + "vault" ], - "summary": "Resolve or reject several attention requests (per-item results).", + "summary": "Approve or deny several vault confirmations (per-item results).", "security": [ { "cookieAuth": [] @@ -21086,7 +26928,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "attention:resolve", + "x-browserhive-scope": "vault:confirm", "parameters": [ { "schema": { @@ -21103,14 +26945,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkAttentionRequest" + "$ref": "#/components/schemas/BulkVaultConfirmRequest" } } } }, "responses": { "200": { - "description": "Resolve or reject several attention requests (per-item results).", + "description": "Approve or deny several vault confirmations (per-item results).", "content": { "application/json": { "schema": { @@ -21122,9 +26964,99 @@ "400": { "description": "VALIDATION_FAILED", "content": { - "application/problem+json": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/vault": { + "get": { + "operationId": "getVault", + "tags": [ + "vault" + ], + "summary": "Backend capabilities, unlock descriptor and counts (never shells out).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "vault:read", + "responses": { + "200": { + "description": "Backend capabilities, unlock descriptor and counts (never shells out).", + "content": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/VaultOverview" } } } @@ -21149,8 +27081,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -21182,13 +27114,13 @@ } } }, - "/api/v1/vault/confirm": { + "/api/v1/vault/status": { "get": { - "operationId": "listVaultConfirm", + "operationId": "getVaultStatus", "tags": [ "vault" ], - "summary": "Vault fill confirmations (open and history).", + "summary": "Lock state (may call the backend).", "security": [ { "cookieAuth": [] @@ -21198,113 +27130,13 @@ } ], "x-browserhive-scope": "vault:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "created_at", - "resolved_at" - ], - "default": "created_at" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pending", - "resolved", - "rejected", - "timeout", - "cancelled" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - } - ], "responses": { "200": { - "description": "Vault fill confirmations (open and history).", + "description": "Lock state (may call the backend).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultConfirmPage" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/VaultStatus" } } } @@ -21358,17 +27190,27 @@ } } } + }, + "502": { + "description": "VAULT_BACKEND_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } }, - "/api/v1/vault/confirm/{request_id}/resolve": { + "/api/v1/vault/unlock": { "post": { - "operationId": "resolveVaultConfirm", + "operationId": "unlockVault", "tags": [ "vault" ], - "summary": "Approve or deny a pending vault fill (`reason` is audit-only).", + "summary": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", "security": [ { "cookieAuth": [] @@ -21377,35 +27219,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:confirm", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^a-[A-Za-z0-9_-]{12}$" - }, - "required": true, - "name": "request_id", - "in": "path" - } - ], + "x-browserhive-scope": "vault:write", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveVaultConfirmRequest" + "$ref": "#/components/schemas/UnlockVaultRequest" } } } }, "responses": { "200": { - "description": "Approve or deny a pending vault fill (`reason` is audit-only).", + "description": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveRequestResponse" + "$ref": "#/components/schemas/UnlockVaultResponse" } } } @@ -21421,7 +27252,7 @@ } }, "401": { - "description": "UNAUTHORIZED", + "description": "UNAUTHORIZED, VAULT_UNLOCK_FAILED", "content": { "application/problem+json": { "schema": { @@ -21441,17 +27272,7 @@ } }, "404": { - "description": "VAULT_NOT_CONFIGURED, NOT_FOUND", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "CONFIRM_NOT_OPEN", + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -21493,13 +27314,13 @@ } } }, - "/api/v1/vault/confirm/bulk": { + "/api/v1/vault/lock": { "post": { - "operationId": "bulkVaultConfirm", + "operationId": "lockVault", "tags": [ "vault" ], - "summary": "Approve or deny several vault confirmations (per-item results).", + "summary": "Forget the backend session.", "security": [ { "cookieAuth": [] @@ -21508,45 +27329,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:confirm", - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "idempotency-key", - "in": "header" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkVaultConfirmRequest" - } - } - } - }, + "x-browserhive-scope": "vault:write", "responses": { "200": { - "description": "Approve or deny several vault confirmations (per-item results).", + "description": "Forget the backend session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkRequestsResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/ArchiveSessionResponse" } } } @@ -21581,16 +27371,6 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -21614,13 +27394,13 @@ } } }, - "/api/v1/vault": { - "get": { - "operationId": "getVault", + "/api/v1/vault/sync": { + "post": { + "operationId": "syncVault", "tags": [ "vault" ], - "summary": "Backend capabilities, unlock descriptor and counts (never shells out).", + "summary": "Refresh the backend's local cache.", "security": [ { "cookieAuth": [] @@ -21629,14 +27409,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "vault:write", "responses": { "200": { - "description": "Backend capabilities, unlock descriptor and counts (never shells out).", + "description": "Refresh the backend's local cache.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultOverview" + "$ref": "#/components/schemas/SyncVaultResponse" + } + } + } + }, + "400": { + "description": "VAULT_SYNC_UNSUPPORTED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -21661,8 +27451,18 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "404": { + "description": "VAULT_NOT_CONFIGURED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "VAULT_LOCKED", "content": { "application/problem+json": { "schema": { @@ -21694,13 +27494,13 @@ } } }, - "/api/v1/vault/status": { + "/api/v1/vault/groups": { "get": { - "operationId": "getVaultStatus", + "operationId": "listVaultGroups", "tags": [ "vault" ], - "summary": "Lock state (may call the backend).", + "summary": "Backend groups with item/binding coverage, policies and same-name duplicates.", "security": [ { "cookieAuth": [] @@ -21712,11 +27512,11 @@ "x-browserhive-scope": "vault:read", "responses": { "200": { - "description": "Lock state (may call the backend).", + "description": "Backend groups with item/binding coverage, policies and same-name duplicates.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultStatus" + "$ref": "#/components/schemas/VaultGroupsResponse" } } } @@ -21751,8 +27551,8 @@ } } }, - "429": { - "description": "RATE_LIMITED", + "409": { + "description": "VAULT_LOCKED", "content": { "application/problem+json": { "schema": { @@ -21761,8 +27561,8 @@ } } }, - "500": { - "description": "INTERNAL_ERROR", + "429": { + "description": "RATE_LIMITED", "content": { "application/problem+json": { "schema": { @@ -21771,8 +27571,8 @@ } } }, - "502": { - "description": "VAULT_BACKEND_ERROR", + "500": { + "description": "INTERNAL_ERROR", "content": { "application/problem+json": { "schema": { @@ -21784,13 +27584,13 @@ } } }, - "/api/v1/vault/unlock": { - "post": { - "operationId": "unlockVault", + "/api/v1/vault/groups/{group_id}/policy": { + "put": { + "operationId": "putVaultGroupPolicy", "tags": [ "vault" ], - "summary": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", + "summary": "Create or update a group policy (`If-Match: ` on update).", "security": [ { "cookieAuth": [] @@ -21800,23 +27600,44 @@ } ], "x-browserhive-scope": "vault:write", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": true, + "name": "group_id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "required": false, + "name": "if-match", + "in": "header" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnlockVaultRequest" + "$ref": "#/components/schemas/PutGroupPolicyRequest" } } } }, "responses": { "200": { - "description": "Unlock with the secret `unlock.mode` names (Bitwarden: a session token, never the master password).", + "description": "Create or update a group policy (`If-Match: ` on update).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnlockVaultResponse" + "$ref": "#/components/schemas/PutGroupPolicyResponse" } } } @@ -21832,7 +27653,7 @@ } }, "401": { - "description": "UNAUTHORIZED, VAULT_UNLOCK_FAILED", + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -21861,6 +27682,16 @@ } } }, + "409": { + "description": "CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "413": { "description": "PAYLOAD_TOO_LARGE", "content": { @@ -21894,13 +27725,13 @@ } } }, - "/api/v1/vault/lock": { - "post": { - "operationId": "lockVault", + "/api/v1/vault/items": { + "get": { + "operationId": "listVaultItems", "tags": [ "vault" ], - "summary": "Forget the backend session.", + "summary": "Backend items with derived handles and binding coverage.", "security": [ { "cookieAuth": [] @@ -21909,14 +27740,103 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", + "x-browserhive-scope": "vault:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "handle", + "name" + ], + "default": "handle" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "group_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + } + ], "responses": { "200": { - "description": "Forget the backend session.", + "description": "Backend items with derived handles and binding coverage.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ArchiveSessionResponse" + "$ref": "#/components/schemas/VaultItemsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -21951,6 +27871,16 @@ } } }, + "409": { + "description": "VAULT_LOCKED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -21974,35 +27904,115 @@ } } }, - "/api/v1/vault/sync": { - "post": { - "operationId": "syncVault", + "/api/v1/vault/bindings": { + "get": { + "operationId": "listVaultBindings", "tags": [ "vault" ], - "summary": "Refresh the backend's local cache.", + "summary": "Stored bindings, ordered by handle.", "security": [ { - "cookieAuth": [] + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "vault:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "handle", + "updated_at", + "created_at" + ], + "default": "handle" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "group_id", + "in": "query" }, { - "bearerAuth": [] + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" } ], - "x-browserhive-scope": "vault:write", "responses": { "200": { - "description": "Refresh the backend's local cache.", + "description": "Stored bindings, ordered by handle.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SyncVaultResponse" + "$ref": "#/components/schemas/VaultBindingsPage" } } } }, "400": { - "description": "VAULT_SYNC_UNSUPPORTED", + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -22041,16 +28051,6 @@ } } }, - "409": { - "description": "VAULT_LOCKED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -22074,13 +28074,13 @@ } } }, - "/api/v1/vault/groups": { - "get": { - "operationId": "listVaultGroups", + "/api/v1/vault/bindings/{handle}": { + "put": { + "operationId": "putVaultBinding", "tags": [ "vault" ], - "summary": "Backend groups with item/binding coverage, policies and same-name duplicates.", + "summary": "Create (item_name required) or update a binding (`If-Match: `).", "security": [ { "cookieAuth": [] @@ -22089,14 +28089,54 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "vault:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" + }, + "required": true, + "name": "handle", + "in": "path" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "required": false, + "name": "if-match", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutVaultBindingRequest" + } + } + } + }, "responses": { "200": { - "description": "Backend groups with item/binding coverage, policies and same-name duplicates.", + "description": "Create (item_name required) or update a binding (`If-Match: `).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultGroupsResponse" + "$ref": "#/components/schemas/PutVaultBindingResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -22132,7 +28172,17 @@ } }, "409": { - "description": "VAULT_LOCKED", + "description": "CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -22162,15 +28212,13 @@ } } } - } - }, - "/api/v1/vault/groups/{group_id}/policy": { - "put": { - "operationId": "putVaultGroupPolicy", + }, + "delete": { + "operationId": "deleteVaultBinding", "tags": [ "vault" ], - "summary": "Create or update a group policy (`If-Match: ` on update).", + "summary": "Remove a binding.", "security": [ { "cookieAuth": [] @@ -22184,40 +28232,20 @@ { "schema": { "type": "string", - "minLength": 1, - "maxLength": 128 + "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" }, "required": true, - "name": "group_id", + "name": "handle", "in": "path" - }, - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "required": false, - "name": "if-match", - "in": "header" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutGroupPolicyRequest" - } - } - } - }, "responses": { "200": { - "description": "Create or update a group policy (`If-Match: ` on update).", + "description": "Remove a binding.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PutGroupPolicyResponse" + "$ref": "#/components/schemas/DeleteVaultBindingResponse" } } } @@ -22262,26 +28290,6 @@ } } }, - "409": { - "description": "CONFLICT", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -22305,108 +28313,39 @@ } } }, - "/api/v1/vault/items": { - "get": { - "operationId": "listVaultItems", + "/api/v1/vault/bindings/resolve": { + "post": { + "operationId": "resolveVaultBindings", "tags": [ "vault" ], - "summary": "Backend items with derived handles and binding coverage.", + "summary": "Dry-run the fill gates of every binding against a URL.", "security": [ { "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "vault:read", - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "handle", - "name" - ], - "default": "handle" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "group_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" + }, + { + "bearerAuth": [] } ], + "x-browserhive-scope": "vault:read", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveBindingsRequest" + } + } + } + }, "responses": { "200": { - "description": "Backend items with derived handles and binding coverage.", + "description": "Dry-run the fill gates of every binding against a URL.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultItemsPage" + "$ref": "#/components/schemas/ResolveBindingsResponse" } } } @@ -22451,8 +28390,8 @@ } } }, - "409": { - "description": "VAULT_LOCKED", + "413": { + "description": "PAYLOAD_TOO_LARGE", "content": { "application/problem+json": { "schema": { @@ -22484,13 +28423,13 @@ } } }, - "/api/v1/vault/bindings": { + "/api/v1/vault/log": { "get": { - "operationId": "listVaultBindings", + "operationId": "listVaultLog", "tags": [ "vault" ], - "summary": "Stored bindings, ordered by handle.", + "summary": "Vault access audit log.", "security": [ { "cookieAuth": [] @@ -22549,24 +28488,78 @@ "schema": { "type": "string", "enum": [ - "handle", - "updated_at", - "created_at" + "ts", + "entry_name", + "result", + "session" ], - "default": "handle" + "default": "ts" }, "required": false, "name": "sort", "in": "query" }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "success", + "origin_mismatch", + "auth_failed", + "blocked", + "denied" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "result", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "pass", + "fail", + "skipped" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "origin_check", + "in": "query" + }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 128 + "enum": [ + "on", + "off" + ] }, "required": false, - "name": "group_id", + "name": "evaluate", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", "in": "query" }, { @@ -22576,163 +28569,57 @@ "maxLength": 200 }, "required": false, - "name": "q", + "name": "entry_name", "in": "query" - } - ], - "responses": { - "200": { - "description": "Stored bindings, ordered by handle.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultBindingsPage" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/vault/bindings/{handle}": { - "put": { - "operationId": "putVaultBinding", - "tags": [ - "vault" - ], - "summary": "Create (item_name required) or update a binding (`If-Match: `).", - "security": [ { - "cookieAuth": [] + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" }, - { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "vault:write", - "parameters": [ { "schema": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" + "type": [ + "integer", + "null" + ], + "minimum": 0 }, - "required": true, - "name": "handle", - "in": "path" + "required": false, + "name": "since", + "in": "query" }, { "schema": { - "type": "integer", - "exclusiveMinimum": 0 + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "required": false, - "name": "if-match", - "in": "header" + "name": "until", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutVaultBindingRequest" - } - } - } - }, "responses": { "200": { - "description": "Create (item_name required) or update a binding (`If-Match: `).", + "description": "Vault access audit log.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PutVaultBindingResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/VaultLogPage" } } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "400": { + "description": "VALIDATION_FAILED", "content": { "application/problem+json": { "schema": { @@ -22741,8 +28628,8 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -22751,8 +28638,8 @@ } } }, - "409": { - "description": "CONFLICT", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -22761,8 +28648,8 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", + "404": { + "description": "VAULT_NOT_CONFIGURED", "content": { "application/problem+json": { "schema": { @@ -22792,13 +28679,15 @@ } } } - }, - "delete": { - "operationId": "deleteVaultBinding", + } + }, + "/api/v1/vault/export": { + "get": { + "operationId": "exportVault", "tags": [ "vault" ], - "summary": "Remove a binding.", + "summary": "Export bindings and policies as the v3 document.", "security": [ { "cookieAuth": [] @@ -22807,35 +28696,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" - }, - "required": true, - "name": "handle", - "in": "path" - } - ], + "x-browserhive-scope": "vault:read", "responses": { "200": { - "description": "Remove a binding.", + "description": "Export bindings and policies as the v3 document.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteVaultBindingResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/VaultExportDocument" } } } @@ -22893,13 +28761,13 @@ } } }, - "/api/v1/vault/bindings/resolve": { + "/api/v1/vault/import": { "post": { - "operationId": "resolveVaultBindings", + "operationId": "importVault", "tags": [ "vault" ], - "summary": "Dry-run the fill gates of every binding against a URL.", + "summary": "Import a v3 document (`?mode=merge|replace`).", "security": [ { "cookieAuth": [] @@ -22908,24 +28776,39 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "vault:write", + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "merge", + "replace" + ], + "default": "merge" + }, + "required": false, + "name": "mode", + "in": "query" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveBindingsRequest" + "$ref": "#/components/schemas/VaultExportDocument" } } } }, "responses": { "200": { - "description": "Dry-run the fill gates of every binding against a URL.", + "description": "Import a v3 document (`?mode=merge|replace`).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveBindingsResponse" + "$ref": "#/components/schemas/ImportVaultResponse" } } } @@ -23003,13 +28886,13 @@ } } }, - "/api/v1/vault/log": { + "/api/v1/blocklist": { "get": { - "operationId": "listVaultLog", + "operationId": "getBlocklist", "tags": [ - "vault" + "blocklist" ], - "summary": "Vault access audit log.", + "summary": "Loaded patterns with hit counts, skipped lines and window stats.", "security": [ { "cookieAuth": [] @@ -23018,150 +28901,8 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "blocklist:read", "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "entry_name", - "result", - "session" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "success", - "origin_mismatch", - "auth_failed", - "blocked", - "denied" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "result", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "pass", - "fail", - "skipped" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "origin_check", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "on", - "off" - ] - }, - "required": false, - "name": "evaluate", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "entry_name", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, { "schema": { "type": [ @@ -23189,11 +28930,11 @@ ], "responses": { "200": { - "description": "Vault access audit log.", + "description": "Loaded patterns with hit counts, skipped lines and window stats.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultLogPage" + "$ref": "#/components/schemas/BlocklistOverview" } } } @@ -23228,16 +28969,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -23261,13 +28992,13 @@ } } }, - "/api/v1/vault/export": { - "get": { - "operationId": "exportVault", + "/api/v1/blocklist/reload": { + "post": { + "operationId": "reloadBlocklist", "tags": [ - "vault" + "blocklist" ], - "summary": "Export bindings and policies as the v3 document.", + "summary": "Re-read the blocklist file; on failure the previous list stays active.", "security": [ { "cookieAuth": [] @@ -23276,20 +29007,20 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:read", + "x-browserhive-scope": "blocklist:write", "responses": { "200": { - "description": "Export bindings and policies as the v3 document.", + "description": "Re-read the blocklist file; on failure the previous list stays active.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VaultExportDocument" + "$ref": "#/components/schemas/ReloadBlocklistResponse" } } } }, - "401": { - "description": "UNAUTHORIZED", + "400": { + "description": "BLOCKLIST_LOAD_FAILED", "content": { "application/problem+json": { "schema": { @@ -23298,8 +29029,8 @@ } } }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "401": { + "description": "UNAUTHORIZED", "content": { "application/problem+json": { "schema": { @@ -23308,8 +29039,8 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", "content": { "application/problem+json": { "schema": { @@ -23341,13 +29072,13 @@ } } }, - "/api/v1/vault/import": { - "post": { - "operationId": "importVault", + "/api/v1/blocklist/attempts": { + "get": { + "operationId": "listBlockedAttempts", "tags": [ - "vault" + "blocklist" ], - "summary": "Import a v3 document (`?mode=merge|replace`).", + "summary": "Blocked request audit (served even when no blocklist is configured).", "security": [ { "cookieAuth": [] @@ -23356,39 +29087,158 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "vault:write", + "x-browserhive-scope": "blocklist:read", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", "enum": [ - "merge", - "replace" + "asc", + "desc" ], - "default": "merge" + "default": "desc" }, "required": false, - "name": "mode", + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ts", + "domain", + "pattern", + "session", + "source" + ], + "default": "ts" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "required": false, + "name": "pattern", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "required": false, + "name": "domain", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "tool", + "request" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "source", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "required": false, + "name": "q", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultExportDocument" - } - } - } - }, "responses": { "200": { - "description": "Import a v3 document (`?mode=merge|replace`).", + "description": "Blocked request audit (served even when no blocklist is configured).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportVaultResponse" + "$ref": "#/components/schemas/BlockedAttemptsPage" } } } @@ -23423,26 +29273,6 @@ } } }, - "404": { - "description": "VAULT_NOT_CONFIGURED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -23466,13 +29296,13 @@ } } }, - "/api/v1/blocklist": { + "/api/v1/system": { "get": { - "operationId": "getBlocklist", + "operationId": "getSystem", "tags": [ - "blocklist" + "system" ], - "summary": "Loaded patterns with hit counts, skipped lines and window stats.", + "summary": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", "security": [ { "cookieAuth": [] @@ -23481,50 +29311,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "blocklist:read", - "parameters": [ - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" - } - ], + "x-browserhive-scope": "system:read", "responses": { "200": { - "description": "Loaded patterns with hit counts, skipped lines and window stats.", + "description": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlocklistOverview" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SystemInfo" } } } @@ -23570,15 +29364,15 @@ } } } - } - }, - "/api/v1/blocklist/reload": { - "post": { - "operationId": "reloadBlocklist", + } + }, + "/api/v1/system/config": { + "get": { + "operationId": "getSystemConfig", "tags": [ - "blocklist" + "system" ], - "summary": "Re-read the blocklist file; on failure the previous list stays active.", + "summary": "Every config key with its value, source and shadowed values (secrets redacted).", "security": [ { "cookieAuth": [] @@ -23587,24 +29381,14 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "blocklist:write", + "x-browserhive-scope": "system:read", "responses": { "200": { - "description": "Re-read the blocklist file; on failure the previous list stays active.", + "description": "Every config key with its value, source and shadowed values (secrets redacted).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReloadBlocklistResponse" - } - } - } - }, - "400": { - "description": "BLOCKLIST_LOAD_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" + "$ref": "#/components/schemas/SystemConfigResponse" } } } @@ -23652,13 +29436,13 @@ } } }, - "/api/v1/blocklist/attempts": { + "/api/v1/system/public-url": { "get": { - "operationId": "listBlockedAttempts", + "operationId": "getPublicUrlStatus", "tags": [ - "blocklist" + "system" ], - "summary": "Blocked request audit (served even when no blocklist is configured).", + "summary": "The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)", "security": [ { "cookieAuth": [] @@ -23667,158 +29451,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "blocklist:read", + "x-browserhive-scope": "system:read", "parameters": [ { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" - }, - "required": false, - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false - }, - "required": false, - "name": "total", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": [ - "ts", - "domain", - "pattern", - "session", - "source" - ], - "default": "ts" - }, - "required": false, - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 512 - }, - "required": false, - "name": "pattern", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 253 - }, - "required": false, - "name": "domain", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "tool", - "request" - ] - }, - "minItems": 1 - }, - "required": false, - "name": "source", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "boolean" }, "required": false, - "name": "until", + "name": "refresh", "in": "query" } ], "responses": { "200": { - "description": "Blocked request audit (served even when no blocklist is configured).", + "description": "The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BlockedAttemptsPage" + "$ref": "#/components/schemas/PublicUrlStatus" } } } @@ -23876,13 +29526,13 @@ } } }, - "/api/v1/system": { + "/api/v1/system/realtime": { "get": { - "operationId": "getSystem", + "operationId": "getSystemRealtime", "tags": [ "system" ], - "summary": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", + "summary": "Open realtime connections with topics, screencasts and backpressure counters.", "security": [ { "cookieAuth": [] @@ -23894,11 +29544,11 @@ "x-browserhive-scope": "system:read", "responses": { "200": { - "description": "Server facts, runtime, capacity, retention, storage, telemetry and open degradations.", + "description": "Open realtime connections with topics, screencasts and backpressure counters.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemInfo" + "$ref": "#/components/schemas/SystemRealtimeResponse" } } } @@ -23946,13 +29596,13 @@ } } }, - "/api/v1/system/config": { + "/api/v1/system/mcp/connections": { "get": { - "operationId": "getSystemConfig", + "operationId": "listMcpConnections", "tags": [ "system" ], - "summary": "Every config key with its value, source and shadowed values (secrets redacted).", + "summary": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", "security": [ { "cookieAuth": [] @@ -23962,13 +29612,49 @@ } ], "x-browserhive-scope": "system:read", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "default": 0 + }, + "required": false, + "name": "offset", + "in": "query" + } + ], "responses": { "200": { - "description": "Every config key with its value, source and shadowed values (secrets redacted).", + "description": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigResponse" + "$ref": "#/components/schemas/McpConnectionsResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -24016,13 +29702,13 @@ } } }, - "/api/v1/system/public-url": { - "get": { - "operationId": "getPublicUrlStatus", + "/api/v1/system/log-level": { + "patch": { + "operationId": "setLogLevel", "tags": [ "system" ], - "summary": "The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)", + "summary": "Change the log level spec at runtime (`info,sessions=debug`).", "security": [ { "cookieAuth": [] @@ -24031,24 +29717,24 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", - "parameters": [ - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "refresh", - "in": "query" + "x-browserhive-scope": "system:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetLogLevelRequest" + } + } } - ], + }, "responses": { "200": { - "description": "The publicUrl check: does the public address reach this BrowserHive? (cached 60 s)", + "description": "Change the log level spec at runtime (`info,sessions=debug`).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicUrlStatus" + "$ref": "#/components/schemas/SetLogLevelResponse" } } } @@ -24083,6 +29769,16 @@ } } }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -24106,13 +29802,13 @@ } } }, - "/api/v1/system/realtime": { + "/api/v1/system/events": { "get": { - "operationId": "getSystemRealtime", + "operationId": "listSystemEvents", "tags": [ "system" ], - "summary": "Open realtime connections with topics, screencasts and backpressure counters.", + "summary": "Degradations (`resolved=open` by default).", "security": [ { "cookieAuth": [] @@ -24122,13 +29818,126 @@ } ], "x-browserhive-scope": "system:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false + }, + "required": false, + "name": "total", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "last_seen_at", + "first_seen_at", + "count" + ], + "default": "last_seen_at" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "required": false, + "name": "since", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "info", + "warn", + "error" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "severity", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "all", + "open", + "resolved" + ], + "default": "open" + }, + "required": false, + "name": "resolved", + "in": "query" + } + ], "responses": { "200": { - "description": "Open realtime connections with topics, screencasts and backpressure counters.", + "description": "Degradations (`resolved=open` by default).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemRealtimeResponse" + "$ref": "#/components/schemas/SystemEventsPage" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -24176,32 +29985,146 @@ } } }, - "/api/v1/system/mcp/connections": { + "/api/v1/logs": { "get": { - "operationId": "listMcpConnections", + "operationId": "listLogs", "tags": [ - "system" + "logs" ], - "summary": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", + "summary": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", "security": [ { "cookieAuth": [] }, { - "bearerAuth": [] - } - ], - "x-browserhive-scope": "system:read", - "parameters": [ + "bearerAuth": [] + } + ], + "x-browserhive-scope": "logs:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "required": false, + "name": "dir", + "in": "query" + }, + { + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "after_seq", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "level", + "in": "query" + }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 + }, + "required": false, + "name": "module", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" + }, + "required": false, + "name": "session_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "required": false, + "name": "trace_id", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": false, + "name": "request_id", + "in": "query" + }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 + "type": "string", + "minLength": 1, + "maxLength": 200 }, "required": false, - "name": "limit", + "name": "q", "in": "query" }, { @@ -24210,111 +30133,32 @@ "integer", "null" ], - "minimum": 0, - "default": 0 + "minimum": 0 }, "required": false, - "name": "offset", + "name": "since", "in": "query" - } - ], - "responses": { - "200": { - "description": "MCP connections with their self-reported identity: live ones first, then recent (D-30).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpConnectionsResponse" - } - } - } - }, - "400": { - "description": "VALIDATION_FAILED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "401": { - "description": "UNAUTHORIZED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "429": { - "description": "RATE_LIMITED", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "500": { - "description": "INTERNAL_ERROR", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/system/log-level": { - "patch": { - "operationId": "setLogLevel", - "tags": [ - "system" - ], - "summary": "Change the log level spec at runtime (`info,sessions=debug`).", - "security": [ - { - "cookieAuth": [] }, { - "bearerAuth": [] + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required": false, + "name": "until", + "in": "query" } ], - "x-browserhive-scope": "system:write", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetLogLevelRequest" - } - } - } - }, "responses": { "200": { - "description": "Change the log level spec at runtime (`info,sessions=debug`).", + "description": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetLogLevelResponse" + "$ref": "#/components/schemas/LogsPage" } } } @@ -24349,16 +30193,6 @@ } } }, - "413": { - "description": "PAYLOAD_TOO_LARGE", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "description": "RATE_LIMITED", "content": { @@ -24382,13 +30216,13 @@ } } }, - "/api/v1/system/events": { + "/api/v1/logs/export": { "get": { - "operationId": "listSystemEvents", + "operationId": "exportLogs", "tags": [ - "system" + "logs" ], - "summary": "Degradations (`resolved=open` by default).", + "summary": "Every matching ring-buffer record as NDJSON.", "security": [ { "cookieAuth": [] @@ -24397,117 +30231,119 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "system:read", + "x-browserhive-scope": "logs:read", "parameters": [ { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "pattern": "^[A-Za-z0-9_-]+$" + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ] + }, + "minItems": 1 }, "required": false, - "name": "cursor", + "name": "level", "in": "query" }, { "schema": { - "type": "integer", - "minimum": 1, - "maximum": 500, - "default": 50 + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "minItems": 1 }, "required": false, - "name": "limit", + "name": "module", "in": "query" }, { "schema": { "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" + "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "required": false, - "name": "dir", + "name": "session_id", "in": "query" }, { "schema": { - "type": "boolean", - "default": false + "type": "string", + "minLength": 1, + "maxLength": 64 }, "required": false, - "name": "total", + "name": "trace_id", "in": "query" }, { "schema": { "type": "string", - "enum": [ - "last_seen_at", - "first_seen_at", - "count" - ], - "default": "last_seen_at" + "minLength": 1, + "maxLength": 128 }, "required": false, - "name": "sort", + "name": "request_id", "in": "query" }, { "schema": { - "type": "integer", - "minimum": 0 + "type": "string", + "minLength": 1, + "maxLength": 200 }, "required": false, - "name": "since", + "name": "q", "in": "query" }, { "schema": { "type": [ - "array", + "integer", "null" ], - "items": { - "type": "string", - "enum": [ - "info", - "warn", - "error" - ] - }, - "minItems": 1 + "minimum": 0 }, "required": false, - "name": "severity", + "name": "since", "in": "query" }, { "schema": { - "type": "string", - "enum": [ - "all", - "open", - "resolved" + "type": [ + "integer", + "null" ], - "default": "open" + "minimum": 0 }, "required": false, - "name": "resolved", + "name": "until", "in": "query" } ], "responses": { "200": { - "description": "Degradations (`resolved=open` by default).", + "description": "One record per line.", "content": { - "application/json": { + "application/x-ndjson": { "schema": { - "$ref": "#/components/schemas/SystemEventsPage" + "type": "string", + "format": "binary" } } } @@ -24565,13 +30401,13 @@ } } }, - "/api/v1/logs": { + "/api/v1/notifications/reports": { "get": { - "operationId": "listLogs", + "operationId": "listReports", "tags": [ - "logs" + "notifications" ], - "summary": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", + "summary": "Reports in BrowserHive: the in-app copies of digests and anomaly alerts, newest first.", "security": [ { "cookieAuth": [] @@ -24580,7 +30416,7 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "logs:read", + "x-browserhive-scope": "notifications:read", "parameters": [ { "schema": { @@ -24597,8 +30433,8 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 1000, - "default": 200 + "maximum": 200, + "default": 50 }, "required": false, "name": "limit", @@ -24606,27 +30442,11 @@ }, { "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - }, - "required": false, - "name": "dir", - "in": "query" - }, - { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 + "type": "boolean", + "default": false }, "required": false, - "name": "after_seq", + "name": "total", "in": "query" }, { @@ -24638,73 +30458,34 @@ "items": { "type": "string", "enum": [ - "error", - "warn", - "info", - "debug", - "trace" + "digest.daily", + "digest.weekly", + "report.anomaly" ] }, "minItems": 1 }, "required": false, - "name": "level", - "in": "query" - }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "module", - "in": "query" - }, - { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "required": false, - "name": "trace_id", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "request_id", + "name": "kind", "in": "query" }, { "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 + "anyOf": [ + { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + { + "type": "string", + "enum": [ + "in-app" + ] + } + ] }, "required": false, - "name": "q", + "name": "channel", "in": "query" }, { @@ -24734,11 +30515,11 @@ ], "responses": { "200": { - "description": "Records from the in-process ring buffer: newest first by default (`dir=desc`, the cursor pages to older records); `dir=asc` pages oldest to newest; `after_seq` bounds to newer records.", + "description": "Reports in BrowserHive: the in-app copies of digests and anomaly alerts, newest first.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LogsPage" + "$ref": "#/components/schemas/ReportsPage" } } } @@ -24796,13 +30577,13 @@ } } }, - "/api/v1/logs/export": { + "/api/v1/notifications/reports/{notification_id}": { "get": { - "operationId": "exportLogs", + "operationId": "getReport", "tags": [ - "logs" + "notifications" ], - "summary": "Every matching ring-buffer record as NDJSON.", + "summary": "One report with its message and the channels it reached.", "security": [ { "cookieAuth": [] @@ -24811,119 +30592,193 @@ "bearerAuth": [] } ], - "x-browserhive-scope": "logs:read", + "x-browserhive-scope": "notifications:read", "parameters": [ { "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "enum": [ - "error", - "warn", - "info", - "debug", - "trace" - ] - }, - "minItems": 1 + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" }, - "required": false, - "name": "level", - "in": "query" + "required": true, + "name": "notification_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "One report with its message and the channels it reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportDetailResponse" + } + } + } }, - { - "schema": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "minItems": 1 - }, - "required": false, - "name": "module", - "in": "query" + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "REPORT_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/notifications/report-settings": { + "get": { + "operationId": "getReportSettings", + "tags": [ + "notifications" + ], + "summary": "The in-app reports: the digest schedule and the anomaly switch (D-45).", + "security": [ { - "schema": { - "type": "string", - "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" - }, - "required": false, - "name": "session_id", - "in": "query" + "cookieAuth": [] }, { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "required": false, - "name": "trace_id", - "in": "query" + "bearerAuth": [] + } + ], + "x-browserhive-scope": "notifications:read", + "responses": { + "200": { + "description": "The in-app reports: the digest schedule and the anomaly switch (D-45).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportSettingsResponse" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "required": false, - "name": "request_id", - "in": "query" + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "required": false, - "name": "q", - "in": "query" + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "operationId": "putReportSettings", + "tags": [ + "notifications" + ], + "summary": "Replaces the in-app reports settings; a changed schedule re-arms from now.", + "security": [ { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "since", - "in": "query" + "cookieAuth": [] }, { - "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "required": false, - "name": "until", - "in": "query" + "bearerAuth": [] } ], + "x-browserhive-scope": "channels:write", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutReportSettingsRequest" + } + } + } + }, "responses": { "200": { - "description": "One record per line.", + "description": "Replaces the in-app reports settings; a changed schedule re-arms from now.", "content": { - "application/x-ndjson": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ReportSettingsResponse" } } } @@ -24958,6 +30813,16 @@ } } }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "429": { "description": "RATE_LIMITED", "content": { @@ -25091,6 +30956,28 @@ "name": "type", "in": "query" }, + { + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "minItems": 1 + }, + "required": false, + "name": "category", + "in": "query" + }, { "schema": { "type": [ @@ -26078,6 +31965,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -27812,6 +33700,137 @@ } } }, + "/api/v1/channels/{channel_id}/digest": { + "post": { + "operationId": "sendChannelDigest", + "tags": [ + "channels" + ], + "summary": "Preview the channel's digest of the period that ends now, or also send it now (D-43).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "x-browserhive-scope": "channels:write", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^nc-[A-Za-z0-9_-]{4,64}$" + }, + "required": true, + "name": "channel_id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelDigestRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Preview the channel's digest of the period that ends now, or also send it now (D-43).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelDigestResponse" + } + } + } + }, + "400": { + "description": "VALIDATION_FAILED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "UNAUTHORIZED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "FORBIDDEN, PASSWORD_CHANGE_REQUIRED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "CHANNEL_NOT_FOUND", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "CHANNEL_NOT_READY", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "PAYLOAD_TOO_LARGE", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "RATE_LIMITED", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "INTERNAL_ERROR", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/search": { "get": { "operationId": "search", diff --git a/packages/contracts/src/enums/notification-kind.ts b/packages/contracts/src/enums/notification-kind.ts index 5567896..a56004a 100644 --- a/packages/contracts/src/enums/notification-kind.ts +++ b/packages/contracts/src/enums/notification-kind.ts @@ -16,6 +16,7 @@ export const NotificationKind = z.enum([ 'system.degraded', 'channel.broken', 'digest.daily', + 'digest.weekly', 'report.anomaly', 'test', ]); diff --git a/packages/contracts/src/errors/codes-service.ts b/packages/contracts/src/errors/codes-service.ts index c138df8..9a5e3ca 100644 --- a/packages/contracts/src/errors/codes-service.ts +++ b/packages/contracts/src/errors/codes-service.ts @@ -350,6 +350,19 @@ export const SERVICE_ERRORS = { cause: 'The row was pruned by retention, or deleted with its channel.', resolution: 'Nothing to do.', }), + REPORT_NOT_FOUND: defineError({ + code: 'REPORT_NOT_FOUND', + httpStatus: 404, + category: 'domain', + retryable: 'never', + title: 'Report not found', + message: 'Report {notification_id} does not exist.', + hint: 'Reports are kept for 90 days.', + details: z.object({ notification_id: z.string() }), + docs: true, + cause: 'The id is not an in-app report, or retention pruned it.', + resolution: 'List the reports with GET /api/v1/notifications/reports.', + }), INTERNAL_ERROR: defineError({ code: 'INTERNAL_ERROR', httpStatus: 500, diff --git a/packages/contracts/src/http/channels.ts b/packages/contracts/src/http/channels.ts index abf953c..b5e91c3 100644 --- a/packages/contracts/src/http/channels.ts +++ b/packages/contracts/src/http/channels.ts @@ -17,9 +17,10 @@ import { NotificationChannelSecretRefs, NotificationChannelTarget, SecretEnvName, + WEEKDAYS, } from '../notifications/channel.ts'; -import { NotificationMessage } from '../notifications/message.ts'; -import { AvailableChannelKind, PreviewSample } from '../notifications/platforms.ts'; +import { NotificationMessage, NotificationReport } from '../notifications/message.ts'; +import { ANOMALY_CHECKS, AvailableChannelKind, PreviewSample } from '../notifications/platforms.ts'; import { Count, Cursor, csv, DurationMs, EpochMs, limitQuery, page } from './common.ts'; /** Channel id (`nc-…`). */ @@ -31,6 +32,8 @@ export type ChannelId = z.infer; export const ChannelCapabilitiesDto = z.object({ rich_blocks: z.boolean(), tables: z.boolean(), + /** Charts are drawn natively (only the generic webhook); elsewhere they arrive as text bars. */ + charts: z.boolean(), images: z.boolean(), act_buttons: z.boolean(), open_links: z.boolean(), @@ -79,6 +82,48 @@ export const ChannelConnection = z.object({ /** Press listener state. */ export type ChannelConnection = z.infer; +/** One active anomaly check of a channel (D-44). */ +export const ChannelAnomalyState = z.object({ + check: z.enum(ANOMALY_CHECKS), + /** When it became active. */ + since: EpochMs, + /** The measured value (percent, minutes, sessions, requests, events). */ + value: z.number(), + threshold: z.number(), +}); +/** One active anomaly check. */ +export type ChannelAnomalyState = z.infer; + +/** A channel's scheduled reports (D-43, D-44), as the cards and the CLI show them. */ +export const ChannelReports = z.object({ + /** The effective IANA zone: `rules.time_zone`, or the host's. */ + time_zone: z.string(), + /** The zone is the host's (no `rules.time_zone`). */ + host_zone: z.boolean(), + digest: z + .object({ + every: z.enum(['day', 'week']), + at: z.string(), + day: z.enum(WEEKDAYS).nullable(), + /** A daily digest that runs Monday to Friday only. */ + weekdays_only: z.boolean(), + /** The next scheduled time. */ + next_at: EpochMs, + /** End of the last window handled, or `null` before the first. */ + last_until: EpochMs.nullable(), + }) + .nullable(), + anomaly: z + .object({ + /** The next hourly check. */ + next_check_at: EpochMs, + active: z.array(ChannelAnomalyState), + }) + .nullable(), +}); +/** A channel's scheduled reports. */ +export type ChannelReports = z.infer; + /** One configured channel as the API shows it. Never carries a secret value. */ export const ChannelView = z.object({ channel_id: ChannelId, @@ -109,6 +154,8 @@ export const ChannelView = z.object({ stats: ChannelStats, /** The press listener, or `null` when the channel receives no presses (act buttons off). */ connection: ChannelConnection.nullable(), + /** The scheduled reports and their next run (D-43, D-44). */ + reports: ChannelReports, }); /** One configured channel. */ export type ChannelView = z.infer; @@ -143,7 +190,12 @@ export const ChannelPatch = z.strictObject({ export type ChannelPatch = z.infer; /** `GET /channels` body. */ -export const ChannelsResponse = z.object({ data: z.array(ChannelView), now: EpochMs }); +export const ChannelsResponse = z.object({ + data: z.array(ChannelView), + now: EpochMs, + /** The zone a channel without `rules.time_zone` uses (the host's). */ + host_time_zone: z.string(), +}); /** `GET /channels` body. */ export type ChannelsResponse = z.infer; @@ -174,6 +226,8 @@ export const DeliveryRow = z.object({ message_ref: z.record(z.string(), z.union([z.string(), z.number()])).nullable(), created_at: EpochMs, updated_at: EpochMs, + /** A report's window and late marker (digests and anomaly alerts), else `null`. */ + report: NotificationReport.nullable(), }); /** One delivery log row. */ export type DeliveryRow = z.infer; @@ -274,6 +328,28 @@ export const ChannelPreview = z.object({ /** `POST /channels/preview` response. */ export type ChannelPreview = z.infer; +/** `POST /channels/{id}/digest` body. */ +export const ChannelDigestRequest = z.strictObject({ + /** `false` (default) previews the digest only; `true` also sends it now. */ + send: z.boolean().default(false), +}); +/** `POST /channels/{id}/digest` body. */ +export type ChannelDigestRequest = z.infer; + +/** `POST /channels/{id}/digest` response. */ +export const ChannelDigestResponse = z.object({ + preview: ChannelPreview, + window: z.object({ since: EpochMs, until: EpochMs }), + /** Nothing happened in the period (a scheduled digest would not be sent). */ + empty: z.boolean(), + sent: z.boolean(), + ok: z.boolean(), + delivery: DeliveryRow.nullable(), + error: z.object({ code: z.string(), message: z.string() }).nullable(), +}); +/** `POST /channels/{id}/digest` response. */ +export type ChannelDigestResponse = z.infer; + /** `GET /channels/env` query. */ export const ChannelEnvQuery = z.strictObject({ names: z.preprocess( diff --git a/packages/contracts/src/http/endpoints.ts b/packages/contracts/src/http/endpoints.ts index 1669b4c..2a5b31f 100644 --- a/packages/contracts/src/http/endpoints.ts +++ b/packages/contracts/src/http/endpoints.ts @@ -134,6 +134,10 @@ export const HTTP_ENDPOINTS: readonly HttpEndpoint[] = [ ep('markAllNotificationsRead', 'post', '/notifications/read-all', 'notifications:write'), ep('dismissNotification', 'delete', '/notifications/{notification_id}', 'notifications:write'), ep('dismissAllNotifications', 'post', '/notifications/dismiss-all', 'notifications:write'), + ep('listReports', 'get', '/notifications/reports', 'notifications:read'), + ep('getReport', 'get', '/notifications/reports/{notification_id}', 'notifications:read'), + ep('getReportSettings', 'get', '/notifications/report-settings', 'notifications:read'), + ep('putReportSettings', 'put', '/notifications/report-settings', 'channels:write'), // §4.8.1 notification channels ep('listChannels', 'get', '/channels', 'channels:read'), ep('createChannel', 'post', '/channels', 'channels:write'), @@ -154,6 +158,7 @@ export const HTTP_ENDPOINTS: readonly HttpEndpoint[] = [ ep('pauseChannel', 'post', '/channels/{channel_id}/pause', 'channels:write'), ep('resumeChannel', 'post', '/channels/{channel_id}/resume', 'channels:write'), ep('testChannel', 'post', '/channels/{channel_id}/test', 'channels:write'), + ep('sendChannelDigest', 'post', '/channels/{channel_id}/digest', 'channels:write'), ep('getPreferences', 'get', '/me/preferences', null), ep('putPreferences', 'put', '/me/preferences', 'preferences:write'), ep('search', 'get', '/search', 'sessions:read'), diff --git a/packages/contracts/src/http/index.ts b/packages/contracts/src/http/index.ts index 2925037..ff253bd 100644 --- a/packages/contracts/src/http/index.ts +++ b/packages/contracts/src/http/index.ts @@ -74,8 +74,11 @@ export { ActionRow, ActionsPage, ActionsQuery, + ChannelAnomalyState, ChannelCapabilitiesDto, ChannelConnection, + ChannelDigestRequest, + ChannelDigestResponse, ChannelEnvQuery, ChannelEnvResponse, ChannelId, @@ -84,6 +87,7 @@ export { ChannelPatch, ChannelPreview, ChannelPreviewRequest, + ChannelReports, ChannelResponse, ChannelSecretState, ChannelStats, @@ -210,6 +214,18 @@ export { SessionPagesPage, SessionPagesQuery, } from './pages.ts'; +export { + PutReportSettingsRequest, + REPORTS_IN_APP_ONLY, + ReportChannel, + ReportDetailResponse, + ReportIdParams, + ReportItem, + ReportKind, + ReportSettingsResponse, + ReportsPage, + ReportsQuery, +} from './reports.ts'; export { ClientErrorReport, SearchQuery, SearchResponse } from './search.ts'; export { ArchiveSessionResponse, diff --git a/packages/contracts/src/http/notifications.ts b/packages/contracts/src/http/notifications.ts index d8ef8a1..b920575 100644 --- a/packages/contracts/src/http/notifications.ts +++ b/packages/contracts/src/http/notifications.ts @@ -71,6 +71,8 @@ export const NotificationsQuery = listQuery({ filters: { read: z.enum(['all', 'unread', 'read']).default('all'), type: csv(NotificationType), + /** With `type`, one facet: a row matches when its type or its category is selected (D-45). */ + category: csv(NotificationCategory), ...windowQuery, }, }); diff --git a/packages/contracts/src/http/reports.ts b/packages/contracts/src/http/reports.ts new file mode 100644 index 0000000..4c11080 --- /dev/null +++ b/packages/contracts/src/http/reports.ts @@ -0,0 +1,87 @@ +/** @module contracts/http/reports — reports in the dashboard (D-45, spec 03 §4.8): the history of in-app report copies, one report, and the in-app report settings */ +import { z } from 'zod'; +import { NotificationDeliveryStatus } from '../enums/index.ts'; +import { NotificationId } from '../ids/index.ts'; +import { ReportSettings } from '../notifications/channel.ts'; +import { NotificationMessage, NotificationReport } from '../notifications/message.ts'; +import { ChannelId, ChannelReports } from './channels.ts'; +import { Cursor, csv, limitQuery, page, QueryBool, windowQuery } from './common.ts'; +import { Notification } from './notifications.ts'; + +/** The report kinds (D-43, D-44). */ +export const ReportKind = z.enum(['digest.daily', 'digest.weekly', 'report.anomaly']); +/** A report kind. */ +export type ReportKind = z.infer; + +/** `channel` filter value for reports that reached no external channel. */ +export const REPORTS_IN_APP_ONLY = 'in-app'; + +/** `GET /notifications/reports` query. */ +export const ReportsQuery = z.strictObject({ + cursor: Cursor.optional(), + limit: limitQuery(200, 50), + total: QueryBool.default(false), + kind: csv(ReportKind), + /** A channel id (reports with a delivery to it), or `in-app` (reports that reached no channel). */ + channel: z.union([ChannelId, z.literal(REPORTS_IN_APP_ONLY)]).optional(), + ...windowQuery, +}); +/** `GET /notifications/reports` query. */ +export type ReportsQuery = z.infer; + +/** A channel a report reached, with the latest status of its delivery. */ +export const ReportChannel = z.object({ + channel_id: z.string(), + name: z.string(), + kind: z.string(), + status: NotificationDeliveryStatus, + /** Suppression or failure reason of that status, or `null`. */ + reason: z.string().nullable(), +}); +/** A channel a report reached. */ +export type ReportChannel = z.infer; + +/** One report of the history: its in-app copy, its window and the channels it reached. */ +export const ReportItem = z.object({ + notification: Notification, + /** Window, zone, late and on-demand markers; `null` for a row without a stored message. */ + report: NotificationReport.nullable(), + channels: z.array(ReportChannel), +}); +/** One report of the history. */ +export type ReportItem = z.infer; + +/** `GET /notifications/reports` body. */ +export const ReportsPage = page(ReportItem); +/** `GET /notifications/reports` body. */ +export type ReportsPage = z.infer; + +/** Path params `{notification_id}` of a report. */ +export const ReportIdParams = z.strictObject({ notification_id: NotificationId }); +/** Path params of a report. */ +export type ReportIdParams = z.infer; + +/** `GET /notifications/reports/{notification_id}` body. */ +export const ReportDetailResponse = z.object({ + report: ReportItem, + /** The in-app copy's current message (`full` level). */ + message: NotificationMessage, +}); +/** One report. */ +export type ReportDetailResponse = z.infer; + +/** `GET`/`PUT /notifications/report-settings` body. */ +export const ReportSettingsResponse = z.object({ + settings: ReportSettings, + /** The zone the in-app reports use without `time_zone`. */ + host_time_zone: z.string(), + /** The next digest, the anomaly check and its active checks, as a channel's view shows them. */ + reports: ChannelReports, +}); +/** The in-app report settings. */ +export type ReportSettingsResponse = z.infer; + +/** `PUT /notifications/report-settings` body. */ +export const PutReportSettingsRequest = z.strictObject({ settings: ReportSettings }); +/** `PUT /notifications/report-settings` body. */ +export type PutReportSettingsRequest = z.infer; diff --git a/packages/contracts/src/notifications/channel.ts b/packages/contracts/src/notifications/channel.ts index 72f6f51..4089b41 100644 --- a/packages/contracts/src/notifications/channel.ts +++ b/packages/contracts/src/notifications/channel.ts @@ -45,6 +45,98 @@ export type QuietHours = z.infer; const PerCategory = (value: T) => z.partialRecord(NotificationCategory, value); +/** + * Whether `name` is a time zone the runtime knows (`Intl`). + * + * @returns True for a known IANA zone (or `UTC`). + */ +export function isValidTimeZone(name: string): boolean { + try { + new Intl.DateTimeFormat('en-GB', { timeZone: name }); + return true; + } catch { + return false; + } +} + +/** Days of the week, as a weekly digest names them. */ +export const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const; +/** A day of the week. */ +export const Weekday = z.enum(WEEKDAYS); +/** A day of the week. */ +export type Weekday = z.infer; + +/** Time a daily digest is sent when none is chosen. */ +export const DEFAULT_DIGEST_AT = '09:00'; +/** Time a weekly digest is sent when none is chosen (D-43). */ +export const DEFAULT_WEEKLY_DIGEST_AT = '17:00'; +/** Day a weekly digest is sent when none is chosen (D-43). */ +export const DEFAULT_DIGEST_DAY: Weekday = 'fri'; + +/** + * A scheduled digest (D-43): every day (with `weekdays_only`, Monday to Friday) or every week (on + * `day`, default Friday) at `at`, in the channel's time zone. The report covers the period that + * ends at that time: with weekdays only, Monday's covers the weekend. + */ +export const DigestRule = z.object({ + every: z.enum(['day', 'week']), + at: ClockTime, + day: Weekday.optional(), + weekdays_only: z.boolean().optional(), +}); +/** A scheduled digest. */ +export type DigestRule = z.infer; + +/** The weekday a weekly digest runs on (its `day`, default Friday). */ +export function digestDay(rule: Pick): Weekday { + return rule.day ?? DEFAULT_DIGEST_DAY; +} + +/** + * The rule a frequency starts with: every day at 09:00, every week on Friday at 17:00 (D-43). + * + * @returns A new rule. + */ +export function defaultDigest(every: DigestRule['every']): DigestRule { + return every === 'week' + ? { every: 'week', at: DEFAULT_WEEKLY_DIGEST_AT, day: DEFAULT_DIGEST_DAY } + : { every: 'day', at: DEFAULT_DIGEST_AT }; +} + +/** + * The hourly anomaly checks (D-44). Each key is a threshold (or a switch); absent = its default + * ({@link ANOMALY_DEFAULTS}); `null` (or `false`) switches that check off. + */ +export const AnomalyRule = z.object({ + /** Percent of failed tool calls in the last hour. */ + error_rate: z.number().min(1).max(100).nullable().optional(), + /** Calls needed before the error rate counts. */ + min_calls: z.number().int().min(1).max(100_000).optional(), + /** Minutes an attention request may wait. */ + attention_minutes: z.number().int().min(1).max(10_080).nullable().optional(), + /** Blocked requests this many times the hourly average of the 24 hours before. */ + blocked_spike: z.number().min(1.5).max(1000).nullable().optional(), + /** Blocked requests needed before a spike counts. */ + blocked_min: z.number().int().min(1).max(1_000_000).optional(), + /** Live sessions at `maxSessions`. */ + capacity: z.boolean().optional(), + /** An unresolved error-severity system event. */ + degraded: z.boolean().optional(), +}); +/** The anomaly checks. */ +export type AnomalyRule = z.infer; + +/** Thresholds used when a channel's `anomaly` rule leaves a key out (D-44). */ +export const ANOMALY_DEFAULTS = { + error_rate: 20, + min_calls: 20, + attention_minutes: 30, + blocked_spike: 3, + blocked_min: 50, + capacity: true, + degraded: true, +} as const; + /** * What a channel receives and how (`notification_channels.rules_json`). Every key is optional: * absent means "no restriction" or the documented default. Unknown keys are dropped on read, so a @@ -60,6 +152,15 @@ export const NotificationChannelRules = z.object({ /** Harness slugs (`claude-code`); absent = any. Self-reported, routing only (D-30). */ harness: z.array(z.string().min(1).max(32)).max(32).optional(), quiet_hours: QuietHours.optional(), + /** + * The channel's IANA time zone for its reports and quiet hours (`quiet_hours.time_zone` still + * wins for quiet hours); absent = the host's zone (D-43). + */ + time_zone: z.string().min(1).max(64).optional(), + /** A scheduled digest (D-43); absent = none. */ + digest: DigestRule.optional(), + /** Hourly anomaly alerts (D-44); absent = off. */ + anomaly: AnomalyRule.optional(), /** Content level; absent = `titles`. */ content: NotificationContentLevel.optional(), /** Screenshots per category (D-36); absent = off. */ @@ -121,6 +222,22 @@ export const SUPPRESSION_REASONS = [ 'edit_unsupported', 'delete_unsupported', 'no_adapter', + 'empty', ] as const; /** A suppression reason. */ export type SuppressionReason = (typeof SUPPRESSION_REASONS)[number]; + +/** + * The in-app reports (D-45): a digest schedule and the anomaly switch for the dashboard itself, + * with no external channel. The same keys as a channel's report rules; `{}` = off (the default). + */ +export const ReportSettings = z.strictObject({ + /** The in-app digest; absent = off. */ + digest: DigestRule.optional(), + /** The in-app anomaly alerts (their thresholds); absent = off. */ + anomaly: AnomalyRule.optional(), + /** The IANA zone of the in-app reports; absent = the host's. */ + time_zone: z.string().min(1).max(64).optional(), +}); +/** The in-app reports. */ +export type ReportSettings = z.infer; diff --git a/packages/contracts/src/notifications/index.ts b/packages/contracts/src/notifications/index.ts index 5596e2d..0694719 100644 --- a/packages/contracts/src/notifications/index.ts +++ b/packages/contracts/src/notifications/index.ts @@ -1,17 +1,29 @@ /** @module contracts/notifications — the notification contract (D-32): `NotificationMessage`, its taxonomy, channel rules and the published JSON Schema */ export { + ANOMALY_DEFAULTS, + AnomalyRule, DEFAULT_CONTENT_LEVEL, + DEFAULT_DIGEST_AT, + DEFAULT_DIGEST_DAY, + DEFAULT_WEEKLY_DIGEST_AT, + DigestRule, + defaultDigest, + digestDay, + isValidTimeZone, NotificationChannelName, NotificationChannelRules, NotificationChannelSecretRefs, NotificationChannelTarget, QuietHours, RESERVED_ENV_PREFIX, + ReportSettings, SecretEnvName, StartupNotificationChannel, SUPPRESSION_REASONS, type SuppressionReason, + WEEKDAYS, + Weekday, } from './channel.ts'; export { NOTIFICATION_MESSAGE_SCHEMA_ID, notificationMessageJsonSchema } from './json-schema.ts'; export { @@ -19,6 +31,7 @@ export { ActionStyle, Block, type BlockType, + ChartBlock, CodeBlock, DashboardPath, DividerBlock, @@ -37,6 +50,7 @@ export { ListBlock, NOTIFICATION_ACTIONS_MAX, NOTIFICATION_BLOCKS_MAX, + NOTIFICATION_CHART_POINTS_MAX, NOTIFICATION_LABEL_MAX, NOTIFICATION_SCHEMA_VERSION, NOTIFICATION_SUMMARY_MAX, @@ -47,6 +61,7 @@ export { NotificationEntities, NotificationMessage, NotificationPrivacy, + NotificationReport, OpenAction, QuoteBlock, TableBlock, @@ -58,6 +73,9 @@ export { ACTION_TOKEN_LENGTH, ACTION_TOKEN_PREFIX, ACTION_TOKEN_TTL_MS, + ANOMALY_CHECK_TEXT, + ANOMALY_CHECKS, + type AnomalyCheck, AVAILABLE_CHANNEL_KINDS, AVAILABLE_DISCORD_MODES, AvailableChannelKind, @@ -67,6 +85,7 @@ export { type ChannelKindSpec, checkChannelConfig, checkChannelRules, + checkReportRules, DELIVERY_REASON_TEXT, DISCORD_BOT_PERMISSIONS, DISCORD_MODES, @@ -84,6 +103,19 @@ export { TELEGRAM_DELETE_WINDOW_MS, TELEGRAM_TTL_MAX_MS, } from './platforms.ts'; +export { + digestWindow, + nextHour, + nextOccurrence, + occurrencesBetween, + parseClock, + periodMs, + previousOccurrence, + scheduleKey, + type WallTime, + wallTime, + zonedInstant, +} from './schedule.ts'; export { classifyLegacy, IN_APP_ONLY_KINDS, diff --git a/packages/contracts/src/notifications/message.ts b/packages/contracts/src/notifications/message.ts index 2a74976..49e5da5 100644 --- a/packages/contracts/src/notifications/message.ts +++ b/packages/contracts/src/notifications/message.ts @@ -124,6 +124,25 @@ export const CodeBlock = z.object({ }); /** A separator. */ export const DividerBlock = z.object({ type: z.literal('divider') }); +/** Most bars in a `chart` block. */ +export const NOTIFICATION_CHART_POINTS_MAX = 48; + +/** + * Bars over equal steps from `start` (a digest's tool calls per hour). No platform draws charts + * natively: `degrade` turns one into a line of text bars wherever `charts` is not a capability. + */ +export const ChartBlock = z.object({ + type: z.literal('chart'), + label: Label, + values: z.array(z.number().nonnegative()).min(1).max(NOTIFICATION_CHART_POINTS_MAX), + /** Start of the first bar. */ + start: EpochMs, + /** Width of one bar. */ + step_ms: z.number().int().positive(), + /** Unit of the values (`calls`), or `null`. */ + unit: z.string().max(24).nullable(), +}); + /** Small print at the end (the "Open in BrowserHive" link, a "you missed N" note). */ export const FooterBlock = z.object({ type: z.literal('footer'), content: InlineRun }); @@ -139,6 +158,7 @@ export const Block = z.discriminatedUnion('type', [ CodeBlock, DividerBlock, FooterBlock, + ChartBlock, ]); /** One block. */ export type Block = z.infer; @@ -218,6 +238,24 @@ export const NotificationPrivacy = z.object({ /** Applied privacy. */ export type NotificationPrivacy = z.infer; +/** + * What a scheduled report covers (D-43, D-44): its window, the time zone its dates are written in, + * and whether it was sent late (after downtime), with earlier windows skipped, or on demand. + */ +export const NotificationReport = z.object({ + window: z.object({ since: EpochMs, until: EpochMs }), + /** IANA zone the report's dates and times are written in. */ + time_zone: z.string().min(1).max(64), + /** Produced more than 5 minutes after its scheduled time (BrowserHive was not running). */ + late: z.boolean(), + /** Earlier scheduled windows skipped while BrowserHive was off. */ + skipped: z.number().int().nonnegative(), + /** Sent on demand ("Send a digest now"), outside the schedule. */ + manual: z.boolean(), +}); +/** What a report covers. */ +export type NotificationReport = z.infer; + /** * The notification contract (D-32). Every revision is the complete state: consumers always render * the whole message and never merge revisions. Consumers MUST ignore kinds, blocks, inlines and @@ -244,6 +282,8 @@ export const NotificationMessage = z.object({ actions: z.array(NotificationAction).max(NOTIFICATION_ACTIONS_MAX), entities: NotificationEntities, privacy: NotificationPrivacy, + /** Present on scheduled reports only (`digest.*`, `report.anomaly`). */ + report: NotificationReport.optional(), }); /** The notification contract. */ export type NotificationMessage = z.infer; diff --git a/packages/contracts/src/notifications/platforms.ts b/packages/contracts/src/notifications/platforms.ts index 5a2bf4f..d247232 100644 --- a/packages/contracts/src/notifications/platforms.ts +++ b/packages/contracts/src/notifications/platforms.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import type { NotificationCategory } from '../enums/notification-category.ts'; -import { type NotificationChannelRules, RESERVED_ENV_PREFIX } from './channel.ts'; +import { isValidTimeZone, type NotificationChannelRules, RESERVED_ENV_PREFIX } from './channel.ts'; /** Platforms that have an adapter (N1). The other `NotificationChannelKind` members are reserved. */ export const AVAILABLE_CHANNEL_KINDS = ['telegram', 'discord', 'ntfy', 'webhook'] as const; @@ -469,6 +469,43 @@ export function hasPresserIdentity(kind: string): boolean { return kind === 'telegram' || kind === 'discord'; } +function zoneProblem(field: string, zone: string): { field: string; message: string } { + return { + field, + message: `'${zone.slice(0, 64)}' is not a time zone; use an IANA name such as Europe/Berlin.`, + }; +} + +/** + * Checks the report rules shared by a channel and the in-app settings (D-43, D-45): a known time + * zone, a weekday only for a weekly digest, weekdays only only for a daily one. + * + * @returns Every problem, its field prefixed with `prefix` (`rules.` for a channel). + */ +export function checkReportRules( + rules: Pick, + prefix = '', +): readonly { readonly field: string; readonly message: string }[] { + const problems: { field: string; message: string }[] = []; + if (rules.time_zone !== undefined && !isValidTimeZone(rules.time_zone)) { + problems.push(zoneProblem(`${prefix}time_zone`, rules.time_zone)); + } + const digest = rules.digest; + if (digest !== undefined && digest.every === 'day' && digest.day !== undefined) { + problems.push({ + field: `${prefix}digest.day`, + message: 'a weekday applies to a weekly digest only.', + }); + } + if (digest !== undefined && digest.every === 'week' && digest.weekdays_only !== undefined) { + problems.push({ + field: `${prefix}digest.weekdays_only`, + message: 'weekdays only applies to a daily digest.', + }); + } + return problems; +} + /** * Checks the act-button rules of a channel (D-41): the switch only where presses can arrive, and * an allow-list of numeric platform user ids only where pressers are identified. Shared by the @@ -495,6 +532,11 @@ export function checkChannelRules(input: { : `${input.kind} cannot receive button presses.`, }); } + const quietZone = input.rules.quiet_hours?.time_zone; + if (quietZone !== undefined && !isValidTimeZone(quietZone)) { + problems.push(zoneProblem('rules.quiet_hours.time_zone', quietZone)); + } + problems.push(...checkReportRules(input.rules, 'rules.')); const allow = input.rules.allow_list ?? []; if (allow.length > 0 && !hasPresserIdentity(input.kind)) { problems.push({ @@ -535,6 +577,8 @@ export const PREVIEW_SAMPLES = [ 'crash', 'degraded', 'test', + 'digest', + 'anomaly', ] as const; /** A preview sample. */ export const PreviewSample = z.enum(PREVIEW_SAMPLES); @@ -550,6 +594,8 @@ export const PREVIEW_SAMPLE_LABEL: { readonly [S in PreviewSample]: string } = { crash: 'Session crashed', degraded: 'System degraded', test: 'Test message', + digest: 'Daily digest', + anomaly: 'Something looks off', }; /** Presets of the setup wizard (spec 04 §12.11.1). */ @@ -558,6 +604,8 @@ export const CHANNEL_PRESETS: readonly { readonly label: string; readonly describe: string; readonly categories: readonly NotificationCategory[] | null; + /** Switches the scheduled reports on (the daily digest and the anomaly alerts, D-43, D-44). */ + readonly reports?: true; }[] = [ { id: 'needs-me', @@ -583,8 +631,35 @@ export const CHANNEL_PRESETS: readonly { describe: 'Every notification BrowserHive produces.', categories: null, }, + { + id: 'daily-digest', + label: 'Daily digest', + describe: 'A summary every morning and a heads-up when something looks off; nothing instant.', + categories: ['reports'], + reports: true, + }, ]; +/** The anomaly checks (D-44), in the order reports list them. */ +export const ANOMALY_CHECKS = [ + 'error_rate', + 'attention', + 'capacity', + 'blocked', + 'degraded', +] as const; +/** One anomaly check. */ +export type AnomalyCheck = (typeof ANOMALY_CHECKS)[number]; + +/** What each anomaly check watches, in plain words. */ +export const ANOMALY_CHECK_TEXT: { readonly [C in AnomalyCheck]: string } = { + error_rate: 'Many tool calls failing', + attention: 'An attention request waiting too long', + capacity: 'Sessions at the limit (maxSessions)', + blocked: 'A spike in blocked requests', + degraded: 'BrowserHive itself degraded', +}; + /** * What a delivery-log reason means, in one sentence (the "why wasn't this sent?" view). Dynamic * reasons (`backlog:N`, an error code on `dead`) are handled by {@link deliveryReasonText}. @@ -617,6 +692,8 @@ export const DELIVERY_REASON_TEXT: Readonly> = { auth: 'The platform refused the credentials (a wrong or revoked token or URL).', rejected: 'The platform refused the message.', test: 'A test message sent from the dashboard or the CLI.', + empty: 'Nothing happened in the period of this digest, so nothing was sent.', + manual: 'A digest sent on demand ("Send a digest now").', }; /** diff --git a/packages/contracts/src/notifications/schedule.ts b/packages/contracts/src/notifications/schedule.ts new file mode 100644 index 0000000..52c61e6 --- /dev/null +++ b/packages/contracts/src/notifications/schedule.ts @@ -0,0 +1,239 @@ +/** @module contracts/notifications/schedule — the calendar maths of scheduled reports (D-43, spec 03 §9.7), shared by the server and the dashboard: wall-clock times in an IANA zone with DST handled (a skipped time is shifted by the gap, a repeated one fires once), the occurrences of a digest rule, its windows, the next run and the hourly anomaly slots. Pure (`Intl` only). */ + +import { type DigestRule, digestDay, WEEKDAYS } from './channel.ts'; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +/** Longest span enumerated for missed occurrences; older ones are only counted. */ +const MAX_SCAN_MS = 400 * DAY; + +/** A calendar date and wall-clock time in some zone. */ +export interface WallTime { + readonly year: number; + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; + /** 0 = Monday … 6 = Sunday. */ + readonly weekday: number; +} + +const FORMATS = new Map(); + +function formatter(zone: string): Intl.DateTimeFormat { + let f = FORMATS.get(zone); + if (f === undefined) { + f = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + weekday: 'short', + hourCycle: 'h23', + }); + FORMATS.set(zone, f); + } + return f; +} + +const WEEKDAY_INDEX: Readonly> = { + Mon: 0, + Tue: 1, + Wed: 2, + Thu: 3, + Fri: 4, + Sat: 5, + Sun: 6, +}; + +/** + * The wall-clock time of an instant in `zone`. + * + * @returns Year, month (1–12), day, hour (0–23), minute and weekday (0 = Monday). + */ +export function wallTime(at: number, zone: string): WallTime { + const parts = formatter(zone).formatToParts(at); + const get = (type: Intl.DateTimeFormatPartTypes) => + parts.find((p) => p.type === type)?.value ?? '0'; + return { + year: Number(get('year')), + month: Number(get('month')), + day: Number(get('day')), + hour: Number(get('hour')) % 24, + minute: Number(get('minute')), + weekday: WEEKDAY_INDEX[get('weekday')] ?? 0, + }; +} + +/** Offset of `zone` from UTC at an instant (local − UTC, ms). */ +function offsetAt(at: number, zone: string): number { + const w = wallTime(at, zone); + const local = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute); + return local - Math.floor(at / MINUTE) * MINUTE; +} + +/** + * The instant a wall-clock time has in `zone`. A time that occurs twice (fall back) resolves to + * its first occurrence; a time that does not exist (spring forward) is shifted forward by the gap + * (02:30 on a night that jumps from 02:00 to 03:00 is 03:30). + * + * @returns Epoch ms. + */ +export function zonedInstant( + date: { readonly year: number; readonly month: number; readonly day: number }, + clock: { readonly hour: number; readonly minute: number }, + zone: string, +): number { + const local = Date.UTC(date.year, date.month - 1, date.day, clock.hour, clock.minute); + const before = offsetAt(local - 12 * HOUR, zone); + const after = offsetAt(local + 12 * HOUR, zone); + const matches = (at: number) => { + const w = wallTime(at, zone); + return ( + w.year === date.year && + w.month === date.month && + w.day === date.day && + w.hour === clock.hour && + w.minute === clock.minute + ); + }; + const candidates = [local - before, local - after].filter(matches).sort((a, b) => a - b); + const first = candidates[0]; + // No candidate: the time falls in a spring-forward gap. The pre-transition offset lands the + // same distance past the gap's end. + return first ?? local - before; +} + +/** `HH:MM` as hour and minute. */ +export function parseClock(at: string): { hour: number; minute: number } { + const [h = '0', m = '0'] = at.split(':'); + return { hour: Number(h), minute: Number(m) }; +} + +/** The date `n` days after a calendar date (pure calendar arithmetic, no zone). */ +function addDays( + date: { readonly year: number; readonly month: number; readonly day: number }, + n: number, +): { year: number; month: number; day: number } { + const d = new Date(Date.UTC(date.year, date.month - 1, date.day + n)); + return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; +} + +/** Weekday index (0 = Monday) of a calendar date. */ +function weekdayOf(date: { readonly year: number; readonly month: number; readonly day: number }) { + return (new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay() + 6) % 7; +} + +/** Whether a rule fires on a calendar date (its weekday; Monday to Friday for weekdays only). */ +function firesOn( + rule: DigestRule, + date: { readonly year: number; readonly month: number; readonly day: number }, +): boolean { + if (rule.every === 'week') return weekdayOf(date) === WEEKDAYS.indexOf(digestDay(rule)); + return rule.weekdays_only !== true || weekdayOf(date) < 5; +} + +/** Nominal length of one period of a rule. */ +export function periodMs(rule: DigestRule): number { + return rule.every === 'week' ? 7 * DAY : DAY; +} + +/** + * Every scheduled instant of a rule in `(from, to]`, oldest first. A span longer than 400 days is + * scanned from 400 days before `to` only; `older` counts the occurrences before that (nominally). + * + * @returns The occurrences and the count of older ones not enumerated. + */ +export function occurrencesBetween( + rule: DigestRule, + zone: string, + from: number, + to: number, +): { readonly at: readonly number[]; readonly older: number } { + if (to <= from) return { at: [], older: 0 }; + const start = Math.max(from, to - MAX_SCAN_MS); + const older = start > from ? Math.floor((start - from) / periodMs(rule)) : 0; + const clock = parseClock(rule.at); + const out: number[] = []; + let date = addDays(wallTime(start, zone), -1); + const last = addDays(wallTime(to, zone), 1); + const lastKey = Date.UTC(last.year, last.month - 1, last.day); + while (Date.UTC(date.year, date.month - 1, date.day) <= lastKey) { + if (firesOn(rule, date)) { + const at = zonedInstant(date, clock, zone); + if (at > start && at <= to) out.push(at); + } + date = addDays(date, 1); + } + return { at: out, older }; +} + +/** + * The first scheduled instant strictly after `after`. + * + * @returns Epoch ms. + */ +export function nextOccurrence(rule: DigestRule, zone: string, after: number): number { + const clock = parseClock(rule.at); + let date = addDays(wallTime(after, zone), -1); + for (let i = 0; i < 16; i++) { + if (firesOn(rule, date)) { + const at = zonedInstant(date, clock, zone); + if (at > after) return at; + } + date = addDays(date, 1); + } + return after + periodMs(rule); +} + +/** + * The last scheduled instant strictly before `before`. + * + * @returns Epoch ms. + */ +export function previousOccurrence(rule: DigestRule, zone: string, before: number): number { + const clock = parseClock(rule.at); + let date = addDays(wallTime(before, zone), 1); + for (let i = 0; i < 16; i++) { + if (firesOn(rule, date)) { + const at = zonedInstant(date, clock, zone); + if (at < before) return at; + } + date = addDays(date, -1); + } + return before - periodMs(rule); +} + +/** + * The window a report scheduled at `occurrence` covers: from the previous scheduled instant (23 + * or 25 hours before across a DST change) to `occurrence`, starting no earlier than the end of the + * last window already reported. + * + * @returns `{since, until}`. + */ +export function digestWindow( + rule: DigestRule, + zone: string, + occurrence: number, + lastUntil: number | null, +): { readonly since: number; readonly until: number } { + const previous = previousOccurrence(rule, zone, occurrence); + const since = + lastUntil !== null && lastUntil > previous && lastUntil < occurrence ? lastUntil : previous; + return { since, until: occurrence }; +} + +/** Identity of a rule in a zone: a change re-arms the schedule (spec 03 §9.7). */ +export function scheduleKey(rule: DigestRule, zone: string): string { + const day = + rule.every === 'week' ? `:${digestDay(rule)}` : rule.weekdays_only === true ? '-weekdays' : ''; + return `${rule.every}${day}@${rule.at}@${zone}`; +} + +/** The next top of the hour after `after` (the anomaly slots, UTC-aligned). */ +export function nextHour(after: number): number { + return Math.floor(after / HOUR) * HOUR + HOUR; +} diff --git a/packages/contracts/src/notifications/taxonomy.ts b/packages/contracts/src/notifications/taxonomy.ts index 99afb6f..f57c827 100644 --- a/packages/contracts/src/notifications/taxonomy.ts +++ b/packages/contracts/src/notifications/taxonomy.ts @@ -18,6 +18,7 @@ export const KIND_CATEGORY: { readonly [K in NotificationKind]: NotificationCate 'system.degraded': 'system', 'channel.broken': 'system', 'digest.daily': 'reports', + 'digest.weekly': 'reports', 'report.anomaly': 'reports', test: 'system', }; @@ -34,6 +35,7 @@ export const KIND_SEVERITY: { readonly [K in NotificationKind]: NotificationSeve 'system.degraded': 'error', 'channel.broken': 'error', 'digest.daily': 'info', + 'digest.weekly': 'info', 'report.anomaly': 'warn', test: 'info', }; @@ -50,6 +52,7 @@ export const KIND_LABEL: { readonly [K in NotificationKind]: string } = { 'system.degraded': 'BrowserHive degraded', 'channel.broken': 'Notification channel failing', 'digest.daily': 'Daily digest', + 'digest.weekly': 'Weekly digest', 'report.anomaly': 'Something looks off', test: 'Test notification', }; @@ -74,6 +77,7 @@ export const KIND_TYPE: { readonly [K in NotificationKind]: NotificationType } = 'system.degraded': 'system', 'channel.broken': 'system', 'digest.daily': 'lifecycle', + 'digest.weekly': 'lifecycle', 'report.anomaly': 'system', test: 'system', }; diff --git a/packages/contracts/test/errors.registry.test.ts b/packages/contracts/test/errors.registry.test.ts index 8581ffd..58368e6 100644 --- a/packages/contracts/test/errors.registry.test.ts +++ b/packages/contracts/test/errors.registry.test.ts @@ -57,6 +57,7 @@ const SPEC_CODES = [ 'CHANNEL_KIND_UNAVAILABLE', 'CHANNEL_PLATFORM_ERROR', 'DELIVERY_NOT_FOUND', + 'REPORT_NOT_FOUND', 'SESSION_NOT_AVAILABLE', 'ELEMENT_NOT_FOUND', 'NAVIGATION_TIMEOUT', diff --git a/packages/contracts/test/goldens/ws/ws-protocol.json b/packages/contracts/test/goldens/ws/ws-protocol.json index 1db57f6..56c0886 100644 --- a/packages/contracts/test/goldens/ws/ws-protocol.json +++ b/packages/contracts/test/goldens/ws/ws-protocol.json @@ -3067,6 +3067,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -3260,6 +3261,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -3501,6 +3503,105 @@ ], "additionalProperties": false }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ], + "additionalProperties": false + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "anyOf": [ + { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + { + "type": "null" + } + ] + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 10080 + }, + { + "type": "null" + } + ] + }, + "blocked_spike": { + "anyOf": [ + { + "type": "number", + "minimum": 1.5, + "maximum": 1000 + }, + { + "type": "null" + } + ] + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "content": { "type": "string", "enum": [ @@ -3588,6 +3689,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -3637,6 +3741,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -3811,6 +3916,152 @@ "type": "null" } ] + }, + "reports": { + "type": "object", + "properties": { + "time_zone": { + "type": "string" + }, + "host_zone": { + "type": "boolean" + }, + "digest": { + "anyOf": [ + { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string" + }, + "day": { + "anyOf": [ + { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + { + "type": "null" + } + ] + }, + "weekdays_only": { + "type": "boolean" + }, + "next_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_until": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "every", + "at", + "day", + "weekdays_only", + "next_at", + "last_until" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "anomaly": { + "anyOf": [ + { + "type": "object", + "properties": { + "next_check_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "active": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": { + "type": "string", + "enum": [ + "error_rate", + "attention", + "capacity", + "blocked", + "degraded" + ] + }, + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "value": { + "type": "number" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "check", + "since", + "value", + "threshold" + ], + "additionalProperties": false + } + } + }, + "required": [ + "next_check_at", + "active" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "time_zone", + "host_zone", + "digest", + "anomaly" + ], + "additionalProperties": false } }, "required": [ @@ -3835,7 +4086,8 @@ "created_at", "updated_at", "stats", - "connection" + "connection", + "reports" ], "additionalProperties": false } @@ -3912,6 +4164,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -4021,6 +4274,62 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991 + }, + "report": { + "anyOf": [ + { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "until": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "since", + "until" + ], + "additionalProperties": false + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] } }, "required": [ @@ -4041,7 +4350,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ], "additionalProperties": false } @@ -7266,6 +7576,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -7459,6 +7770,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -7700,6 +8012,105 @@ ], "additionalProperties": false }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "digest": { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + "weekdays_only": { + "type": "boolean" + } + }, + "required": [ + "every", + "at" + ], + "additionalProperties": false + }, + "anomaly": { + "type": "object", + "properties": { + "error_rate": { + "anyOf": [ + { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + { + "type": "null" + } + ] + }, + "min_calls": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "attention_minutes": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 10080 + }, + { + "type": "null" + } + ] + }, + "blocked_spike": { + "anyOf": [ + { + "type": "number", + "minimum": 1.5, + "maximum": 1000 + }, + { + "type": "null" + } + ] + }, + "blocked_min": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "capacity": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "content": { "type": "string", "enum": [ @@ -7787,6 +8198,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -7836,6 +8250,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -8010,6 +8425,152 @@ "type": "null" } ] + }, + "reports": { + "type": "object", + "properties": { + "time_zone": { + "type": "string" + }, + "host_zone": { + "type": "boolean" + }, + "digest": { + "anyOf": [ + { + "type": "object", + "properties": { + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] + }, + "at": { + "type": "string" + }, + "day": { + "anyOf": [ + { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + }, + { + "type": "null" + } + ] + }, + "weekdays_only": { + "type": "boolean" + }, + "next_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_until": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "every", + "at", + "day", + "weekdays_only", + "next_at", + "last_until" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "anomaly": { + "anyOf": [ + { + "type": "object", + "properties": { + "next_check_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "active": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": { + "type": "string", + "enum": [ + "error_rate", + "attention", + "capacity", + "blocked", + "degraded" + ] + }, + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "value": { + "type": "number" + }, + "threshold": { + "type": "number" + } + }, + "required": [ + "check", + "since", + "value", + "threshold" + ], + "additionalProperties": false + } + } + }, + "required": [ + "next_check_at", + "active" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "time_zone", + "host_zone", + "digest", + "anomaly" + ], + "additionalProperties": false } }, "required": [ @@ -8034,7 +8595,8 @@ "created_at", "updated_at", "stats", - "connection" + "connection", + "reports" ], "additionalProperties": false } @@ -8111,6 +8673,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -8220,6 +8783,62 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991 + }, + "report": { + "anyOf": [ + { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "until": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "since", + "until" + ], + "additionalProperties": false + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "late": { + "type": "boolean" + }, + "skipped": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "manual": { + "type": "boolean" + } + }, + "required": [ + "window", + "time_zone", + "late", + "skipped", + "manual" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] } }, "required": [ @@ -8240,7 +8859,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ], "additionalProperties": false } diff --git a/packages/core/src/app/config/notification-channel-flag.test.ts b/packages/core/src/app/config/notification-channel-flag.test.ts index 4bf176a..041926f 100644 --- a/packages/core/src/app/config/notification-channel-flag.test.ts +++ b/packages/core/src/app/config/notification-channel-flag.test.ts @@ -75,7 +75,8 @@ describe('parseNotificationChannelFlags', () => { sessions: ['shop-*', 'scrape-*'], harness: ['claude-code'], content: 'full', - quiet_hours: { start: '22:00', end: '07:30', time_zone: 'Europe/Berlin' }, + quiet_hours: { start: '22:00', end: '07:30' }, + time_zone: 'Europe/Berlin', ttl_ms: { 'needs-you': 7_200_000, problems: 86_400_000 }, delete_when_resolved: { 'needs-you': true }, images: { 'needs-you': true }, @@ -218,4 +219,74 @@ describe('parseNotificationChannelFlags', () => { topic: 'BH_NTFY_TOKEN', }); }); + + it('parses digests, the channel time zone and anomaly thresholds (D-43, D-44)', () => { + const base = 'telegram:name=morning,token=env:BH_TG_TOKEN,chat=1'; + const rules = (extra: string) => parse(`${base},${extra}`).channels[0]?.rules; + expect(rules('digest=daily@08:30,tz=Europe/Berlin')).toEqual({ + digest: { every: 'day', at: '08:30' }, + time_zone: 'Europe/Berlin', + }); + expect(rules('digest=daily')).toEqual({ digest: { every: 'day', at: '09:00' } }); + expect(rules('digest=weekly:fri@17:00')).toEqual({ + digest: { every: 'week', at: '17:00', day: 'fri' }, + }); + // Weekly defaults to Friday 17:00; each part can be given alone (D-43). + expect(rules('digest=weekly')).toEqual({ digest: { every: 'week', at: '17:00', day: 'fri' } }); + expect(rules('digest=weekly:mon')).toEqual({ + digest: { every: 'week', at: '17:00', day: 'mon' }, + }); + expect(rules('digest=weekly@08:00')).toEqual({ + digest: { every: 'week', at: '08:00', day: 'fri' }, + }); + expect(rules('digest=daily:weekdays')).toEqual({ + digest: { every: 'day', at: '09:00', weekdays_only: true }, + }); + expect(rules('digest=daily:weekdays@07:30')).toEqual({ + digest: { every: 'day', at: '07:30', weekdays_only: true }, + }); + expect(rules('anomaly=on')).toEqual({ anomaly: {} }); + expect(rules('anomaly=off')).toEqual({}); + expect( + rules( + 'anomaly=on,anomaly.errorRate=10,anomaly.minCalls=5,anomaly.attention=off,anomaly.blocked=2.5,anomaly.blockedMin=10,anomaly.capacity=off,anomaly.degraded=on', + ), + ).toEqual({ + anomaly: { + error_rate: 10, + min_calls: 5, + attention_minutes: null, + blocked_spike: 2.5, + blocked_min: 10, + capacity: false, + degraded: true, + }, + }); + }); + + it('refuses bad digest, zone and anomaly values with plain texts', () => { + const base = 'telegram:name=morning,token=env:BH_TG_TOKEN,chat=1'; + const problems = (extra: string) => parse(`${base},${extra}`).problems; + expect(problems('digest=hourly')).toEqual([ + "--notificationChannel 'morning': digest must be daily@HH:MM, daily:weekdays@HH:MM or weekly:@HH:MM, like daily@09:00 or weekly:fri@17:00.", + ]); + expect(problems('digest=weekly:weekdays')).toEqual([ + "--notificationChannel 'morning': weekdays applies to a daily digest only (daily:weekdays@…).", + ]); + expect(problems('digest=daily:mon@09:00')).toEqual([ + "--notificationChannel 'morning': a weekday applies to a weekly digest only (weekly:mon@…).", + ]); + expect(problems('tz=Mars/Olympus')).toEqual([ + "--notificationChannel 'morning': tz 'Mars/Olympus' is not an IANA time zone (like Europe/Berlin).", + ]); + expect(problems('anomaly.errorRate=10')).toEqual([ + "--notificationChannel 'morning': anomaly.errorRate needs anomaly=on.", + ]); + expect(problems('anomaly=on,anomaly.errorRate=0')).toEqual([ + "--notificationChannel 'morning': anomaly.errorRate must be a number from 1 to 100, or off.", + ]); + expect(problems('anomaly=maybe')).toEqual([ + "--notificationChannel 'morning': anomaly must be on or off.", + ]); + }); }); diff --git a/packages/core/src/app/config/notification-channel-flag.ts b/packages/core/src/app/config/notification-channel-flag.ts index 89ed089..fa9108d 100644 --- a/packages/core/src/app/config/notification-channel-flag.ts +++ b/packages/core/src/app/config/notification-channel-flag.ts @@ -7,18 +7,22 @@ import { NotificationSeverity, } from '@browserhive/contracts/enums'; import { + type AnomalyRule, AVAILABLE_CHANNEL_KINDS, AvailableChannelKind, CHANNEL_KIND_SPECS, type ChannelKindSpec, checkChannelConfig, checkChannelRules, + DEFAULT_DIGEST_DAY, + defaultDigest, NotificationChannelName, type NotificationChannelRules, NTFY_DEFAULT_SERVER, RESERVED_ENV_PREFIX, StartupNotificationChannel, TELEGRAM_TTL_MAX_MS, + WEEKDAYS, } from '@browserhive/contracts/notifications'; import { withSuggestion } from './failure.ts'; import { suggest } from './suggest.ts'; @@ -49,6 +53,15 @@ const RULE_PARAMS = [ 'maskImages', 'actButtons', 'allow', + 'digest', + 'anomaly', + 'anomaly.errorRate', + 'anomaly.minCalls', + 'anomaly.attention', + 'anomaly.blocked', + 'anomaly.blockedMin', + 'anomaly.capacity', + 'anomaly.degraded', ] as const; /** Secret parameters that must be `env:NAME` (topics and url may also be literal). */ @@ -102,8 +115,8 @@ function validZone(zone: string): boolean { function parseBool(value: string): boolean | null { const v = value.toLowerCase(); - if (v === 'true' || v === '1' || v === 'yes') return true; - if (v === 'false' || v === '0' || v === 'no') return false; + if (v === 'true' || v === '1' || v === 'yes' || v === 'on') return true; + if (v === 'false' || v === '0' || v === 'no' || v === 'off') return false; return null; } @@ -341,6 +354,98 @@ function categoriesOf( return items.filter(isCategory); } +const DIGEST_RE = + /^(daily|weekly)(?::(mon|tue|wed|thu|fri|sat|sun|weekdays))?(?:@([01]\d|2[0-3]):([0-5]\d))?$/; + +const ANOMALY_NUMBERS = [ + { param: 'anomaly.errorRate', key: 'error_rate', min: 1, max: 100, off: true, int: false }, + { param: 'anomaly.minCalls', key: 'min_calls', min: 1, max: 100_000, off: false, int: true }, + { + param: 'anomaly.attention', + key: 'attention_minutes', + min: 1, + max: 10_080, + off: true, + int: true, + }, + { param: 'anomaly.blocked', key: 'blocked_spike', min: 1.5, max: 1000, off: true, int: false }, + { + param: 'anomaly.blockedMin', + key: 'blocked_min', + min: 1, + max: 1_000_000, + off: false, + int: true, + }, +] as const; + +/** `digest`, `anomaly` and `anomaly.*` (spec 08 §5.7, D-43, D-44). */ +function parseReports( + params: ReadonlyMap, + label: string, + rules: { -readonly [K in keyof NotificationChannelRules]: NotificationChannelRules[K] }, + problems: string[], +): void { + const digest = params.get('digest'); + if (digest !== undefined) { + const m = DIGEST_RE.exec(digest.toLowerCase()); + if (m === null) { + problems.push( + `${label}: digest must be daily@HH:MM, daily:weekdays@HH:MM or weekly:@HH:MM, like daily@09:00 or weekly:fri@17:00.`, + ); + } else if (m[1] === 'daily' && m[2] !== undefined && m[2] !== 'weekdays') { + problems.push(`${label}: a weekday applies to a weekly digest only (weekly:${m[2]}@…).`); + } else if (m[1] === 'weekly' && m[2] === 'weekdays') { + problems.push(`${label}: weekdays applies to a daily digest only (daily:weekdays@…).`); + } else { + const every = m[1] === 'weekly' ? 'week' : 'day'; + const base = defaultDigest(every); + const at = m[3] === undefined ? base.at : `${m[3]}:${m[4]}`; + rules.digest = + every === 'week' + ? { every, at, day: WEEKDAYS.find((d) => d === m[2]) ?? DEFAULT_DIGEST_DAY } + : { every, at, ...(m[2] === 'weekdays' && { weekdays_only: true }) }; + } + } + const anomaly = params.get('anomaly'); + const on = anomaly === undefined ? null : parseBool(anomaly); + if (anomaly !== undefined && on === null) problems.push(`${label}: anomaly must be on or off.`); + const tuned = [...params.keys()].filter((k) => k.startsWith('anomaly.')); + if (tuned.length > 0 && on !== true) { + problems.push(`${label}: ${tuned[0]} needs anomaly=on.`); + return; + } + if (on !== true) return; + const rule: { -readonly [K in keyof AnomalyRule]: AnomalyRule[K] } = {}; + for (const spec of ANOMALY_NUMBERS) { + const value = params.get(spec.param); + if (value === undefined) continue; + if (spec.off && ['off', 'false', 'no'].includes(value.toLowerCase())) { + Object.assign(rule, { [spec.key]: null }); + continue; + } + const n = Number(value); + if (!Number.isFinite(n) || n < spec.min || n > spec.max || (spec.int && !Number.isInteger(n))) { + problems.push( + `${label}: ${spec.param} must be ${spec.int ? 'a whole number' : 'a number'} from ${spec.min} to ${spec.max}${spec.off ? ', or off' : ''}.`, + ); + continue; + } + Object.assign(rule, { [spec.key]: n }); + } + for (const [param, key] of [ + ['anomaly.capacity', 'capacity'], + ['anomaly.degraded', 'degraded'], + ] as const) { + const value = params.get(param); + if (value === undefined) continue; + const flag = parseBool(value); + if (flag === null) problems.push(`${label}: ${param} must be on or off.`); + else rule[key] = flag; + } + rules.anomaly = rule; +} + function parseRules( params: ReadonlyMap, label: string, @@ -380,17 +485,18 @@ function parseRules( const m = QUIET_RE.exec(quiet); if (m === null) problems.push(`${label}: quiet must be HH:MM-HH:MM, like 22:00-07:30.`); else { - rules.quiet_hours = { - start: `${m[1]}:${m[2]}`, - end: `${m[3]}:${m[4]}`, - ...(tz !== undefined && { time_zone: tz }), - }; + rules.quiet_hours = { start: `${m[1]}:${m[2]}`, end: `${m[3]}:${m[4]}` }; } } if (tz !== undefined) { - if (quiet === undefined) problems.push(`${label}: tz applies to quiet hours; set quiet too.`); - else if (!validZone(tz)) problems.push(`${label}: tz '${tz}' is not an IANA time zone.`); + // The channel's zone: its quiet hours and its reports (D-43). + if (validZone(tz)) rules.time_zone = tz; + else + problems.push( + `${label}: tz '${tz.slice(0, 64)}' is not an IANA time zone (like Europe/Berlin).`, + ); } + parseReports(params, label, rules, problems); const ttl: Partial> = {}; for (const [key, value] of params) { if (!key.startsWith('ttl.')) continue; diff --git a/packages/core/src/app/notifications/channel-registry.ts b/packages/core/src/app/notifications/channel-registry.ts index 76bab81..0b1aa7f 100644 --- a/packages/core/src/app/notifications/channel-registry.ts +++ b/packages/core/src/app/notifications/channel-registry.ts @@ -50,6 +50,8 @@ export interface ChannelRegistryDeps { readonly env?: (name: string) => string | undefined; /** Registers a resolved secret with the redactor (`SecretRegistry.add`). */ readonly registerSecret?: (value: string) => void; + /** Called after a startup channel no longer declared was removed (its cursors go with it). */ + readonly onRemoved?: (channelId: string) => Promise; } /** @@ -112,6 +114,7 @@ export class ChannelRegistry { for (const row of existing) { if (row.source === 'startup' && !declared.has(row.name)) { await this.deps.repo.remove(row.channelId); + await this.deps.onRemoved?.(row.channelId); this.log.info('startup channel removed', { channel: row.name }); } } diff --git a/packages/core/src/app/notifications/channel-service.ts b/packages/core/src/app/notifications/channel-service.ts index d02ef63..c5161c6 100644 --- a/packages/core/src/app/notifications/channel-service.ts +++ b/packages/core/src/app/notifications/channel-service.ts @@ -3,6 +3,7 @@ import type { NotificationCategory } from '@browserhive/contracts/enums'; import { type ChannelCapabilitiesDto, + type ChannelDigestResponse, type ChannelInput, type ChannelPatch, type ChannelPreview, @@ -51,6 +52,7 @@ import { type TelegramSetup, type TelegramStart, } from '../../ports/notification-channel.ts'; +import type { NotificationCursorRepository } from '../../ports/persistence/notification-actions.ts'; import type { ChannelDeliveryStats, NotificationChannelRecord, @@ -65,8 +67,11 @@ import { restrictContent } from './content-level.ts'; import { degrade } from './degrade.ts'; import { applyImageRule, wantsImages } from './images.ts'; import { clip, decodeMessage, encodeMessage } from './message.ts'; +import type { ReportScheduler } from './report-scheduler.ts'; +import { forgetChannelCursors, reportsView } from './report-scheduler.ts'; import { contentLevelOf, deleteWhenResolved, expiryFor } from './routing.ts'; import { sampleMessage } from './samples.ts'; +import { runtimeZone, usableZone } from './schedule.ts'; /** Window of the per-channel counts on the cards. */ const STATS_WINDOW_MS = 24 * 60 * 60_000; @@ -113,6 +118,15 @@ export interface ChannelServiceDeps { readonly redactor?: Redactor; /** Timer for debounced feed events (defaults to `setTimeout`). */ readonly schedule?: (fn: () => void, ms: number) => void; + /** The scheduled reports (D-43, D-44): views, "Send a digest now", cursor cleanup. */ + readonly reports?: Pick< + ReportScheduler, + 'view' | 'zoneOf' | 'manualDigest' | 'storeManualCopy' | 'forget' + >; + /** The channel cursors (removed with a channel). */ + readonly cursors?: NotificationCursorRepository; + /** The host's IANA zone (the default of `rules.time_zone`); default the runtime's. */ + readonly hostZone?: () => string; } /** A page of the delivery log. */ @@ -132,6 +146,15 @@ export interface DeliveryListInput { readonly kinds?: readonly string[]; } +/** What a preview renders for: a saved channel's setup, or a draft's. */ +interface PreviewSetup { + readonly kind: string; + readonly mode: string | null; + readonly target: Readonly>; + readonly rules: NotificationChannelRules; + readonly secretRefs: Readonly>; +} + interface ConnectSession { readonly id: string; readonly tokenEnv: string; @@ -160,6 +183,7 @@ export function capabilitiesDto(c: ChannelCapabilities): ChannelCapabilitiesDto return { rich_blocks: c.richBlocks, tables: c.tables, + charts: c.charts, images: c.images, act_buttons: c.actButtons, open_links: c.openLinks, @@ -277,6 +301,11 @@ export class ChannelService { // Views // --------------------------------------------------------------------------------------------- + /** The zone a channel without `rules.time_zone` uses (`GET /channels`). */ + hostTimeZone(): string { + return usableZone(this.deps.hostZone?.() ?? runtimeZone(), 'UTC'); + } + /** Every channel (dashboard and startup), by name. */ async list(): Promise { const stats = await this.stats(); @@ -362,6 +391,9 @@ export class ChannelService { last_status: s?.lastStatus ?? null, }, connection: this.deps.connection?.(r.channelId) ?? null, + reports: + this.deps.reports?.view(r) ?? + reportsView(r.rules, this.deps.clock.now(), this.hostTimeZone()), }; } @@ -503,6 +535,8 @@ export class ChannelService { throw new AppError('CHANNEL_READ_ONLY', { channel_id: channelId, name: current.name }); } await this.deps.repos.notificationChannels.remove(channelId); + if (this.deps.cursors !== undefined) await forgetChannelCursors(this.deps.cursors, channelId); + this.deps.reports?.forget(channelId); await this.deps.registry.reload(); this.log.info('channel removed', { channel: current.name }); this.deps.bus.publish('channel.removed', { type: 'channel.removed', channel_id: channelId }); @@ -577,20 +611,7 @@ export class ChannelService { * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_NOT_READY`. */ async test(channelId: string): Promise { - const entry = this.entry(channelId); - const adapter = entry.adapter; - const capabilities = entry.capabilities; - const missing = this.missingOf(entry.record); - if (adapter === null || capabilities === null || missing.length > 0) { - throw new AppError('CHANNEL_NOT_READY', { - channel_id: channelId, - problem: - missing.length > 0 - ? `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set.` - : (entry.problem ?? 'the channel has no adapter.'), - missing, - }); - } + const entry = this.readyEntry(channelId); const now = this.deps.clock.now(); const notificationId = `n-${this.deps.ids.opaque(12)}`; const sample = sampleMessage('test', { now }); @@ -622,6 +643,93 @@ export class ChannelService { thread: message.thread, messageJson: encodeMessage(message), }; + const sent = await this.sendNow(entry, message, record, 'test'); + return { ok: sent.error === null, delivery: sent.delivery, error: sent.error }; + } + + /** + * "Send a digest now" (spec 03 §4.8.1, D-43): the channel's digest of the period that ends now, + * previewed (pure) or also sent at once outside the queue as a `manual` report; the schedule and + * its cursor are untouched. + * + * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_NOT_READY` (sending without an adapter). + */ + async digest(channelId: string, send: boolean): Promise { + const reports = this.deps.reports; + const entry = send ? this.readyEntry(channelId) : this.entry(channelId); + if (reports === undefined) { + throw new AppError('CHANNEL_NOT_READY', { + channel_id: channelId, + problem: 'scheduled reports are not available in this process.', + missing: [], + }); + } + const built = await reports.manualDigest(entry.record); + const r = entry.record; + const preview = this.render( + { kind: r.kind, mode: r.mode, target: r.target, secretRefs: r.secretRefs, rules: r.rules }, + built.message, + 'digest', + true, + ); + const base = { preview, window: built.window, empty: built.empty }; + if (!send) return { ...base, sent: false, ok: true, delivery: null, error: null }; + const ready = this.readyEntry(channelId); + // A digest someone asked for has its own in-app copy (D-45), which the channel row names. + const copy = await reports.storeManualCopy(built); + const sent = await this.sendNow( + ready, + built.message, + copy === null ? built.record : { ...built.record, sourceEventId: copy }, + 'manual', + ); + return { + ...base, + sent: true, + ok: sent.error === null, + delivery: sent.delivery, + error: sent.error, + }; + } + + /** The channel, or `CHANNEL_NOT_READY` when it cannot send (no adapter, a variable unset). */ + private readyEntry(channelId: string): RegisteredChannel & { + readonly adapter: NonNullable; + readonly capabilities: ChannelCapabilities; + } { + const entry = this.entry(channelId); + const adapter = entry.adapter; + const capabilities = entry.capabilities; + const missing = this.missingOf(entry.record); + if (adapter === null || capabilities === null || missing.length > 0) { + throw new AppError('CHANNEL_NOT_READY', { + channel_id: channelId, + problem: + missing.length > 0 + ? `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set.` + : (entry.problem ?? 'the channel has no adapter.'), + missing, + }); + } + return { ...entry, adapter, capabilities }; + } + + /** + * Stores a notification that only this channel receives, sends it through the adapter now + * (outside the outbox queue: the caller waits for the platform's answer) and records it in the + * delivery log with `reason` (`test`, `manual`). + * + * @returns The delivery row and the classified error, if any. + */ + private async sendNow( + entry: ReturnType, + message: NotificationMessage, + record: NotificationRecord, + reason: string, + ): Promise<{ delivery: DeliveryRow | null; error: { code: string; message: string } | null }> { + const channelId = entry.record.channelId; + const notificationId = record.notificationId; + const now = this.deps.clock.now(); await this.deps.uow.transaction(async (r) => { await r.notifications.insert(record); await r.notificationDeliveries.enqueue([ @@ -631,7 +739,7 @@ export class ChannelService { revision: 1, op: 'send', status: 'pending', - reason: 'test', + reason, nextAttemptAt: null, createdAt: now, }, @@ -640,17 +748,17 @@ export class ChannelService { const job = ( await this.deps.repos.notificationDeliveries.list({ channelId, notificationId, limit: 1 }) )[0]; - if (job === undefined) throw new Error('test delivery was not recorded'); + if (job === undefined) throw new Error('direct delivery was not recorded'); await this.deps.repos.notificationDeliveries.claim(job.seq, now); const delivery: ChannelDelivery = { - message: this.shape(message, entry.record.rules, capabilities), + message: this.shape(message, entry.record.rules, entry.capabilities), links: this.deps.links, replyTo: null, }; const started = this.deps.clock.now(); let error: { code: string; message: string } | null = null; try { - const result = await adapter.send(delivery); + const result = await entry.adapter.send(delivery); const done = this.deps.clock.now(); const rules = entry.record.rules; let expiresAt = expiryFor(rules, message, done); @@ -658,7 +766,7 @@ export class ChannelService { await this.deps.uow.transaction(async (r) => { await r.notificationDeliveries.finish(job.seq, { status: 'sent', - reason: 'test', + reason, lastError: null, durationMs: Math.max(0, done - started), messageRef: result.ref, @@ -676,7 +784,7 @@ export class ChannelService { deletedAt: null, }); }); - this.log.info('channel test sent', { channel: entry.record.name }); + this.log.info('channel direct send', { channel: entry.record.name, reason }); } catch (err) { const done = this.deps.clock.now(); const code = err instanceof ChannelSendError ? err.code : 'unavailable'; @@ -691,14 +799,14 @@ export class ChannelService { durationMs: Math.max(0, done - started), updatedAt: done, }); - this.log.warn('channel test failed', { channel: entry.record.name, code }); + this.log.warn('channel direct send failed', { channel: entry.record.name, code }); } const row = await this.deps.repos.notificationDeliveries.get(job.seq); const dto = row === null ? null : await this.deliveryRow(row, new Map()); if (dto !== null) this.deps.bus.publish('delivery.updated', { type: 'delivery.updated', delivery: dto }); this.scheduleChannel(channelId); - return { ok: error === null, delivery: dto, error }; + return { delivery: dto, error }; } private scrub(text: string): string { @@ -712,46 +820,72 @@ export class ChannelService { * @throws AppError `CHANNEL_NOT_FOUND`, `CHANNEL_KIND_UNAVAILABLE`. */ preview(request: ChannelPreviewRequest): ChannelPreview { - let kind: string; - let mode: string | null; - let target: Readonly>; - let rules: NotificationChannelRules; - let secretRefs: Readonly> = {}; + let setup: PreviewSetup; if (request.channel_id !== undefined) { const r = this.entry(request.channel_id).record; - kind = r.kind; - mode = r.mode; - target = r.target; - rules = r.rules; - secretRefs = r.secretRefs; + setup = { + kind: r.kind, + mode: r.mode, + target: r.target, + rules: r.rules, + secretRefs: r.secretRefs, + }; } else { - kind = request.kind ?? 'webhook'; const spec = CHANNEL_KIND_SPECS[request.kind ?? 'webhook']; - mode = request.mode ?? spec.defaultMode; - target = request.target ?? {}; - rules = request.rules ?? {}; - // Names only; anything that is not a variable name (a pasted value) is never echoed back. - secretRefs = Object.fromEntries( - Object.entries(request.secret_refs ?? {}).filter( - ([, name]) => SecretEnvName.safeParse(name).success, + setup = { + kind: request.kind ?? 'webhook', + mode: request.mode ?? spec.defaultMode, + target: request.target ?? {}, + rules: request.rules ?? {}, + // Names only; anything that is not a variable name (a pasted value) is never echoed back. + secretRefs: Object.fromEntries( + Object.entries(request.secret_refs ?? {}).filter( + ([, name]) => SecretEnvName.safeParse(name).success, + ), ), - ); + }; } + const rules = setup.rules; + const now = this.deps.clock.now(); + const options = { + now, + level: contentLevelOf(rules), + zone: this.deps.reports?.zoneOf(rules) ?? usableZone(rules.time_zone, this.hostTimeZone()), + ...(rules.digest !== undefined && { digest: rules.digest }), + }; + const plain = sampleMessage(request.sample, options); + // A crash's image is its last stored frame, which cannot be masked: a masking channel gets none. + const maskedCrash = request.sample === 'crash' && rules.mask_images === true; + const withImage = + wantsImages(rules, plain.category) && !maskedCrash + ? sampleMessage(request.sample, { + ...options, + image: rules.mask_images === true ? 'masked' : 'unmasked', + }) + : plain; + return this.render(setup, withImage, request.sample); + } + + /** + * Renders a message exactly as a channel (saved, or a draft) would send it: content level, image + * rule, degrade, the renderer, and the secrets of paths replaced by their variable names. Pure. + * + * @throws AppError `CHANNEL_KIND_UNAVAILABLE`. + */ + private render( + setup: PreviewSetup, + message: NotificationMessage, + sample: PreviewSample, + real = false, + ): ChannelPreview { + const { kind, mode, target, rules, secretRefs } = setup; const renderer = this.deps.renderers.get(kind); const parsedKind = AvailableChannelKind.safeParse(kind); if (renderer === undefined || !parsedKind.success) { throw new AppError('CHANNEL_KIND_UNAVAILABLE', { kind, mode_text: '' }); } const capabilities = renderer.capabilities({ mode, target, secretRefs, rules }); - const now = this.deps.clock.now(); - const plain = sampleMessage(request.sample, { now }); - const withImage = wantsImages(rules, plain.category) - ? sampleMessage(request.sample, { - now, - image: rules.mask_images === true ? 'masked' : 'unmasked', - }) - : plain; - const shown = this.shape(withImage, rules, capabilities); + const shown = this.shape(message, rules, capabilities); const rendered = renderer.render( { message: shown, links: this.deps.links, replyTo: null }, { mode, target, op: 'send', ref: null, actToken: (id) => `bh1:preview-${id}` }, @@ -775,7 +909,7 @@ export class ChannelService { return { kind: parsedKind.data, mode, - sample: request.sample, + sample, capabilities: capabilitiesDto(capabilities), message: shown, requests, @@ -784,8 +918,8 @@ export class ChannelService { parsedKind.data, rules, capabilities, - plain.category, - request.sample, + message.category, + real ? null : sample, target, ), }; @@ -796,7 +930,8 @@ export class ChannelService { rules: NotificationChannelRules, caps: ChannelCapabilities, category: NotificationCategory, - sample: PreviewSample, + /** The sample, or `null` for a real message (no sample notes). */ + sample: PreviewSample | null, target: Readonly>, ): string[] { const notes: string[] = []; @@ -822,6 +957,18 @@ export class ChannelService { if (rules.images?.[category] === true && !wantsImages(rules, category)) { notes.push('Screenshots are on, but they need the content level "full".'); } + if (sample === 'digest') { + notes.push( + rules.digest === undefined + ? 'A sample with made-up figures. Turn on the daily digest to receive one on schedule.' + : 'A sample with made-up figures; the real digest counts what happened in the period, and a period with no activity sends nothing.', + ); + } + if (sample === 'anomaly') { + notes.push( + 'A sample alert. Real alerts are sent only when a check crosses its threshold, and the message is edited when things get back to normal.', + ); + } const server = (target['server'] ?? NTFY_DEFAULT_SERVER).replace(/\/+$/, ''); if (kind === 'ntfy' && wantsImages(rules, category) && server === NTFY_DEFAULT_SERVER) { notes.push( @@ -913,6 +1060,10 @@ export class ChannelService { message_ref: row.messageRef === null ? null : { ...row.messageRef }, created_at: row.createdAt, updated_at: row.updatedAt, + report: + n !== null && n.category === 'reports' + ? (decodeMessage(n.messageJson)?.report ?? null) + : null, }); } @@ -1218,16 +1369,13 @@ export class ChannelService { limit: FEED_ROWS, }); const cache = new Map(); - const latest = new Map(); - for (const row of rows) { - const key = `${row.channelId}:${row.op}`; - if (!latest.has(key)) latest.set(key, row); - } - for (const row of latest.values()) { + // Every row of the notification (a newer job supersedes older ones, so the older rows change + // too), oldest first, so clients end on the newest state. + for (const row of [...rows].reverse()) { const dto = await this.deliveryRow(row, cache); this.deps.bus.publish('delivery.updated', { type: 'delivery.updated', delivery: dto }); - this.scheduleChannel(row.channelId); } + for (const id of new Set(rows.map((r) => r.channelId))) this.scheduleChannel(id); })().catch((err: unknown) => this.log.warn('delivery feed failed', { err: serializeError(err) }), ); diff --git a/packages/core/src/app/notifications/content-level.ts b/packages/core/src/app/notifications/content-level.ts index ed65202..923c9f9 100644 --- a/packages/core/src/app/notifications/content-level.ts +++ b/packages/core/src/app/notifications/content-level.ts @@ -17,7 +17,8 @@ const RANK: { readonly [L in NotificationContentLevel]: number } = { * tables, lists, images or paragraphs, where free text and screenshots live); * - `counts`: the kind's generic title (with the group count when the title had one), the session * slug as the summary, no blocks, the actions, and only the session entities. - * A message already at a lower level is never raised. + * A message already at a lower level is never raised, and one already at `level` is returned as it + * is: a report is built at its channel's level by its producer (spec 03 §9.7). * * @returns The restricted message. */ @@ -26,7 +27,7 @@ export function restrictContent( level: NotificationContentLevel, ): NotificationMessage { const target = RANK[level] < RANK[message.privacy.level] ? level : message.privacy.level; - if (target === 'full') return message; + if (target === 'full' || target === message.privacy.level) return message; if (target === 'titles') { const blocks = message.blocks.filter((b) => b.type === 'fields' || b.type === 'footer'); return { ...message, blocks, privacy: { level: 'titles', has_image: false } }; diff --git a/packages/core/src/app/notifications/degrade.test.ts b/packages/core/src/app/notifications/degrade.test.ts index d449e8e..2630d5d 100644 --- a/packages/core/src/app/notifications/degrade.test.ts +++ b/packages/core/src/app/notifications/degrade.test.ts @@ -8,7 +8,7 @@ import { } from '@browserhive/contracts/notifications'; import { capabilities } from '../../../test/helpers/fake-channel.ts'; import { restrictContent } from './content-level.ts'; -import { degrade, OPEN_IN_BROWSERHIVE } from './degrade.ts'; +import { degrade, OPEN_IN_BROWSERHIVE, sparkline } from './degrade.ts'; import { buildMessage, code, link, text } from './message.ts'; const TABLE: Block = { @@ -101,7 +101,7 @@ describe('degrade', () => { it('turns a table into a list of column: value rows', () => { const out = degrade(message({ blocks: [TABLE] }), capabilities({ tables: false })); expect(out.blocks[0]).toMatchObject({ type: 'list', ordered: false }); - expect(JSON.stringify(out.blocks[0])).toContain('Tool: '); + expect(JSON.stringify(out.blocks[0])).toContain('"Tool:"'); expect(out.blocks[0]?.type === 'list' && out.blocks[0].items).toHaveLength(2); }); @@ -143,7 +143,7 @@ describe('degrade', () => { capabilities({ richBlocks: false }), ); expect(out.blocks.every((b) => b.type === 'text')).toBe(true); - expect(JSON.stringify(out.blocks)).toContain('Session: '); + expect(JSON.stringify(out.blocks)).toContain('"Session:"'); }); it('moves the first link into a footer where link buttons are unsupported', () => { @@ -213,3 +213,52 @@ describe('restrictContent', () => { expect(restrictContent(counts, 'full')).toEqual(counts); }); }); + +describe('charts (D-32, spec 03 §9.2)', () => { + const CHART: Block = { + type: 'chart', + label: 'Tool calls per hour', + values: [0, 2, 4, 8], + start: 0, + step_ms: 3_600_000, + unit: 'calls', + }; + + it('scales text bars against the largest value; all zero is flat', () => { + expect(sparkline([0, 2, 4, 8])).toBe('▁▃▅█'); + expect(sparkline([0, 0, 0])).toBe('▁▁▁'); + expect(sparkline([5])).toBe('█'); + }); + + it('turns a chart into one paragraph where charts are not a capability', () => { + const out = degrade(message({ blocks: [CHART] }), capabilities({ charts: false })); + expect(out.blocks).toEqual([ + { + type: 'text', + content: [ + { type: 'bold', text: 'Tool calls per hour' }, + { type: 'text', text: ' ' }, + { type: 'code', text: '▁▃▅█' }, + { type: 'text', text: ' peak\u00a08\u00a0calls' }, + ], + }, + ]); + expect(NotificationMessage.safeParse(out).success).toBe(true); + }); + + it('keeps a chart where charts render natively (the generic webhook)', () => { + const out = degrade(message({ blocks: [CHART] }), capabilities({ charts: true })); + expect(out.blocks).toEqual([CHART]); + }); +}); + +describe('restrictContent of a report built at its level', () => { + it('returns a message already at the target level unchanged (tables kept at titles)', () => { + const built = { + ...message({ blocks: [TABLE] }), + privacy: { level: 'titles', has_image: false }, + }; + expect(restrictContent(built as Message, 'titles')).toBe(built as Message); + expect(restrictContent(built as Message, 'counts').blocks).toEqual([]); + }); +}); diff --git a/packages/core/src/app/notifications/degrade.ts b/packages/core/src/app/notifications/degrade.ts index 48c5667..cf3b076 100644 --- a/packages/core/src/app/notifications/degrade.ts +++ b/packages/core/src/app/notifications/degrade.ts @@ -8,7 +8,7 @@ import type { OpenAction, } from '@browserhive/contracts/notifications'; import type { ChannelCapabilities } from '../../ports/notification-channel.ts'; -import { bold, clip, link, text } from './message.ts'; +import { bold, clip, code, formatCount, link, text } from './message.ts'; /** Label of the link that replaces cut content and actions a channel cannot show. */ export const OPEN_IN_BROWSERHIVE = 'Open in BrowserHive'; @@ -45,9 +45,48 @@ function blockLength(block: Block): number { return block.alt.length; case 'divider': return 0; + case 'chart': + return block.label.length + block.values.length + 16; } } +/** Eighth-block characters, lowest first. */ +const BARS = '▁▂▃▄▅▆▇█'; + +/** + * Text bars for a series (`▁▂▅▇█▃`): each value scaled against the largest; all zero is all `▁`. + * + * @returns One character per value. + */ +export function sparkline(values: readonly number[]): string { + const max = Math.max(0, ...values); + if (max <= 0) return BARS[0]?.repeat(values.length) ?? ''; + return values + .map( + (v) => + BARS[Math.min(BARS.length - 1, Math.round((Math.max(0, v) / max) * (BARS.length - 1)))], + ) + .join(''); +} + +/** No-break space: "peak 1,525 calls" wraps as one piece on a narrow phone. */ +const NBSP = '\u00a0'; + +/** A chart as one paragraph: its label, the bars in monospace and the peak. */ +function chartToText(block: Extract): Block { + const peak = Math.max(0, ...block.values); + const unit = block.unit === null ? '' : `${NBSP}${block.unit}`; + return { + type: 'text', + content: [ + bold(block.label), + text(' '), + code(sparkline(block.values)), + text(` peak${NBSP}${formatCount(peak)}${unit}`), + ], + }; +} + /** A table as a list: one item per row, `column: value` pairs joined with `·`. */ function tableToList(block: Extract): Block[] { if (block.rows.length === 0) return []; @@ -56,7 +95,8 @@ function tableToList(block: Extract): Block[] { row.forEach((cell, i) => { if (i > 0) out.push(text(' · ')); const column = block.columns[i]; - if (column !== undefined) out.push(bold(`${column}: `)); + // The space stays outside the bold run: `**Tool:** x`, which every markdown renders. + if (column !== undefined) out.push(bold(`${column}:`), text(' ')); out.push(...cell); }); return out; @@ -72,7 +112,7 @@ function toPlain(block: Block): Block[] { case 'fields': return block.items.map((i) => ({ type: 'text', - content: [bold(`${i.label}: `), ...i.value], + content: [bold(`${i.label}:`), text(' '), ...i.value], })); case 'quote': return [{ type: 'text', content: [text('“'), ...block.content, text('”')] }]; @@ -104,6 +144,10 @@ function adaptBlocks(blocks: readonly Block[], caps: ChannelCapabilities): Block out.push(...tableToList(block)); continue; } + if (block.type === 'chart' && !caps.charts) { + out.push(chartToText(block)); + continue; + } out.push(block); } if (!caps.richBlocks) out = out.flatMap(toPlain); @@ -159,7 +203,8 @@ function openFooter(path: string, prefix = ''): Block { /** * Adapts a message to a renderer's capabilities (D-32): * - images are dropped, or become a "View screenshot" link to their dashboard page; - * - tables become lists, and without rich blocks every block becomes plain paragraphs; + * - tables become lists, charts a line of text bars, and without rich blocks every block becomes + * plain paragraphs; * - act buttons become their `open` fallback where the channel cannot act, and never survive a * state other than `open`; duplicate links go; at most `maxButtons` remain; without link buttons * the first link becomes an "Open in BrowserHive" footer; diff --git a/packages/core/src/app/notifications/in-app-channel.ts b/packages/core/src/app/notifications/in-app-channel.ts index 228335b..a8f7aee 100644 --- a/packages/core/src/app/notifications/in-app-channel.ts +++ b/packages/core/src/app/notifications/in-app-channel.ts @@ -16,6 +16,7 @@ export const IN_APP_CHANNEL = 'in-app'; export const IN_APP_CAPABILITIES: ChannelCapabilities = { richBlocks: true, tables: true, + charts: true, images: true, actButtons: true, openLinks: true, diff --git a/packages/core/src/app/notifications/index.ts b/packages/core/src/app/notifications/index.ts index 33a6866..4659a67 100644 --- a/packages/core/src/app/notifications/index.ts +++ b/packages/core/src/app/notifications/index.ts @@ -103,6 +103,55 @@ export { publicUrlHost, publicUrlOrigin, } from './public-url.ts'; +export { + createReportFacts, + type ReportFacts, + type ReportFactsDeps, +} from './report-facts.ts'; +export { + anomalyCursorKey, + channelCursorKeys, + digestCursorKey, + digestPeriodThread, + forgetChannelCursors, + IN_APP_REPORT_THREAD, + IN_APP_SCHEDULE, + LATE_AFTER_MS, + manualPeriodThread, + REPORT_TICK_MS, + type ReportCounter, + type ReportPass, + ReportScheduler, + type ReportSchedulerDeps, + type ReportSettingsSource, + reportPath, + reportsView, + watchKey, +} from './report-scheduler.ts'; +export { + isInAppReport, + ReportService, + type ReportServiceDeps, +} from './report-service.ts'; +export { + REPORT_SETTINGS_KEY, + ReportSettingsStore, + schedulesReports, +} from './report-settings.ts'; +export { + type ActiveCheck, + type AnomalyFacts, + type AnomalyState, + anomalyThresholds, + buildAnomaly, + buildDigest, + type DigestFacts, + evaluateAnomalies, + isEmptyDigest, + type ReportContent, + type ReportContext, + reportMessage, +} from './reports.ts'; export { contentLevelOf, deleteWhenResolved, @@ -110,6 +159,7 @@ export { inQuietHours, localMinutes, planDeliveries, + quietHoursOf, type RoutableChannel, type RouteDecision, route, @@ -119,6 +169,23 @@ export { SAMPLE_NOTIFICATION_ID, SAMPLE_NOW, SAMPLE_SESSION_ID, + SAMPLE_ZONE, type SampleOptions, + sampleAnomalyFacts, + sampleDigestFacts, sampleMessage, } from './samples.ts'; +export { + digestWindow, + formatClock, + formatDay, + nextHour, + nextOccurrence, + occurrencesBetween, + previousOccurrence, + runtimeZone, + scheduleKey, + usableZone, + wallTime, + zonedInstant, +} from './schedule.ts'; diff --git a/packages/core/src/app/notifications/message.ts b/packages/core/src/app/notifications/message.ts index a9c236e..5955eb5 100644 --- a/packages/core/src/app/notifications/message.ts +++ b/packages/core/src/app/notifications/message.ts @@ -71,6 +71,30 @@ export function formatDuration(ms: number): string { return `${h}h ${String(m % 60).padStart(2, '0')}m`; } +const COUNT_FORMAT = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); +const PERCENT_FORMAT = new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits: 1, +}); + +/** + * A count with thousands separators, like the dashboard (`3,412`; at most one decimal). + * + * @returns The formatted number. + */ +export function formatCount(value: number): string { + return COUNT_FORMAT.format(value); +} + +/** + * A ratio (0–1) as a percentage with at most one decimal (`2.1%`). + * + * @returns The formatted percentage. + */ +export function formatPercent(ratio: number): string { + return PERCENT_FORMAT.format(ratio); +} + /** Everything the first revision of a message is built from (the producer's facts). */ export interface MessageContent { readonly blocks: readonly Block[]; @@ -220,6 +244,8 @@ const LIMIT_BY_KEY: Readonly> = { domain: 253, request_id: 64, decision: 256, + unit: 24, + time_zone: 64, }; /** Keys whose values are identifiers or enums, never free text. */ diff --git a/packages/core/src/app/notifications/outbox.ts b/packages/core/src/app/notifications/outbox.ts index 53f59f3..f76184e 100644 --- a/packages/core/src/app/notifications/outbox.ts +++ b/packages/core/src/app/notifications/outbox.ts @@ -164,8 +164,8 @@ export class NotificationOutbox { * * @returns The rows (empty without external channels). */ - plan(message: NotificationMessage, now: number): NewNotificationDelivery[] { - return planDeliveries(message, this.deps.registry.channels(), now); + plan(message: NotificationMessage, now: number, addressedTo?: string): NewNotificationDelivery[] { + return planDeliveries(message, this.deps.registry.channels(), now, addressedTo); } /** Wakes the worker after a commit that enqueued work. No-op without channels. */ diff --git a/packages/core/src/app/notifications/public-url.test.ts b/packages/core/src/app/notifications/public-url.test.ts index 57223b6..bbd5014 100644 --- a/packages/core/src/app/notifications/public-url.test.ts +++ b/packages/core/src/app/notifications/public-url.test.ts @@ -61,6 +61,12 @@ describe('classifyPublicUrlProbe', () => { body: 'Sign in', }; expect(classifyPublicUrlProbe(html, 'me').outcome).toBe('login'); + // A proxy's error page is not a login: the proxy cannot reach BrowserHive. + for (const status of [502, 503, 504]) { + const verdict = classifyPublicUrlProbe({ ...html, status }, 'me'); + expect(verdict.outcome).toBe('unreachable'); + expect(verdict.detail).toContain(`HTTP ${status}`); + } }); it('unreachable for network errors, elsewhere for other servers', () => { diff --git a/packages/core/src/app/notifications/public-url.ts b/packages/core/src/app/notifications/public-url.ts index 2da7b9f..4d74d52 100644 --- a/packages/core/src/app/notifications/public-url.ts +++ b/packages/core/src/app/notifications/public-url.ts @@ -135,6 +135,16 @@ export function classifyPublicUrlProbe( browserhive: true, }; } + if (status === 502 || status === 503 || status === 504) { + // A proxy answered for an upstream it cannot reach (often an error page in HTML): the address + // is set up, but BrowserHive is not behind it right now. + return { + outcome: 'unreachable', + detail: `It answers HTTP ${status}: something in front of BrowserHive (a proxy or tunnel) cannot reach it.`, + statusCode: status, + browserhive: false, + }; + } if ((result.contentType ?? '').includes('text/html')) { return { outcome: 'login', diff --git a/packages/core/src/app/notifications/report-facts.ts b/packages/core/src/app/notifications/report-facts.ts new file mode 100644 index 0000000..c8d1a01 --- /dev/null +++ b/packages/core/src/app/notifications/report-facts.ts @@ -0,0 +1,153 @@ +/** @module app/notifications/report-facts — gathers what the scheduled reports say (D-43, D-44, spec 03 §9.7) from the analytics read model and the repositories: bounded, indexed queries only, never raw SQL. */ + +import { parseSessionId } from '@browserhive/contracts/ids'; +import type { DigestRule } from '@browserhive/contracts/notifications'; +import type { AnalyticsQueries } from '../../ports/persistence/analytics.ts'; +import type { Repositories } from '../../ports/persistence/unit-of-work.ts'; +import { type AnomalyFacts, type DigestFacts, SLOWEST_TOOL_MIN_CALLS } from './reports.ts'; + +const HOUR = 3_600_000; +/** Number of top errors a digest lists. */ +const TOP_ERRORS = 3; + +/** Where the facts come from. */ +export interface ReportFactsDeps { + readonly analytics: Pick< + AnalyticsQueries, + 'windowCounts' | 'toolLatency' | 'topErrors' | 'harnessMetrics' | 'activity' + >; + readonly repos: Pick< + Repositories, + 'operatorRequests' | 'vaultAudit' | 'blocklistAudit' | 'systemEvents' + >; + /** Live sessions and `maxSessions` now. */ + readonly capacity: () => { readonly live: number; readonly max: number }; +} + +/** The facts behind the reports (a port so the scheduler can be tested without a database). */ +export interface ReportFacts { + digest( + window: { readonly since: number; readonly until: number }, + rule: DigestRule, + ): Promise; + anomaly(now: number): Promise; +} + +/** + * The facts of one digest window and of the anomaly check, from the read model. + * + * @returns A {@link ReportFacts}. + */ +export function createReportFacts(deps: ReportFactsDeps): ReportFacts { + const { analytics, repos } = deps; + return { + async digest(window, rule) { + const span = window.until - window.since; + const previous = { since: window.since - span, until: window.since }; + const [counts, prev, attention, pending, vault, blocked, latency, prevLatency, errors] = + await Promise.all([ + analytics.windowCounts(window), + analytics.windowCounts(previous), + repos.operatorRequests.windowStats('attention', window), + repos.operatorRequests.countOpen('attention'), + repos.vaultAudit.countByResult(window), + repos.blocklistAudit.stats({ since: window.since, until: window.until - 1 }, 1), + analytics.toolLatency(window), + analytics.toolLatency(previous), + analytics.topErrors(window, TOP_ERRORS), + ]); + const [open, harnesses, activity] = await Promise.all([ + repos.systemEvents.open(), + analytics.harnessMetrics({ since: window.since, until: window.until - 1 }), + analytics.activity({ + since: window.since, + until: window.until - 1, + bucketMs: rule.every === 'week' ? 12 * HOUR : HOUR, + }), + ]); + const slowest = latency + .filter((r) => r.calls >= SLOWEST_TOOL_MIN_CALLS) + .sort((a, b) => b.p95Ms - a.p95Ms || a.tool.localeCompare(b.tool))[0]; + const before = + slowest === undefined + ? undefined + : prevLatency.find((r) => r.tool === slowest.tool && r.calls >= SLOWEST_TOOL_MIN_CALLS); + const pattern = blocked.topPatterns[0]; + const domain = blocked.topDomains[0]; + const buckets = activity.buckets.slice(-48); + return { + window: { since: window.since, until: window.until }, + sessionsStarted: counts.sessionsStarted, + sessionsLive: deps.capacity().live, + toolCalls: counts.toolCalls, + errors: counts.errors, + previous: { toolCalls: prev.toolCalls, errors: prev.errors }, + attention: { ...attention, pending }, + vault: vault.map((v) => ({ result: v.result, count: v.count })), + blocked: { + count: counts.blocked, + topPattern: + pattern === undefined ? null : { pattern: pattern.pattern, count: pattern.count }, + topDomain: domain === undefined ? null : { domain: domain.domain, count: domain.count }, + }, + slowest: + slowest === undefined + ? null + : { + tool: slowest.tool, + p95Ms: slowest.p95Ms, + previousP95Ms: before?.p95Ms ?? null, + }, + topErrors: errors.map((e) => ({ ...e })), + degradations: open + .filter((e) => e.severity === 'error' || e.severity === 'warn') + .map((e) => ({ + code: e.code, + severity: e.severity, + message: e.message, + since: e.firstSeenAt, + })), + harnesses: harnesses.map((h) => ({ + harness: h.harness, + sessions: h.sessions, + toolCalls: h.toolCalls, + errors: h.errors, + })), + chart: { + start: buckets[0]?.ts ?? window.since, + stepMs: activity.window.bucketMs, + values: buckets.map((b) => b.toolCalls), + }, + }; + }, + + async anomaly(now) { + const window = { since: now - HOUR, until: now }; + const [counts, baseline, waiting, open] = await Promise.all([ + analytics.windowCounts(window), + analytics.windowCounts({ since: now - 25 * HOUR, until: now - HOUR }), + repos.operatorRequests.open('attention'), + repos.systemEvents.open(), + ]); + const capacity = deps.capacity(); + return { + window, + toolCalls: counts.toolCalls, + errors: counts.errors, + blocked: counts.blocked, + blockedBaselinePerHour: baseline.blocked / 24, + attentionWaiting: waiting + .map((r) => ({ + sessionSlug: r.sessionSlug ?? parseSessionId(r.sessionId)?.slug ?? null, + waitedMs: Math.max(0, now - r.createdAt), + })) + .sort((a, b) => b.waitedMs - a.waitedMs), + live: capacity.live, + maxSessions: capacity.max, + degradations: open + .filter((e) => e.severity === 'error') + .map((e) => ({ code: e.code, message: e.message, since: e.firstSeenAt })), + }; + }, + }; +} diff --git a/packages/core/src/app/notifications/report-scheduler.in-app.test.ts b/packages/core/src/app/notifications/report-scheduler.in-app.test.ts new file mode 100644 index 0000000..635bcc9 --- /dev/null +++ b/packages/core/src/app/notifications/report-scheduler.in-app.test.ts @@ -0,0 +1,478 @@ +/** @module app/notifications/report-scheduler.in-app.test — reports in the dashboard (D-45, spec 03 §9.7) on a fake clock with in-memory repositories: one in-app copy per period shared by the channels of that period (two zones make two), channel copies naming it, digests stored read and anomaly alerts unread, the in-app schedule with no channel at all, the anomaly watches shared by equal thresholds and closed when unwanted, the on-demand copy and the announcements. */ + +import { describe, expect, it } from 'bun:test'; +import type { Notification } from '@browserhive/contracts/http'; +import type { + NotificationChannelRules, + ReportSettings, +} from '@browserhive/contracts/notifications'; +import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; +import { capabilities, channelRecord, FakeChannel } from '../../../test/helpers/fake-channel.ts'; +import { FakeClock } from '../../../test/helpers/fake-clock.ts'; +import { FakeIdGenerator } from '../../../test/helpers/fake-id-generator.ts'; +import { InMemoryRepositories, InMemoryUnitOfWork } from '../../../test/helpers/in-memory-repos.ts'; +import { createRedactor } from '../../kernel/redact.ts'; +import { ManualIntervals } from '../maintenance/test-support.ts'; +import { ChannelRegistry } from './channel-registry.ts'; +import { decodeMessage } from './message.ts'; +import type { ReportFacts } from './report-facts.ts'; +import { digestCursorKey, IN_APP_SCHEDULE, ReportScheduler } from './report-scheduler.ts'; +import { ReportService } from './report-service.ts'; +import { ReportSettingsStore } from './report-settings.ts'; +import type { AnomalyFacts, DigestFacts } from './reports.ts'; +import { planDeliveries } from './routing.ts'; +import { sampleAnomalyFacts, sampleDigestFacts } from './samples.ts'; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const ZONE = 'Europe/Berlin'; +/** 2026-09-28 12:00 UTC (14:00 in Berlin). */ +const START = Date.UTC(2026, 8, 28, 12); +/** 09:00 Berlin on 29 Sep = 07:00 UTC. */ +const NINE = Date.UTC(2026, 8, 29, 7); +const A = 'nc-00000000000a'; +const B = 'nc-00000000000b'; +const DAILY: NotificationChannelRules = { + digest: { every: 'day', at: '09:00' }, + time_zone: ZONE, +}; + +const quiet = (now: number): AnomalyFacts => ({ + ...sampleAnomalyFacts(now), + toolCalls: 0, + errors: 0, + attentionWaiting: [], +}); +const failing = (now: number): AnomalyFacts => ({ + ...sampleAnomalyFacts(now), + attentionWaiting: [], + toolCalls: 100, + errors: 40, +}); + +const EMPTY_DAY = (w: { since: number; until: number }): DigestFacts => ({ + ...sampleDigestFacts(w.until, { every: 'day', at: '09:00' }), + sessionsStarted: 0, + toolCalls: 0, + errors: 0, + attention: { + created: 0, + resolved: 0, + rejected: 0, + timedOut: 0, + cancelled: 0, + pending: 0, + medianWaitMs: null, + }, + vault: [], + blocked: { count: 0, topPattern: null, topDomain: null }, + degradations: [], +}); + +interface Setup { + readonly channels?: readonly { id: string; name: string; rules: NotificationChannelRules }[]; + readonly settings?: ReportSettings; + readonly digest?: (window: { since: number; until: number }) => DigestFacts; + readonly anomaly?: (now: number) => AnomalyFacts; +} + +async function setup(opts: Setup = {}) { + const clock = new FakeClock(START); + const repos = new InMemoryRepositories(); + const uow = new InMemoryUnitOfWork(repos); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + for (const c of opts.channels ?? []) { + await repos.notificationChannels.upsert( + channelRecord({ channelId: c.id, name: c.name, rules: c.rules }), + ); + } + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + factories: new Map([['fake', () => new FakeChannel('fake', capabilities())]]), + }); + await registry.load(); + const settings = new ReportSettingsStore(repos.notificationCursors); + if (opts.settings !== undefined) await settings.save(opts.settings, START); + const facts: ReportFacts = { + digest: async (window, rule) => + opts.digest?.(window) ?? { ...sampleDigestFacts(window.until, rule), window: { ...window } }, + anomaly: async (now) => (opts.anomaly ?? quiet)(now), + }; + const intervals = new ManualIntervals(); + const announced: { op: string; notification: Notification }[] = []; + const make = () => + new ReportScheduler({ + registry, + facts, + uow, + repos, + outbox: { + plan: (message, now, to) => planDeliveries(message, registry.channels(), now, to), + kick: () => undefined, + }, + clock, + ids, + logger, + hostZone: () => 'UTC', + settings, + inbox: (op, notification) => void announced.push({ op, notification }), + redactor: createRedactor(), + scheduler: intervals, + }); + const scheduler = make(); + const rows = () => [...repos.notifications.rows.values()]; + const inApp = () => rows().filter((r) => (r.thread ?? '').startsWith('report:')); + const copies = () => + rows().filter((r) => r.category === 'reports' && !(r.thread ?? '').startsWith('report:')); + const service = new ReportService({ repo: repos.notifications, settings, scheduler, clock }); + return { + clock, + repos, + registry, + settings, + scheduler, + make, + intervals, + announced, + inApp, + copies, + service, + }; +} + +describe('in-app digest copies (D-45)', () => { + it('writes one in-app copy for two channels on the same period, both copies naming it', async () => { + const t = await setup({ + channels: [ + { id: A, name: 'phone', rules: DAILY }, + { id: B, name: 'team', rules: { ...DAILY, content: 'counts' } }, + ], + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 30_000); + await t.scheduler.tick(); + const [copy] = t.inApp(); + expect(t.inApp()).toHaveLength(1); + expect(copy).toMatchObject({ + kind: 'digest.daily', + type: 'lifecycle', + principalId: null, + // A digest never counts toward the badge and stays in the inbox until dismissed. + readAt: NINE + 30_000, + dismissedAt: null, + target: `/notifications/reports/${copy?.notificationId}`, + }); + const message = decodeMessage(copy?.messageJson ?? null); + expect(message?.privacy.level).toBe('full'); + expect(message?.alert).toBe(false); + expect(message?.report?.window).toEqual({ since: NINE - DAY, until: NINE }); + expect(t.copies().map((c) => c.sourceEventId)).toEqual([ + copy?.notificationId ?? null, + copy?.notificationId ?? null, + ]); + expect(t.copies().every((c) => c.dismissedAt !== null)).toBe(true); + expect(t.announced.map((a) => [a.op, a.notification.notification_id])).toEqual([ + ['created', copy?.notificationId ?? ''], + ]); + expect(await t.repos.notifications.unreadCount(null)).toBe(0); + // The history lists it once, with both channels. + const page = await t.service.list({ limit: 10 }); + expect(page.items).toHaveLength(1); + expect(page.items[0]?.channels.map((c) => [c.name, c.status])).toEqual([ + ['phone', 'pending'], + ['team', 'pending'], + ]); + }); + + it('writes two copies when the zones differ, even for the same instants', async () => { + const t = await setup({ + channels: [ + { id: A, name: 'berlin', rules: DAILY }, + { + id: B, + name: 'london', + rules: { digest: { every: 'day', at: '08:00' }, time_zone: 'Europe/London' }, + }, + ], + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 30_000); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(2); + const zones = t.inApp().map((r) => decodeMessage(r.messageJson)?.report?.time_zone); + expect(zones.sort()).toEqual(['Europe/Berlin', 'Europe/London']); + }); + + it('writes no in-app copy for an empty period', async () => { + const t = await setup({ + channels: [{ id: A, name: 'phone', rules: DAILY }], + digest: EMPTY_DAY, + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(0); + expect(t.copies()).toHaveLength(1); + expect(t.copies()[0]?.sourceEventId).toBeNull(); + }); + + it('shares the period with the in-app schedule', async () => { + const t = await setup({ + channels: [{ id: A, name: 'phone', rules: DAILY }], + settings: { digest: { every: 'day', at: '09:00' }, time_zone: ZONE }, + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(1); + expect(t.announced).toHaveLength(1); + // "BrowserHive only" is a report that reached no channel: not this one. + expect((await t.service.list({ limit: 10, inAppOnly: true })).items).toHaveLength(0); + expect((await t.service.list({ limit: 10, channelId: A })).items).toHaveLength(1); + }); +}); + +describe('the in-app schedule (D-45)', () => { + it('produces digests with no channel at all', async () => { + const t = await setup({ settings: { digest: { every: 'day', at: '09:00' }, time_zone: ZONE } }); + t.scheduler.start(); + expect(t.intervals.fns).toHaveLength(1); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(1); + expect(t.copies()).toHaveLength(0); + expect(t.repos.notificationDeliveries.rows).toHaveLength(0); + expect(await t.repos.notificationCursors.get(digestCursorKey(IN_APP_SCHEDULE))).toContain( + `day@09:00@${ZONE}`, + ); + const page = await t.service.list({ limit: 10, inAppOnly: true }); + expect(page.items.map((i) => i.channels)).toEqual([[]]); + t.scheduler.stop(); + }); + + it('arms when the settings switch on and re-arms a changed schedule without a late digest', async () => { + const t = await setup(); + t.scheduler.start(); + expect(t.intervals.fns).toHaveLength(0); + await t.settings.save({ digest: { every: 'day', at: '09:00' }, time_zone: ZONE }, START); + expect(t.intervals.fns).toHaveLength(1); + await t.scheduler.tick(); + // Moved to 08:00 at 10:00 the next day: 08:00 passed, nothing is sent late. + await t.clock.set(NINE + HOUR); + await t.settings.save({ digest: { every: 'day', at: '08:00' }, time_zone: ZONE }, NINE + HOUR); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(0); + await t.settings.save({}, NINE + HOUR); + expect(t.intervals.fns).toHaveLength(0); + t.scheduler.stop(); + }); + + it('runs a weekly digest on Friday at 17:00 over seven days, weekend included', async () => { + const t = await setup({ + settings: { digest: { every: 'week', at: '17:00' }, time_zone: ZONE }, + }); + await t.scheduler.tick(); + // Fri 2 Oct 2026 17:00 in Berlin = 15:00 UTC. + const friday = Date.UTC(2026, 9, 2, 15); + await t.clock.set(friday + 1000); + await t.scheduler.tick(); + const report = decodeMessage(t.inApp()[0]?.messageJson ?? null)?.report; + expect(report?.window).toEqual({ since: friday - 7 * DAY, until: friday }); + expect(t.inApp()[0]?.kind).toBe('digest.weekly'); + }); + + it('skips the weekend with weekdays only; Monday covers it', async () => { + const t = await setup({ + settings: { digest: { every: 'day', at: '09:00', weekdays_only: true }, time_zone: ZONE }, + }); + await t.scheduler.tick(); + // Sat 3 and Sun 4 Oct: nothing. Mon 5 Oct 09:00 Berlin = 07:00 UTC. + await t.clock.set(Date.UTC(2026, 9, 2, 7, 1)); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(1); + await t.clock.set(Date.UTC(2026, 9, 3, 7, 1)); + await t.scheduler.tick(); + await t.clock.set(Date.UTC(2026, 9, 4, 7, 1)); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(1); + await t.clock.set(Date.UTC(2026, 9, 5, 7, 1)); + await t.scheduler.tick(); + const monday = t.inApp().find((r) => r.createdAt === Date.UTC(2026, 9, 5, 7, 1)); + expect(decodeMessage(monday?.messageJson ?? null)?.report).toMatchObject({ + window: { since: Date.UTC(2026, 9, 2, 7), until: Date.UTC(2026, 9, 5, 7) }, + late: false, + skipped: 0, + }); + }); + + it('shows the in-app schedule like a channel', async () => { + const t = await setup({ + settings: { digest: { every: 'day', at: '09:00', weekdays_only: true }, anomaly: {} }, + }); + expect(t.service.settings()).toMatchObject({ + host_time_zone: 'UTC', + reports: { + time_zone: 'UTC', + host_zone: true, + digest: { every: 'day', weekdays_only: true, next_at: Date.UTC(2026, 8, 29, 9) }, + anomaly: { next_check_at: START, active: [] }, + }, + }); + }); + + it('refuses a weekday on a daily digest and an unknown zone', async () => { + const t = await setup(); + await expect( + t.service.saveSettings({ digest: { every: 'day', at: '09:00', day: 'mon' } }), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await expect(t.service.saveSettings({ time_zone: 'Mars/Olympus' })).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + }); +}); + +describe('anomaly watches (D-45)', () => { + it('writes one unread in-app alert for two channels with the same thresholds', async () => { + const t = await setup({ + channels: [ + { id: A, name: 'phone', rules: { anomaly: {} } }, + // The same thresholds spelled out: one watch. + { id: B, name: 'team', rules: { anomaly: { error_rate: 20, min_calls: 20 } } }, + ], + anomaly: failing, + }); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(1); + const [alert] = t.inApp(); + expect(alert).toMatchObject({ + kind: 'report.anomaly', + type: 'system', + readAt: null, + dismissedAt: null, + state: 'open', + target: `/notifications/reports/${alert?.notificationId}`, + }); + expect(await t.repos.notifications.unreadCount(null)).toBe(1); + expect(t.copies().map((c) => c.sourceEventId)).toEqual([ + alert?.notificationId ?? null, + alert?.notificationId ?? null, + ]); + expect(t.announced.map((a) => a.op)).toEqual(['created']); + }); + + it('keeps separate watches for different thresholds', async () => { + const t = await setup({ + channels: [ + { id: A, name: 'phone', rules: { anomaly: {} } }, + { id: B, name: 'team', rules: { anomaly: { error_rate: 50 } } }, + ], + anomaly: failing, + }); + await t.scheduler.tick(); + // 40 % fails: only the default (20 %) watch fires. + expect(t.inApp()).toHaveLength(1); + expect(t.copies()).toHaveLength(1); + }); + + it('says back to normal silently, in place', async () => { + let facts = failing; + const t = await setup({ + settings: { anomaly: {} }, + anomaly: (now) => facts(now), + }); + await t.scheduler.tick(); + const [alert] = t.inApp(); + facts = quiet; + await t.clock.set(START + HOUR + 1000); + await t.scheduler.tick(); + expect(t.inApp()).toHaveLength(1); + const row = t.repos.notifications.rows.get(alert?.notificationId ?? ''); + expect(row?.state).toBe('resolved'); + const message = decodeMessage(row?.messageJson ?? null); + expect(message?.title).toBe('Back to normal'); + expect(message?.alert).toBe(false); + expect(t.announced.map((a) => [a.op, a.notification.state])).toEqual([ + ['created', 'open'], + ['updated', 'resolved'], + ]); + }); + + it('closes the open alert of a watch no longer wanted', async () => { + const t = await setup({ settings: { anomaly: {} }, anomaly: failing }); + t.scheduler.start(); + await t.scheduler.tick(); + const [alert] = t.inApp(); + await t.settings.save({}, START + 1000); + await t.scheduler.tick(); + const row = t.repos.notifications.rows.get(alert?.notificationId ?? ''); + expect(row?.state).toBe('final'); + expect(decodeMessage(row?.messageJson ?? null)?.summary).toBe('No longer checked.'); + expect(t.intervals.fns).toHaveLength(0); + t.scheduler.stop(); + }); + + it('survives a restart without repeating the alert', async () => { + const t = await setup({ settings: { anomaly: {} }, anomaly: failing }); + await t.scheduler.tick(); + await t.clock.set(START + HOUR + 1000); + await t.make().tick(); + expect(t.inApp()).toHaveLength(1); + }); +}); + +describe('on-demand digests (D-45)', () => { + it('store their own in-app copy, even when empty', async () => { + const t = await setup({ + channels: [{ id: A, name: 'phone', rules: DAILY }], + digest: EMPTY_DAY, + }); + const record = await t.repos.notificationChannels.get(A); + if (record === null) throw new Error('no channel'); + const built = await t.scheduler.manualDigest(record); + const id = await t.scheduler.storeManualCopy(built); + expect(t.inApp().map((r) => r.notificationId)).toEqual([id ?? 'missing']); + expect(decodeMessage(t.inApp()[0]?.messageJson ?? null)?.report?.manual).toBe(true); + // Asking twice for the same instant finds the same copy. + expect(await t.scheduler.storeManualCopy(built)).toBe(id); + }); +}); + +describe('ReportService (D-45)', () => { + it('reads one report and refuses a row that is not an in-app copy', async () => { + const t = await setup({ channels: [{ id: A, name: 'phone', rules: DAILY }] }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + const [copy] = t.inApp(); + const detail = await t.service.get(copy?.notificationId ?? ''); + expect(detail.message.kind).toBe('digest.daily'); + expect(detail.report.channels.map((c) => c.channel_id)).toEqual([A]); + await expect(t.service.get(t.copies()[0]?.notificationId ?? '')).rejects.toMatchObject({ + code: 'REPORT_NOT_FOUND', + }); + await expect(t.service.get('n-unknown00000')).rejects.toMatchObject({ + code: 'REPORT_NOT_FOUND', + }); + }); + + it('filters the history by kind and period', async () => { + const t = await setup({ + channels: [{ id: A, name: 'phone', rules: { ...DAILY, anomaly: {} } }], + anomaly: failing, + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + const kinds = async (kind: readonly string[]) => + (await t.service.list({ limit: 10, kinds: kind })).items.map((i) => i.notification.kind); + expect(await kinds(['digest.daily'])).toEqual(['digest.daily']); + expect(await kinds(['report.anomaly'])).toEqual(['report.anomaly']); + const since = await t.service.list({ limit: 10, since: NINE }); + expect(since.items.map((i) => i.notification.kind)).toEqual(['digest.daily']); + }); +}); diff --git a/packages/core/src/app/notifications/report-scheduler.test.ts b/packages/core/src/app/notifications/report-scheduler.test.ts new file mode 100644 index 0000000..4140802 --- /dev/null +++ b/packages/core/src/app/notifications/report-scheduler.test.ts @@ -0,0 +1,471 @@ +/** @module app/notifications/report-scheduler.test — the scheduled reports on a fake clock with manual intervals and in-memory repositories (D-43, D-44, spec 03 §9.7): zero cost without a schedule, arming, on-time and late digests with the skipped count, exactly once across ticks and restarts, rule changes, empty and quiet digests, paused channels, the anomaly episode (fire, hold, silent revision, back to normal, quiet hours), cursor cleanup and the views. */ + +import { describe, expect, it } from 'bun:test'; +import type { NotificationChannelRules } from '@browserhive/contracts/notifications'; +import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; +import { capabilities, channelRecord, FakeChannel } from '../../../test/helpers/fake-channel.ts'; +import { FakeClock } from '../../../test/helpers/fake-clock.ts'; +import { FakeIdGenerator } from '../../../test/helpers/fake-id-generator.ts'; +import { InMemoryRepositories, InMemoryUnitOfWork } from '../../../test/helpers/in-memory-repos.ts'; +import { createRedactor } from '../../kernel/redact.ts'; +import { ManualIntervals } from '../maintenance/test-support.ts'; +import { ChannelRegistry } from './channel-registry.ts'; +import { decodeMessage } from './message.ts'; +import type { ReportFacts } from './report-facts.ts'; +import { + anomalyCursorKey, + digestCursorKey, + forgetChannelCursors, + ReportScheduler, +} from './report-scheduler.ts'; +import type { AnomalyFacts, DigestFacts } from './reports.ts'; +import { planDeliveries } from './routing.ts'; +import { sampleAnomalyFacts, sampleDigestFacts } from './samples.ts'; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const ZONE = 'Europe/Berlin'; +/** 2026-09-28 12:00 UTC (14:00 in Berlin). */ +const START = Date.UTC(2026, 8, 28, 12); +/** 09:00 Berlin on 29 Sep = 07:00 UTC. */ +const NINE = Date.UTC(2026, 8, 29, 7); +const CHANNEL = 'nc-000000000001'; + +/** The outbox's planning over the registry (what `NotificationOutbox.plan` does). */ +function routing(registry: ChannelRegistry, kick: () => void) { + return { + plan: (message: Parameters[0], now: number, to?: string) => + planDeliveries(message, registry.channels(), now, to), + kick, + }; +} + +interface Setup { + readonly rules?: NotificationChannelRules; + readonly status?: 'active' | 'paused'; + readonly digest?: (window: { since: number; until: number }) => DigestFacts; + readonly anomaly?: (now: number) => AnomalyFacts; +} + +async function setup(opts: Setup = {}) { + const clock = new FakeClock(START); + const repos = new InMemoryRepositories(); + const uow = new InMemoryUnitOfWork(repos); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const fake = new FakeChannel(CHANNEL, capabilities()); + await repos.notificationChannels.upsert( + channelRecord({ + rules: opts.rules ?? { digest: { every: 'day', at: '09:00' }, time_zone: ZONE }, + status: opts.status ?? 'active', + }), + ); + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + factories: new Map([['fake', () => fake]]), + }); + await registry.load(); + const calls = { digest: [] as { since: number; until: number }[], anomaly: 0, kicks: 0 }; + const facts: ReportFacts = { + digest: async (window, rule) => { + calls.digest.push({ ...window }); + return opts.digest?.(window) ?? sampleDigestFacts(window.until, rule); + }, + anomaly: async (now) => { + calls.anomaly += 1; + return ( + opts.anomaly?.(now) ?? { + ...sampleAnomalyFacts(now), + toolCalls: 0, + errors: 0, + attentionWaiting: [], + } + ); + }, + }; + const intervals = new ManualIntervals(); + const counted: { kind: string; outcome: string; n: number }[] = []; + const make = () => + new ReportScheduler({ + registry, + facts, + uow, + repos, + outbox: routing(registry, () => { + calls.kicks += 1; + }), + clock, + ids, + logger, + hostZone: () => 'UTC', + redactor: createRedactor(), + scheduler: intervals, + counter: { add: (n, a) => void counted.push({ ...a, n }) }, + }); + const scheduler = make(); + /** The channel copies (stored out of the inbox). */ + const reports = () => + [...repos.notifications.rows.values()].filter( + (r) => r.category === 'reports' && !(r.thread ?? '').startsWith('report:'), + ); + /** The in-app copies (D-45). */ + const inApp = () => + [...repos.notifications.rows.values()].filter((r) => (r.thread ?? '').startsWith('report:')); + const deliveries = () => repos.notificationDeliveries.rows; + return { + clock, + repos, + registry, + scheduler, + make, + calls, + intervals, + counted, + reports, + inApp, + deliveries, + }; +} + +describe('ReportScheduler: timers', () => { + it('arms no timer and makes no query when no channel schedules a report', async () => { + const t = await setup({ rules: { categories: ['needs-you'] } }); + t.scheduler.start(); + expect(t.intervals.fns).toHaveLength(0); + await t.scheduler.tick(); + expect(t.calls.digest).toHaveLength(0); + expect(t.calls.anomaly).toBe(0); + expect(t.repos.notificationCursors.rows.size).toBe(0); + }); + + it('arms when a schedule appears and disarms when it goes', async () => { + const t = await setup({ rules: {} }); + t.scheduler.start(); + expect(t.intervals.fns).toHaveLength(0); + const row = await t.repos.notificationChannels.get(CHANNEL); + if (row === null) throw new Error('no row'); + await t.repos.notificationChannels.upsert({ ...row, rules: { anomaly: {} } }); + await t.registry.reload(); + expect(t.intervals.fns).toHaveLength(1); + await t.repos.notificationChannels.upsert({ ...row, rules: {} }); + await t.registry.reload(); + expect(t.intervals.fns).toHaveLength(0); + t.scheduler.stop(); + }); +}); + +describe('ReportScheduler: digests (D-43)', () => { + it('arms at the first tick and sends nothing for the past', async () => { + const t = await setup(); + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(0); + const cursor = JSON.parse( + (await t.repos.notificationCursors.get(digestCursorKey(CHANNEL))) ?? '{}', + ); + expect(cursor).toEqual({ spec: `day@09:00@${ZONE}`, last: START, until: null }); + }); + + it('sends the digest at its time, once, as an addressed, dismissed notification', async () => { + const t = await setup(); + await t.scheduler.tick(); + await t.clock.set(NINE + 30_000); + const pass = await t.scheduler.tick(); + expect(pass.digests).toBe(1); + const [row] = t.reports(); + expect(row).toMatchObject({ + kind: 'digest.daily', + readAt: NINE + 30_000, + dismissedAt: NINE + 30_000, + }); + const message = decodeMessage(row?.messageJson ?? null); + expect(message?.report).toEqual({ + window: { since: NINE - DAY, until: NINE }, + time_zone: ZONE, + late: false, + skipped: 0, + manual: false, + }); + expect(message?.alert).toBe(true); + expect(message?.privacy.level).toBe('titles'); + expect(t.deliveries().map((d) => [d.channelId, d.op, d.status])).toEqual([ + [CHANNEL, 'send', 'pending'], + ]); + expect(t.calls.kicks).toBe(1); + expect(t.calls.digest).toEqual([{ since: NINE - DAY, until: NINE }]); + // A second tick and a fresh scheduler (a restart) over the same database send nothing more. + await t.scheduler.tick(); + await t.make().tick(); + expect(t.reports()).toHaveLength(1); + expect(t.counted).toContainEqual({ kind: 'digest.daily', outcome: 'sent', n: 1 }); + }); + + it('after downtime sends the newest missed window once, late, with the skipped count', async () => { + const t = await setup(); + await t.scheduler.tick(); + // Off from 28 Sep 14:00 until 2 Oct 11:00 Berlin: 29, 30 Sep, 1 and 2 Oct at 09:00 were missed. + await t.clock.set(Date.UTC(2026, 9, 2, 9)); + await t.make().tick(); + const reports = t.reports(); + expect(reports).toHaveLength(1); + const message = decodeMessage(reports[0]?.messageJson ?? null); + const newest = Date.UTC(2026, 9, 2, 7); + expect(message?.report).toMatchObject({ + window: { since: newest - DAY, until: newest }, + late: true, + skipped: 3, + }); + expect(JSON.stringify(message?.blocks)).toContain('3 earlier digests were skipped'); + expect(t.counted).toContainEqual({ kind: 'digest.daily', outcome: 'late', n: 1 }); + expect(t.counted).toContainEqual({ kind: 'digest.daily', outcome: 'skipped', n: 3 }); + }); + + it('re-arms without a late send when the schedule changes', async () => { + const t = await setup(); + await t.scheduler.tick(); + await t.clock.set(NINE + 60_000); + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(1); + // At 10:00 the operator moves the digest to 08:00: that time already passed today. + await t.clock.set(NINE + HOUR); + const row = await t.repos.notificationChannels.get(CHANNEL); + if (row === null) throw new Error('no row'); + await t.repos.notificationChannels.upsert({ + ...row, + rules: { ...row.rules, digest: { every: 'day', at: '08:00' } }, + }); + await t.registry.reload(); + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(1); + // The next day at 08:00 the window starts where the last digest ended (09:00). + await t.clock.set(NINE + DAY - HOUR + 60_000); + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(2); + expect(t.calls.digest.at(-1)).toEqual({ since: NINE, until: NINE + DAY - HOUR }); + }); + + it('stores an empty day as suppressed: empty and sends nothing', async () => { + const t = await setup({ + digest: (w) => ({ + ...sampleDigestFacts(w.until, { every: 'day', at: '09:00' }), + sessionsStarted: 0, + toolCalls: 0, + errors: 0, + attention: { + created: 0, + resolved: 0, + rejected: 0, + timedOut: 0, + cancelled: 0, + pending: 0, + medianWaitMs: null, + }, + vault: [], + blocked: { count: 0, topPattern: null, topDomain: null }, + degradations: [], + }), + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + expect(t.deliveries().map((d) => [d.status, d.reason])).toEqual([['suppressed', 'empty']]); + expect(t.calls.kicks).toBe(0); + expect(t.counted).toContainEqual({ kind: 'digest.daily', outcome: 'empty', n: 1 }); + }); + + it('sends silently when its time falls in the quiet hours', async () => { + const t = await setup({ + rules: { + digest: { every: 'day', at: '09:00' }, + time_zone: ZONE, + quiet_hours: { start: '08:00', end: '10:00' }, + }, + }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + expect(decodeMessage(t.reports()[0]?.messageJson ?? null)?.alert).toBe(false); + expect(t.deliveries()[0]?.status).toBe('pending'); + }); + + it("logs a paused channel's digest as channel_paused and moves on", async () => { + const t = await setup({ status: 'paused' }); + await t.scheduler.tick(); + await t.clock.set(NINE + 1000); + await t.scheduler.tick(); + expect(t.deliveries().map((d) => [d.status, d.reason])).toEqual([ + ['suppressed', 'channel_paused'], + ]); + await t.clock.set(NINE + 2 * HOUR); + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(1); + }); + + it('follows the channel zone: the same rule fires at another instant in Tokyo', async () => { + const t = await setup({ + rules: { digest: { every: 'day', at: '09:00' }, time_zone: 'Asia/Tokyo' }, + }); + await t.scheduler.tick(); + await t.clock.set(Date.UTC(2026, 8, 29, 0, 1)); // 09:01 in Tokyo, 02:01 in Berlin + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(1); + }); + + it('builds the on-demand digest without touching the schedule', async () => { + const t = await setup(); + await t.scheduler.tick(); + const before = await t.repos.notificationCursors.get(digestCursorKey(CHANNEL)); + const row = await t.repos.notificationChannels.get(CHANNEL); + if (row === null) throw new Error('no row'); + const built = await t.scheduler.manualDigest(row); + expect(built.window).toEqual({ since: START - DAY, until: START }); + expect(built.message.report?.manual).toBe(true); + expect(await t.repos.notificationCursors.get(digestCursorKey(CHANNEL))).toBe(before); + }); + + it('shows the next run in the channel zone', async () => { + const t = await setup(); + const row = await t.repos.notificationChannels.get(CHANNEL); + if (row === null) throw new Error('no row'); + expect(t.scheduler.view(row)).toMatchObject({ + time_zone: ZONE, + host_zone: false, + digest: { every: 'day', at: '09:00', day: null, next_at: NINE, last_until: null }, + anomaly: null, + }); + }); +}); + +describe('ReportScheduler: anomaly alerts (D-44)', () => { + const failing = (now: number): AnomalyFacts => ({ + ...sampleAnomalyFacts(now), + attentionWaiting: [], + toolCalls: 100, + errors: 40, + }); + const failingAndFull = (now: number): AnomalyFacts => ({ ...failing(now), live: 10 }); + const healthy = (now: number): AnomalyFacts => ({ ...failing(now), errors: 0 }); + + it('checks once an hour, alerts on a crossing, holds, edits silently, then says back to normal', async () => { + let facts = failingAndFull; + const t = await setup({ + rules: { anomaly: {}, time_zone: ZONE }, + anomaly: (now) => facts(now), + }); + await t.scheduler.tick(); + expect(t.calls.anomaly).toBe(1); + const first = t.reports(); + expect(first).toHaveLength(1); + const alert = decodeMessage(first[0]?.messageJson ?? null); + expect(alert).toMatchObject({ + kind: 'report.anomaly', + state: 'open', + alert: true, + severity: 'error', + }); + // Same hour: no second check. + await t.clock.set(START + 10 * 60_000); + await t.scheduler.tick(); + expect(t.calls.anomaly).toBe(1); + // Next hour, still failing: nothing new, nothing edited. + await t.clock.set(START + HOUR + 60_000); + await t.scheduler.tick(); + expect(t.calls.anomaly).toBe(2); + expect(t.deliveries()).toHaveLength(1); + // Capacity clears, the error rate stays: a silent edit of the same alert. + facts = failing; + await t.clock.set(START + 2 * HOUR + 60_000); + await t.scheduler.tick(); + const revised = decodeMessage( + t.repos.notifications.rows.get(first[0]?.notificationId ?? '')?.messageJson ?? null, + ); + expect(revised).toMatchObject({ revision: 2, alert: false, state: 'open', severity: 'warn' }); + expect(t.deliveries().map((d) => [d.op, d.revision])).toEqual([ + ['send', 1], + ['edit', 2], + ]); + // Everything clears: resolved, silently. + facts = healthy; + await t.clock.set(START + 3 * HOUR + 60_000); + await t.scheduler.tick(); + const resolved = decodeMessage( + t.repos.notifications.rows.get(first[0]?.notificationId ?? '')?.messageJson ?? null, + ); + expect(resolved).toMatchObject({ + revision: 3, + state: 'resolved', + alert: false, + title: 'Back to normal', + }); + expect(t.counted).toContainEqual({ kind: 'report.anomaly', outcome: 'resolved', n: 1 }); + // A later crossing is a new alert. + facts = failing; + await t.clock.set(START + 4 * HOUR + 60_000); + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(2); + }); + + it('a new crossing during an open alert sends a new alert and closes the old one', async () => { + let facts = failing; + const t = await setup({ + rules: { anomaly: {}, time_zone: ZONE }, + anomaly: (now) => facts(now), + }); + await t.scheduler.tick(); + facts = failingAndFull; + await t.clock.set(START + HOUR + 60_000); + await t.scheduler.tick(); + const rows = t.reports().sort((a, b) => a.createdAt - b.createdAt); + expect(rows.map((r) => r.state)).toEqual(['final', 'open']); + expect(decodeMessage(rows[1]?.messageJson ?? null)?.title).toBe( + 'Something looks off: 2 checks', + ); + }); + + it('runs no check during quiet hours and checks at the first tick after them', async () => { + const t = await setup({ + rules: { anomaly: {}, time_zone: 'UTC', quiet_hours: { start: '11:00', end: '13:00' } }, + anomaly: failing, + }); + await t.scheduler.tick(); + // The channel is quiet; its watch (the in-app alert, D-45) has no quiet hours. + expect(t.reports()).toHaveLength(0); + expect(t.inApp()).toHaveLength(1); + await t.clock.set(START + HOUR); // 13:00 UTC: quiet hours are over + await t.scheduler.tick(); + expect(t.reports()).toHaveLength(1); + // The channel's alert names the episode's in-app copy; the watch did not alert again. + expect(t.inApp()).toHaveLength(1); + expect(t.reports()[0]?.sourceEventId).toBe(t.inApp()[0]?.notificationId); + }); + + it('survives a restart without repeating the alert', async () => { + const t = await setup({ rules: { anomaly: {} }, anomaly: failing }); + await t.scheduler.tick(); + await t.clock.set(START + HOUR + 60_000); + await t.make().tick(); + expect(t.reports()).toHaveLength(1); + const cursor = JSON.parse( + (await t.repos.notificationCursors.get(anomalyCursorKey(CHANNEL))) ?? '{}', + ); + expect(Object.keys(cursor.active)).toEqual(['error_rate']); + }); +}); + +describe('forgetChannelCursors', () => { + it('removes the ntfy, digest and anomaly cursors of a channel', async () => { + const t = await setup(); + for (const key of [ + `ntfy:${CHANNEL}`, + digestCursorKey(CHANNEL), + anomalyCursorKey(CHANNEL), + 'telegram:1', + ]) { + await t.repos.notificationCursors.set(key, '1', 0); + } + await forgetChannelCursors(t.repos.notificationCursors, CHANNEL); + expect([...t.repos.notificationCursors.rows.keys()]).toEqual(['telegram:1']); + }); +}); diff --git a/packages/core/src/app/notifications/report-scheduler.ts b/packages/core/src/app/notifications/report-scheduler.ts new file mode 100644 index 0000000..d32a950 --- /dev/null +++ b/packages/core/src/app/notifications/report-scheduler.ts @@ -0,0 +1,1227 @@ +/** @module app/notifications/report-scheduler — the scheduled reports (D-43, D-44, D-45, spec 03 §9.7): a 60 s tick while any channel or the in-app settings schedule a digest or anomaly alerts; per schedule, the durable cursor in `notification_cursors`, the newest missed window sent late once with the skipped count, empty digests stored `suppressed: empty`, the hourly anomaly check with hysteresis; one in-app copy per report period (shared by every schedule of that period) and one anomaly watch per set of thresholds; each report written with its delivery rows, its in-app copy and its cursor in one transaction. */ + +import type { ChannelReports, Notification } from '@browserhive/contracts/http'; +import { + ANOMALY_CHECKS, + type AnomalyCheck, + type AnomalyRule, + type DigestRule, + digestDay, + KIND_CATEGORY, + KIND_TYPE, + type NotificationChannelRules, + type NotificationMessage, + type ReportSettings, +} from '@browserhive/contracts/notifications'; +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import { createRedactor, type Redactor } from '../../kernel/redact.ts'; +import type { Clock } from '../../ports/clock.ts'; +import type { IdGenerator } from '../../ports/id-generator.ts'; +import type { Logger } from '../../ports/logger.ts'; +import type { NotificationCursorRepository } from '../../ports/persistence/notification-actions.ts'; +import type { + NewNotificationDelivery, + NotificationChannelRecord, + NotificationRecord, +} from '../../ports/persistence/records.ts'; +import type { Repositories, UnitOfWork } from '../../ports/persistence/unit-of-work.ts'; +import { type IntervalScheduler, realIntervalScheduler } from '../maintenance/timer.ts'; +import type { ChannelRegistry } from './channel-registry.ts'; +import { scrubMessage } from './message.ts'; +import { toNotification } from './notification-service.ts'; +import type { NotificationOutbox } from './outbox.ts'; +import type { ReportFacts } from './report-facts.ts'; +import { schedulesReports } from './report-settings.ts'; +import { + type ActiveCheck, + type AnomalyFacts, + type AnomalyState, + anomalyThresholds, + buildAnomaly, + buildDigest, + type DigestFacts, + evaluateAnomalies, + isEmptyDigest, + type ReportContent, + type ReportContext, + reportMessage, +} from './reports.ts'; +import { contentLevelOf, inQuietHours, quietHoursOf } from './routing.ts'; +import { + digestWindow, + formatClock, + nextHour, + nextOccurrence, + occurrencesBetween, + periodMs, + scheduleKey, + usableZone, +} from './schedule.ts'; + +/** Tick of the scheduler while a schedule wants a report. */ +export const REPORT_TICK_MS = 60_000; +/** A report produced this long after its scheduled time is late (D-43). */ +export const LATE_AFTER_MS = 5 * 60_000; +const HOUR = 3_600_000; + +/** Key of the in-app schedule (D-45): its cursors are `digest:in-app` and `anomaly:in-app`. */ +export const IN_APP_SCHEDULE = 'in-app'; +/** Threads of in-app report copies start with this (D-45). */ +export const IN_APP_REPORT_THREAD = 'report:'; + +/** Cursor key of a schedule's digest (a channel id, or `in-app`). */ +export const digestCursorKey = (key: string) => `digest:${key}`; +/** Cursor key of a channel's anomaly state (`anomaly:in-app` holds the watches). */ +export const anomalyCursorKey = (key: string) => `anomaly:${key}`; + +/** Thread of the in-app copy of a scheduled digest's period: schedule identity + window (D-45). */ +export function digestPeriodThread( + spec: string, + window: { readonly since: number; readonly until: number }, +): string { + return `${IN_APP_REPORT_THREAD}digest:${spec}:${window.since}:${window.until}`; +} + +/** Thread of the in-app copy of an on-demand digest (a period of its own). */ +export function manualPeriodThread( + zone: string, + window: { readonly since: number; readonly until: number }, +): string { + return `${IN_APP_REPORT_THREAD}digest:now:${zone}:${window.since}:${window.until}`; +} + +/** Dashboard path of an in-app report copy. */ +export function reportPath(notificationId: string): string { + return `/notifications/reports/${notificationId}`; +} + +/** + * Identity of an anomaly watch: the effective thresholds of a rule (D-45), so rules that differ + * only in how they spell a default share a watch. + * + * @returns A stable key. + */ +export function watchKey(rule: AnomalyRule): string { + const t = anomalyThresholds(rule); + return [ + t.errorRate ?? 'off', + t.minCalls, + t.attentionMinutes ?? 'off', + t.blockedSpike ?? 'off', + t.blockedMin, + t.capacity ? 'on' : 'off', + t.degraded ? 'on' : 'off', + ].join('/'); +} + +/** + * Every cursor a channel owns (its ntfy reply subscription, its digest schedule, its anomaly + * state): removed with the channel. + * + * @returns The keys. + */ +export function channelCursorKeys(channelId: string): readonly string[] { + return [`ntfy:${channelId}`, digestCursorKey(channelId), anomalyCursorKey(channelId)]; +} + +/** Removes every cursor of a channel (a delete, or a startup channel no longer declared). */ +export async function forgetChannelCursors( + cursors: NotificationCursorRepository, + channelId: string, +): Promise { + for (const key of channelCursorKeys(channelId)) await cursors.remove(key); +} + +/** Where a digest schedule stands. */ +interface DigestCursor { + /** The rule and zone it belongs to (`scheduleKey`). */ + readonly spec: string; + /** The last handled occurrence, or when the rule was armed. */ + readonly last: number; + /** End of the last window reported, or `null`. */ + readonly until: number | null; +} + +/** Where a channel's (or a watch's) anomaly checks stand. */ +interface AnomalyCursor { + /** The last check. */ + readonly last: number; + readonly active: AnomalyState; + /** The open alert, or `null`. */ + readonly notificationId: string | null; +} + +/** Counter of report decisions (spec 10 §7). */ +export interface ReportCounter { + add(value: number, attributes: { readonly kind: string; readonly outcome: string }): void; +} + +/** The in-app settings as the scheduler reads them. */ +export interface ReportSettingsSource { + current(): ReportSettings; + onChange(listener: () => void): () => void; +} + +/** Dependencies of {@link ReportScheduler}. */ +export interface ReportSchedulerDeps { + readonly registry: ChannelRegistry; + readonly facts: ReportFacts; + readonly uow: UnitOfWork; + readonly repos: Pick; + readonly outbox: Pick; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly logger: Logger; + /** The host's IANA zone, read at each evaluation (composition: the runtime's default zone). */ + readonly hostZone: () => string; + /** The in-app reports (D-45); absent = none. */ + readonly settings?: ReportSettingsSource; + /** Announces an in-app copy on the `notifications` topic (created, or revised). */ + readonly inbox?: (op: 'created' | 'updated', notification: Notification) => void; + readonly redactor?: Redactor; + readonly scheduler?: IntervalScheduler; + readonly counter?: ReportCounter; + /** Called after a report's delivery rows were written (the live delivery log). */ + readonly onDeliveryChange?: (notificationId: string) => void; + readonly tickMs?: number; +} + +/** Summary of one tick (tests, logs). */ +export interface ReportPass { + readonly digests: number; + readonly anomalies: number; +} + +/** A report built for a channel, ready to store or send. */ +export interface BuiltReport { + readonly message: NotificationMessage; + readonly record: NotificationRecord; + readonly window: { readonly since: number; readonly until: number }; + readonly empty: boolean; + /** The facts it was built from (its in-app copy reuses them). */ + readonly facts: DigestFacts; + readonly rule: DigestRule; + readonly ctx: ReportContext; +} + +/** A schedule: a channel, or the in-app settings (D-45). */ +interface Schedule { + /** Channel id, or {@link IN_APP_SCHEDULE}. */ + readonly key: string; + readonly name: string; + readonly rules: NotificationChannelRules; + /** The channel; `null` for the in-app schedule. */ + readonly channel: NotificationChannelRecord | null; +} + +/** An in-app copy to find by thread, or to insert (prepared outside the transaction). */ +interface InAppCopy { + readonly thread: string; + readonly copy: { readonly record: NotificationRecord } | null; +} + +/** A stored message revised (its row patch and its new message). */ +interface Revised { + readonly record: NotificationRecord; + readonly message: NotificationMessage; +} + +/** Everything one report decision writes in its transaction. */ +interface WritePlan { + /** The channel copy of a new report, or `null`. */ + readonly row?: NotificationRecord | null; + readonly jobs?: readonly NewNotificationDelivery[]; + /** Revisions of channel copies (planned for `channelId`) or of in-app copies (no jobs). */ + readonly revised?: readonly Revised[]; + readonly channelId?: string; + /** The period's in-app copy the new row links to (found or inserted). */ + readonly inApp?: InAppCopy | null; + /** New in-app rows to insert as they are (anomaly watch alerts). */ + readonly inAppRows?: readonly NotificationRecord[]; + /** An in-app row the new channel row names (an anomaly watch's open alert). */ + readonly linkTo?: string | null; + readonly cursor: { readonly key: string; readonly value: string }; + readonly now: number; +} + +function parseJson(raw: string | null): T | null { + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function readDigestCursor(raw: string | null): DigestCursor | null { + const v = parseJson<{ spec?: unknown; last?: unknown; until?: unknown }>(raw); + if (v === null || typeof v.spec !== 'string' || typeof v.last !== 'number') return null; + return { spec: v.spec, last: v.last, until: typeof v.until === 'number' ? v.until : null }; +} + +function anomalyCursorOf(v: { + last?: unknown; + active?: unknown; + notification_id?: unknown; +}): AnomalyCursor | null { + if (typeof v.last !== 'number') return null; + const active: Partial> = {}; + if (v.active !== null && typeof v.active === 'object') { + for (const check of ANOMALY_CHECKS) { + const a = (v.active as Record)[check] as Partial | undefined; + if ( + a !== undefined && + typeof a.since === 'number' && + typeof a.value === 'number' && + typeof a.threshold === 'number' + ) { + active[check] = { since: a.since, value: a.value, threshold: a.threshold }; + } + } + } + return { + last: v.last, + active, + notificationId: typeof v.notification_id === 'string' ? v.notification_id : null, + }; +} + +function readAnomalyCursor(raw: string | null): AnomalyCursor | null { + const v = parseJson<{ last?: unknown; active?: unknown; notification_id?: unknown }>(raw); + return v === null ? null : anomalyCursorOf(v); +} + +function anomalyCursorJson(c: AnomalyCursor) { + return { last: c.last, active: c.active, notification_id: c.notificationId }; +} + +function writeAnomalyCursor(c: AnomalyCursor): string { + return JSON.stringify(anomalyCursorJson(c)); +} + +function readWatches(raw: string | null): Map { + const v = parseJson<{ watches?: unknown }>(raw); + const out = new Map(); + if (v === null || v.watches === null || typeof v.watches !== 'object') return out; + for (const [key, value] of Object.entries(v.watches as Record)) { + if (value === null || typeof value !== 'object') continue; + const c = anomalyCursorOf(value as Record); + if (c !== null) out.set(key, c); + } + return out; +} + +function writeWatches(watches: ReadonlyMap): string { + const out: Record = {}; + for (const [key, c] of watches) out[key] = anomalyCursorJson(c); + return JSON.stringify({ watches: out }); +} + +function sameWatches( + a: ReadonlyMap, + b: ReadonlyMap, +): boolean { + if (a.size !== b.size) return false; + for (const [key, value] of a) { + const other = b.get(key); + if (other === undefined || writeAnomalyCursor(other) !== writeAnomalyCursor(value)) { + return false; + } + } + return true; +} + +/** A short, stable hash of a watch key for its threads (FNV-1a, 8 hex digits). */ +function shortHash(text: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, '0'); +} + +/** How a report row sits in the inbox. */ +type RowMode = 'channel' | 'digest' | 'alert'; + +/** + * Produces the scheduled reports. `tick()` is idempotent and serialised: a call while a pass runs + * returns that pass. With nothing scheduling a report no timer is armed (D-43). + */ +export class ReportScheduler { + private readonly log: Logger; + private readonly redactor: Redactor; + private cancel: (() => void) | undefined; + private offRegistry: (() => void) | undefined; + private offSettings: (() => void) | undefined; + private started = false; + private current: Promise | undefined; + private readonly digestCache = new Map(); + private readonly anomalyCache = new Map(); + private watchCache: Map | undefined; + + constructor(private readonly deps: ReportSchedulerDeps) { + this.log = deps.logger.child({ module: 'notifications' }); + this.redactor = deps.redactor ?? createRedactor(); + } + + /** Arms the timer while a schedule wants a report, follows reloads, and catches up now. */ + start(): void { + if (this.started) return; + this.started = true; + this.offRegistry = this.deps.registry.onChange(() => this.arm()); + this.offSettings = this.deps.settings?.onChange(() => { + this.arm(); + if (this.wanted()) void this.tick().catch((err: unknown) => this.report(err)); + }); + this.arm(); + if (this.wanted()) void this.tick().catch((err: unknown) => this.report(err)); + } + + /** Stops the timer. Idempotent. */ + stop(): void { + this.offRegistry?.(); + this.offRegistry = undefined; + this.offSettings?.(); + this.offSettings = undefined; + this.cancel?.(); + this.cancel = undefined; + this.started = false; + } + + /** Every schedule: the channels with a digest or anomaly alerts, then the in-app settings. */ + private schedules(): Schedule[] { + const out: Schedule[] = this.deps.registry + .channels() + .filter((c) => c.record.rules.digest !== undefined || c.record.rules.anomaly !== undefined) + .map((c) => ({ + key: c.record.channelId, + name: c.record.name, + rules: c.record.rules, + channel: c.record, + })); + const settings = this.settings(); + if (schedulesReports(settings)) { + out.push({ key: IN_APP_SCHEDULE, name: IN_APP_SCHEDULE, rules: settings, channel: null }); + } + return out; + } + + private settings(): ReportSettings { + return this.deps.settings?.current() ?? {}; + } + + /** Something to do: a schedule, or a watch left to close. */ + private wanted(): boolean { + return this.schedules().length > 0 || (this.watchCache?.size ?? 0) > 0; + } + + private arm(): void { + const want = this.started && this.wanted(); + if (want && this.cancel === undefined) { + this.cancel = (this.deps.scheduler ?? realIntervalScheduler).setInterval(() => { + void this.tick().catch((err: unknown) => this.report(err)); + }, this.deps.tickMs ?? REPORT_TICK_MS); + } else if (!want && this.cancel !== undefined) { + this.cancel(); + this.cancel = undefined; + } + } + + private report(err: unknown): void { + this.log.error('report tick failed', { err: serializeError(err) }); + } + + /** The zone a schedule's reports use. */ + zoneOf(rules: Pick): string { + return usableZone(rules.time_zone, this.hostZone()); + } + + private hostZone(): string { + return usableZone(this.deps.hostZone(), 'UTC'); + } + + /** + * One pass: the anomaly watches (the in-app alerts), then every schedule's due digest and, for + * channels, the anomaly check when due. + * + * @returns How many digests and anomaly decisions were written. + */ + tick(): Promise { + if (this.current !== undefined) return this.current; + const run = this.pass().finally(() => { + this.current = undefined; + // A watch closed in this pass may leave nothing to do. + this.arm(); + }); + this.current = run; + return run; + } + + private async pass(): Promise { + const now = this.deps.clock.now(); + let digests = 0; + let anomalies = 0; + let anomalyFacts: Promise | undefined; + const facts = () => { + anomalyFacts ??= this.deps.facts.anomaly(now); + return anomalyFacts; + }; + try { + anomalies += await this.watchTick(now, facts); + } catch (err) { + this.log.error('anomaly watch failed', { err: serializeError(err) }); + } + for (const schedule of this.schedules()) { + const rules = schedule.rules; + try { + if (rules.digest !== undefined && (await this.digestTick(schedule, rules.digest, now))) { + digests++; + } + } catch (err) { + this.log.error('digest failed', { channel: schedule.name, err: serializeError(err) }); + } + if (schedule.channel === null || rules.anomaly === undefined) continue; + try { + if (await this.anomalyTick(schedule.channel, now, facts)) anomalies++; + } catch (err) { + this.log.error('anomaly check failed', { + channel: schedule.name, + err: serializeError(err), + }); + } + } + return { digests, anomalies }; + } + + // ----------------------------------------------------------------------------------------------- + // Digests + // ----------------------------------------------------------------------------------------------- + + private async digestCursor(key: string): Promise { + const cached = this.digestCache.get(key); + if (cached !== undefined) return cached; + const read = readDigestCursor( + await this.deps.repos.notificationCursors.get(digestCursorKey(key)), + ); + if (read !== null) this.digestCache.set(key, read); + return read; + } + + /** Handles a schedule's due digest; `true` when one was produced. */ + private async digestTick(schedule: Schedule, rule: DigestRule, now: number) { + const zone = this.zoneOf(schedule.rules); + const spec = scheduleKey(rule, zone); + const cursorKey = digestCursorKey(schedule.key); + const cursor = await this.digestCursor(schedule.key); + if (cursor === null || cursor.spec !== spec) { + // A new schedule (or a changed one) arms from now: an edit never causes a late digest. + const armed: DigestCursor = { spec, last: now, until: cursor?.until ?? null }; + await this.deps.repos.notificationCursors.set(cursorKey, JSON.stringify(armed), now); + this.digestCache.set(schedule.key, armed); + return false; + } + const due = occurrencesBetween(rule, zone, cursor.last, now); + const newest = due.at[due.at.length - 1]; + if (newest === undefined) return false; + const skipped = due.at.length - 1 + due.older; + const late = now - newest > LATE_AFTER_MS; + const window = digestWindow(rule, zone, newest, cursor.until); + const facts = await this.deps.facts.digest(window, rule); + const empty = isEmptyDigest(facts); + const timing = { zone, scheduledAt: newest, late, skipped, manual: false }; + const next: DigestCursor = { spec, last: newest, until: window.until }; + let row: NotificationRecord | null = null; + let jobs: readonly NewNotificationDelivery[] = []; + const record = schedule.channel; + if (record !== null) { + const quietHours = quietHoursOf(record.rules); + const built = this.digestOf(record, rule, facts, now, { + ...timing, + level: contentLevelOf(record.rules), + quiet: quietHours !== null && inQuietHours(newest, quietHours), + }); + row = built.record; + jobs = this.deps.outbox.plan(built.message, now, record.channelId); + if (empty) { + jobs = jobs.map((j) => + j.status === 'pending' + ? { ...j, status: 'suppressed', reason: 'empty', nextAttemptAt: null } + : j, + ); + } + } + // An empty period has no in-app copy (never empty, D-43). + const inApp = empty + ? null + : await this.inAppDigest(digestPeriodThread(spec, window), rule, facts, now, timing); + const inserted = await this.write({ + row, + jobs, + inApp, + cursor: { key: cursorKey, value: JSON.stringify(next) }, + now, + ...(record !== null && { channelId: record.channelId }), + }); + this.digestCache.set(schedule.key, next); + const kind = rule.every === 'week' ? 'digest.weekly' : 'digest.daily'; + if (record !== null) this.count(kind, empty ? 'empty' : late ? 'late' : 'sent'); + if (inserted) this.count(kind, 'in_app'); + if (skipped > 0) this.count(kind, 'skipped', skipped); + this.log.info(empty ? 'digest empty' : 'digest produced', { + channel: schedule.name, + late, + skipped, + }); + return row !== null || inserted; + } + + /** The channel copy of a digest: the channel's level, zone and quiet hours, out of the inbox. */ + private digestOf( + record: NotificationChannelRecord, + rule: DigestRule, + facts: DigestFacts, + now: number, + ctx: ReportContext, + ): { message: NotificationMessage; record: NotificationRecord } { + const content = buildDigest(facts, rule, ctx); + const thread = `digest:${record.channelId}:${facts.window.until}`; + const message = this.seal(this.messageOf(content, thread, now, 1, ctx.level, null)); + return { message, record: this.recordOf(message, content.target, now, 'channel') }; + } + + /** + * The in-app copy of a digest period (D-45): looked up by thread, built at `full` only when + * missing (the transaction checks again). + */ + private async inAppDigest( + thread: string, + rule: DigestRule, + facts: DigestFacts, + now: number, + timing: Pick, + ): Promise { + const existing = await this.deps.repos.notifications.findLatestByThread(null, thread); + if (existing !== null) return { thread, copy: null }; + const content = buildDigest(facts, rule, { ...timing, level: 'full', quiet: false }); + const id = this.newId(); + const message = this.seal( + reportMessage( + // A digest never rings in the dashboard. + { ...content, alert: false }, + { id, thread, revision: 1, createdAt: now, updatedAt: now, level: 'full' }, + ), + ); + return { thread, copy: { record: this.recordOf(message, reportPath(id), now, 'digest') } }; + } + + /** + * The digest of a window for a channel, sealed (redacted, validated) with a fresh notification + * id and its channel row (read and dismissed: the inbox shows the period's in-app copy, D-45). + * + * @returns The report. + */ + async buildDigestFor( + record: NotificationChannelRecord, + rule: DigestRule, + window: { readonly since: number; readonly until: number }, + now: number, + ctx: ReportContext, + ): Promise { + const facts = await this.deps.facts.digest(window, rule); + const built = this.digestOf(record, rule, facts, now, ctx); + return { ...built, window, empty: isEmptyDigest(facts), facts, rule, ctx }; + } + + /** + * The on-demand digest of a channel: the period that ends now (a day, or a week), never late and + * never suppressed as empty; the schedule and its cursor are untouched. + * + * @returns The report (a daily one when the channel schedules no digest). + */ + async manualDigest(record: NotificationChannelRecord): Promise { + const now = this.deps.clock.now(); + const rule: DigestRule = record.rules.digest ?? { every: 'day', at: '09:00' }; + const window = { since: now - periodMs(rule), until: now }; + return this.buildDigestFor(record, rule, window, now, { + zone: this.zoneOf(record.rules), + level: contentLevelOf(record.rules), + scheduledAt: now, + late: false, + skipped: 0, + manual: true, + quiet: false, + }); + } + + /** + * Stores the in-app copy of an on-demand digest that is being sent (D-45: a period of its own, + * kept even when empty because someone asked for it) and announces it. + * + * @returns The copy's notification id, for the channel row's `source_event_id`. + */ + async storeManualCopy(built: BuiltReport): Promise { + const now = this.deps.clock.now(); + const thread = manualPeriodThread(built.ctx.zone, built.window); + const inApp = await this.inAppDigest(thread, built.rule, built.facts, now, built.ctx); + let found: { id: string | null; inserted: NotificationRecord | null } = { + id: null, + inserted: null, + }; + await this.deps.uow.transaction(async (repos) => { + found = await this.linkInApp(repos, inApp); + }); + if (found.inserted !== null) { + this.announce('created', found.inserted); + this.count(built.message.kind, 'in_app'); + } + return found.id; + } + + // ----------------------------------------------------------------------------------------------- + // Anomaly checks + // ----------------------------------------------------------------------------------------------- + + private async anomalyCursor(channelId: string): Promise { + const cached = this.anomalyCache.get(channelId); + if (cached !== undefined) return cached; + const read = readAnomalyCursor( + await this.deps.repos.notificationCursors.get(anomalyCursorKey(channelId)), + ); + if (read !== null) this.anomalyCache.set(channelId, read); + return read; + } + + private async watches(): Promise> { + if (this.watchCache !== undefined) return this.watchCache; + const read = readWatches( + await this.deps.repos.notificationCursors.get(anomalyCursorKey(IN_APP_SCHEDULE)), + ); + this.watchCache = read; + return read; + } + + /** The watches the schedules want: one per distinct effective thresholds (D-45). */ + private wantedWatches(): Map { + const out = new Map(); + for (const schedule of this.schedules()) { + const rule = schedule.rules.anomaly; + if (rule !== undefined) out.set(watchKey(rule), rule); + } + return out; + } + + /** + * One step of the anomaly checks for a stored state: the outcome of D-44 as the rows to write. + * `thread` names the thread of a new alert; `mode` says how its row sits in the inbox. + */ + private async anomalyStep(input: { + readonly cursor: AnomalyCursor | null; + readonly rule: AnomalyRule; + readonly facts: AnomalyFacts; + readonly now: number; + readonly ctx: ReportContext; + readonly thread: () => string; + readonly mode: RowMode; + }): Promise<{ + readonly row: NotificationRecord | null; + readonly message: NotificationMessage | null; + readonly revised: Revised | null; + readonly next: AnomalyCursor; + readonly outcome: 'sent' | 'resolved' | 'revised' | 'none'; + }> { + const { cursor, facts, now, ctx } = input; + const previous: AnomalyState = cursor?.active ?? {}; + const evaluation = evaluateAnomalies(facts, input.rule, previous, now); + const openId = cursor?.notificationId ?? null; + const activeNow = Object.keys(evaluation.active).length > 0; + if (evaluation.fired.length > 0) { + const content = buildAnomaly( + { facts, active: evaluation.active, fired: evaluation.fired }, + ctx, + ); + const id = this.newId(); + const target = input.mode === 'channel' ? content.target : reportPath(id); + const message = this.seal( + reportMessage(content, { + id, + thread: input.thread(), + revision: 1, + createdAt: now, + updatedAt: now, + level: ctx.level, + }), + ); + const superseded = + openId === null + ? null + : await this.revision(openId, now, (prev) => ({ + ...prev, + state: 'final', + alert: false, + summary: `Superseded by the report of ${formatClock(now, ctx.zone)}.`, + actions: [], + })); + return { + row: this.recordOf(message, target, now, input.mode), + message, + revised: superseded, + next: { last: now, active: evaluation.active, notificationId: id }, + outcome: 'sent', + }; + } + if (!activeNow && evaluation.cleared.length > 0 && openId !== null) { + const began = Math.min(...Object.values(previous).map((a) => a?.since ?? now), now); + const content = buildAnomaly({ facts, active: {}, fired: [], resolvedSince: began }, ctx); + const revised = await this.revision(openId, now, (prev) => + this.messageOf(content, prev.thread, now, prev.revision + 1, ctx.level, prev), + ); + return { + row: null, + message: null, + revised, + next: { last: now, active: {}, notificationId: null }, + outcome: 'resolved', + }; + } + if (activeNow && evaluation.cleared.length > 0 && openId !== null) { + const content = buildAnomaly({ facts, active: evaluation.active, fired: [] }, ctx); + const revised = await this.revision(openId, now, (prev) => + this.messageOf(content, prev.thread, now, prev.revision + 1, ctx.level, prev), + ); + return { + row: null, + message: null, + revised, + next: { last: now, active: evaluation.active, notificationId: openId }, + outcome: 'revised', + }; + } + return { + row: null, + message: null, + revised: null, + next: { last: now, active: evaluation.active, notificationId: activeNow ? openId : null }, + outcome: 'none', + }; + } + + /** + * The anomaly watches (D-45): each wanted watch checked at its hourly slot (no quiet hours, + * `full`, the in-app zone); a watch no longer wanted is dropped and its open alert closed. + * + * @returns How many watch decisions wrote a notification. + */ + private async watchTick(now: number, factsOf: () => Promise): Promise { + const wanted = this.wantedWatches(); + const stored = await this.watches(); + if (wanted.size === 0 && stored.size === 0) return 0; + const slot = Math.floor(now / HOUR) * HOUR; + const zone = this.zoneOf(this.settings()); + const next = new Map(stored); + const rows: NotificationRecord[] = []; + const revised: Revised[] = []; + let decisions = 0; + for (const [key, cursor] of stored) { + if (wanted.has(key)) continue; + next.delete(key); + if (cursor.notificationId === null) continue; + const closed = await this.revision(cursor.notificationId, now, (prev) => ({ + ...prev, + state: 'final', + alert: false, + summary: 'No longer checked.', + actions: [], + })); + if (closed !== null) revised.push(closed); + } + for (const [key, rule] of wanted) { + const cursor = stored.get(key) ?? null; + if (cursor !== null && cursor.last >= slot) continue; + const step = await this.anomalyStep({ + cursor, + rule, + facts: await factsOf(), + now, + ctx: { + zone, + level: 'full', + scheduledAt: now, + late: false, + skipped: 0, + manual: false, + quiet: false, + }, + thread: () => `${IN_APP_REPORT_THREAD}anomaly:${shortHash(key)}:${now}`, + mode: 'alert', + }); + next.set(key, step.next); + if (step.row !== null) rows.push(step.row); + if (step.revised !== null) revised.push(step.revised); + if (step.outcome !== 'none') { + decisions++; + this.count('report.anomaly', step.outcome === 'sent' ? 'in_app' : step.outcome); + } + } + if (rows.length === 0 && revised.length === 0 && sameWatches(stored, next)) return 0; + await this.write({ + inAppRows: rows, + revised, + cursor: { key: anomalyCursorKey(IN_APP_SCHEDULE), value: writeWatches(next) }, + now, + }); + this.watchCache = next; + if (rows.length > 0) this.log.info('anomaly alert', { channel: IN_APP_SCHEDULE }); + return decisions; + } + + /** Runs a channel's check when due; `true` when a notification was written or revised. */ + private async anomalyTick( + record: NotificationChannelRecord, + now: number, + factsOf: () => Promise, + ): Promise { + const rule = record.rules.anomaly; + if (rule === undefined) return false; + const cursor = await this.anomalyCursor(record.channelId); + const slot = Math.floor(now / HOUR) * HOUR; + if (cursor !== null && cursor.last >= slot) return false; + const quiet = quietHoursOf(record.rules); + // During quiet hours no check runs and `last` stays: the first tick after them checks. + if (quiet !== null && inQuietHours(now, quiet)) return false; + const key = anomalyCursorKey(record.channelId); + const step = await this.anomalyStep({ + cursor, + rule, + facts: await factsOf(), + now, + ctx: { + zone: this.zoneOf(record.rules), + level: contentLevelOf(record.rules), + scheduledAt: now, + late: false, + skipped: 0, + manual: false, + quiet: false, + }, + thread: () => `anomaly:${record.channelId}`, + mode: 'channel', + }); + if (step.outcome === 'none') { + await this.deps.repos.notificationCursors.set(key, writeAnomalyCursor(step.next), now); + this.anomalyCache.set(record.channelId, step.next); + return false; + } + // A new channel alert names its watch's open alert (the in-app copy of the episode). + const linkTo = + step.row === null + ? null + : ((await this.watches()).get(watchKey(rule))?.notificationId ?? null); + await this.write({ + row: step.row, + jobs: step.message === null ? [] : this.deps.outbox.plan(step.message, now, record.channelId), + revised: step.revised === null ? [] : [step.revised], + channelId: record.channelId, + linkTo, + cursor: { key, value: writeAnomalyCursor(step.next) }, + now, + }); + this.anomalyCache.set(record.channelId, step.next); + if (step.outcome !== 'revised') this.count('report.anomaly', step.outcome); + this.log.info(step.outcome === 'resolved' ? 'anomaly cleared' : 'anomaly alert', { + channel: record.name, + }); + return true; + } + + /** A revision of a stored report notification, or `null` when it is gone. */ + private async revision( + notificationId: string, + now: number, + change: (prev: NotificationMessage) => NotificationMessage, + ): Promise { + const row = await this.deps.repos.notifications.get(notificationId); + if (row === null || row.messageJson === null) return null; + let prev: NotificationMessage; + try { + prev = JSON.parse(row.messageJson) as NotificationMessage; + } catch { + return null; + } + const next = change(prev); + const message = this.seal({ + ...next, + id: prev.id, + thread: prev.thread, + revision: prev.revision + 1, + alert: false, + at: { created: prev.at.created, updated: Math.max(now, prev.at.updated) }, + }); + return { + record: { + ...row, + state: message.state, + severity: message.severity, + revision: message.revision, + messageJson: JSON.stringify(message), + }, + message, + }; + } + + // ----------------------------------------------------------------------------------------------- + // Shared + // ----------------------------------------------------------------------------------------------- + + private newId(): string { + return `n-${this.deps.ids.opaque(12)}`; + } + + private messageOf( + content: ReportContent, + thread: string, + now: number, + revision: number, + level: NotificationMessage['privacy']['level'], + prev: NotificationMessage | null, + ): NotificationMessage { + return reportMessage(content, { + id: prev?.id ?? this.newId(), + thread, + revision, + createdAt: prev?.at.created ?? now, + updatedAt: now, + level, + }); + } + + /** Redacts and validates; a message that still fails loses its blocks rather than the report. */ + private seal(message: NotificationMessage): NotificationMessage { + try { + return scrubMessage(message, this.redactor); + } catch (err) { + this.log.error('report build failed', { kind: message.kind, err: serializeError(err) }); + return scrubMessage({ ...message, blocks: [], actions: [] }, this.redactor); + } + } + + /** + * The row of a report: a channel copy (read and dismissed: never in the inbox), an in-app digest + * (read: no badge) or an in-app anomaly alert (unread) (D-45). + */ + private recordOf( + message: NotificationMessage, + target: string, + now: number, + mode: RowMode, + ): NotificationRecord { + return { + notificationId: message.id, + principalId: null, + type: KIND_TYPE[message.kind], + title: message.title, + body: message.summary, + sessionId: null, + target, + sourceEventId: null, + createdAt: now, + updatedAt: now, + count: 1, + groupKey: null, + readAt: mode === 'alert' ? null : now, + dismissedAt: mode === 'channel' ? now : null, + kind: message.kind, + category: KIND_CATEGORY[message.kind], + severity: message.severity, + state: message.state, + revision: message.revision, + thread: message.thread, + messageJson: JSON.stringify(message), + }; + } + + /** Finds the period's in-app copy in the transaction, inserting the prepared one when missing. */ + private async linkInApp( + repos: Repositories, + inApp: InAppCopy, + ): Promise<{ id: string | null; inserted: NotificationRecord | null }> { + const existing = await repos.notifications.findLatestByThread(null, inApp.thread); + if (existing !== null) return { id: existing.notificationId, inserted: null }; + if (inApp.copy === null) return { id: null, inserted: null }; + await repos.notifications.insert(inApp.copy.record); + return { id: inApp.copy.record.notificationId, inserted: inApp.copy.record }; + } + + /** + * Writes one report decision in one transaction: the period's in-app copy (found or inserted), + * new in-app rows, revisions, the channel row naming its in-app copy, their delivery rows and the + * cursor; then announces the in-app changes and wakes the outbox. + * + * @returns Whether an in-app copy of a digest period was inserted. + */ + private async write(plan: WritePlan): Promise { + const { now } = plan; + const revised = plan.revised ?? []; + // With a channel, the revisions are that channel's copies; without, in-app copies (no jobs). + const channelId = plan.channelId; + const channelRevisions = channelId === undefined ? [] : revised; + const revisionJobs = + channelId === undefined + ? [] + : channelRevisions.flatMap((r) => this.deps.outbox.plan(r.message, now, channelId)); + const jobs = [...revisionJobs, ...(plan.jobs ?? [])]; + let found: { id: string | null; inserted: NotificationRecord | null } = { + id: plan.linkTo ?? null, + inserted: null, + }; + await this.deps.uow.transaction(async (repos) => { + if (plan.inApp !== undefined && plan.inApp !== null) { + found = await this.linkInApp(repos, plan.inApp); + } + for (const r of revised) { + await repos.notifications.revise(r.record.notificationId, { + state: r.message.state, + severity: r.message.severity, + revision: r.message.revision, + messageJson: JSON.stringify(r.message), + }); + } + for (const row of plan.inAppRows ?? []) await repos.notifications.insert(row); + if (plan.row !== undefined && plan.row !== null) { + await repos.notifications.insert( + found.id === null ? plan.row : { ...plan.row, sourceEventId: found.id }, + ); + } + if (jobs.length > 0) await repos.notificationDeliveries.enqueue(jobs); + await repos.notificationCursors.set(plan.cursor.key, plan.cursor.value, now); + }); + if (found.inserted !== null) this.announce('created', found.inserted); + for (const row of plan.inAppRows ?? []) this.announce('created', row); + if (channelId === undefined) for (const r of revised) this.announce('updated', r.record); + const touched = [ + ...channelRevisions.map((r) => r.record.notificationId), + ...(plan.row === undefined || plan.row === null ? [] : [plan.row.notificationId]), + ]; + for (const id of touched) { + try { + this.deps.onDeliveryChange?.(id); + } catch (err) { + this.log.warn('delivery feed failed', { err: serializeError(err) }); + } + } + if (jobs.some((j) => j.status === 'pending')) this.deps.outbox.kick(); + return found.inserted !== null; + } + + /** Publishes an in-app copy on the `notifications` topic. */ + private announce(op: 'created' | 'updated', record: NotificationRecord): void { + if (this.deps.inbox === undefined) return; + try { + this.deps.inbox(op, toNotification(record)); + } catch (err) { + this.log.warn('inbox announce failed', { err: serializeError(err) }); + } + } + + private count(kind: string, outcome: string, n = 1): void { + this.deps.counter?.add(n, { kind, outcome }); + } + + /** Drops a channel's cached cursors (after its cursors were removed). */ + forget(channelId: string): void { + this.digestCache.delete(channelId); + this.anomalyCache.delete(channelId); + } + + /** + * The scheduled reports of a channel as the API shows them (`ChannelView.reports`). + * + * @returns The view; cursors not yet read count as "armed now". + */ + view(record: NotificationChannelRecord): ChannelReports { + const ac = this.anomalyCache.get(record.channelId); + return reportsView(record.rules, this.deps.clock.now(), this.hostZone(), { + until: this.digestCache.get(record.channelId)?.until ?? null, + ...(ac !== undefined && { anomaly: { last: ac.last, active: ac.active } }), + }); + } + + /** + * The in-app reports as the Reports tab shows them: the next digest and the in-app watch. + * + * @returns The view. + */ + inAppView(settings: ReportSettings): ChannelReports { + const watch = + settings.anomaly === undefined ? undefined : this.watchCache?.get(watchKey(settings.anomaly)); + return reportsView(settings, this.deps.clock.now(), this.hostZone(), { + until: this.digestCache.get(IN_APP_SCHEDULE)?.until ?? null, + ...(watch !== undefined && { anomaly: { last: watch.last, active: watch.active } }), + }); + } + + /** The host's zone (the in-app reports without `time_zone`). */ + hostTimeZone(): string { + return this.hostZone(); + } + + /** Reads every schedule's cursors into the cache (the views before the first tick). */ + async load(): Promise { + for (const schedule of this.schedules()) { + await this.digestCursor(schedule.key); + if (schedule.channel !== null) await this.anomalyCursor(schedule.key); + } + await this.watches(); + } +} + +/** + * The scheduled reports of a channel (or of the in-app settings) as the API shows them + * (`ChannelView.reports`), from its rules and, when known, its cursors (without them: armed now, + * a check due now). + * + * @returns The view. + */ +export function reportsView( + rules: Pick, + now: number, + hostZone: string, + state: { + readonly until?: number | null; + readonly anomaly?: { readonly last: number; readonly active: AnomalyState }; + } = {}, +): ChannelReports { + const zone = usableZone(rules.time_zone, usableZone(hostZone, 'UTC')); + const digest = rules.digest; + const ac = state.anomaly; + return { + time_zone: zone, + host_zone: rules.time_zone === undefined, + digest: + digest === undefined + ? null + : { + every: digest.every, + at: digest.at, + day: digest.every === 'week' ? digestDay(digest) : null, + weekdays_only: digest.every === 'day' && digest.weekdays_only === true, + next_at: nextOccurrence(digest, zone, now), + last_until: state.until ?? null, + }, + anomaly: + rules.anomaly === undefined + ? null + : { + next_check_at: + ac === undefined || ac.last < Math.floor(now / HOUR) * HOUR + ? now + : nextHour(Math.max(now, ac.last)), + active: ANOMALY_CHECKS.flatMap((check) => { + const a = ac?.active[check]; + return a === undefined + ? [] + : [{ check, since: a.since, value: a.value, threshold: a.threshold }]; + }), + }, + }; +} diff --git a/packages/core/src/app/notifications/report-service.ts b/packages/core/src/app/notifications/report-service.ts new file mode 100644 index 0000000..3b1e1f5 --- /dev/null +++ b/packages/core/src/app/notifications/report-service.ts @@ -0,0 +1,127 @@ +/** @module app/notifications/report-service — reports in the dashboard (D-45, spec 03 §4.8): the history of in-app report copies with the channels each reached, one report with its message, and the in-app report settings with their schedule view. */ + +import type { + ReportChannel, + ReportDetailResponse, + ReportItem, + ReportSettingsResponse, +} from '@browserhive/contracts/http'; +import { + type NotificationMessage, + NotificationReport, + type ReportSettings, +} from '@browserhive/contracts/notifications'; +import { AppError } from '../../kernel/errors/app-error.ts'; +import type { Clock } from '../../ports/clock.ts'; +import type { + NotificationRepository, + ReportChannelRow, +} from '../../ports/persistence/notifications.ts'; +import type { Page, ReportListQuery } from '../../ports/persistence/queries.ts'; +import type { NotificationRecord } from '../../ports/persistence/records.ts'; +import { decodeMessage } from './message.ts'; +import { toNotification } from './notification-service.ts'; +import { IN_APP_REPORT_THREAD, type ReportScheduler } from './report-scheduler.ts'; +import type { ReportSettingsStore } from './report-settings.ts'; + +/** Dependencies of {@link ReportService}. */ +export interface ReportServiceDeps { + readonly repo: Pick; + readonly settings: Pick; + readonly scheduler: Pick; + readonly clock: Clock; +} + +/** Whether a row is an in-app report copy (D-45). */ +export function isInAppReport(record: NotificationRecord): boolean { + return record.category === 'reports' && (record.thread ?? '').startsWith(IN_APP_REPORT_THREAD); +} + +function channelOf(row: ReportChannelRow): ReportChannel { + return { + channel_id: row.channelId, + name: row.name, + kind: row.kind, + status: row.status as ReportChannel['status'], + reason: row.reason, + }; +} + +function messageOf(record: NotificationRecord): NotificationMessage | null { + return decodeMessage(record.messageJson); +} + +function reportOf(message: NotificationMessage | null): ReportItem['report'] { + if (message?.report === undefined) return null; + const parsed = NotificationReport.safeParse(message.report); + return parsed.success ? parsed.data : null; +} + +/** The Reports tab's reads and the in-app settings. */ +export class ReportService { + constructor(private readonly deps: ReportServiceDeps) {} + + /** + * One page of the history. + * + * @returns Wire items, newest first. + */ + async list(query: ReportListQuery): Promise> { + const page = await this.deps.repo.listReports(query); + const channels = await this.deps.repo.reportChannels(page.items.map((r) => r.notificationId)); + return { + ...page, + items: page.items.map((record) => this.item(record, channels.get(record.notificationId))), + }; + } + + /** + * One report with its current message. + * + * @throws AppError `REPORT_NOT_FOUND` for an unknown id or a row that is not an in-app copy. + */ + async get(notificationId: string): Promise { + const record = await this.deps.repo.get(notificationId); + const message = record === null ? null : messageOf(record); + if (record === null || !isInAppReport(record) || message === null) { + throw new AppError('REPORT_NOT_FOUND', { notification_id: notificationId }); + } + const channels = await this.deps.repo.reportChannels([notificationId]); + return { report: this.item(record, channels.get(notificationId), message), message }; + } + + /** The in-app settings with their schedule view. */ + settings(): ReportSettingsResponse { + return this.view(this.deps.settings.current()); + } + + /** + * Stores new in-app settings (the scheduler re-arms from now). + * + * @returns The stored settings with their view. + */ + async saveSettings(settings: ReportSettings): Promise { + const saved = await this.deps.settings.save(settings, this.deps.clock.now()); + return this.view(saved); + } + + private view(settings: ReportSettings): ReportSettingsResponse { + return { + settings, + host_time_zone: this.deps.scheduler.hostTimeZone(), + reports: this.deps.scheduler.inAppView(settings), + }; + } + + private item( + record: NotificationRecord, + channels: readonly ReportChannelRow[] | undefined, + message: NotificationMessage | null = messageOf(record), + ): ReportItem { + return { + notification: toNotification(record), + report: reportOf(message), + channels: (channels ?? []).map(channelOf), + }; + } +} diff --git a/packages/core/src/app/notifications/report-settings.ts b/packages/core/src/app/notifications/report-settings.ts new file mode 100644 index 0000000..b7ebb75 --- /dev/null +++ b/packages/core/src/app/notifications/report-settings.ts @@ -0,0 +1,71 @@ +/** @module app/notifications/report-settings — the in-app reports (D-45, spec 03 §9.7): the dashboard's own digest schedule and anomaly switch, server-wide, kept in `notification_cursors['settings:in-app-reports']`; read once at start, validated on every write, and announced to the scheduler on change. */ + +import { checkReportRules, ReportSettings } from '@browserhive/contracts/notifications'; +import { AppError } from '../../kernel/errors/app-error.ts'; +import type { NotificationCursorRepository } from '../../ports/persistence/notification-actions.ts'; + +/** Where the in-app report settings are kept. */ +export const REPORT_SETTINGS_KEY = 'settings:in-app-reports'; + +/** Whether the settings schedule anything (a digest or the anomaly alerts). */ +export function schedulesReports(settings: ReportSettings): boolean { + return settings.digest !== undefined || settings.anomaly !== undefined; +} + +/** + * The in-app report settings: `{}` (off) until saved. A stored value that no longer validates is + * read as off rather than failing the start. + */ +export class ReportSettingsStore { + private value: ReportSettings = {}; + private readonly listeners = new Set<() => void>(); + + constructor(private readonly cursors: NotificationCursorRepository) {} + + /** Reads the stored settings (at start). */ + async load(): Promise { + const raw = await this.cursors.get(REPORT_SETTINGS_KEY); + if (raw === null) return; + try { + const parsed = ReportSettings.safeParse(JSON.parse(raw)); + this.value = parsed.success ? parsed.data : {}; + } catch { + this.value = {}; + } + } + + /** The current settings. */ + current(): ReportSettings { + return this.value; + } + + /** + * Validates and stores new settings, then tells the listeners (the scheduler re-arms). + * + * @throws AppError `VALIDATION_FAILED` for an unknown zone or a rule that mixes daily and weekly + * options. + */ + async save(settings: ReportSettings, now: number): Promise { + const problems = checkReportRules(settings); + if (problems.length > 0) { + throw new AppError('VALIDATION_FAILED', { + issues: problems.map((p) => ({ + path: `settings.${p.field}`, + message: p.message, + code: 'custom', + })), + }); + } + const clean = ReportSettings.parse(settings); + await this.cursors.set(REPORT_SETTINGS_KEY, JSON.stringify(clean), now); + this.value = clean; + for (const listener of this.listeners) listener(); + return clean; + } + + /** Subscribes to changes; returns the unsubscribe function. */ + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} diff --git a/packages/core/src/app/notifications/reports.test.ts b/packages/core/src/app/notifications/reports.test.ts new file mode 100644 index 0000000..c589bc2 --- /dev/null +++ b/packages/core/src/app/notifications/reports.test.ts @@ -0,0 +1,367 @@ +/** @module app/notifications/reports.test — the pure report producers (D-43, D-44, spec 03 §9.7) table-driven over fixture facts: what each content level carries, the empty-digest rule, late and skipped notes, silent sends in quiet hours, the anomaly checks with their hysteresis, and the anomaly alert. */ + +import { describe, expect, it } from 'bun:test'; +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import { + type AnomalyRule, + type DigestRule, + NotificationMessage, +} from '@browserhive/contracts/notifications'; +import { + type AnomalyFacts, + type AnomalyState, + anomalyThresholds, + buildAnomaly, + buildDigest, + type DigestFacts, + evaluateAnomalies, + isEmptyDigest, + type ReportContext, + reportMessage, +} from './reports.ts'; +import { sampleAnomalyFacts, sampleDigestFacts } from './samples.ts'; + +const HOUR = 3_600_000; +const UNTIL = Date.UTC(2026, 8, 29, 7); // 09:00 in Berlin +const DAILY: DigestRule = { every: 'day', at: '09:00' }; + +function ctx(overrides: Partial = {}): ReportContext { + return { + zone: 'Europe/Berlin', + level: 'titles', + scheduledAt: UNTIL, + late: false, + skipped: 0, + manual: false, + quiet: false, + ...overrides, + }; +} + +function emptyFacts(): DigestFacts { + return { + window: { since: UNTIL - 24 * HOUR, until: UNTIL }, + sessionsStarted: 0, + sessionsLive: 0, + toolCalls: 0, + errors: 0, + previous: { toolCalls: 0, errors: 0 }, + attention: { + created: 0, + resolved: 0, + rejected: 0, + timedOut: 0, + cancelled: 0, + pending: 0, + medianWaitMs: null, + }, + vault: [], + blocked: { count: 0, topPattern: null, topDomain: null }, + slowest: null, + topErrors: [], + degradations: [], + harnesses: [{ harness: 'unknown', sessions: 0, toolCalls: 0, errors: 0 }], + chart: { start: UNTIL - 24 * HOUR, stepMs: HOUR, values: Array(24).fill(0) }, + }; +} + +/** Every string a rendered report carries. */ +function text(content: ReturnType): string { + return JSON.stringify({ t: content.title, s: content.summary, b: content.blocks }); +} + +describe('isEmptyDigest', () => { + const cases: readonly [string, (f: DigestFacts) => DigestFacts, boolean][] = [ + ['nothing happened', (f) => f, true], + ['a live session alone is still empty', (f) => ({ ...f, sessionsLive: 3 }), true], + ['a session started', (f) => ({ ...f, sessionsStarted: 1 }), false], + ['a tool call', (f) => ({ ...f, toolCalls: 1 }), false], + ['an attention request', (f) => ({ ...f, attention: { ...f.attention, created: 1 } }), false], + ['a vault access', (f) => ({ ...f, vault: [{ result: 'success', count: 1 }] }), false], + ['a blocked request', (f) => ({ ...f, blocked: { ...f.blocked, count: 1 } }), false], + [ + 'an open degradation', + (f) => ({ + ...f, + degradations: [{ code: 'X', severity: 'warn', message: 'm', since: 0 }], + }), + false, + ], + ]; + for (const [name, change, empty] of cases) { + it(name, () => expect(isEmptyDigest(change(emptyFacts()))).toBe(empty)); + } +}); + +describe('buildDigest', () => { + const facts = sampleDigestFacts(UNTIL, DAILY); + const names = [ + 'navigate', + 'NAVIGATION_TIMEOUT', + '*.doubleclick.net', + 'Claude Code', + 'RETENTION_FAILED', + 'origin mismatch', + ]; + const levels: readonly [NotificationContentLevel, readonly string[], readonly string[]][] = [ + ['counts', [], [...names, 'ads.example.net', 'database is locked']], + ['titles', names, ['ads.example.net', 'database is locked']], + ['full', [...names, 'ads.example.net', 'database is locked'], []], + ]; + for (const [level, present, absent] of levels) { + it(`carries at ${level} only what that level allows`, () => { + const out = text(buildDigest(facts, DAILY, ctx({ level }))); + for (const name of present) expect(out).toContain(name); + for (const name of absent) expect(out).not.toContain(name); + // The numbers are there at every level. + expect(out).toContain('3,412'); + expect(out).toContain('68 errors (2%)'); + }); + } + + it('titles the day in the channel zone and links the window on the Overview', () => { + const d = buildDigest(facts, DAILY, ctx()); + expect(d.kind).toBe('digest.daily'); + expect(d.title).toBe('Daily digest · Tue 29 Sep'); + expect(d.summary).toBe('12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)'); + expect(d.actions[0]).toMatchObject({ + kind: 'open', + path: `/overview?since=${UNTIL - 24 * HOUR}&until=${UNTIL}`, + }); + expect(d.report).toEqual({ + window: { since: UNTIL - 24 * HOUR, until: UNTIL }, + time_zone: 'Europe/Berlin', + late: false, + skipped: 0, + manual: false, + }); + expect(d.blocks.some((b) => b.type === 'chart')).toBe(true); + expect(d.blocks.at(-1)).toEqual({ + type: 'footer', + content: [{ type: 'text', text: '28 Sep 09:00 → 29 Sep 09:00 · Europe/Berlin' }], + }); + }); + + it('writes a weekly digest over its span', () => { + const weekly: DigestRule = { every: 'week', at: '09:00', day: 'tue' }; + const d = buildDigest(sampleDigestFacts(UNTIL, weekly), weekly, ctx()); + expect(d.kind).toBe('digest.weekly'); + expect(d.title).toBe('Weekly digest · 22–29 Sep'); + }); + + it('says when it is late, how many windows were skipped, or that it was sent on demand', () => { + const late = text(buildDigest(facts, DAILY, ctx({ late: true, skipped: 2 }))); + expect(late).toContain('Sent late: BrowserHive was not running at 09:00 (Tue 29 Sep).'); + expect(late).toContain('2 earlier digests were skipped while BrowserHive was off.'); + expect(text(buildDigest(facts, DAILY, ctx({ skipped: 1 })))).toContain( + '1 earlier digest was skipped', + ); + expect(text(buildDigest(facts, DAILY, ctx({ manual: true })))).toContain('Sent on demand'); + }); + + it('is silent inside quiet hours', () => { + expect(buildDigest(facts, DAILY, ctx()).alert).toBe(true); + expect(buildDigest(facts, DAILY, ctx({ quiet: true })).alert).toBe(false); + }); + + it('says plainly that nothing happened', () => { + const d = buildDigest(emptyFacts(), DAILY, ctx()); + expect(d.summary).toBe('Nothing happened: no sessions, tool calls or requests today.'); + expect(d.blocks.some((b) => b.type === 'chart')).toBe(false); + }); + + it('builds a valid contract message at the level it was produced for', () => { + const message = reportMessage(buildDigest(facts, DAILY, ctx({ level: 'counts' })), { + id: 'n-000000000001', + thread: 'digest:nc-x:1', + revision: 1, + createdAt: UNTIL, + updatedAt: UNTIL, + level: 'counts', + }); + expect(NotificationMessage.parse(message).privacy).toEqual({ + level: 'counts', + has_image: false, + }); + expect(message.category).toBe('reports'); + expect(message.state).toBe('final'); + }); +}); + +describe('evaluateAnomalies (D-44)', () => { + const quiet: AnomalyFacts = { + window: { since: UNTIL - HOUR, until: UNTIL }, + toolCalls: 100, + errors: 0, + blocked: 0, + blockedBaselinePerHour: 10, + attentionWaiting: [], + live: 0, + maxSessions: 10, + degradations: [], + }; + const on = (f: Partial, rule: AnomalyRule = {}, prev: AnomalyState = {}) => + Object.keys(evaluateAnomalies({ ...quiet, ...f }, rule, prev, UNTIL).active); + const was = (check: keyof AnomalyState): AnomalyState => ({ + [check]: { since: UNTIL - HOUR, value: 1, threshold: 1 }, + }); + + const cases: readonly [string, string[], string[]][] = [ + ['nothing crosses', on({}), []], + ['error rate at 20 % with 20 calls fires', on({ toolCalls: 20, errors: 4 }), ['error_rate']], + ['error rate with too few calls does not', on({ toolCalls: 19, errors: 19 }), []], + ['error rate below the threshold does not', on({ toolCalls: 100, errors: 19 }), []], + [ + 'an active error rate stays above half the threshold', + on({ toolCalls: 100, errors: 11 }, {}, was('error_rate')), + ['error_rate'], + ], + [ + 'an active error rate clears below half', + on({ toolCalls: 100, errors: 9 }, {}, was('error_rate')), + [], + ], + [ + 'a request waiting 30 minutes fires', + on({ attentionWaiting: [{ sessionSlug: 'a', waitedMs: 30 * 60_000 }] }), + ['attention'], + ], + [ + 'a request waiting 29 minutes does not', + on({ attentionWaiting: [{ sessionSlug: 'a', waitedMs: 29 * 60_000 }] }), + [], + ], + ['sessions at the limit fire', on({ live: 10 }), ['capacity']], + ['one below the limit does not fire', on({ live: 9 }), []], + ['active capacity stays at 90 %', on({ live: 9 }, {}, was('capacity')), ['capacity']], + ['active capacity clears below 90 %', on({ live: 8 }, {}, was('capacity')), []], + ['no limit, no capacity check', on({ live: 50, maxSessions: 0 }), []], + ['a blocked spike fires', on({ blocked: 60 }), ['blocked']], + ['a spike under the minimum does not', on({ blocked: 49, blockedBaselinePerHour: 1 }), []], + ['a high but usual level does not', on({ blocked: 60, blockedBaselinePerHour: 30 }), []], + ['an active spike stays above half', on({ blocked: 30 }, {}, was('blocked')), ['blocked']], + ['an active spike clears below half', on({ blocked: 20 }, {}, was('blocked')), []], + [ + 'an unresolved error event fires', + on({ degradations: [{ code: 'X', message: 'm', since: 0 }] }), + ['degraded'], + ], + [ + 'checks switched off never fire', + on( + { + toolCalls: 100, + errors: 100, + live: 10, + blocked: 1000, + attentionWaiting: [{ sessionSlug: 'a', waitedMs: 10 * HOUR }], + degradations: [{ code: 'X', message: 'm', since: 0 }], + }, + { + error_rate: null, + attention_minutes: null, + blocked_spike: null, + capacity: false, + degraded: false, + }, + ), + [], + ], + [ + 'a tuned threshold is used', + on({ toolCalls: 100, errors: 6 }, { error_rate: 5, min_calls: 50 }), + ['error_rate'], + ], + ]; + for (const [name, got, want] of cases) { + it(name, () => expect(got).toEqual(want)); + } + + it('reports crossings and clears, keeping when an active check began', () => { + const first = evaluateAnomalies({ ...quiet, live: 10 }, {}, {}, UNTIL); + expect(first.fired).toEqual(['capacity']); + const later = evaluateAnomalies( + { ...quiet, live: 10, toolCalls: 50, errors: 50 }, + {}, + first.active, + UNTIL + HOUR, + ); + expect(later.fired).toEqual(['error_rate']); + expect(later.active.capacity?.since).toBe(UNTIL); + const cleared = evaluateAnomalies(quiet, {}, later.active, UNTIL + 2 * HOUR); + expect(cleared.cleared).toEqual(['error_rate', 'capacity']); + expect(cleared.active).toEqual({}); + }); + + it('fills the defaults', () => { + expect(anomalyThresholds({})).toEqual({ + errorRate: 20, + minCalls: 20, + attentionMinutes: 30, + blockedSpike: 3, + blockedMin: 50, + capacity: true, + degraded: true, + }); + }); +}); + +describe('buildAnomaly', () => { + const facts = sampleAnomalyFacts(UNTIL); + const evaluation = evaluateAnomalies(facts, {}, {}, UNTIL); + + it('lists every active check, the new ones first, as a table', () => { + const a = buildAnomaly({ facts, active: evaluation.active, fired: evaluation.fired }, ctx()); + expect(a.title).toBe('Something looks off: 2 checks'); + expect(a.summary).toBe( + '34% of tool calls failed in the last hour · An attention request has waited 47 min', + ); + expect(a.severity).toBe('warn'); + expect(a.alert).toBe(true); + expect(a.state).toBe('open'); + const table = a.blocks.find((b) => b.type === 'table'); + expect(table?.type === 'table' && table.rows.length).toBe(2); + }); + + it('explains a single check in one sentence', () => { + const one = evaluateAnomalies(facts, { attention_minutes: null }, {}, UNTIL); + const a = buildAnomaly({ facts, active: one.active, fired: one.fired }, ctx()); + expect(a.title).toBe('Something looks off: 34% of tool calls failed in the last hour'); + expect(a.summary).toBe('72 of 212 tool calls failed between 08:00 and 09:00 (alert at 20%).'); + }); + + it('is an error while BrowserHive is degraded or at capacity', () => { + const degraded = { + ...facts, + degradations: [{ code: 'RETENTION_FAILED', message: 'x', since: 0 }], + }; + const e = evaluateAnomalies(degraded, {}, {}, UNTIL); + expect( + buildAnomaly({ facts: degraded, active: e.active, fired: e.fired }, ctx()).severity, + ).toBe('error'); + }); + + it('keeps names out of counts', () => { + const a = buildAnomaly( + { facts, active: evaluation.active, fired: evaluation.fired }, + ctx({ level: 'counts' }), + ); + expect(JSON.stringify(a.blocks)).not.toContain('checkout'); + }); + + it('turns into a silent "Back to normal" without buttons', () => { + const a = buildAnomaly( + { facts, active: {}, fired: [], resolvedSince: UNTIL - 2 * HOUR }, + ctx(), + ); + expect(a).toMatchObject({ + title: 'Back to normal', + state: 'resolved', + alert: false, + actions: [], + }); + expect(a.summary).toBe( + 'Every check is back under its threshold since 09:00 · it lasted 2h 00m.', + ); + }); +}); diff --git a/packages/core/src/app/notifications/reports.ts b/packages/core/src/app/notifications/reports.ts new file mode 100644 index 0000000..9a21d1f --- /dev/null +++ b/packages/core/src/app/notifications/reports.ts @@ -0,0 +1,796 @@ +/** @module app/notifications/reports — the pure producers of scheduled reports (D-43, D-44, spec 03 §9.7): the digest from its facts at a channel's content level, the empty-digest rule, the anomaly checks with hysteresis, and the anomaly alert. Table-driven; no I/O and no clock. */ + +import type { + NotificationContentLevel, + NotificationKind, + NotificationSeverity, + NotificationState, +} from '@browserhive/contracts/enums'; +import { harnessLabel } from '@browserhive/contracts/harness'; +import { + ANOMALY_CHECKS, + ANOMALY_DEFAULTS, + type AnomalyCheck, + type AnomalyRule, + type Block, + type DigestRule, + type Inline, + type NotificationAction, + type NotificationMessage, + type NotificationReport, +} from '@browserhive/contracts/notifications'; +import { + bold, + buildMessage, + code, + formatCount, + formatDuration, + formatPercent, + text, +} from './message.ts'; +import { formatClock, formatDay, formatSpan, formatStamp } from './schedule.ts'; + +/** Tools with fewer calls than this in a window do not compete for "slowest tool". */ +export const SLOWEST_TOOL_MIN_CALLS = 5; + +/** What a digest reports about one window (gathered by `ReportFacts`). */ +export interface DigestFacts { + readonly window: { readonly since: number; readonly until: number }; + readonly sessionsStarted: number; + readonly sessionsLive: number; + readonly toolCalls: number; + readonly errors: number; + /** The same counts over the period before the window (the comparison). */ + readonly previous: { readonly toolCalls: number; readonly errors: number }; + readonly attention: { + readonly created: number; + readonly resolved: number; + readonly rejected: number; + readonly timedOut: number; + readonly cancelled: number; + readonly pending: number; + /** Median wait of the answered requests; `null` without any. */ + readonly medianWaitMs: number | null; + }; + /** Vault accesses by result, most first. */ + readonly vault: readonly { readonly result: string; readonly count: number }[]; + readonly blocked: { + readonly count: number; + readonly topPattern: { readonly pattern: string; readonly count: number } | null; + readonly topDomain: { readonly domain: string; readonly count: number } | null; + }; + /** The tool with the highest p95 (≥ {@link SLOWEST_TOOL_MIN_CALLS} calls), and its previous p95. */ + readonly slowest: { + readonly tool: string; + readonly p95Ms: number; + readonly previousP95Ms: number | null; + } | null; + readonly topErrors: readonly { + readonly errorCode: string; + readonly tool: string; + readonly count: number; + readonly sessions: number; + }[]; + /** Unresolved degradations (warn and error). */ + readonly degradations: readonly { + readonly code: string; + readonly severity: string; + readonly message: string; + readonly since: number; + }[]; + readonly harnesses: readonly { + readonly harness: string; + readonly sessions: number; + readonly toolCalls: number; + readonly errors: number; + }[]; + /** Tool calls per bucket over the window (the chart). */ + readonly chart: { readonly start: number; readonly stepMs: number; readonly values: number[] }; +} + +/** How a report is being produced for one channel. */ +export interface ReportContext { + /** IANA zone the report's dates are written in. */ + readonly zone: string; + readonly level: NotificationContentLevel; + /** The scheduled time (a digest), or the check time (an anomaly alert). */ + readonly scheduledAt: number; + readonly late: boolean; + readonly skipped: number; + readonly manual: boolean; + /** The scheduled time falls in the channel's quiet hours: send silently. */ + readonly quiet: boolean; +} + +/** The parts of a report message a producer decides (the rest is the notification's identity). */ +export interface ReportContent { + readonly kind: NotificationKind; + readonly severity: NotificationSeverity; + readonly state: NotificationState; + readonly alert: boolean; + readonly title: string; + readonly summary: string; + readonly blocks: readonly Block[]; + readonly actions: readonly NotificationAction[]; + readonly report: NotificationReport; + /** Dashboard path of the in-app row. */ + readonly target: string; +} + +/** + * The contract message of a report (not yet redacted): the report's own content level and window. + * A later revision is silent; a message out of `open` keeps its links only while `final`. + * + * @returns The message. + */ +export function reportMessage( + content: ReportContent, + input: { + readonly id: string; + readonly thread: string; + readonly revision: number; + readonly createdAt: number; + readonly updatedAt: number; + readonly level: NotificationContentLevel; + }, +): NotificationMessage { + const base = buildMessage({ + id: input.id, + revision: input.revision, + thread: input.thread, + kind: content.kind, + severity: content.severity, + state: content.state, + alert: input.revision === 1 ? content.alert : false, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + title: content.title, + summary: content.summary, + blocks: content.blocks, + actions: content.state === 'open' || content.state === 'final' ? content.actions : [], + entities: {}, + }); + return { ...base, privacy: { level: input.level, has_image: false }, report: content.report }; +} + +/** + * Whether a window had nothing worth a digest (D-43): no session started, no tool call, no + * attention request, no vault access, no blocked request and no open degradation. + * + * @returns True when a scheduled digest is suppressed as `empty`. + */ +export function isEmptyDigest(facts: DigestFacts): boolean { + return ( + facts.sessionsStarted === 0 && + facts.toolCalls === 0 && + facts.attention.created === 0 && + facts.vault.every((v) => v.count === 0) && + facts.blocked.count === 0 && + facts.degradations.length === 0 + ); +} + +/** A latency with one decimal in seconds below a minute (`4.2 s`), else like a duration. */ +function formatLatency(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 60_000) return `${(Math.round(ms / 100) / 10).toFixed(1)} s`; + return formatDuration(ms); +} + +function plural(n: number, one: string, many = `${one}s`): string { + return `${formatCount(n)} ${n === 1 ? one : many}`; +} + +function rate(errors: number, calls: number): number { + return calls === 0 ? 0 : errors / calls; +} + +/** Vault results in words. */ +const VAULT_RESULT_TEXT: Readonly> = { + success: 'ok', + origin_mismatch: 'origin mismatch', + auth_failed: 'auth failed', + blocked: 'blocked', + denied: 'denied', +}; + +function overviewPath(since: number, until: number): string { + return `/overview?since=${since}&until=${until}`; +} + +function reportInfo( + ctx: ReportContext, + window: { since: number; until: number }, +): NotificationReport { + return { + window: { since: window.since, until: window.until }, + time_zone: ctx.zone, + late: ctx.late, + skipped: ctx.skipped, + manual: ctx.manual, + }; +} + +/** The "sent late" / "skipped" / "on demand" note, or `null`. */ +function timingNote(ctx: ReportContext, noun: string): Inline[] | null { + const parts: Inline[] = []; + if (ctx.manual) parts.push(text('Sent on demand.')); + if (ctx.late) { + parts.push( + text( + `Sent late: BrowserHive was not running at ${formatClock(ctx.scheduledAt, ctx.zone)} (${formatDay(ctx.scheduledAt, ctx.zone)}).`, + ), + ); + } + if (ctx.skipped > 0) { + parts.push( + text( + `${ctx.late ? ' ' : ''}${plural(ctx.skipped, `earlier ${noun}`)} ${ctx.skipped === 1 ? 'was' : 'were'} skipped while BrowserHive was off.`, + ), + ); + } + return parts.length === 0 ? null : parts; +} + +/** + * The digest of one window at a channel's content level (spec 03 §9.7): + * - `counts`: numbers and fixed labels only; + * - `titles`: + tool names, error codes, harnesses, vault results, degradation codes, the top + * blocklist pattern, the tables; + * - `full`: + degradation messages and the most blocked domain. + * + * @returns The report content (redaction and the contract's limits are applied by the caller). + */ +export function buildDigest( + facts: DigestFacts, + rule: DigestRule, + ctx: ReportContext, +): ReportContent { + const { since, until } = facts.window; + const weekly = rule.every === 'week'; + const names = ctx.level !== 'counts'; + const full = ctx.level === 'full'; + const kind: NotificationKind = weekly ? 'digest.weekly' : 'digest.daily'; + const title = weekly + ? `Weekly digest · ${formatSpan(since, until, ctx.zone)}` + : `Daily digest · ${formatDay(until, ctx.zone)}`; + const empty = isEmptyDigest(facts); + const errRate = rate(facts.errors, facts.toolCalls); + const summary = empty + ? `Nothing happened: no sessions, tool calls or requests ${weekly ? 'this week' : 'today'}.` + : `${plural(facts.sessionsStarted, 'session')} (${formatCount(facts.sessionsLive)} live) · ${plural(facts.toolCalls, 'tool call')} · ${plural(facts.errors, 'error')} (${formatPercent(errRate)})`; + + const fields: { label: string; value: Inline[] }[] = []; + fields.push({ + label: 'Sessions', + value: [ + text( + `${formatCount(facts.sessionsStarted)} started · ${formatCount(facts.sessionsLive)} live now`, + ), + ], + }); + const calls: Inline[] = [ + text( + `${formatCount(facts.toolCalls)} · ${plural(facts.errors, 'error')} (${formatPercent(errRate)})`, + ), + ]; + if (facts.previous.toolCalls > 0) { + calls.push( + text(` · was ${formatPercent(rate(facts.previous.errors, facts.previous.toolCalls))}`), + ); + } + fields.push({ label: 'Tool calls', value: calls }); + const a = facts.attention; + if (a.created > 0 || a.pending > 0) { + const answered = a.resolved + a.rejected; + const parts = [plural(a.created, 'request')]; + if (answered > 0) { + parts.push( + `${formatCount(answered)} answered${a.medianWaitMs === null ? '' : ` (median ${formatDuration(a.medianWaitMs)})`}`, + ); + } + if (a.timedOut > 0) parts.push(`${formatCount(a.timedOut)} timed out`); + if (a.pending > 0) parts.push(`${formatCount(a.pending)} waiting now`); + fields.push({ label: 'Attention', value: [text(parts.join(' · '))] }); + } + const vaultTotal = facts.vault.reduce((n, v) => n + v.count, 0); + if (vaultTotal > 0) { + const failed = facts.vault + .filter((v) => v.result !== 'success') + .reduce((n, v) => n + v.count, 0); + const detail = names + ? facts.vault + .filter((v) => v.count > 0) + .map((v) => `${formatCount(v.count)} ${VAULT_RESULT_TEXT[v.result] ?? v.result}`) + .join(' · ') + : `${formatCount(failed)} failed`; + fields.push({ + label: 'Vault fills', + value: [text(`${formatCount(vaultTotal)} · ${detail}`)], + }); + } + if (facts.blocked.count > 0) { + const value: Inline[] = [text(formatCount(facts.blocked.count))]; + if (names && facts.blocked.topPattern !== null) { + value.push(text(' · top '), code(facts.blocked.topPattern.pattern)); + value.push(text(` (${formatCount(facts.blocked.topPattern.count)})`)); + } + if (full && facts.blocked.topDomain !== null) { + value.push(text(' · most blocked '), code(facts.blocked.topDomain.domain)); + value.push(text(` (${formatCount(facts.blocked.topDomain.count)})`)); + } + fields.push({ label: 'Blocked requests', value }); + } + if (names && facts.slowest !== null) { + const was = + facts.slowest.previousP95Ms === null + ? '' + : ` (was ${formatLatency(facts.slowest.previousP95Ms)})`; + fields.push({ + label: 'Slowest tool (p95)', + value: [code(facts.slowest.tool), text(` ${formatLatency(facts.slowest.p95Ms)}${was}`)], + }); + } + if (facts.degradations.length > 0) { + fields.push({ + label: 'Open problems', + value: names + ? facts.degradations + .slice(0, 3) + .flatMap((d, i) => [ + ...(i > 0 ? [text(', ')] : []), + code(d.code), + text(` since ${formatStamp(d.since, ctx.zone)}`), + ]) + : [text(formatCount(facts.degradations.length))], + }); + } + + const blocks: Block[] = []; + const note = timingNote(ctx, weekly ? 'weekly digest' : 'digest'); + if (note !== null) blocks.push({ type: 'text', content: note }); + blocks.push({ type: 'fields', items: fields.slice(0, 12) }); + if (!empty && facts.chart.values.length > 0) { + blocks.push({ + type: 'chart', + label: weekly ? 'Tool calls per 12 hours' : 'Tool calls per hour', + values: facts.chart.values.slice(0, 48), + start: facts.chart.start, + step_ms: facts.chart.stepMs, + unit: 'calls', + }); + } + if (names && facts.topErrors.length > 0) { + blocks.push({ type: 'heading', text: 'Top errors' }); + blocks.push({ + type: 'table', + columns: ['Error', 'Tool', 'Count', 'Sessions'], + rows: facts.topErrors + .slice(0, 5) + .map((e) => [ + [code(e.errorCode)], + [code(e.tool)], + [text(formatCount(e.count))], + [text(formatCount(e.sessions))], + ]), + }); + } + const harnesses = facts.harnesses.filter((h) => h.sessions > 0 || h.toolCalls > 0); + if (names && harnesses.length > 0) { + blocks.push({ type: 'heading', text: 'By harness' }); + blocks.push({ + type: 'table', + columns: ['Harness', 'Sessions', 'Tool calls', 'Errors'], + rows: harnesses + .slice(0, 8) + .map((h) => [ + [text(harnessLabel(h.harness))], + [text(formatCount(h.sessions))], + [text(formatCount(h.toolCalls))], + [text(formatCount(h.errors))], + ]), + }); + } + if (full && facts.degradations.length > 0) { + blocks.push({ + type: 'list', + ordered: false, + items: facts.degradations + .slice(0, 5) + .map((d) => [bold(d.code), text(` since ${formatStamp(d.since, ctx.zone)}: ${d.message}`)]), + }); + } + blocks.push({ + type: 'footer', + content: [ + text(`${formatStamp(since, ctx.zone)} → ${formatStamp(until, ctx.zone)} · ${ctx.zone}`), + ], + }); + return { + kind, + severity: 'info', + state: 'final', + alert: !ctx.quiet, + title, + summary, + blocks, + actions: [ + { + kind: 'open', + id: 'overview', + label: 'Open Overview', + style: 'primary', + path: overviewPath(since, until), + }, + ], + report: reportInfo(ctx, facts.window), + target: overviewPath(since, until), + }; +} + +// ------------------------------------------------------------------------------------------------- +// Anomaly checks (D-44) +// ------------------------------------------------------------------------------------------------- + +/** What the hourly check looks at (the trailing hour, gathered once for every channel). */ +export interface AnomalyFacts { + readonly window: { readonly since: number; readonly until: number }; + readonly toolCalls: number; + readonly errors: number; + readonly blocked: number; + /** Blocked requests per hour over the 24 hours before the window. */ + readonly blockedBaselinePerHour: number; + /** Pending attention requests with how long each has waited, longest first. */ + readonly attentionWaiting: readonly { + readonly sessionSlug: string | null; + readonly waitedMs: number; + }[]; + readonly live: number; + readonly maxSessions: number; + /** Unresolved error-severity system events. */ + readonly degradations: readonly { + readonly code: string; + readonly message: string; + readonly since: number; + }[]; +} + +/** One active check: when it became active, what was measured and against what. */ +export interface ActiveCheck { + readonly since: number; + readonly value: number; + readonly threshold: number; +} + +/** The active checks of a channel. */ +export type AnomalyState = Readonly>>; + +/** Resolved thresholds (`null` = check off). */ +export interface AnomalyThresholds { + readonly errorRate: number | null; + readonly minCalls: number; + readonly attentionMinutes: number | null; + readonly blockedSpike: number | null; + readonly blockedMin: number; + readonly capacity: boolean; + readonly degraded: boolean; +} + +/** + * A channel's thresholds with the defaults of D-44 filled in. + * + * @returns The thresholds. + */ +export function anomalyThresholds(rule: AnomalyRule): AnomalyThresholds { + const pick = (value: T | null | undefined, fallback: T): T | null => + value === null ? null : (value ?? fallback); + return { + errorRate: pick(rule.error_rate, ANOMALY_DEFAULTS.error_rate), + minCalls: rule.min_calls ?? ANOMALY_DEFAULTS.min_calls, + attentionMinutes: pick(rule.attention_minutes, ANOMALY_DEFAULTS.attention_minutes), + blockedSpike: pick(rule.blocked_spike, ANOMALY_DEFAULTS.blocked_spike), + blockedMin: rule.blocked_min ?? ANOMALY_DEFAULTS.blocked_min, + capacity: rule.capacity ?? ANOMALY_DEFAULTS.capacity, + degraded: rule.degraded ?? ANOMALY_DEFAULTS.degraded, + }; +} + +/** Outcome of one evaluation. */ +export interface AnomalyEvaluation { + readonly active: AnomalyState; + /** Checks that became active now (crossings), in report order. */ + readonly fired: readonly AnomalyCheck[]; + /** Checks that were active and cleared. */ + readonly cleared: readonly AnomalyCheck[]; +} + +const round1 = (n: number) => Math.round(n * 10) / 10; + +/** + * Evaluates a channel's checks on the facts of the trailing hour with hysteresis (the table of + * D-44): a check fires at its threshold and, once active, stays active until it falls below its + * clear level. + * + * @returns The new active set, the crossings and the clears. + */ +export function evaluateAnomalies( + facts: AnomalyFacts, + rule: AnomalyRule, + previous: AnomalyState, + now: number, +): AnomalyEvaluation { + const t = anomalyThresholds(rule); + const measured: Partial> = + {}; + if (t.errorRate !== null) { + const pct = facts.toolCalls === 0 ? 0 : (facts.errors / facts.toolCalls) * 100; + const was = previous.error_rate !== undefined; + const on = was + ? facts.toolCalls >= Math.ceil(t.minCalls / 2) && pct >= t.errorRate / 2 + : facts.toolCalls >= t.minCalls && pct >= t.errorRate; + measured.error_rate = { value: round1(pct), threshold: t.errorRate, on }; + } + if (t.attentionMinutes !== null) { + const longest = facts.attentionWaiting.reduce((m, r) => Math.max(m, r.waitedMs), 0); + const minutes = Math.floor(longest / 60_000); + measured.attention = { + value: minutes, + threshold: t.attentionMinutes, + on: minutes >= t.attentionMinutes, + }; + } + if (t.capacity && facts.maxSessions > 0) { + const max = facts.maxSessions; + const was = previous.capacity !== undefined; + const on = was ? facts.live >= Math.min(0.9 * max, max - 1) : facts.live >= max; + measured.capacity = { value: facts.live, threshold: max, on }; + } + if (t.blockedSpike !== null) { + const base = facts.blockedBaselinePerHour; + const threshold = Math.max(t.blockedMin, Math.ceil(t.blockedSpike * base)); + const was = previous.blocked !== undefined; + const on = was + ? facts.blocked >= t.blockedMin / 2 && facts.blocked >= (t.blockedSpike / 2) * base + : facts.blocked >= t.blockedMin && facts.blocked >= t.blockedSpike * base; + measured.blocked = { value: facts.blocked, threshold, on }; + } + if (t.degraded) { + measured.degraded = { + value: facts.degradations.length, + threshold: 1, + on: facts.degradations.length > 0, + }; + } + const active: Partial> = {}; + const fired: AnomalyCheck[] = []; + const cleared: AnomalyCheck[] = []; + for (const check of ANOMALY_CHECKS) { + const m = measured[check]; + const before = previous[check]; + if (m?.on === true) { + active[check] = { since: before?.since ?? now, value: m.value, threshold: m.threshold }; + if (before === undefined) fired.push(check); + } else if (before !== undefined) { + cleared.push(check); + } + } + return { active, fired, cleared }; +} + +/** Label of a check in a report table. */ +const CHECK_LABEL: { readonly [C in AnomalyCheck]: string } = { + error_rate: 'Tool-call error rate', + attention: 'Attention waiting', + capacity: 'Live sessions', + blocked: 'Blocked requests (hour)', + degraded: 'Open degradations', +}; + +/** Minutes as `47 min` or `2h 05m`. */ +function formatMinutes(minutes: number): string { + if (minutes < 60) return `${formatCount(minutes)} min`; + return formatDuration(minutes * 60_000); +} + +function checkValue(check: AnomalyCheck, value: number): string { + switch (check) { + case 'error_rate': + return `${formatCount(value)}%`; + case 'attention': + return formatMinutes(value); + default: + return formatCount(value); + } +} + +function checkThreshold(check: AnomalyCheck, threshold: number): string { + switch (check) { + case 'error_rate': + return `≥ ${formatCount(threshold)}%`; + case 'attention': + return `≥ ${formatMinutes(threshold)}`; + case 'capacity': + return `limit ${formatCount(threshold)}`; + default: + return `≥ ${formatCount(threshold)}`; + } +} + +/** One-line headline of an active check. */ +function headline( + check: AnomalyCheck, + a: ActiveCheck, + facts: AnomalyFacts, + names: boolean, +): string { + switch (check) { + case 'error_rate': + return `${formatCount(a.value)}% of tool calls failed in the last hour`; + case 'attention': + return `An attention request has waited ${formatMinutes(a.value)}`; + case 'capacity': + return `Sessions at the limit (${formatCount(a.value)} of ${formatCount(a.threshold)})`; + case 'blocked': + return `Blocked requests spiked: ${formatCount(a.value)} in the last hour`; + case 'degraded': { + const first = facts.degradations[0]; + return names && first !== undefined + ? `BrowserHive is degraded: ${first.code}` + : 'BrowserHive is degraded'; + } + } +} + +/** One sentence with the numbers behind a single active check (the summary of a one-check alert). */ +function detail( + check: AnomalyCheck, + a: ActiveCheck, + facts: AnomalyFacts, + ctx: ReportContext, + names: boolean, +): string { + const span = `between ${formatClock(facts.window.since, ctx.zone)} and ${formatClock(facts.window.until, ctx.zone)}`; + switch (check) { + case 'error_rate': + return `${formatCount(facts.errors)} of ${formatCount(facts.toolCalls)} tool calls failed ${span} (alert at ${formatCount(a.threshold)}%).`; + case 'attention': { + const slug = names ? facts.attentionWaiting[0]?.sessionSlug : null; + const who = + slug === null || slug === undefined ? 'The oldest request' : `The request of ${slug}`; + return `${who} has waited ${formatMinutes(a.value)} for an answer (alert at ${formatMinutes(a.threshold)}).`; + } + case 'capacity': + return `${formatCount(facts.live)} of ${formatCount(facts.maxSessions)} sessions are live; new sessions are refused until one closes.`; + case 'blocked': + return `${formatCount(facts.blocked)} requests were blocked ${span}, against about ${formatCount(Math.round(facts.blockedBaselinePerHour))} an hour the day before.`; + case 'degraded': + return `${formatCount(facts.degradations.length)} problem${facts.degradations.length === 1 ? ' is' : 's are'} open on the System page.`; + } +} + +/** Inputs of {@link buildAnomaly}. */ +export interface AnomalyInput { + readonly facts: AnomalyFacts; + readonly active: AnomalyState; + /** The crossings of this check (listed first, marked new). */ + readonly fired: readonly AnomalyCheck[]; + /** `resolved` when every check cleared (with when the episode began). */ + readonly resolvedSince?: number; +} + +/** + * The anomaly alert (D-44): every active check with its value, threshold and since when, the new + * ones first; or "Back to normal" once all cleared (a silent, resolved revision). + * + * @returns The report content. + */ +export function buildAnomaly(input: AnomalyInput, ctx: ReportContext): ReportContent { + const { facts } = input; + const names = ctx.level !== 'counts'; + const window = facts.window; + const target = '/overview?range=24h'; + const actions: NotificationAction[] = [ + { kind: 'open', id: 'overview', label: 'Open Overview', style: 'primary', path: target }, + ]; + const footer: Block = { + type: 'footer', + content: [ + text( + `Checked ${formatClock(window.since, ctx.zone)}–${formatClock(window.until, ctx.zone)} · ${ctx.zone}`, + ), + ], + }; + if (input.resolvedSince !== undefined) { + const lasted = formatDuration(Math.max(0, ctx.scheduledAt - input.resolvedSince)); + return { + kind: 'report.anomaly', + severity: 'info', + state: 'resolved', + alert: false, + title: 'Back to normal', + summary: `Every check is back under its threshold since ${formatClock(ctx.scheduledAt, ctx.zone)} · it lasted ${lasted}.`, + blocks: [footer], + actions: [], + report: reportInfo(ctx, window), + target, + }; + } + const order = [ + ...input.fired, + ...ANOMALY_CHECKS.filter((c) => input.active[c] !== undefined && !input.fired.includes(c)), + ]; + const lines = order.flatMap((c) => { + const a = input.active[c]; + return a === undefined ? [] : [{ check: c, active: a }]; + }); + const first = lines[0]; + const title = + first === undefined + ? 'Something looks off' + : lines.length === 1 + ? `Something looks off: ${headline(first.check, first.active, facts, names)}` + : `Something looks off: ${lines.length} checks`; + const summary = + first !== undefined && lines.length === 1 + ? detail(first.check, first.active, facts, ctx, names) + : lines.map((l) => headline(l.check, l.active, facts, names)).join(' · '); + const blocks: Block[] = []; + blocks.push({ + type: 'table', + columns: ['Check', 'Now', 'Threshold', 'Since'], + rows: lines.map((l) => [ + [ + text(CHECK_LABEL[l.check]), + ...(input.fired.includes(l.check) ? [text(' '), bold('new')] : []), + ], + [text(checkValue(l.check, l.active.value))], + [text(checkThreshold(l.check, l.active.threshold))], + [text(formatClock(l.active.since, ctx.zone))], + ]), + }); + if (names && input.active.degraded !== undefined && facts.degradations.length > 0) { + blocks.push({ + type: 'list', + ordered: false, + items: facts.degradations + .slice(0, 5) + .map((d) => [ + code(d.code), + text( + ctx.level === 'full' + ? ` since ${formatStamp(d.since, ctx.zone)}: ${d.message}` + : ` since ${formatStamp(d.since, ctx.zone)}`, + ), + ]), + }); + } + if (names && input.active.attention !== undefined) { + const slugs = facts.attentionWaiting + .map((r) => r.sessionSlug) + .filter((s): s is string => s !== null) + .slice(0, 3); + if (slugs.length > 0) { + blocks.push({ + type: 'text', + content: [ + text('Waiting: '), + ...slugs.flatMap((s, i) => [...(i > 0 ? [text(', ')] : []), code(s)]), + ], + }); + } + } + blocks.push(footer); + const severe = input.active.degraded !== undefined || input.active.capacity !== undefined; + return { + kind: 'report.anomaly', + severity: severe ? 'error' : 'warn', + state: 'open', + alert: input.fired.length > 0, + title, + summary, + blocks, + actions, + report: reportInfo(ctx, window), + target, + }; +} diff --git a/packages/core/src/app/notifications/routing.test.ts b/packages/core/src/app/notifications/routing.test.ts index 86834eb..a33c43d 100644 --- a/packages/core/src/app/notifications/routing.test.ts +++ b/packages/core/src/app/notifications/routing.test.ts @@ -10,6 +10,7 @@ import { inQuietHours, localMinutes, planDeliveries, + quietHoursOf, type RoutableChannel, route, } from './routing.ts'; @@ -222,3 +223,73 @@ describe('planDeliveries', () => { }); }); }); + +describe('addressed reports (D-43, spec 03 §9.4)', () => { + const report = message({ + kind: 'digest.daily', + severity: 'info', + state: 'final', + thread: 'digest:nc-a:1', + entities: {}, + }); + const channels: RoutableChannel[] = [ + { + record: channelRecord({ + channelId: 'nc-a', + rules: { + categories: ['needs-you'], + min_severity: 'error', + sessions: ['shop-*'], + quiet_hours: { start: '00:00', end: '23:59' }, + }, + }), + capabilities: capabilities(), + }, + { record: channelRecord({ channelId: 'nc-b' }), capabilities: capabilities() }, + { + record: channelRecord({ channelId: 'nc-c', status: 'paused' }), + capabilities: capabilities(), + }, + ]; + + it('plans only the channel it is addressed to, bypassing its filters and quiet hours', () => { + const rows = planDeliveries(report, channels, NOW, 'nc-a'); + expect(rows.map((r) => [r.channelId, r.status, r.op])).toEqual([['nc-a', 'pending', 'send']]); + }); + + it('still honours a paused channel', () => { + const rows = planDeliveries(report, channels, NOW, 'nc-c'); + expect(rows.map((r) => [r.status, r.reason])).toEqual([['suppressed', 'channel_paused']]); + }); + + it('without an address, the same report is filtered like any notification', () => { + const rows = planDeliveries(report, channels, NOW); + expect(rows.find((r) => r.channelId === 'nc-a')?.reason).toBe('filtered'); + }); +}); + +describe('quietHoursOf', () => { + it('uses the channel time zone unless the quiet hours name their own', () => { + expect( + quietHoursOf({ quiet_hours: { start: '22:00', end: '07:00' }, time_zone: 'Asia/Tokyo' }), + ).toEqual({ + start: '22:00', + end: '07:00', + time_zone: 'Asia/Tokyo', + }); + expect( + quietHoursOf({ + quiet_hours: { start: '22:00', end: '07:00', time_zone: 'Europe/Berlin' }, + time_zone: 'Asia/Tokyo', + })?.time_zone, + ).toBe('Europe/Berlin'); + expect(quietHoursOf({ time_zone: 'Asia/Tokyo' })).toBeNull(); + }); + + it('applies the channel zone to route()', () => { + // 12:00 UTC is 21:00 in Tokyo: inside 20:00–23:00 there, outside it in UTC. + const rules = { quiet_hours: { start: '20:00', end: '23:00' }, time_zone: 'Asia/Tokyo' }; + expect(route(rules, message(), NOW)).toEqual({ deliver: false, reason: 'quiet_hours' }); + expect(route({ quiet_hours: rules.quiet_hours }, message(), NOW)).toEqual({ deliver: true }); + }); +}); diff --git a/packages/core/src/app/notifications/routing.ts b/packages/core/src/app/notifications/routing.ts index d2cc1aa..a390666 100644 --- a/packages/core/src/app/notifications/routing.ts +++ b/packages/core/src/app/notifications/routing.ts @@ -72,6 +72,19 @@ export function inQuietHours(now: number, hours: QuietHours): boolean { return start < end ? at >= start && at < end : at >= start || at < end; } +/** + * A channel's quiet hours in its zone: `quiet_hours.time_zone`, else the channel's `time_zone` + * (D-43), else the host's. + * + * @returns The hours with their zone, or `null` without quiet hours. + */ +export function quietHoursOf(rules: NotificationChannelRules): QuietHours | null { + const hours = rules.quiet_hours; + if (hours === undefined) return null; + const zone = hours.time_zone ?? rules.time_zone; + return zone === undefined ? hours : { ...hours, time_zone: zone }; +} + /** * Applies a channel's rules to a message. Category, minimum severity, session globs and harness * filter everything; quiet hours hold back only alerting revisions below `critical` (a silent @@ -105,11 +118,12 @@ export function route( return { deliver: false, reason: 'filtered' }; } } + const quiet = quietHoursOf(rules); if ( - rules.quiet_hours !== undefined && + quiet !== null && message.alert && message.severity !== 'critical' && - inQuietHours(now, rules.quiet_hours) + inQuietHours(now, quiet) ) { return { deliver: false, reason: 'quiet_hours' }; } @@ -153,7 +167,9 @@ export function contentLevelOf(rules: NotificationChannelRules) { * The outbox rows for one notification change (spec 03 §9.4): per external channel, a pending * `send` (first revision, or an alerting revision on a platform that cannot edit), a pending * `edit` (later revisions), or a `suppressed` row with its reason. In-app-only kinds produce no - * rows at all (the D-34 loop cut). Pure: the caller writes the rows in the notification's + * rows at all (the D-34 loop cut). An **addressed** notification (a scheduled report, spec 03 §9.7) + * is planned for its one channel only, and that channel's filters and quiet hours do not apply + * (the schedule is the opt-in, D-43). Pure: the caller writes the rows in the notification's * transaction. * * @returns The rows to enqueue (empty with no external channel). @@ -162,10 +178,15 @@ export function planDeliveries( message: NotificationMessage, channels: readonly RoutableChannel[], now: number, + addressedTo?: string, ): NewNotificationDelivery[] { if (channels.length === 0 || IN_APP_ONLY_KINDS.has(message.kind)) return []; const rows: NewNotificationDelivery[] = []; - for (const { record, capabilities } of channels) { + const targets = + addressedTo === undefined + ? channels + : channels.filter((c) => c.record.channelId === addressedTo); + for (const { record, capabilities } of targets) { const first = message.revision === 1; const base = { channelId: record.channelId, @@ -185,7 +206,8 @@ export function planDeliveries( suppressed(op, 'no_adapter'); continue; } - const decision = route(record.rules, message, now); + const decision: RouteDecision = + addressedTo === undefined ? route(record.rules, message, now) : { deliver: true }; if (!decision.deliver) { suppressed(op, decision.reason); continue; diff --git a/packages/core/src/app/notifications/samples.ts b/packages/core/src/app/notifications/samples.ts index fd74ac1..75c9981 100644 --- a/packages/core/src/app/notifications/samples.ts +++ b/packages/core/src/app/notifications/samples.ts @@ -1,7 +1,14 @@ /** @module app/notifications/samples — realistic sample notifications built through the real producers, for the channel preview, the test send and the renderer goldens (spec 03 §4.8.1). Pure: fixed ids and times. */ -import type { Block } from '@browserhive/contracts/notifications'; -import { NotificationMessage, type PreviewSample } from '@browserhive/contracts/notifications'; +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import { + type Block, + DEFAULT_CONTENT_LEVEL, + DEFAULT_DIGEST_AT, + type DigestRule, + NotificationMessage, + type PreviewSample, +} from '@browserhive/contracts/notifications'; import { AttentionCreatedEvent, AttentionResolvedEvent, @@ -13,6 +20,14 @@ import { import type { DomainEvents } from '../events/catalog.ts'; import { buildMessage, reviseMessage, time } from './message.ts'; import { draftFor, type ProducedEvent, revisionFor } from './producers.ts'; +import { + type AnomalyFacts, + buildAnomaly, + buildDigest, + type DigestFacts, + evaluateAnomalies, + reportMessage, +} from './reports.ts'; /** Session id of every sample. */ export const SAMPLE_SESSION_ID = 'checkout-a1b2c3d4'; @@ -31,6 +46,107 @@ export interface SampleOptions { readonly now?: number; /** Add a screenshot block (attention, vault confirm, crash); default none. */ readonly image?: 'none' | 'masked' | 'unmasked'; + /** Content level the report samples are built at (reports are built per level); default `titles`. */ + readonly level?: NotificationContentLevel; + /** Zone of the report samples; default {@link SAMPLE_ZONE}. */ + readonly zone?: string; + /** Schedule of the digest sample; default daily at 09:00. */ + readonly digest?: DigestRule; + /** The digest sample was sent late, with this many earlier windows skipped. */ + readonly late?: { readonly skipped: number }; + /** The anomaly sample as its "Back to normal" revision. */ + readonly resolved?: boolean; +} + +/** Zone of the report samples (stable goldens). */ +export const SAMPLE_ZONE = 'Europe/Berlin'; + +const HOUR = 3_600_000; + +/** + * Figures of the digest sample: a busy day on a small fleet (the research's R3 example). + * + * @returns Digest facts for the window that ends at `until`. + */ +export function sampleDigestFacts(until: number, rule: DigestRule): DigestFacts { + const weekly = rule.every === 'week'; + const span = weekly ? 7 * 24 * HOUR : 24 * HOUR; + const step = weekly ? 12 * HOUR : HOUR; + const n = span / step; + const shape = [ + 2, 1, 0, 0, 0, 1, 4, 18, 96, 212, 305, 280, 190, 240, 330, 412, 380, 260, 150, 120, 88, 60, 40, + 23, + ]; + const values = Array.from( + { length: n }, + (_, i) => (shape[i % shape.length] ?? 0) * (weekly ? 5 : 1), + ); + const scale = weekly ? 7 : 1; + return { + window: { since: until - span, until }, + sessionsStarted: 12 * scale, + sessionsLive: 2, + toolCalls: 3412 * scale, + errors: 68 * scale, + previous: { toolCalls: 2980 * scale, errors: 36 * scale }, + attention: { + created: 4 * scale, + resolved: 3 * scale, + rejected: 0, + timedOut: 1 * scale, + cancelled: 0, + pending: 0, + medianWaitMs: 96_000, + }, + vault: [ + { result: 'success', count: 8 * scale }, + { result: 'origin_mismatch', count: 1 * scale }, + ], + blocked: { + count: 27 * scale, + topPattern: { pattern: '*.doubleclick.net', count: 19 * scale }, + topDomain: { domain: 'ads.example.net', count: 12 * scale }, + }, + slowest: { tool: 'navigate', p95Ms: 4180, previousP95Ms: 2900 }, + topErrors: [ + { errorCode: 'NAVIGATION_TIMEOUT', tool: 'navigate', count: 31 * scale, sessions: 4 }, + { errorCode: 'ELEMENT_NOT_FOUND', tool: 'click', count: 22 * scale, sessions: 6 }, + { errorCode: 'CAPTCHA_DETECTED', tool: 'navigate', count: 15 * scale, sessions: 2 }, + ], + degradations: [ + { + code: 'RETENTION_FAILED', + severity: 'error', + message: 'retention sweep failed: database is locked', + since: until - 6 * HOUR, + }, + ], + harnesses: [ + { harness: 'claude-code', sessions: 8 * scale, toolCalls: 2410 * scale, errors: 51 * scale }, + { harness: 'cursor', sessions: 3 * scale, toolCalls: 880 * scale, errors: 15 * scale }, + { harness: 'unknown', sessions: 1 * scale, toolCalls: 122 * scale, errors: 2 * scale }, + ], + chart: { start: until - span, stepMs: step, values }, + }; +} + +/** + * Facts of the anomaly sample: failing tool calls and a request nobody answered. + * + * @returns Anomaly facts for the hour that ends at `now`. + */ +export function sampleAnomalyFacts(now: number): AnomalyFacts { + return { + window: { since: now - HOUR, until: now }, + toolCalls: 212, + errors: 72, + blocked: 18, + blockedBaselinePerHour: 11, + attentionWaiting: [{ sessionSlug: 'checkout', waitedMs: 47 * 60_000 }], + live: 3, + maxSessions: 10, + degradations: [], + }; } function request(kind: 'attention' | 'vault_confirm', now: number, extra: object) { @@ -244,6 +360,57 @@ function testMessage(now: number): NotificationMessage { }); } +function reportContext(now: number, options: SampleOptions) { + return { + zone: options.zone ?? SAMPLE_ZONE, + level: options.level ?? DEFAULT_CONTENT_LEVEL, + scheduledAt: now, + late: options.late !== undefined, + skipped: options.late?.skipped ?? 0, + manual: false, + quiet: false, + }; +} + +function digestSample(now: number, options: SampleOptions): NotificationMessage { + const rule: DigestRule = options.digest ?? { every: 'day', at: DEFAULT_DIGEST_AT }; + const ctx = reportContext(now, options); + const content = buildDigest(sampleDigestFacts(now, rule), rule, ctx); + return NotificationMessage.parse( + reportMessage(content, { + id: SAMPLE_NOTIFICATION_ID, + thread: `digest:sample:${now}`, + revision: 1, + createdAt: now, + updatedAt: now, + level: ctx.level, + }), + ); +} + +function anomalySample(now: number, options: SampleOptions): NotificationMessage { + const facts = sampleAnomalyFacts(now); + const evaluation = evaluateAnomalies(facts, {}, {}, now); + const ctx = reportContext(now, options); + const content = + options.resolved === true + ? buildAnomaly( + { facts, active: {}, fired: [], resolvedSince: now - 2 * HOUR - 5 * 60_000 }, + ctx, + ) + : buildAnomaly({ facts, active: evaluation.active, fired: evaluation.fired }, ctx); + return NotificationMessage.parse( + reportMessage(content, { + id: SAMPLE_NOTIFICATION_ID, + thread: 'anomaly:sample', + revision: options.resolved === true ? 2 : 1, + createdAt: now, + updatedAt: now, + level: ctx.level, + }), + ); +} + /** * A realistic notification of the given sample kind, built through the real producers so a * preview or a golden shows exactly what a real notification would carry. @@ -293,6 +460,10 @@ export function sampleMessage( case 'test': message = testMessage(now); break; + case 'digest': + return digestSample(now, options); + case 'anomaly': + return anomalySample(now, options); } if (image !== 'none' && imagePath !== null) { message = withImage(message, image === 'masked', now, imagePath); diff --git a/packages/core/src/app/notifications/schedule.test.ts b/packages/core/src/app/notifications/schedule.test.ts new file mode 100644 index 0000000..57bc418 --- /dev/null +++ b/packages/core/src/app/notifications/schedule.test.ts @@ -0,0 +1,210 @@ +/** @module app/notifications/schedule.test — the calendar maths of scheduled reports (D-43, spec 03 §9.7): wall-clock instants across DST in both directions, daily and weekly occurrences, 23/24/25-hour windows, host vs channel zone, the next run and the dates reports print. */ + +import { describe, expect, it } from 'bun:test'; +import type { DigestRule } from '@browserhive/contracts/notifications'; +import { + digestWindow, + formatDay, + formatSpan, + formatStamp, + nextHour, + nextOccurrence, + occurrencesBetween, + previousOccurrence, + scheduleKey, + usableZone, + wallTime, + zonedInstant, +} from './schedule.ts'; + +const HOUR = 3_600_000; +const DAILY_9: DigestRule = { every: 'day', at: '09:00' }; +const utc = (y: number, m: number, d: number, h = 0, min = 0) => Date.UTC(y, m - 1, d, h, min); + +describe('zonedInstant', () => { + const cases: readonly [string, string, [number, number, number], [number, number], number][] = [ + ['summer time', 'Europe/Berlin', [2026, 9, 29], [9, 0], utc(2026, 9, 29, 7)], + ['winter time', 'Europe/Berlin', [2026, 12, 1], [9, 0], utc(2026, 12, 1, 8)], + // 02:30 does not exist on 29 Mar 2026 in Berlin (02:00 → 03:00): shifted to 03:30 CEST. + ['spring forward (gap)', 'Europe/Berlin', [2026, 3, 29], [2, 30], utc(2026, 3, 29, 1, 30)], + // 02:30 happens twice on 25 Oct 2026 in Berlin: the first one (CEST, 00:30 UTC). + ['fall back (repeat)', 'Europe/Berlin', [2026, 10, 25], [2, 30], utc(2026, 10, 25, 0, 30)], + ['New York spring forward', 'America/New_York', [2026, 3, 8], [2, 30], utc(2026, 3, 8, 7, 30)], + ['New York fall back', 'America/New_York', [2026, 11, 1], [1, 30], utc(2026, 11, 1, 5, 30)], + ['half-hour zone', 'Asia/Kolkata', [2026, 9, 29], [9, 0], utc(2026, 9, 29, 3, 30)], + ['UTC', 'UTC', [2026, 9, 29], [9, 0], utc(2026, 9, 29, 9)], + ]; + for (const [name, zone, [year, month, day], [hour, minute], expected] of cases) { + it(name, () => { + expect(zonedInstant({ year, month, day }, { hour, minute }, zone)).toBe(expected); + }); + } + + it('reads the wall clock back', () => { + expect(wallTime(utc(2026, 3, 29, 1, 30), 'Europe/Berlin')).toMatchObject({ + hour: 3, + minute: 30, + weekday: 6, + }); + }); +}); + +describe('occurrences and windows', () => { + it('lists a daily schedule in its zone, oldest first', () => { + const from = utc(2026, 9, 27, 12); + const to = utc(2026, 9, 30, 12); + expect(occurrencesBetween(DAILY_9, 'Europe/Berlin', from, to).at).toEqual([ + utc(2026, 9, 28, 7), + utc(2026, 9, 29, 7), + utc(2026, 9, 30, 7), + ]); + }); + + it('is exclusive of from and inclusive of to', () => { + const at = utc(2026, 9, 29, 7); + expect(occurrencesBetween(DAILY_9, 'Europe/Berlin', at, at + HOUR).at).toEqual([]); + expect(occurrencesBetween(DAILY_9, 'Europe/Berlin', at - HOUR, at).at).toEqual([at]); + }); + + it('gives 23-, 24- and 25-hour windows across the DST changes', () => { + const zone = 'Europe/Berlin'; + const span = (occ: number) => { + const w = digestWindow(DAILY_9, zone, occ, null); + return (w.until - w.since) / HOUR; + }; + // The night of 28→29 Mar 2026 is an hour short; 24→25 Oct is an hour long. + expect(span(utc(2026, 3, 29, 7))).toBe(23); + expect(span(utc(2026, 3, 30, 7))).toBe(24); + expect(span(utc(2026, 10, 25, 8))).toBe(25); + }); + + it('fires once on a repeated local time and at the shifted time on a skipped one', () => { + const at230: DigestRule = { every: 'day', at: '02:30' }; + const fallBack = occurrencesBetween( + at230, + 'Europe/Berlin', + utc(2026, 10, 24, 12), + utc(2026, 10, 25, 12), + ); + expect(fallBack.at).toEqual([utc(2026, 10, 25, 0, 30)]); + const springForward = occurrencesBetween( + at230, + 'Europe/Berlin', + utc(2026, 3, 28, 12), + utc(2026, 3, 29, 12), + ); + expect(springForward.at).toEqual([utc(2026, 3, 29, 1, 30)]); + }); + + it('keeps a weekly schedule on its weekday', () => { + const weekly: DigestRule = { every: 'week', at: '08:30', day: 'mon' }; + const found = occurrencesBetween(weekly, 'Europe/Berlin', utc(2026, 9, 20), utc(2026, 10, 12)); + expect(found.at).toEqual( + [ + utc(2026, 9, 21, 6, 30), + utc(2026, 9, 28, 6, 30), + utc(2026, 10, 5, 6, 30), + utc(2026, 10, 12, 6, 30), + ].filter((t) => t <= utc(2026, 10, 12)), + ); + for (const at of found.at) expect(wallTime(at, 'Europe/Berlin').weekday).toBe(0); + const w = digestWindow(weekly, 'Europe/Berlin', utc(2026, 9, 28, 6, 30), null); + expect(w.since).toBe(utc(2026, 9, 21, 6, 30)); + }); + + it('runs a weekly rule without a day on Friday, covering the full seven days (D-43)', () => { + const weekly: DigestRule = { every: 'week', at: '17:00' }; + const found = occurrencesBetween(weekly, 'Europe/Berlin', utc(2026, 9, 21), utc(2026, 10, 3)); + // Fri 25 Sep and Fri 2 Oct at 17:00 in Berlin (UTC+2). + expect(found.at).toEqual([utc(2026, 9, 25, 15), utc(2026, 10, 2, 15)]); + const w = digestWindow(weekly, 'Europe/Berlin', utc(2026, 10, 2, 15), null); + expect(w).toEqual({ since: utc(2026, 9, 25, 15), until: utc(2026, 10, 2, 15) }); + expect((w.until - w.since) / HOUR).toBe(7 * 24); + }); + + it('runs every day, weekends included, unless weekdays only (D-43)', () => { + const zone = 'Europe/Berlin'; + // Mon 21 Sep → Mon 28 Sep 2026: seven daily runs, five with weekdays only. + const every = occurrencesBetween(DAILY_9, zone, utc(2026, 9, 21, 8), utc(2026, 9, 28, 8)); + expect(every.at).toHaveLength(7); + const weekdays: DigestRule = { ...DAILY_9, weekdays_only: true }; + const found = occurrencesBetween(weekdays, zone, utc(2026, 9, 21, 8), utc(2026, 9, 28, 8)); + expect(found.at.map((at) => wallTime(at, zone).weekday)).toEqual([1, 2, 3, 4, 0]); + // Monday's digest starts at Friday's run: the weekend is in it. + const monday = digestWindow(weekdays, zone, utc(2026, 9, 28, 7), null); + expect(monday).toEqual({ since: utc(2026, 9, 25, 7), until: utc(2026, 9, 28, 7) }); + expect(nextOccurrence(weekdays, zone, utc(2026, 9, 25, 8))).toBe(utc(2026, 9, 28, 7)); + expect(previousOccurrence(weekdays, zone, utc(2026, 9, 28, 7))).toBe(utc(2026, 9, 25, 7)); + }); + + it('keeps weekdays only on the wall clock across a DST week', () => { + const zone = 'Europe/Berlin'; + const weekdays: DigestRule = { ...DAILY_9, weekdays_only: true }; + // Clocks go back on Sun 25 Oct 2026: Friday 09:00 is 07:00 UTC, Monday 09:00 is 08:00 UTC. + const found = occurrencesBetween(weekdays, zone, utc(2026, 10, 22, 12), utc(2026, 10, 27, 12)); + expect(found.at).toEqual([utc(2026, 10, 23, 7), utc(2026, 10, 26, 8), utc(2026, 10, 27, 8)]); + const monday = digestWindow(weekdays, zone, utc(2026, 10, 26, 8), null); + // Friday 09:00 CEST → Monday 09:00 CET: 73 hours (the weekend and the extra hour). + expect((monday.until - monday.since) / HOUR).toBe(73); + }); + + it('starts a window at the end of the last one when that is later', () => { + const occ = utc(2026, 9, 29, 7); + const lastUntil = utc(2026, 9, 28, 16); + expect(digestWindow(DAILY_9, 'Europe/Berlin', occ, lastUntil)).toEqual({ + since: lastUntil, + until: occ, + }); + expect(digestWindow(DAILY_9, 'Europe/Berlin', occ, utc(2026, 9, 1)).since).toBe( + utc(2026, 9, 28, 7), + ); + }); + + it('follows the channel zone, not the host zone', () => { + const from = utc(2026, 9, 28, 23); + const to = utc(2026, 9, 29, 22, 59); + expect(occurrencesBetween(DAILY_9, 'Asia/Tokyo', from, to).at).toEqual([utc(2026, 9, 29, 0)]); + expect(occurrencesBetween(DAILY_9, 'America/New_York', from, to).at).toEqual([ + utc(2026, 9, 29, 13), + ]); + }); + + it('counts, without listing, occurrences older than 400 days', () => { + const to = utc(2026, 9, 29, 12); + const r = occurrencesBetween(DAILY_9, 'UTC', to - 500 * 24 * HOUR, to); + expect(r.at.length).toBe(400); + expect(r.older).toBe(100); + }); + + it('knows the next and previous runs', () => { + const now = utc(2026, 9, 29, 10); + expect(nextOccurrence(DAILY_9, 'Europe/Berlin', now)).toBe(utc(2026, 9, 30, 7)); + expect(previousOccurrence(DAILY_9, 'Europe/Berlin', now)).toBe(utc(2026, 9, 29, 7)); + expect(nextHour(utc(2026, 9, 29, 10, 20))).toBe(utc(2026, 9, 29, 11)); + }); +}); + +describe('keys, zones and labels', () => { + it('changes the schedule key with the zone or the rule', () => { + expect(scheduleKey(DAILY_9, 'UTC')).toBe('day@09:00@UTC'); + // A weekly rule without a day runs on Friday (D-43). + expect(scheduleKey({ every: 'week', at: '09:00' }, 'UTC')).toBe('week:fri@09:00@UTC'); + expect(scheduleKey({ ...DAILY_9, weekdays_only: true }, 'UTC')).toBe('day-weekdays@09:00@UTC'); + expect(scheduleKey(DAILY_9, 'Europe/Berlin')).not.toBe(scheduleKey(DAILY_9, 'UTC')); + }); + + it('falls back from an unknown zone', () => { + expect(usableZone('Mars/Olympus', 'UTC')).toBe('UTC'); + expect(usableZone(undefined, 'Europe/Berlin')).toBe('Europe/Berlin'); + expect(usableZone('Asia/Tokyo', 'UTC')).toBe('Asia/Tokyo'); + }); + + it('prints dates in the zone', () => { + const at = utc(2026, 9, 28, 23, 30); + expect(formatDay(at, 'UTC')).toBe('Mon 28 Sep'); + expect(formatDay(at, 'Asia/Tokyo')).toBe('Tue 29 Sep'); + expect(formatStamp(at, 'Europe/Berlin')).toBe('29 Sep 01:30'); + expect(formatSpan(utc(2026, 9, 21, 7), utc(2026, 9, 28, 7), 'UTC')).toBe('21–28 Sep'); + expect(formatSpan(utc(2026, 9, 28, 7), utc(2026, 10, 5, 7), 'UTC')).toBe('28 Sep – 5 Oct'); + }); +}); diff --git a/packages/core/src/app/notifications/schedule.ts b/packages/core/src/app/notifications/schedule.ts new file mode 100644 index 0000000..73dcd4d --- /dev/null +++ b/packages/core/src/app/notifications/schedule.ts @@ -0,0 +1,106 @@ +/** @module app/notifications/schedule — the scheduled reports' calendar (D-43, spec 03 §9.7): the zone maths shared with the dashboard (`@browserhive/contracts/notifications`), the zone fallbacks, and the dates reports print in a channel's zone. */ + +import { type Weekday, wallTime } from '@browserhive/contracts/notifications'; + +export { + digestWindow, + nextHour, + nextOccurrence, + occurrencesBetween, + parseClock, + periodMs, + previousOccurrence, + scheduleKey, + type WallTime, + wallTime, + zonedInstant, +} from '@browserhive/contracts/notifications'; + +/** The weekday of a rule as its short English name. */ +export function weekdayName(day: Weekday): string { + return WEEKDAY_LABEL[day]; +} + +const WEEKDAY_LABEL: { readonly [D in Weekday]: string } = { + mon: 'Monday', + tue: 'Tuesday', + wed: 'Wednesday', + thu: 'Thursday', + fri: 'Friday', + sat: 'Saturday', + sun: 'Sunday', +}; + +const SHORT_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const; +const SHORT_MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const; + +/** `09:05` in the zone. */ +export function formatClock(at: number, zone: string): string { + const w = wallTime(at, zone); + return `${String(w.hour).padStart(2, '0')}:${String(w.minute).padStart(2, '0')}`; +} + +/** `Tue 29 Sep` in the zone. */ +export function formatDay(at: number, zone: string): string { + const w = wallTime(at, zone); + return `${SHORT_DAYS[w.weekday] ?? ''} ${w.day} ${SHORT_MONTHS[w.month - 1] ?? ''}`; +} + +/** `29 Sep 09:00` in the zone. */ +export function formatStamp(at: number, zone: string): string { + const w = wallTime(at, zone); + return `${w.day} ${SHORT_MONTHS[w.month - 1] ?? ''} ${formatClock(at, zone)}`; +} + +/** + * The span of a weekly window as dates (`22–29 Sep`, `29 Sep – 6 Oct`), in the zone. + * + * @returns The text. + */ +export function formatSpan(since: number, until: number, zone: string): string { + const a = wallTime(since, zone); + const b = wallTime(until, zone); + const ma = SHORT_MONTHS[a.month - 1] ?? ''; + const mb = SHORT_MONTHS[b.month - 1] ?? ''; + return a.month === b.month && a.year === b.year + ? `${a.day}–${b.day} ${mb}` + : `${a.day} ${ma} – ${b.day} ${mb}`; +} + +/** + * The runtime's default zone (the host's `TZ`, or the system zone), `UTC` when unknown. + * + * @returns An IANA zone name. + */ +export function runtimeZone(): string { + return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; +} + +/** + * Whether `zone` is usable here; falls back to `fallback` otherwise (a zone the runtime does not + * know never stops a report). + * + * @returns A usable zone. + */ +export function usableZone(zone: string | undefined, fallback: string): string { + if (zone === undefined) return fallback; + try { + new Intl.DateTimeFormat('en-US', { timeZone: zone }); + return zone; + } catch { + return fallback; + } +} diff --git a/packages/core/src/infra/notifications/discord.ts b/packages/core/src/infra/notifications/discord.ts index b26fb2e..264cd92 100644 --- a/packages/core/src/infra/notifications/discord.ts +++ b/packages/core/src/infra/notifications/discord.ts @@ -53,6 +53,7 @@ export const DISCORD_LIMITS = { export const DISCORD_WEBHOOK_CAPABILITIES: ChannelCapabilities = { richBlocks: true, tables: false, + charts: false, images: true, actButtons: false, openLinks: true, @@ -87,15 +88,28 @@ export function discordColor(message: Pick[\]()])/g, '\\$1').replace(/^(\s*)([#+-]|\d+\.)/gm, '$1\\$2'); +/** + * Escapes Discord markdown (and mention/timestamp syntax) in user text. A heading, list or quote + * marker is escaped only where a line starts (`lineStart` says whether the text itself begins a + * line); an ordered-list marker is escaped on its dot (`1\.`), since a backslash before a digit + * shows as a backslash. + */ +export function escapeMarkdown(text: string, lineStart = true): string { + return text + .replace(/([\\*_~`|<>[\]()])/g, '\\$1') + .replace( + /(^|\n)(\s*)(?:([#+-])|(\d+)\.)/g, + (match, nl: string, ws: string, mark, digits, offset: number) => { + if (offset === 0 && nl === '' && !lineStart) return match; + return mark !== undefined ? `${nl}${ws}\\${mark}` : `${nl}${ws}${digits}\\.`; + }, + ); } -function inlineNode(node: Inline, links: LinkBuilder): string { +function inlineNode(node: Inline, links: LinkBuilder, lineStart: boolean): string { switch (node.type) { case 'text': - return escapeMarkdown(node.text); + return escapeMarkdown(node.text, lineStart); case 'bold': return `**${escapeMarkdown(node.text)}**`; case 'italic': @@ -104,7 +118,7 @@ function inlineNode(node: Inline, links: LinkBuilder): string { return `\`${node.text.replace(/`/g, 'ʼ')}\``; case 'link': return links.local - ? escapeMarkdown(node.text) + ? escapeMarkdown(node.text, lineStart) : `[${escapeMarkdown(node.text)}](${links.url(node.path).replace(/\)/g, '%29')})`; case 'time': return ``; @@ -112,7 +126,7 @@ function inlineNode(node: Inline, links: LinkBuilder): string { } function inline(run: readonly Inline[], links: LinkBuilder): string { - return run.map((node) => inlineNode(node, links)).join(''); + return run.map((node, i) => inlineNode(node, links, i === 0)).join(''); } function block(b: Block, links: LinkBuilder): string { @@ -144,6 +158,7 @@ function block(b: Block, links: LinkBuilder): string { case 'table': case 'image': case 'divider': + case 'chart': return ''; } } @@ -171,7 +186,21 @@ export function discordEmbed( if (message.summary.trim() !== '' && message.summary !== message.title) { paragraphs.push(escapeMarkdown(message.summary)); } - for (const b of bodyBlocks(message)) { + const body = bodyBlocks(message); + // Embed fields always show below the description, so a fields block with more content after it + // (a digest's chart and tables) is written in place, as lines, to keep the reading order. + const lastContent = body.findLastIndex( + (b) => b.type !== 'footer' && b.type !== 'image' && b.type !== 'divider', + ); + for (const [index, b] of body.entries()) { + if (b.type === 'fields' && index < lastContent) { + paragraphs.push( + b.items + .map((item) => `**${escapeMarkdown(item.label)}:** ${inline(item.value, links) || '—'}`) + .join('\n'), + ); + continue; + } if (b.type === 'fields') { for (const item of b.items) { const value = clipText(inline(item.value, links) || '—', DISCORD_LIMITS.fieldValue); diff --git a/packages/core/src/infra/notifications/ntfy.ts b/packages/core/src/infra/notifications/ntfy.ts index 2fb67b2..f71d1b4 100644 --- a/packages/core/src/infra/notifications/ntfy.ts +++ b/packages/core/src/infra/notifications/ntfy.ts @@ -44,6 +44,7 @@ export const NTFY_ACTIONS_MAX = 3; export const NTFY_CAPABILITIES: ChannelCapabilities = { richBlocks: true, tables: false, + charts: false, images: true, actButtons: false, openLinks: true, @@ -86,9 +87,12 @@ export function ntfyPriority(message: Pick): string[] { +/** ntfy tags (emoji short codes): the outcome once settled, a chart for a digest, else the severity. */ +export function ntfyTags( + message: Pick & { readonly kind?: string }, +): string[] { if (message.state === 'resolved') return ['white_check_mark']; + if (message.kind?.startsWith('digest.') === true) return ['bar_chart']; if (message.state === 'expired') return ['hourglass']; switch (message.severity) { case 'info': @@ -122,6 +126,7 @@ function blockText(b: Block): string { case 'table': case 'image': case 'divider': + case 'chart': return ''; } } diff --git a/packages/core/src/infra/notifications/render-common.ts b/packages/core/src/infra/notifications/render-common.ts index 5a1b12f..effacec 100644 --- a/packages/core/src/infra/notifications/render-common.ts +++ b/packages/core/src/infra/notifications/render-common.ts @@ -8,9 +8,12 @@ export const LOCAL_LINKS_LABEL = 'Open on this computer'; /** Wire name of an attached screenshot. */ export const SCREENSHOT_FILENAME = 'screenshot.jpg'; -/** The leading mark of a message: its outcome once settled, else its severity. */ -export function severityMark(message: Pick): string { +/** The leading mark of a message: its outcome once settled, a chart for a digest, else its severity. */ +export function severityMark( + message: Pick & { readonly kind?: string }, +): string { if (message.state === 'resolved') return '✅'; + if (message.kind?.startsWith('digest.') === true) return '📊'; if (message.state === 'expired') return '⌛'; if (message.state === 'acted') return '👤'; switch (message.severity) { diff --git a/packages/core/src/infra/notifications/telegram.ts b/packages/core/src/infra/notifications/telegram.ts index 077e148..8d919e3 100644 --- a/packages/core/src/infra/notifications/telegram.ts +++ b/packages/core/src/infra/notifications/telegram.ts @@ -67,6 +67,7 @@ export const RICH_PHOTO_ID = 'shot'; export const TELEGRAM_CAPABILITIES: ChannelCapabilities = { richBlocks: true, tables: true, + charts: false, images: true, actButtons: false, openLinks: true, @@ -247,6 +248,7 @@ function renderBlock(block: Block, links: LinkBuilder, budget: number): Frag { return wrap('i', inlineRun(block.content, links, budget)); case 'image': case 'divider': + case 'chart': return EMPTY; } } @@ -545,6 +547,9 @@ function richBlock(block: Block, links: LinkBuilder): string { } case 'divider': return '
'; + case 'chart': + // Charts arrive as text (`degrade`, capability `charts: false`). + return ''; case 'footer': { const inner = richInline(block.content, links); return inner === '' ? '' : `
${inner}
`; diff --git a/packages/core/src/infra/notifications/webhook.ts b/packages/core/src/infra/notifications/webhook.ts index b1482ef..1969b81 100644 --- a/packages/core/src/infra/notifications/webhook.ts +++ b/packages/core/src/infra/notifications/webhook.ts @@ -26,6 +26,7 @@ export const TIMESTAMP_HEADER = 'X-BrowserHive-Timestamp'; export const WEBHOOK_CAPABILITIES: ChannelCapabilities = { richBlocks: true, tables: true, + charts: true, images: false, actButtons: false, openLinks: true, diff --git a/packages/core/src/infra/persistence/analytics.ts b/packages/core/src/infra/persistence/analytics.ts index abd522a..06095ce 100644 --- a/packages/core/src/infra/persistence/analytics.ts +++ b/packages/core/src/infra/persistence/analytics.ts @@ -9,11 +9,15 @@ import type { ActivitySummary, AnalyticsQueries, HarnessMetricsRow, + ReportWindow, TimelineItem, TimelineKind, TimelineQuery, + ToolLatencyRow, ToolMetricsQuery, ToolMetricsRow, + TopErrorRow, + WindowCounts, } from '../../ports/persistence/analytics.ts'; import type { DomainCount } from '../../ports/persistence/pages.ts'; import type { Page, TopDomainsQuery } from '../../ports/persistence/queries.ts'; @@ -445,4 +449,89 @@ export class SqliteAnalyticsQueries implements AnalyticsQueries { await this.#drain(); return this.#repos.pages.topDomains(query); } + + async windowCounts(window: ReportWindow): Promise { + await this.#drain(); + const { since, until } = window; + const row = await sql<{ + sessions: number; + calls: number; + errors: number; + blocked: number; + attention: number; + vault: number; + }>` + SELECT + (SELECT COUNT(*) FROM sessions WHERE created_at >= ${since} AND created_at < ${until}) AS sessions, + (SELECT COUNT(*) FROM tool_calls WHERE ts >= ${since} AND ts < ${until}) AS calls, + (SELECT COUNT(*) FROM tool_calls WHERE error_code IS NOT NULL AND ts >= ${since} AND ts < ${until}) AS errors, + (SELECT COUNT(*) FROM blocked_requests WHERE ts >= ${since} AND ts < ${until}) AS blocked, + (SELECT COUNT(*) FROM operator_requests WHERE kind = 'attention' AND created_at >= ${since} AND created_at < ${until}) AS attention, + (SELECT COUNT(*) FROM vault_access WHERE ts >= ${since} AND ts < ${until}) AS vault`.execute( + this.#db, + ); + const r = row.rows[0]; + return { + sessionsStarted: asNumber(r?.sessions), + toolCalls: asNumber(r?.calls), + errors: asNumber(r?.errors), + blocked: asNumber(r?.blocked), + attention: asNumber(r?.attention), + vaultAccess: asNumber(r?.vault), + }; + } + + async toolLatency(window: ReportWindow): Promise { + await this.#drain(); + // The 95th percentile by rank inside SQLite (a window function), so only one row per tool + // leaves the database: the smallest duration whose rank is at least 95 % of the calls. + const rows = await sql<{ tool: string; calls: number; errors: number; p95: number | null }>` + WITH ranked AS ( + SELECT tool, duration_ms, error_code, + ROW_NUMBER() OVER (PARTITION BY tool ORDER BY duration_ms) AS rn, + COUNT(*) OVER (PARTITION BY tool) AS n + FROM tool_calls + WHERE ts >= ${window.since} AND ts < ${window.until} + ) + SELECT tool, MAX(n) AS calls, + SUM(CASE WHEN error_code IS NOT NULL THEN 1 ELSE 0 END) AS errors, + MIN(CASE WHEN rn * 100 >= 95 * n THEN duration_ms END) AS p95 + FROM ranked + GROUP BY tool`.execute(this.#db); + return rows.rows + .map((r) => ({ + tool: r.tool, + calls: asNumber(r.calls), + errors: asNumber(r.errors), + p95Ms: asNumber(r.p95), + })) + .sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool)); + } + + async topErrors(window: ReportWindow, limit: number): Promise { + await this.#drain(); + const rows = await this.#db + .selectFrom('tool_calls') + .select([ + 'error_code', + 'tool', + sql`COUNT(*)`.as('n'), + sql`COUNT(DISTINCT session_id)`.as('sessions'), + ]) + .where('error_code', 'is not', null) + .where('ts', '>=', window.since) + .where('ts', '<', window.until) + .groupBy(['error_code', 'tool']) + .orderBy('n', 'desc') + .orderBy('error_code') + .orderBy('tool') + .limit(Math.max(1, Math.min(50, limit))) + .execute(); + return rows.map((r) => ({ + errorCode: r.error_code ?? '', + tool: r.tool, + count: asNumber(r.n), + sessions: asNumber(r.sessions), + })); + } } diff --git a/packages/core/src/infra/persistence/repositories/notifications.ts b/packages/core/src/infra/persistence/repositories/notifications.ts index de221a2..8c63ba8 100644 --- a/packages/core/src/infra/persistence/repositories/notifications.ts +++ b/packages/core/src/infra/persistence/repositories/notifications.ts @@ -4,8 +4,13 @@ import { type Kysely, sql } from 'kysely'; import type { NotificationRepository, PreferenceRepository, + ReportChannelRow, } from '../../../ports/persistence/notifications.ts'; -import type { NotificationListQuery, Page } from '../../../ports/persistence/queries.ts'; +import type { + NotificationListQuery, + Page, + ReportListQuery, +} from '../../../ports/persistence/queries.ts'; import type { JsonValue, NotificationGroupPatch, @@ -31,6 +36,10 @@ import { } from './common.ts'; const RESOURCE = 'notifications'; +const REPORTS_RESOURCE = 'notifications.reports'; +/** In-app report copies have threads `report:…` (D-45); `;` sorts right after `:`. */ +const REPORT_THREAD_FROM = 'report:'; +const REPORT_THREAD_TO = 'report;'; const SORTS: Readonly> = { created_at: { expr: sql.ref('created_at'), nullValue: 0 }, updated_at: { expr: sql.ref('updated_at'), nullValue: 0 }, @@ -172,8 +181,17 @@ export class SqliteNotificationRepository implements NotificationRepository { } if (query.read === 'unread') qb = qb.where('read_at', 'is', null); if (query.read === 'read') qb = qb.where('read_at', 'is not', null); - if (query.types !== undefined && query.types.length > 0) - qb = qb.where('type', 'in', [...query.types]); + const types = query.types ?? []; + const categories = query.categories ?? []; + if (types.length > 0 || categories.length > 0) { + // Type and category are one facet: a row matches either (D-45). + qb = qb.where((eb) => + eb.or([ + ...(types.length > 0 ? [eb('type', 'in', [...types])] : []), + ...(categories.length > 0 ? [eb('category', 'in', [...categories])] : []), + ]), + ); + } if (query.since !== undefined) qb = qb.where(sortKeyName, '>=', query.since); if (query.until !== undefined) qb = qb.where(sortKeyName, '<=', query.until); return qb; @@ -201,6 +219,107 @@ export class SqliteNotificationRepository implements NotificationRepository { ); } + async listReports(query: ReportListQuery): Promise> { + const limit = clampLimit(query.limit); + const dir = query.dir ?? 'desc'; + const order = SORTS.created_at; + const cursor = decodeCursor(REPORTS_RESOURCE, query.cursor); + const filtered = () => { + let qb = this.#db + .selectFrom('notifications') + .where('category', '=', 'reports') + .where('thread', '>=', REPORT_THREAD_FROM) + .where('thread', '<', REPORT_THREAD_TO); + if (query.kinds !== undefined && query.kinds.length > 0) + qb = qb.where('kind', 'in', [...query.kinds]); + if (query.channelId !== undefined) { + const channelId = query.channelId; + qb = qb.where('notification_id', 'in', (eb) => + eb + .selectFrom('notification_deliveries as d') + .innerJoin('notifications as c', 'c.notification_id', 'd.notification_id') + .select('c.source_event_id') + .where('d.channel_id', '=', channelId) + .where('c.category', '=', 'reports') + .where('c.source_event_id', 'is not', null), + ); + } + if (query.inAppOnly === true) { + qb = qb.where('notification_id', 'not in', (eb) => + eb + .selectFrom('notifications as c') + .select('c.source_event_id') + .where('c.category', '=', 'reports') + .where('c.source_event_id', 'is not', null), + ); + } + if (query.since !== undefined) qb = qb.where('created_at', '>=', query.since); + if (query.until !== undefined) qb = qb.where('created_at', '<=', query.until); + return qb; + }; + let qb = filtered().selectAll(); + if (cursor !== null) qb = qb.where(keysetWhere(order, sql.ref('notification_id'), dir, cursor)); + const rows = await qb + .orderBy(sortKey(order), dir) + .orderBy('notification_id', dir) + .limit(limit + 1) + .execute(); + let total: number | undefined; + if (query.total === true) { + total = asNumber( + (await filtered().select(sql`COUNT(*)`.as('n')).executeTakeFirst())?.n, + ); + } + return toPage( + REPORTS_RESOURCE, + rows, + limit, + notificationFromRow, + (row) => ({ key: row.created_at, id: row.notification_id }), + total, + ); + } + + async reportChannels( + reportIds: readonly string[], + ): Promise> { + const out = new Map(); + if (reportIds.length === 0) return out; + const rows = await this.#db + .selectFrom('notifications as c') + .innerJoin('notification_deliveries as d', 'd.notification_id', 'c.notification_id') + .leftJoin('notification_channels as ch', 'ch.channel_id', 'd.channel_id') + .select([ + 'c.source_event_id as report_id', + 'd.channel_id', + 'd.status', + 'd.reason', + 'd.seq', + 'ch.name', + 'ch.kind', + ]) + .where('c.source_event_id', 'in', [...reportIds]) + .where('c.category', '=', 'reports') + .orderBy('d.seq', 'asc') + .execute(); + // The latest row per (report, channel) wins; channels keep the order they were first reached. + const latest = new Map>(); + for (const row of rows) { + if (row.report_id === null || row.name === null || row.kind === null) continue; + const byChannel = latest.get(row.report_id) ?? new Map(); + byChannel.set(row.channel_id, { + channelId: row.channel_id, + name: row.name, + kind: row.kind, + status: row.status, + reason: row.reason, + }); + latest.set(row.report_id, byChannel); + } + for (const [id, byChannel] of latest) out.set(id, [...byChannel.values()]); + return out; + } + async unreadCount(principalId: string | null): Promise { let qb = this.#db .selectFrom('notifications') diff --git a/packages/core/src/infra/persistence/repositories/operator-requests.ts b/packages/core/src/infra/persistence/repositories/operator-requests.ts index b85ace5..f8a2b25 100644 --- a/packages/core/src/infra/persistence/repositories/operator-requests.ts +++ b/packages/core/src/infra/persistence/repositories/operator-requests.ts @@ -8,6 +8,7 @@ import type { OperatorRequestListRow, OperatorRequestRepository, OperatorRequestResolution, + OperatorRequestWindowStats, } from '../../../ports/persistence/operator-requests.ts'; import type { AuditListQuery, @@ -213,6 +214,52 @@ export class SqliteOperatorRequestRepository implements OperatorRequestRepositor if (kind !== undefined) qb = qb.where('kind', '=', kind); return asNumber((await qb.executeTakeFirst())?.n); } + + async windowStats( + kind: OperatorRequestKind, + window: { readonly since: number; readonly until: number }, + ): Promise { + // One indexed read (kind, created_at): the status and wait of every request in the window. + const rows = await this.#db + .selectFrom('operator_requests') + .select(['status', sql`resolved_at - created_at`.as('waited')]) + .where('kind', '=', kind) + .where('created_at', '>=', window.since) + .where('created_at', '<', window.until) + .execute(); + return windowStatsOf(rows.map((r) => ({ status: r.status, waited: r.waited }))); + } +} + +/** + * Folds request rows into their window outcomes (shared with the in-memory double). + * + * @returns The counts and the median wait of the answered requests. + */ +export function windowStatsOf( + rows: readonly { readonly status: string; readonly waited: number | null }[], +): OperatorRequestWindowStats { + const count = (status: string) => rows.filter((r) => r.status === status).length; + const waits = rows + .filter((r) => (r.status === 'resolved' || r.status === 'rejected') && r.waited !== null) + .map((r) => Math.max(0, asNumber(r.waited))) + .sort((a, b) => a - b); + const mid = Math.floor(waits.length / 2); + const medianWaitMs = + waits.length === 0 + ? null + : waits.length % 2 === 1 + ? (waits[mid] ?? 0) + : Math.round(((waits[mid - 1] ?? 0) + (waits[mid] ?? 0)) / 2); + return { + created: rows.length, + resolved: count('resolved'), + rejected: count('rejected'), + timedOut: count('timeout'), + cancelled: count('cancelled'), + pending: count('pending'), + medianWaitMs, + }; } const ACTIONS = 'operator_actions'; diff --git a/packages/core/src/infra/persistence/repositories/vault-audit.ts b/packages/core/src/infra/persistence/repositories/vault-audit.ts index de3c099..a441d5c 100644 --- a/packages/core/src/infra/persistence/repositories/vault-audit.ts +++ b/packages/core/src/infra/persistence/repositories/vault-audit.ts @@ -1,6 +1,7 @@ /** @module infra/persistence/repositories/vault-audit — SQLite `VaultAuditRepository`. */ import { type Kysely, sql } from 'kysely'; +import type { VaultAccessResult } from '../../../ports/persistence/enums.ts'; import type { Page, VaultAccessListQuery } from '../../../ports/persistence/queries.ts'; import type { VaultAccessRecord } from '../../../ports/persistence/records.ts'; import type { @@ -126,4 +127,20 @@ export class SqliteVaultAuditRepository implements VaultAuditRepository { total, ); } + + async countByResult(window: { + readonly since: number; + readonly until: number; + }): Promise { + const rows = await this.#db + .selectFrom('vault_access') + .select(['result', sql`COUNT(*)`.as('n')]) + .where('ts', '>=', window.since) + .where('ts', '<', window.until) + .groupBy('result') + .execute(); + return rows + .map((r) => ({ result: r.result as VaultAccessResult, count: asNumber(r.n) })) + .sort((a, b) => b.count - a.count || a.result.localeCompare(b.result)); + } } diff --git a/packages/core/src/infra/persistence/retention.ts b/packages/core/src/infra/persistence/retention.ts index 45bba69..dd322a7 100644 --- a/packages/core/src/infra/persistence/retention.ts +++ b/packages/core/src/infra/persistence/retention.ts @@ -204,8 +204,11 @@ class Sweep { .deleteFrom('notifications') .where((eb) => eb.or([ - eb('dismissed_at', '<', seenCutoff), - eb('read_at', '<', seenCutoff), + // Reports keep their history for the Reports tab whatever their inbox state (D-45). + eb.and([ + eb.or([eb('category', 'is', null), eb('category', '!=', 'reports')]), + eb.or([eb('dismissed_at', '<', seenCutoff), eb('read_at', '<', seenCutoff)]), + ]), eb('created_at', '<', unseenCutoff), ]), ) diff --git a/packages/core/src/infra/telemetry/metrics.ts b/packages/core/src/infra/telemetry/metrics.ts index 86b794b..5cdf498 100644 --- a/packages/core/src/infra/telemetry/metrics.ts +++ b/packages/core/src/infra/telemetry/metrics.ts @@ -23,6 +23,7 @@ export const METRIC = { RETENTION_PRUNED_ROWS: 'browserhive.retention.pruned_rows', NOTIFICATION_DELIVERIES: 'browserhive.notifications.deliveries', NOTIFICATION_ACTIONS: 'browserhive.notifications.actions', + NOTIFICATION_REPORTS: 'browserhive.notifications.reports', PROCESS_RSS_BYTES: 'browserhive.process.rss_bytes', PROCESS_HEAP_BYTES: 'browserhive.process.heap_bytes', PROCESS_EVENT_LOOP_LAG: 'browserhive.process.event_loop_lag', @@ -54,6 +55,8 @@ export interface Instruments { readonly notificationDeliveries: Counter; /** Act-button presses by `channel_kind` and `outcome` (D-41). */ readonly notificationActions: Counter; + /** Scheduled report decisions by `kind` and `outcome` (D-43, D-44). */ + readonly notificationReports: Counter; readonly processRssBytes: ObservableGauge; readonly processHeapBytes: ObservableGauge; readonly processEventLoopLag: ObservableGauge; @@ -201,6 +204,13 @@ export function createInstruments(meter: Meter): Instruments { }), ); }, + get notificationReports() { + return lazy(METRIC.NOTIFICATION_REPORTS, () => + meter.createCounter(METRIC.NOTIFICATION_REPORTS, { + description: 'Scheduled report decisions by kind and outcome', + }), + ); + }, get processRssBytes() { return lazy(METRIC.PROCESS_RSS_BYTES, () => meter.createObservableGauge(METRIC.PROCESS_RSS_BYTES, { diff --git a/packages/core/src/interface/http/routes/channels.ts b/packages/core/src/interface/http/routes/channels.ts index fc7e964..d5c05ef 100644 --- a/packages/core/src/interface/http/routes/channels.ts +++ b/packages/core/src/interface/http/routes/channels.ts @@ -3,6 +3,8 @@ import { ActionsPage, ActionsQuery, + ChannelDigestRequest, + ChannelDigestResponse, ChannelEnvQuery, ChannelEnvResponse, ChannelIdParams, @@ -46,7 +48,11 @@ export const CHANNEL_ROUTES = [ request: {}, responses: { 200: ChannelsResponse }, async handler({ services, ctx }) { - return reply(200, { data: [...(await services.channels.list())], now: ctx.now }); + return reply(200, { + data: [...(await services.channels.list())], + now: ctx.now, + host_time_zone: services.channels.hostTimeZone(), + }); }, }), defineRoute({ @@ -294,4 +300,17 @@ export const CHANNEL_ROUTES = [ return reply(200, await services.channels.test(input.params.channel_id)); }, }), + defineRoute({ + operationId: 'sendChannelDigest', + tags, + summary: + "Preview the channel's digest of the period that ends now, or also send it now (D-43).", + request: { params: ChannelIdParams, body: ChannelDigestRequest }, + responses: { 200: ChannelDigestResponse }, + errors: ['CHANNEL_NOT_FOUND', 'CHANNEL_NOT_READY'], + rateLimit: { limit: 12, windowMs: 60_000, key: 'principal' }, + async handler({ input, services }) { + return reply(200, await services.channels.digest(input.params.channel_id, input.body.send)); + }, + }), ]; diff --git a/packages/core/src/interface/http/routes/notifications.ts b/packages/core/src/interface/http/routes/notifications.ts index 29bb90c..69cedd1 100644 --- a/packages/core/src/interface/http/routes/notifications.ts +++ b/packages/core/src/interface/http/routes/notifications.ts @@ -11,6 +11,13 @@ import { PreferencesResponse, PutPreferencesRequest, PutPreferencesResponse, + PutReportSettingsRequest, + REPORTS_IN_APP_ONLY, + ReportDetailResponse, + ReportIdParams, + ReportSettingsResponse, + ReportsPage, + ReportsQuery, } from '@browserhive/contracts/http'; import { AppError } from '../../../kernel/errors/app-error.ts'; import { defineRoute, reply } from '../define-route.ts'; @@ -22,6 +29,60 @@ const tags = ['notifications']; /** Notification and preference routes. */ export const NOTIFICATION_ROUTES = [ + defineRoute({ + operationId: 'listReports', + tags, + summary: + 'Reports in BrowserHive: the in-app copies of digests and anomaly alerts, newest first.', + request: { query: ReportsQuery }, + responses: { 200: ReportsPage }, + async handler({ input, services, ctx }) { + const q = input.query; + const page = await services.reports.list({ + ...pagingOf(q), + ...(q.kind !== undefined && { kinds: q.kind }), + ...(q.channel !== undefined && + (q.channel === REPORTS_IN_APP_ONLY ? { inAppOnly: true } : { channelId: q.channel })), + ...(q.since !== undefined && { since: q.since }), + ...(q.until !== undefined && { until: q.until }), + }); + return reply( + 200, + envelope(page, (item) => item, { ...q, sort: 'created_at' }, ctx.now, 'created_at'), + ); + }, + }), + defineRoute({ + operationId: 'getReport', + tags, + summary: 'One report with its message and the channels it reached.', + request: { params: ReportIdParams }, + responses: { 200: ReportDetailResponse }, + errors: ['REPORT_NOT_FOUND'], + async handler({ input, services }) { + return reply(200, await services.reports.get(input.params.notification_id)); + }, + }), + defineRoute({ + operationId: 'getReportSettings', + tags, + summary: 'The in-app reports: the digest schedule and the anomaly switch (D-45).', + request: {}, + responses: { 200: ReportSettingsResponse }, + async handler({ services }) { + return reply(200, services.reports.settings()); + }, + }), + defineRoute({ + operationId: 'putReportSettings', + tags, + summary: 'Replaces the in-app reports settings; a changed schedule re-arms from now.', + request: { body: PutReportSettingsRequest }, + responses: { 200: ReportSettingsResponse }, + async handler({ input, services }) { + return reply(200, await services.reports.saveSettings(input.body.settings)); + }, + }), defineRoute({ operationId: 'listNotifications', tags, @@ -36,6 +97,7 @@ export const NOTIFICATION_ROUTES = [ read: q.read, sort: q.sort, ...(q.type !== undefined && { types: q.type }), + ...(q.category !== undefined && { categories: q.category }), ...(q.since !== undefined && { since: q.since }), ...(q.until !== undefined && { until: q.until }), }), diff --git a/packages/core/src/interface/http/services.ts b/packages/core/src/interface/http/services.ts index fc6125f..25f278f 100644 --- a/packages/core/src/interface/http/services.ts +++ b/packages/core/src/interface/http/services.ts @@ -6,8 +6,12 @@ import type { HealthCheckState, HealthStatus, RealtimeConnection, + ReportDetailResponse, + ReportItem, + ReportSettingsResponse, SystemInfo, } from '@browserhive/contracts/http'; +import type { ReportSettings } from '@browserhive/contracts/notifications'; import type { LiveInput } from '@browserhive/contracts/ws'; import type { AttentionService } from '../../app/attention/attention-service.ts'; import type { AuthService } from '../../app/auth/auth-service.ts'; @@ -24,7 +28,11 @@ import type { Desktop } from '../../ports/desktop.ts'; import type { EventPublisher } from '../../ports/event-bus.ts'; import type { IdGenerator } from '../../ports/id-generator.ts'; import type { AnalyticsQueries } from '../../ports/persistence/analytics.ts'; -import type { NotificationListQuery, Page } from '../../ports/persistence/queries.ts'; +import type { + NotificationListQuery, + Page, + ReportListQuery, +} from '../../ports/persistence/queries.ts'; import type { IdempotencyRecord, NotificationRecord } from '../../ports/persistence/records.ts'; import type { Repositories } from '../../ports/persistence/unit-of-work.ts'; import type { ArtifactFiles } from '../../ports/static-assets.ts'; @@ -108,6 +116,14 @@ export interface NotificationsPort { dismissAll(): Promise; } +/** Reports in the dashboard (D-45; the `app/notifications` report service; structural). */ +export interface ReportsPort { + list(query: ReportListQuery): Promise>; + get(notificationId: string): Promise; + settings(): ReportSettingsResponse; + saveSettings(settings: ReportSettings): Promise; +} + /** Per-operator preferences (the `app/notifications` preference service; structural). */ export interface PreferencesPort { list(principal: string): Promise<{ @@ -264,6 +280,7 @@ export interface HttpServices { readonly auth: AuthPort; readonly blocklist: BlocklistPort; readonly notifications: NotificationsPort; + readonly reports: ReportsPort; readonly preferences: PreferencesPort; readonly logs: LogsPort; readonly logLevel: LogLevelController; @@ -298,6 +315,8 @@ export type ChannelsPort = Pick< | 'pause' | 'resume' | 'test' + | 'digest' + | 'hostTimeZone' | 'preview' | 'deliveries' | 'delivery' diff --git a/packages/core/src/ports/notification-channel.ts b/packages/core/src/ports/notification-channel.ts index 8aa4d1d..8828fd4 100644 --- a/packages/core/src/ports/notification-channel.ts +++ b/packages/core/src/ports/notification-channel.ts @@ -19,6 +19,8 @@ export interface ChannelCapabilities { readonly richBlocks: boolean; /** Tables render natively (otherwise they become lists). */ readonly tables: boolean; + /** Charts render natively (otherwise they become a line of text bars). */ + readonly charts: boolean; /** Images can be attached (otherwise dropped, or a link to their dashboard page). */ readonly images: boolean; /** Act buttons can be pressed in the chat (otherwise they become their `open` fallback). */ diff --git a/packages/core/src/ports/persistence/analytics.ts b/packages/core/src/ports/persistence/analytics.ts index 94192b2..d77e4b3 100644 --- a/packages/core/src/ports/persistence/analytics.ts +++ b/packages/core/src/ports/persistence/analytics.ts @@ -77,6 +77,41 @@ export interface HarnessMetricsRow { readonly errors: number; } +/** A half-open window `[since, until)` of the report queries. */ +export interface ReportWindow { + readonly since: number; + readonly until: number; +} + +/** Headline counts of one window (`windowCounts`, spec 03 §9.7). */ +export interface WindowCounts { + readonly sessionsStarted: number; + readonly toolCalls: number; + readonly errors: number; + readonly blocked: number; + /** Attention requests created. */ + readonly attention: number; + readonly vaultAccess: number; +} + +/** Latency of one tool over a window (`toolLatency`). */ +export interface ToolLatencyRow { + readonly tool: string; + readonly calls: number; + readonly errors: number; + /** Same rank as `toolMetrics`: the smallest duration at or above the 95th percentile. */ + readonly p95Ms: number; +} + +/** One error code of one tool over a window (`topErrors`). */ +export interface TopErrorRow { + readonly errorCode: string; + readonly tool: string; + readonly count: number; + /** Distinct sessions that saw it (session-less calls count as none). */ + readonly sessions: number; +} + /** Timeline item kinds. */ export type TimelineKind = 'tool' | 'page' | 'attention' | 'vault' | 'blocked'; @@ -145,4 +180,10 @@ export interface AnalyticsQueries { databaseSize(): Promise; /** Most visited domains (`GET /pages/domains`). */ topDomains(query: TopDomainsQuery): Promise; + /** Headline counts of `[since, until)` in one statement (the reports, 03 §9.7). */ + windowCounts(window: ReportWindow): Promise; + /** Per-tool calls, errors and p95 over `[since, until)`, computed in the database. */ + toolLatency(window: ReportWindow): Promise; + /** The most frequent error codes (with their tool) over `[since, until)`, most first. */ + topErrors(window: ReportWindow, limit: number): Promise; } diff --git a/packages/core/src/ports/persistence/index.ts b/packages/core/src/ports/persistence/index.ts index b0d3132..7b6615b 100644 --- a/packages/core/src/ports/persistence/index.ts +++ b/packages/core/src/ports/persistence/index.ts @@ -6,11 +6,15 @@ export type { ActivityResult, ActivitySummary, AnalyticsQueries, + ReportWindow, TimelineItem, TimelineKind, TimelineQuery, + ToolLatencyRow, ToolMetricsQuery, ToolMetricsRow, + TopErrorRow, + WindowCounts, } from './analytics.ts'; export type { BlockedRequestListRow, BlocklistAuditRepository } from './blocklist-audit.ts'; export type { @@ -107,7 +111,11 @@ export type { NotificationChannelRepository, NotificationDeliveryRepository, } from './notification-outbox.ts'; -export type { NotificationRepository, PreferenceRepository } from './notifications.ts'; +export type { + NotificationRepository, + PreferenceRepository, + ReportChannelRow, +} from './notifications.ts'; export type { ArtifactOutboxRepository, IdempotencyRepository, @@ -134,6 +142,7 @@ export type { Page, PageListQuery, PageQuery, + ReportListQuery, ScreenshotListQuery, SessionFacets, SessionListQuery, diff --git a/packages/core/src/ports/persistence/notifications.ts b/packages/core/src/ports/persistence/notifications.ts index 23e8e8b..9c40d4b 100644 --- a/packages/core/src/ports/persistence/notifications.ts +++ b/packages/core/src/ports/persistence/notifications.ts @@ -1,6 +1,6 @@ /** @module ports/persistence/notifications — notifications inbox and per-principal preferences (D-16). */ -import type { NotificationListQuery, Page } from './queries.ts'; +import type { NotificationListQuery, Page, ReportListQuery } from './queries.ts'; import type { JsonValue, NotificationGroupPatch, @@ -51,6 +51,18 @@ export interface NotificationRepository { listUnsettled(kinds: readonly string[], limit: number): Promise; /** Lists notifications newest first (by `query.sort`, default `updated_at`). */ list(query: NotificationListQuery): Promise>; + /** + * The in-app report copies (D-45: category `reports`, thread `report:…`), newest first by + * `created_at`, whatever their read and dismissed state. + */ + listReports(query: ReportListQuery): Promise>; + /** + * The channels each in-app report copy reached: the delivery rows of the channel copies that + * name it (`source_event_id`), the latest per channel, oldest channel first. + */ + reportChannels( + reportIds: readonly string[], + ): Promise>; /** Unread, undismissed count for a principal (or the anonymous inbox when `null`). */ unreadCount(principalId: string | null): Promise; /** Marks one notification read; returns false when unknown or already read. */ @@ -63,6 +75,15 @@ export interface NotificationRepository { dismissAll(principalId: string | null, at: number): Promise; } +/** A channel a report reached, with its latest delivery status. */ +export interface ReportChannelRow { + readonly channelId: string; + readonly name: string; + readonly kind: string; + readonly status: string; + readonly reason: string | null; +} + /** Repository over `preferences`. */ export interface PreferenceRepository { /** Every preference of a principal. */ diff --git a/packages/core/src/ports/persistence/operator-requests.ts b/packages/core/src/ports/persistence/operator-requests.ts index caab4ef..dd3b3be 100644 --- a/packages/core/src/ports/persistence/operator-requests.ts +++ b/packages/core/src/ports/persistence/operator-requests.ts @@ -30,6 +30,18 @@ export interface OperatorRequestFacets { readonly modes: readonly FacetCount[]; } +/** Outcomes of the requests of one kind created in a window (`windowStats`, spec 03 §9.7). */ +export interface OperatorRequestWindowStats { + readonly created: number; + readonly resolved: number; + readonly rejected: number; + readonly timedOut: number; + readonly cancelled: number; + readonly pending: number; + /** Median `resolved_at - created_at` of the resolved and rejected ones; `null` without any. */ + readonly medianWaitMs: number | null; +} + /** Repository over `operator_requests`. */ export interface OperatorRequestRepository { /** Inserts a `pending` request; a duplicate id or `(session, idempotency_key)` is ignored. */ @@ -48,4 +60,9 @@ export interface OperatorRequestRepository { facets(query: OperatorRequestListQuery): Promise; /** Number of pending requests, optionally of one kind. */ countOpen(kind?: OperatorRequestKind): Promise; + /** Outcomes of the requests of `kind` created in `[since, until)`. */ + windowStats( + kind: OperatorRequestKind, + window: { readonly since: number; readonly until: number }, + ): Promise; } diff --git a/packages/core/src/ports/persistence/queries.ts b/packages/core/src/ports/persistence/queries.ts index 178dedd..0b3556b 100644 --- a/packages/core/src/ports/persistence/queries.ts +++ b/packages/core/src/ports/persistence/queries.ts @@ -1,5 +1,6 @@ /** @module ports/persistence/queries — list query and page shapes shared by the repositories (spec 03 §4–5). */ +import type { NotificationCategory } from '@browserhive/contracts/enums'; import type { AttentionMode, BlockSource, @@ -185,6 +186,17 @@ export interface NotificationListQuery extends PageQuery, TimeWindow { readonly principalId?: string | null; readonly read?: 'all' | 'unread' | 'read'; readonly types?: readonly NotificationType[]; + /** With `types`, one facet: a row matches its type or its category (D-45). */ + readonly categories?: readonly NotificationCategory[]; +} + +/** Filters of `GET /notifications/reports` (on `created_at`, newest first). */ +export interface ReportListQuery extends PageQuery, TimeWindow { + readonly kinds?: readonly string[]; + /** Reports with a delivery to this channel. */ + readonly channelId?: string; + /** Reports that reached no channel. */ + readonly inAppOnly?: boolean; } /** Filters of `GET /system/events`. */ diff --git a/packages/core/src/ports/persistence/vault-audit.ts b/packages/core/src/ports/persistence/vault-audit.ts index b5ae8b5..4b2be97 100644 --- a/packages/core/src/ports/persistence/vault-audit.ts +++ b/packages/core/src/ports/persistence/vault-audit.ts @@ -1,5 +1,6 @@ /** @module ports/persistence/vault-audit — vault fill audit repository. */ +import type { VaultAccessResult } from './enums.ts'; import type { Page, VaultAccessListQuery } from './queries.ts'; import type { VaultAccessRecord } from './records.ts'; @@ -14,4 +15,9 @@ export interface VaultAuditRepository { insert(record: VaultAccessRecord): Promise; /** Lists audit rows (`GET /vault/log`, `GET /sessions/{id}/vault-access`). */ list(query: VaultAccessListQuery): Promise>; + /** Accesses by result over `[since, until)`, most first. */ + countByResult(window: { + readonly since: number; + readonly until: number; + }): Promise; } diff --git a/packages/core/src/public/server.ts b/packages/core/src/public/server.ts index 6589d44..27881b7 100644 --- a/packages/core/src/public/server.ts +++ b/packages/core/src/public/server.ts @@ -12,7 +12,9 @@ export { ChannelRegistry, ChannelService, createLocalLinkBuilder, + createReportFacts, type DeliveryCounter, + forgetChannelCursors, imageVariants, linkBuilderFor, NotificationActionListeners, @@ -21,6 +23,11 @@ export { NotificationService, PublicUrlChecker, publicUrlHost, + type ReportCounter, + ReportScheduler, + ReportService, + ReportSettingsStore, + runtimeZone, } from '../app/notifications/index.ts'; export { Recorder } from '../app/observability/recorder.ts'; export { SystemStatusService } from '../app/observability/system-status.ts'; diff --git a/packages/core/test/goldens/notifications/discord/anomaly-counts.json b/packages/core/test/goldens/notifications/discord/anomaly-counts.json new file mode 100644 index 0000000..2f27493 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/anomaly-counts.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "anomaly-counts", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?range=24h" + } + ] + } + ], + "embeds": [ + { + "title": "⚠️ Something looks off: 2 checks", + "description": "34% of tool calls failed in the last hour · An attention request has waited 47 min\n\n- **Check:** Tool-call error rate **new** · **Now:** 34% · **Threshold:** ≥ 20% · **Since:** 16:13\n- **Check:** Attention waiting **new** · **Now:** 47 min · **Threshold:** ≥ 30 min · **Since:** 16:13\n\n-# Checked 15:13–16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?range=24h", + "color": 16096779, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/anomaly-resolved-edit.json b/packages/core/test/goldens/notifications/discord/anomaly-resolved-edit.json new file mode 100644 index 0000000..d4310b5 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/anomaly-resolved-edit.json @@ -0,0 +1,33 @@ +{ + "kind": "discord", + "variant": "anomaly-resolved-edit", + "mode": "webhook", + "requests": [ + { + "method": "PATCH", + "path": "{secret:webhook}/messages/1101?with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [], + "embeds": [ + { + "title": "✅ Back to normal", + "description": "Every check is back under its threshold since 16:13 · it lasted 2h 05m.\n\n-# Checked 15:13–16:13 · Europe/Berlin", + "color": 2278750, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ], + "attachments": [] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/anomaly.json b/packages/core/test/goldens/notifications/discord/anomaly.json new file mode 100644 index 0000000..2ed23ff --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/anomaly.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "anomaly", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?range=24h" + } + ] + } + ], + "embeds": [ + { + "title": "⚠️ Something looks off: 2 checks", + "description": "34% of tool calls failed in the last hour · An attention request has waited 47 min\n\n- **Check:** Tool-call error rate **new** · **Now:** 34% · **Threshold:** ≥ 20% · **Since:** 16:13\n- **Check:** Attention waiting **new** · **Now:** 47 min · **Threshold:** ≥ 30 min · **Since:** 16:13\n\nWaiting: `checkout`\n\n-# Checked 15:13–16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?range=24h", + "color": 16096779, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/digest-counts.json b/packages/core/test/goldens/notifications/discord/digest-counts.json new file mode 100644 index 0000000..306a378 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-counts.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "digest-counts", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + } + ] + } + ], + "embeds": [ + { + "title": "📊 Daily digest · Mon 21 Sep", + "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\n\n**Sessions:** 12 started · 2 live now\n**Tool calls:** 3,412 · 68 errors \\(2%\\) · was 1.2%\n**Attention:** 4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out\n**Vault fills:** 9 · 1 failed\n**Blocked requests:** 27\n**Open problems:** 1\n\n**Tool calls per hour** `▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁` peak 412 calls\n\n-# 20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "color": 3900150, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/digest-full.json b/packages/core/test/goldens/notifications/discord/digest-full.json new file mode 100644 index 0000000..6b647a5 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-full.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "digest-full", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + } + ] + } + ], + "embeds": [ + { + "title": "📊 Daily digest · Mon 21 Sep", + "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\n\n**Sessions:** 12 started · 2 live now\n**Tool calls:** 3,412 · 68 errors \\(2%\\) · was 1.2%\n**Attention:** 4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out\n**Vault fills:** 9 · 8 ok · 1 origin mismatch\n**Blocked requests:** 27 · top `*.doubleclick.net` \\(19\\) · most blocked `ads.example.net` \\(12\\)\n**Slowest tool \\(p95\\):** `navigate` 4.2 s \\(was 2.9 s\\)\n**Open problems:** `RETENTION_FAILED` since 21 Sep 10:13\n\n**Tool calls per hour** `▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁` peak 412 calls\n\n**Top errors**\n\n- **Error:** `NAVIGATION_TIMEOUT` · **Tool:** `navigate` · **Count:** 31 · **Sessions:** 4\n- **Error:** `ELEMENT_NOT_FOUND` · **Tool:** `click` · **Count:** 22 · **Sessions:** 6\n- **Error:** `CAPTCHA_DETECTED` · **Tool:** `navigate` · **Count:** 15 · **Sessions:** 2\n\n**By harness**\n\n- **Harness:** Claude Code · **Sessions:** 8 · **Tool calls:** 2,410 · **Errors:** 51\n- **Harness:** Cursor · **Sessions:** 3 · **Tool calls:** 880 · **Errors:** 15\n- **Harness:** Unknown · **Sessions:** 1 · **Tool calls:** 122 · **Errors:** 2\n\n- **RETENTION\\_FAILED** since 21 Sep 10:13: retention sweep failed: database is locked\n\n-# 20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "color": 3900150, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/digest-late.json b/packages/core/test/goldens/notifications/discord/digest-late.json new file mode 100644 index 0000000..e8139af --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-late.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "digest-late", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + } + ] + } + ], + "embeds": [ + { + "title": "📊 Daily digest · Mon 21 Sep", + "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\n\nSent late: BrowserHive was not running at 16:13 \\(Mon 21 Sep\\). 2 earlier digests were skipped while BrowserHive was off.\n\n**Sessions:** 12 started · 2 live now\n**Tool calls:** 3,412 · 68 errors \\(2%\\) · was 1.2%\n**Attention:** 4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out\n**Vault fills:** 9 · 8 ok · 1 origin mismatch\n**Blocked requests:** 27 · top `*.doubleclick.net` \\(19\\)\n**Slowest tool \\(p95\\):** `navigate` 4.2 s \\(was 2.9 s\\)\n**Open problems:** `RETENTION_FAILED` since 21 Sep 10:13\n\n**Tool calls per hour** `▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁` peak 412 calls\n\n**Top errors**\n\n- **Error:** `NAVIGATION_TIMEOUT` · **Tool:** `navigate` · **Count:** 31 · **Sessions:** 4\n- **Error:** `ELEMENT_NOT_FOUND` · **Tool:** `click` · **Count:** 22 · **Sessions:** 6\n- **Error:** `CAPTCHA_DETECTED` · **Tool:** `navigate` · **Count:** 15 · **Sessions:** 2\n\n**By harness**\n\n- **Harness:** Claude Code · **Sessions:** 8 · **Tool calls:** 2,410 · **Errors:** 51\n- **Harness:** Cursor · **Sessions:** 3 · **Tool calls:** 880 · **Errors:** 15\n- **Harness:** Unknown · **Sessions:** 1 · **Tool calls:** 122 · **Errors:** 2\n\n-# 20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "color": 3900150, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/digest-weekly.json b/packages/core/test/goldens/notifications/discord/digest-weekly.json new file mode 100644 index 0000000..e5dc1ff --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-weekly.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "digest-weekly", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789395200000&until=1790000000000" + } + ] + } + ], + "embeds": [ + { + "title": "📊 Weekly digest · 14–21 Sep", + "description": "84 sessions \\(2 live\\) · 23,884 tool calls · 476 errors \\(2%\\)\n\n**Sessions:** 84 started · 2 live now\n**Tool calls:** 23,884 · 476 errors \\(2%\\) · was 1.2%\n**Attention:** 28 requests · 21 answered \\(median 1m 36s\\) · 7 timed out\n**Vault fills:** 63 · 56 ok · 7 origin mismatch\n**Blocked requests:** 189 · top `*.doubleclick.net` \\(133\\)\n**Slowest tool \\(p95\\):** `navigate` 4.2 s \\(was 2.9 s\\)\n**Open problems:** `RETENTION_FAILED` since 21 Sep 10:13\n\n**Tool calls per 12 hours** `▁▁▁▁▁▁▁▁▃▆█▇▅▇` peak 1,525 calls\n\n**Top errors**\n\n- **Error:** `NAVIGATION_TIMEOUT` · **Tool:** `navigate` · **Count:** 217 · **Sessions:** 4\n- **Error:** `ELEMENT_NOT_FOUND` · **Tool:** `click` · **Count:** 154 · **Sessions:** 6\n- **Error:** `CAPTCHA_DETECTED` · **Tool:** `navigate` · **Count:** 105 · **Sessions:** 2\n\n**By harness**\n\n- **Harness:** Claude Code · **Sessions:** 56 · **Tool calls:** 16,870 · **Errors:** 357\n- **Harness:** Cursor · **Sessions:** 21 · **Tool calls:** 6,160 · **Errors:** 105\n- **Harness:** Unknown · **Sessions:** 7 · **Tool calls:** 854 · **Errors:** 14\n\n-# 14 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?since=1789395200000&until=1790000000000", + "color": 3900150, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/discord/digest.json b/packages/core/test/goldens/notifications/discord/digest.json new file mode 100644 index 0000000..9e24db4 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest.json @@ -0,0 +1,45 @@ +{ + "kind": "discord", + "variant": "digest", + "mode": "webhook", + "requests": [ + { + "method": "POST", + "path": "{secret:webhook}?wait=true&with_components=true", + "encoding": "json", + "body": { + "content": null, + "allowed_mentions": { + "parse": [] + }, + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + } + ] + } + ], + "embeds": [ + { + "title": "📊 Daily digest · Mon 21 Sep", + "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\n\n**Sessions:** 12 started · 2 live now\n**Tool calls:** 3,412 · 68 errors \\(2%\\) · was 1.2%\n**Attention:** 4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out\n**Vault fills:** 9 · 8 ok · 1 origin mismatch\n**Blocked requests:** 27 · top `*.doubleclick.net` \\(19\\)\n**Slowest tool \\(p95\\):** `navigate` 4.2 s \\(was 2.9 s\\)\n**Open problems:** `RETENTION_FAILED` since 21 Sep 10:13\n\n**Tool calls per hour** `▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁` peak 412 calls\n\n**Top errors**\n\n- **Error:** `NAVIGATION_TIMEOUT` · **Tool:** `navigate` · **Count:** 31 · **Sessions:** 4\n- **Error:** `ELEMENT_NOT_FOUND` · **Tool:** `click` · **Count:** 22 · **Sessions:** 6\n- **Error:** `CAPTCHA_DETECTED` · **Tool:** `navigate` · **Count:** 15 · **Sessions:** 2\n\n**By harness**\n\n- **Harness:** Claude Code · **Sessions:** 8 · **Tool calls:** 2,410 · **Errors:** 51\n- **Harness:** Cursor · **Sessions:** 3 · **Tool calls:** 880 · **Errors:** 15\n- **Harness:** Unknown · **Sessions:** 1 · **Tool calls:** 122 · **Errors:** 2\n\n-# 20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "color": 3900150, + "footer": { + "text": "BrowserHive" + }, + "timestamp": "2026-09-21T14:13:20.000Z" + } + ] + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/anomaly-counts.json b/packages/core/test/goldens/notifications/ntfy/anomaly-counts.json new file mode 100644 index 0000000..84c943c --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/anomaly-counts.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "anomaly-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Something looks off: 2 checks", + "message": "34% of tool calls failed in the last hour · An attention request has waited 47 min\n\n• Check: Tool-call error rate new · Now: 34% · Threshold: ≥ 20% · Since: 16:13\n• Check: Attention waiting new · Now: 47 min · Threshold: ≥ 30 min · Since: 16:13\n\nChecked 15:13–16:13 · Europe/Berlin", + "priority": 4, + "tags": [ + "warning" + ], + "click": "https://bh.example.net/overview?range=24h", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?range=24h", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/anomaly-resolved-edit.json b/packages/core/test/goldens/notifications/ntfy/anomaly-resolved-edit.json new file mode 100644 index 0000000..741cbc1 --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/anomaly-resolved-edit.json @@ -0,0 +1,25 @@ +{ + "kind": "ntfy", + "variant": "anomaly-resolved-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Back to normal", + "message": "Every check is back under its threshold since 16:13 · it lasted 2h 05m.\n\nChecked 15:13–16:13 · Europe/Berlin", + "priority": 2, + "tags": [ + "white_check_mark" + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/anomaly.json b/packages/core/test/goldens/notifications/ntfy/anomaly.json new file mode 100644 index 0000000..a765dc2 --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/anomaly.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "anomaly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Something looks off: 2 checks", + "message": "34% of tool calls failed in the last hour · An attention request has waited 47 min\n\n• Check: Tool-call error rate new · Now: 34% · Threshold: ≥ 20% · Since: 16:13\n• Check: Attention waiting new · Now: 47 min · Threshold: ≥ 30 min · Since: 16:13\n\nWaiting: checkout\n\nChecked 15:13–16:13 · Europe/Berlin", + "priority": 4, + "tags": [ + "warning" + ], + "click": "https://bh.example.net/overview?range=24h", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?range=24h", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/digest-counts.json b/packages/core/test/goldens/notifications/ntfy/digest-counts.json new file mode 100644 index 0000000..8f9c334 --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/digest-counts.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "digest-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Daily digest · Mon 21 Sep", + "message": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 1 failed\nBlocked requests: 27\nOpen problems: 1\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "priority": 3, + "tags": [ + "bar_chart" + ], + "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/digest-full.json b/packages/core/test/goldens/notifications/ntfy/digest-full.json new file mode 100644 index 0000000..e5dbcfc --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/digest-full.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "digest-full", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Daily digest · Mon 21 Sep", + "message": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 8 ok · 1 origin mismatch\nBlocked requests: 27 · top *.doubleclick.net (19) · most blocked ads.example.net (12)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 31 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 22 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 15 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 8 · Tool calls: 2,410 · Errors: 51\n• Harness: Cursor · Sessions: 3 · Tool calls: 880 · Errors: 15\n• Harness: Unknown · Sessions: 1 · Tool calls: 122 · Errors: 2\n\n• RETENTION_FAILED since 21 Sep 10:13: retention sweep failed: database is locked\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "priority": 3, + "tags": [ + "bar_chart" + ], + "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/digest-late.json b/packages/core/test/goldens/notifications/ntfy/digest-late.json new file mode 100644 index 0000000..25f581e --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/digest-late.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "digest-late", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Daily digest · Mon 21 Sep", + "message": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSent late: BrowserHive was not running at 16:13 (Mon 21 Sep). 2 earlier digests were skipped while BrowserHive was off.\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 8 ok · 1 origin mismatch\nBlocked requests: 27 · top *.doubleclick.net (19)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 31 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 22 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 15 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 8 · Tool calls: 2,410 · Errors: 51\n• Harness: Cursor · Sessions: 3 · Tool calls: 880 · Errors: 15\n• Harness: Unknown · Sessions: 1 · Tool calls: 122 · Errors: 2\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "priority": 3, + "tags": [ + "bar_chart" + ], + "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/digest-weekly.json b/packages/core/test/goldens/notifications/ntfy/digest-weekly.json new file mode 100644 index 0000000..2ce6b1e --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/digest-weekly.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "digest-weekly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Weekly digest · 14–21 Sep", + "message": "84 sessions (2 live) · 23,884 tool calls · 476 errors (2%)\n\nSessions: 84 started · 2 live now\nTool calls: 23,884 · 476 errors (2%) · was 1.2%\nAttention: 28 requests · 21 answered (median 1m 36s) · 7 timed out\nVault fills: 63 · 56 ok · 7 origin mismatch\nBlocked requests: 189 · top *.doubleclick.net (133)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per 12 hours ▁▁▁▁▁▁▁▁▃▆█▇▅▇ peak 1,525 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 217 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 154 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 105 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 56 · Tool calls: 16,870 · Errors: 357\n• Harness: Cursor · Sessions: 21 · Tool calls: 6,160 · Errors: 105\n• Harness: Unknown · Sessions: 7 · Tool calls: 854 · Errors: 14\n\n14 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "priority": 3, + "tags": [ + "bar_chart" + ], + "click": "https://bh.example.net/overview?since=1789395200000&until=1790000000000", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789395200000&until=1790000000000", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/ntfy/digest.json b/packages/core/test/goldens/notifications/ntfy/digest.json new file mode 100644 index 0000000..dde6d89 --- /dev/null +++ b/packages/core/test/goldens/notifications/ntfy/digest.json @@ -0,0 +1,34 @@ +{ + "kind": "ntfy", + "variant": "digest", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "/", + "encoding": "json", + "body": { + "topic": "bh-alerts", + "title": "Daily digest · Mon 21 Sep", + "message": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 8 ok · 1 origin mismatch\nBlocked requests: 27 · top *.doubleclick.net (19)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 31 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 22 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 15 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 8 · Tool calls: 2,410 · Errors: 51\n• Harness: Cursor · Sessions: 3 · Tool calls: 880 · Errors: 15\n• Harness: Unknown · Sessions: 1 · Tool calls: 122 · Errors: 2\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "priority": 3, + "tags": [ + "bar_chart" + ], + "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "actions": [ + { + "action": "view", + "label": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "clear": false + } + ], + "markdown": false, + "sequence_id": "n-sample000001" + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/anomaly-counts.json b/packages/core/test/goldens/notifications/telegram-classic/anomaly-counts.json new file mode 100644 index 0000000..517823d --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/anomaly-counts.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "anomaly-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?range=24h", + "style": "primary" + } + ] + ] + }, + "text": "⚠️ Something looks off: 2 checks\n34% of tool calls failed in the last hour · An attention request has waited 47 min\n\n• Check: Tool-call error rate new · Now: 34% · Threshold: ≥ 20% · Since: 16:13\n• Check: Attention waiting new · Now: 47 min · Threshold: ≥ 30 min · Since: 16:13\n\nChecked 15:13–16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/anomaly-resolved-edit.json b/packages/core/test/goldens/notifications/telegram-classic/anomaly-resolved-edit.json new file mode 100644 index 0000000..b3749f3 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/anomaly-resolved-edit.json @@ -0,0 +1,26 @@ +{ + "kind": "telegram-classic", + "variant": "anomaly-resolved-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "editMessageText", + "encoding": "json", + "body": { + "chat_id": -1001234567890, + "message_id": 101, + "text": "✅ Back to normal\nEvery check is back under its threshold since 16:13 · it lasted 2h 05m.\n\nChecked 15:13–16:13 · Europe/Berlin", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": true + }, + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/anomaly.json b/packages/core/test/goldens/notifications/telegram-classic/anomaly.json new file mode 100644 index 0000000..d99b44a --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/anomaly.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "anomaly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?range=24h", + "style": "primary" + } + ] + ] + }, + "text": "⚠️ Something looks off: 2 checks\n34% of tool calls failed in the last hour · An attention request has waited 47 min\n\n• Check: Tool-call error rate new · Now: 34% · Threshold: ≥ 20% · Since: 16:13\n• Check: Attention waiting new · Now: 47 min · Threshold: ≥ 30 min · Since: 16:13\n\nWaiting: checkout\n\nChecked 15:13–16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json b/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json new file mode 100644 index 0000000..4e3309f --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "digest-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + }, + "text": "📊 Daily digest · Mon 21 Sep\n12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 1 failed\nBlocked requests: 27\nOpen problems: 1\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-full.json b/packages/core/test/goldens/notifications/telegram-classic/digest-full.json new file mode 100644 index 0000000..2d2538c --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-full.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "digest-full", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + }, + "text": "📊 Daily digest · Mon 21 Sep\n12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 8 ok · 1 origin mismatch\nBlocked requests: 27 · top *.doubleclick.net (19) · most blocked ads.example.net (12)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 31 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 22 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 15 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 8 · Tool calls: 2,410 · Errors: 51\n• Harness: Cursor · Sessions: 3 · Tool calls: 880 · Errors: 15\n• Harness: Unknown · Sessions: 1 · Tool calls: 122 · Errors: 2\n\n• RETENTION_FAILED since 21 Sep 10:13: retention sweep failed: database is locked\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-late.json b/packages/core/test/goldens/notifications/telegram-classic/digest-late.json new file mode 100644 index 0000000..b5f76e2 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-late.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "digest-late", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + }, + "text": "📊 Daily digest · Mon 21 Sep\n12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSent late: BrowserHive was not running at 16:13 (Mon 21 Sep). 2 earlier digests were skipped while BrowserHive was off.\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 8 ok · 1 origin mismatch\nBlocked requests: 27 · top *.doubleclick.net (19)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 31 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 22 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 15 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 8 · Tool calls: 2,410 · Errors: 51\n• Harness: Cursor · Sessions: 3 · Tool calls: 880 · Errors: 15\n• Harness: Unknown · Sessions: 1 · Tool calls: 122 · Errors: 2\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json b/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json new file mode 100644 index 0000000..82766d1 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "digest-weekly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789395200000&until=1790000000000", + "style": "primary" + } + ] + ] + }, + "text": "📊 Weekly digest · 14–21 Sep\n84 sessions (2 live) · 23,884 tool calls · 476 errors (2%)\n\nSessions: 84 started · 2 live now\nTool calls: 23,884 · 476 errors (2%) · was 1.2%\nAttention: 28 requests · 21 answered (median 1m 36s) · 7 timed out\nVault fills: 63 · 56 ok · 7 origin mismatch\nBlocked requests: 189 · top *.doubleclick.net (133)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per 12 hours ▁▁▁▁▁▁▁▁▃▆█▇▅▇ peak 1,525 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 217 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 154 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 105 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 56 · Tool calls: 16,870 · Errors: 357\n• Harness: Cursor · Sessions: 21 · Tool calls: 6,160 · Errors: 105\n• Harness: Unknown · Sessions: 7 · Tool calls: 854 · Errors: 14\n\n14 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest.json b/packages/core/test/goldens/notifications/telegram-classic/digest.json new file mode 100644 index 0000000..32efdbc --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram-classic/digest.json @@ -0,0 +1,34 @@ +{ + "kind": "telegram-classic", + "variant": "digest", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "parse_mode": "HTML", + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + }, + "text": "📊 Daily digest · Mon 21 Sep\n12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)\n\nSessions: 12 started · 2 live now\nTool calls: 3,412 · 68 errors (2%) · was 1.2%\nAttention: 4 requests · 3 answered (median 1m 36s) · 1 timed out\nVault fills: 9 · 8 ok · 1 origin mismatch\nBlocked requests: 27 · top *.doubleclick.net (19)\nSlowest tool (p95): navigate 4.2 s (was 2.9 s)\nOpen problems: RETENTION_FAILED since 21 Sep 10:13\n\nTool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls\n\nTop errors\n\n• Error: NAVIGATION_TIMEOUT · Tool: navigate · Count: 31 · Sessions: 4\n• Error: ELEMENT_NOT_FOUND · Tool: click · Count: 22 · Sessions: 6\n• Error: CAPTCHA_DETECTED · Tool: navigate · Count: 15 · Sessions: 2\n\nBy harness\n\n• Harness: Claude Code · Sessions: 8 · Tool calls: 2,410 · Errors: 51\n• Harness: Cursor · Sessions: 3 · Tool calls: 880 · Errors: 15\n• Harness: Unknown · Sessions: 1 · Tool calls: 122 · Errors: 2\n\n20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin", + "link_preview_options": { + "is_disabled": true + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/anomaly-counts.json b/packages/core/test/goldens/notifications/telegram/anomaly-counts.json new file mode 100644 index 0000000..545c100 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/anomaly-counts.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "anomaly-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

⚠️ Something looks off: 2 checks

34% of tool calls failed in the last hour · An attention request has waited 47 min

CheckNowThresholdSince
Tool-call error rate new34%≥ 20%16:13
Attention waiting new47 min≥ 30 min16:13
Checked 15:13–16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?range=24h", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/anomaly-resolved-edit.json b/packages/core/test/goldens/notifications/telegram/anomaly-resolved-edit.json new file mode 100644 index 0000000..50712c0 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/anomaly-resolved-edit.json @@ -0,0 +1,25 @@ +{ + "kind": "telegram", + "variant": "anomaly-resolved-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "editMessageText", + "encoding": "json", + "body": { + "chat_id": -1001234567890, + "message_id": 101, + "rich_message": { + "html": "

✅ Back to normal

Every check is back under its threshold since 16:13 · it lasted 2h 05m.

Checked 15:13–16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "reply_markup": { + "inline_keyboard": [] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/anomaly.json b/packages/core/test/goldens/notifications/telegram/anomaly.json new file mode 100644 index 0000000..4ddf08c --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/anomaly.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "anomaly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

⚠️ Something looks off: 2 checks

34% of tool calls failed in the last hour · An attention request has waited 47 min

CheckNowThresholdSince
Tool-call error rate new34%≥ 20%16:13
Attention waiting new47 min≥ 30 min16:13

Waiting: checkout

Checked 15:13–16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?range=24h", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/digest-counts.json b/packages/core/test/goldens/notifications/telegram/digest-counts.json new file mode 100644 index 0000000..61199cb --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/digest-counts.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "digest-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

📊 Daily digest · Mon 21 Sep

12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)

Sessions12 started · 2 live now
Tool calls3,412 · 68 errors (2%) · was 1.2%
Attention4 requests · 3 answered (median 1m 36s) · 1 timed out
Vault fills9 · 1 failed
Blocked requests27
Open problems1

Tool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls

20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/digest-full.json b/packages/core/test/goldens/notifications/telegram/digest-full.json new file mode 100644 index 0000000..fe41c8d --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/digest-full.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "digest-full", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

📊 Daily digest · Mon 21 Sep

12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)

Sessions12 started · 2 live now
Tool calls3,412 · 68 errors (2%) · was 1.2%
Attention4 requests · 3 answered (median 1m 36s) · 1 timed out
Vault fills9 · 8 ok · 1 origin mismatch
Blocked requests27 · top *.doubleclick.net (19) · most blocked ads.example.net (12)
Slowest tool (p95)navigate 4.2 s (was 2.9 s)
Open problemsRETENTION_FAILED since 21 Sep 10:13

Tool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls

Top errors

ErrorToolCountSessions
NAVIGATION_TIMEOUTnavigate314
ELEMENT_NOT_FOUNDclick226
CAPTCHA_DETECTEDnavigate152

By harness

HarnessSessionsTool callsErrors
Claude Code82,41051
Cursor388015
Unknown11222
  • RETENTION_FAILED since 21 Sep 10:13: retention sweep failed: database is locked
20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/digest-late.json b/packages/core/test/goldens/notifications/telegram/digest-late.json new file mode 100644 index 0000000..cb81b28 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/digest-late.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "digest-late", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

📊 Daily digest · Mon 21 Sep

12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)

Sent late: BrowserHive was not running at 16:13 (Mon 21 Sep). 2 earlier digests were skipped while BrowserHive was off.

Sessions12 started · 2 live now
Tool calls3,412 · 68 errors (2%) · was 1.2%
Attention4 requests · 3 answered (median 1m 36s) · 1 timed out
Vault fills9 · 8 ok · 1 origin mismatch
Blocked requests27 · top *.doubleclick.net (19)
Slowest tool (p95)navigate 4.2 s (was 2.9 s)
Open problemsRETENTION_FAILED since 21 Sep 10:13

Tool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls

Top errors

ErrorToolCountSessions
NAVIGATION_TIMEOUTnavigate314
ELEMENT_NOT_FOUNDclick226
CAPTCHA_DETECTEDnavigate152

By harness

HarnessSessionsTool callsErrors
Claude Code82,41051
Cursor388015
Unknown11222
20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/digest-weekly.json b/packages/core/test/goldens/notifications/telegram/digest-weekly.json new file mode 100644 index 0000000..641a942 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/digest-weekly.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "digest-weekly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

📊 Weekly digest · 14–21 Sep

84 sessions (2 live) · 23,884 tool calls · 476 errors (2%)

Sessions84 started · 2 live now
Tool calls23,884 · 476 errors (2%) · was 1.2%
Attention28 requests · 21 answered (median 1m 36s) · 7 timed out
Vault fills63 · 56 ok · 7 origin mismatch
Blocked requests189 · top *.doubleclick.net (133)
Slowest tool (p95)navigate 4.2 s (was 2.9 s)
Open problemsRETENTION_FAILED since 21 Sep 10:13

Tool calls per 12 hours ▁▁▁▁▁▁▁▁▃▆█▇▅▇ peak 1,525 calls

Top errors

ErrorToolCountSessions
NAVIGATION_TIMEOUTnavigate2174
ELEMENT_NOT_FOUNDclick1546
CAPTCHA_DETECTEDnavigate1052

By harness

HarnessSessionsTool callsErrors
Claude Code5616,870357
Cursor216,160105
Unknown785414
14 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789395200000&until=1790000000000", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/telegram/digest.json b/packages/core/test/goldens/notifications/telegram/digest.json new file mode 100644 index 0000000..6d30d77 --- /dev/null +++ b/packages/core/test/goldens/notifications/telegram/digest.json @@ -0,0 +1,33 @@ +{ + "kind": "telegram", + "variant": "digest", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "sendRichMessage", + "encoding": "json", + "body": { + "chat_id": "-1001234567890", + "rich_message": { + "html": "

📊 Daily digest · Mon 21 Sep

12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)

Sessions12 started · 2 live now
Tool calls3,412 · 68 errors (2%) · was 1.2%
Attention4 requests · 3 answered (median 1m 36s) · 1 timed out
Vault fills9 · 8 ok · 1 origin mismatch
Blocked requests27 · top *.doubleclick.net (19)
Slowest tool (p95)navigate 4.2 s (was 2.9 s)
Open problemsRETENTION_FAILED since 21 Sep 10:13

Tool calls per hour ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁ peak 412 calls

Top errors

ErrorToolCountSessions
NAVIGATION_TIMEOUTnavigate314
ELEMENT_NOT_FOUNDclick226
CAPTCHA_DETECTEDnavigate152

By harness

HarnessSessionsTool callsErrors
Claude Code82,41051
Cursor388015
Unknown11222
20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin
", + "skip_entity_detection": true + }, + "disable_notification": false, + "reply_markup": { + "inline_keyboard": [ + [ + { + "text": "Open Overview", + "url": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", + "style": "primary" + } + ] + ] + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/anomaly-counts.json b/packages/core/test/goldens/notifications/webhook/anomaly-counts.json new file mode 100644 index 0000000..825c1c2 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/anomaly-counts.json @@ -0,0 +1,156 @@ +{ + "kind": "webhook", + "variant": "anomaly-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?range=24h" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "anomaly:sample", + "kind": "report.anomaly", + "category": "reports", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Something looks off: 2 checks", + "summary": "34% of tool calls failed in the last hour · An attention request has waited 47 min", + "blocks": [ + { + "type": "table", + "columns": [ + "Check", + "Now", + "Threshold", + "Since" + ], + "rows": [ + [ + [ + { + "type": "text", + "text": "Tool-call error rate" + }, + { + "type": "text", + "text": " " + }, + { + "type": "bold", + "text": "new" + } + ], + [ + { + "type": "text", + "text": "34%" + } + ], + [ + { + "type": "text", + "text": "≥ 20%" + } + ], + [ + { + "type": "text", + "text": "16:13" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Attention waiting" + }, + { + "type": "text", + "text": " " + }, + { + "type": "bold", + "text": "new" + } + ], + [ + { + "type": "text", + "text": "47 min" + } + ], + [ + { + "type": "text", + "text": "≥ 30 min" + } + ], + [ + { + "type": "text", + "text": "16:13" + } + ] + ] + ] + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "Checked 15:13–16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?range=24h" + } + ], + "entities": {}, + "privacy": { + "level": "counts", + "has_image": false + }, + "report": { + "window": { + "since": 1789996400000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/anomaly-resolved-edit.json b/packages/core/test/goldens/notifications/webhook/anomaly-resolved-edit.json new file mode 100644 index 0000000..5f64d08 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/anomaly-resolved-edit.json @@ -0,0 +1,67 @@ +{ + "kind": "webhook", + "variant": "anomaly-resolved-edit", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "edit", + "delivered_at": 1790000000000, + "channel": null, + "links": {}, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 2, + "thread": "anomaly:sample", + "kind": "report.anomaly", + "category": "reports", + "severity": "info", + "state": "resolved", + "alert": false, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Back to normal", + "summary": "Every check is back under its threshold since 16:13 · it lasted 2h 05m.", + "blocks": [ + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "Checked 15:13–16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [], + "entities": {}, + "privacy": { + "level": "titles", + "has_image": false + }, + "report": { + "window": { + "since": 1789996400000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/anomaly.json b/packages/core/test/goldens/notifications/webhook/anomaly.json new file mode 100644 index 0000000..b2002cd --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/anomaly.json @@ -0,0 +1,169 @@ +{ + "kind": "webhook", + "variant": "anomaly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?range=24h" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "anomaly:sample", + "kind": "report.anomaly", + "category": "reports", + "severity": "warn", + "state": "open", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Something looks off: 2 checks", + "summary": "34% of tool calls failed in the last hour · An attention request has waited 47 min", + "blocks": [ + { + "type": "table", + "columns": [ + "Check", + "Now", + "Threshold", + "Since" + ], + "rows": [ + [ + [ + { + "type": "text", + "text": "Tool-call error rate" + }, + { + "type": "text", + "text": " " + }, + { + "type": "bold", + "text": "new" + } + ], + [ + { + "type": "text", + "text": "34%" + } + ], + [ + { + "type": "text", + "text": "≥ 20%" + } + ], + [ + { + "type": "text", + "text": "16:13" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Attention waiting" + }, + { + "type": "text", + "text": " " + }, + { + "type": "bold", + "text": "new" + } + ], + [ + { + "type": "text", + "text": "47 min" + } + ], + [ + { + "type": "text", + "text": "≥ 30 min" + } + ], + [ + { + "type": "text", + "text": "16:13" + } + ] + ] + ] + }, + { + "type": "text", + "content": [ + { + "type": "text", + "text": "Waiting: " + }, + { + "type": "code", + "text": "checkout" + } + ] + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "Checked 15:13–16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?range=24h" + } + ], + "entities": {}, + "privacy": { + "level": "titles", + "has_image": false + }, + "report": { + "window": { + "since": 1789996400000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/digest-counts.json b/packages/core/test/goldens/notifications/webhook/digest-counts.json new file mode 100644 index 0000000..ee64a9d --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/digest-counts.json @@ -0,0 +1,173 @@ +{ + "kind": "webhook", + "variant": "digest-counts", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "digest:sample:1790000000000", + "kind": "digest.daily", + "category": "reports", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Daily digest · Mon 21 Sep", + "summary": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Sessions", + "value": [ + { + "type": "text", + "text": "12 started · 2 live now" + } + ] + }, + { + "label": "Tool calls", + "value": [ + { + "type": "text", + "text": "3,412 · 68 errors (2%)" + }, + { + "type": "text", + "text": " · was 1.2%" + } + ] + }, + { + "label": "Attention", + "value": [ + { + "type": "text", + "text": "4 requests · 3 answered (median 1m 36s) · 1 timed out" + } + ] + }, + { + "label": "Vault fills", + "value": [ + { + "type": "text", + "text": "9 · 1 failed" + } + ] + }, + { + "label": "Blocked requests", + "value": [ + { + "type": "text", + "text": "27" + } + ] + }, + { + "label": "Open problems", + "value": [ + { + "type": "text", + "text": "1" + } + ] + } + ] + }, + { + "type": "chart", + "label": "Tool calls per hour", + "values": [ + 2, + 1, + 0, + 0, + 0, + 1, + 4, + 18, + 96, + 212, + 305, + 280, + 190, + 240, + 330, + 412, + 380, + 260, + 150, + 120, + 88, + 60, + 40, + 23 + ], + "start": 1789913600000, + "step_ms": 3600000, + "unit": "calls" + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?since=1789913600000&until=1790000000000" + } + ], + "entities": {}, + "privacy": { + "level": "counts", + "has_image": false + }, + "report": { + "window": { + "since": 1789913600000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/digest-full.json b/packages/core/test/goldens/notifications/webhook/digest-full.json new file mode 100644 index 0000000..ffc2890 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/digest-full.json @@ -0,0 +1,416 @@ +{ + "kind": "webhook", + "variant": "digest-full", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "digest:sample:1790000000000", + "kind": "digest.daily", + "category": "reports", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Daily digest · Mon 21 Sep", + "summary": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Sessions", + "value": [ + { + "type": "text", + "text": "12 started · 2 live now" + } + ] + }, + { + "label": "Tool calls", + "value": [ + { + "type": "text", + "text": "3,412 · 68 errors (2%)" + }, + { + "type": "text", + "text": " · was 1.2%" + } + ] + }, + { + "label": "Attention", + "value": [ + { + "type": "text", + "text": "4 requests · 3 answered (median 1m 36s) · 1 timed out" + } + ] + }, + { + "label": "Vault fills", + "value": [ + { + "type": "text", + "text": "9 · 8 ok · 1 origin mismatch" + } + ] + }, + { + "label": "Blocked requests", + "value": [ + { + "type": "text", + "text": "27" + }, + { + "type": "text", + "text": " · top " + }, + { + "type": "code", + "text": "*.doubleclick.net" + }, + { + "type": "text", + "text": " (19)" + }, + { + "type": "text", + "text": " · most blocked " + }, + { + "type": "code", + "text": "ads.example.net" + }, + { + "type": "text", + "text": " (12)" + } + ] + }, + { + "label": "Slowest tool (p95)", + "value": [ + { + "type": "code", + "text": "navigate" + }, + { + "type": "text", + "text": " 4.2 s (was 2.9 s)" + } + ] + }, + { + "label": "Open problems", + "value": [ + { + "type": "code", + "text": "RETENTION_FAILED" + }, + { + "type": "text", + "text": " since 21 Sep 10:13" + } + ] + } + ] + }, + { + "type": "chart", + "label": "Tool calls per hour", + "values": [ + 2, + 1, + 0, + 0, + 0, + 1, + 4, + 18, + 96, + 212, + 305, + 280, + 190, + 240, + 330, + 412, + 380, + 260, + 150, + 120, + 88, + 60, + 40, + 23 + ], + "start": 1789913600000, + "step_ms": 3600000, + "unit": "calls" + }, + { + "type": "heading", + "text": "Top errors" + }, + { + "type": "table", + "columns": [ + "Error", + "Tool", + "Count", + "Sessions" + ], + "rows": [ + [ + [ + { + "type": "code", + "text": "NAVIGATION_TIMEOUT" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "31" + } + ], + [ + { + "type": "text", + "text": "4" + } + ] + ], + [ + [ + { + "type": "code", + "text": "ELEMENT_NOT_FOUND" + } + ], + [ + { + "type": "code", + "text": "click" + } + ], + [ + { + "type": "text", + "text": "22" + } + ], + [ + { + "type": "text", + "text": "6" + } + ] + ], + [ + [ + { + "type": "code", + "text": "CAPTCHA_DETECTED" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "15" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "heading", + "text": "By harness" + }, + { + "type": "table", + "columns": [ + "Harness", + "Sessions", + "Tool calls", + "Errors" + ], + "rows": [ + [ + [ + { + "type": "text", + "text": "Claude Code" + } + ], + [ + { + "type": "text", + "text": "8" + } + ], + [ + { + "type": "text", + "text": "2,410" + } + ], + [ + { + "type": "text", + "text": "51" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Cursor" + } + ], + [ + { + "type": "text", + "text": "3" + } + ], + [ + { + "type": "text", + "text": "880" + } + ], + [ + { + "type": "text", + "text": "15" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Unknown" + } + ], + [ + { + "type": "text", + "text": "1" + } + ], + [ + { + "type": "text", + "text": "122" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "list", + "ordered": false, + "items": [ + [ + { + "type": "bold", + "text": "RETENTION_FAILED" + }, + { + "type": "text", + "text": " since 21 Sep 10:13: retention sweep failed: database is locked" + } + ] + ] + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?since=1789913600000&until=1790000000000" + } + ], + "entities": {}, + "privacy": { + "level": "full", + "has_image": false + }, + "report": { + "window": { + "since": 1789913600000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/digest-late.json b/packages/core/test/goldens/notifications/webhook/digest-late.json new file mode 100644 index 0000000..38f2cc3 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/digest-late.json @@ -0,0 +1,401 @@ +{ + "kind": "webhook", + "variant": "digest-late", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "digest:sample:1790000000000", + "kind": "digest.daily", + "category": "reports", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Daily digest · Mon 21 Sep", + "summary": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)", + "blocks": [ + { + "type": "text", + "content": [ + { + "type": "text", + "text": "Sent late: BrowserHive was not running at 16:13 (Mon 21 Sep)." + }, + { + "type": "text", + "text": " 2 earlier digests were skipped while BrowserHive was off." + } + ] + }, + { + "type": "fields", + "items": [ + { + "label": "Sessions", + "value": [ + { + "type": "text", + "text": "12 started · 2 live now" + } + ] + }, + { + "label": "Tool calls", + "value": [ + { + "type": "text", + "text": "3,412 · 68 errors (2%)" + }, + { + "type": "text", + "text": " · was 1.2%" + } + ] + }, + { + "label": "Attention", + "value": [ + { + "type": "text", + "text": "4 requests · 3 answered (median 1m 36s) · 1 timed out" + } + ] + }, + { + "label": "Vault fills", + "value": [ + { + "type": "text", + "text": "9 · 8 ok · 1 origin mismatch" + } + ] + }, + { + "label": "Blocked requests", + "value": [ + { + "type": "text", + "text": "27" + }, + { + "type": "text", + "text": " · top " + }, + { + "type": "code", + "text": "*.doubleclick.net" + }, + { + "type": "text", + "text": " (19)" + } + ] + }, + { + "label": "Slowest tool (p95)", + "value": [ + { + "type": "code", + "text": "navigate" + }, + { + "type": "text", + "text": " 4.2 s (was 2.9 s)" + } + ] + }, + { + "label": "Open problems", + "value": [ + { + "type": "code", + "text": "RETENTION_FAILED" + }, + { + "type": "text", + "text": " since 21 Sep 10:13" + } + ] + } + ] + }, + { + "type": "chart", + "label": "Tool calls per hour", + "values": [ + 2, + 1, + 0, + 0, + 0, + 1, + 4, + 18, + 96, + 212, + 305, + 280, + 190, + 240, + 330, + 412, + 380, + 260, + 150, + 120, + 88, + 60, + 40, + 23 + ], + "start": 1789913600000, + "step_ms": 3600000, + "unit": "calls" + }, + { + "type": "heading", + "text": "Top errors" + }, + { + "type": "table", + "columns": [ + "Error", + "Tool", + "Count", + "Sessions" + ], + "rows": [ + [ + [ + { + "type": "code", + "text": "NAVIGATION_TIMEOUT" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "31" + } + ], + [ + { + "type": "text", + "text": "4" + } + ] + ], + [ + [ + { + "type": "code", + "text": "ELEMENT_NOT_FOUND" + } + ], + [ + { + "type": "code", + "text": "click" + } + ], + [ + { + "type": "text", + "text": "22" + } + ], + [ + { + "type": "text", + "text": "6" + } + ] + ], + [ + [ + { + "type": "code", + "text": "CAPTCHA_DETECTED" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "15" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "heading", + "text": "By harness" + }, + { + "type": "table", + "columns": [ + "Harness", + "Sessions", + "Tool calls", + "Errors" + ], + "rows": [ + [ + [ + { + "type": "text", + "text": "Claude Code" + } + ], + [ + { + "type": "text", + "text": "8" + } + ], + [ + { + "type": "text", + "text": "2,410" + } + ], + [ + { + "type": "text", + "text": "51" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Cursor" + } + ], + [ + { + "type": "text", + "text": "3" + } + ], + [ + { + "type": "text", + "text": "880" + } + ], + [ + { + "type": "text", + "text": "15" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Unknown" + } + ], + [ + { + "type": "text", + "text": "1" + } + ], + [ + { + "type": "text", + "text": "122" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?since=1789913600000&until=1790000000000" + } + ], + "entities": {}, + "privacy": { + "level": "titles", + "has_image": false + }, + "report": { + "window": { + "since": 1789913600000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": true, + "skipped": 2, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/digest-weekly.json b/packages/core/test/goldens/notifications/webhook/digest-weekly.json new file mode 100644 index 0000000..ff475a2 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/digest-weekly.json @@ -0,0 +1,378 @@ +{ + "kind": "webhook", + "variant": "digest-weekly", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?since=1789395200000&until=1790000000000" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "digest:sample:1790000000000", + "kind": "digest.weekly", + "category": "reports", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Weekly digest · 14–21 Sep", + "summary": "84 sessions (2 live) · 23,884 tool calls · 476 errors (2%)", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Sessions", + "value": [ + { + "type": "text", + "text": "84 started · 2 live now" + } + ] + }, + { + "label": "Tool calls", + "value": [ + { + "type": "text", + "text": "23,884 · 476 errors (2%)" + }, + { + "type": "text", + "text": " · was 1.2%" + } + ] + }, + { + "label": "Attention", + "value": [ + { + "type": "text", + "text": "28 requests · 21 answered (median 1m 36s) · 7 timed out" + } + ] + }, + { + "label": "Vault fills", + "value": [ + { + "type": "text", + "text": "63 · 56 ok · 7 origin mismatch" + } + ] + }, + { + "label": "Blocked requests", + "value": [ + { + "type": "text", + "text": "189" + }, + { + "type": "text", + "text": " · top " + }, + { + "type": "code", + "text": "*.doubleclick.net" + }, + { + "type": "text", + "text": " (133)" + } + ] + }, + { + "label": "Slowest tool (p95)", + "value": [ + { + "type": "code", + "text": "navigate" + }, + { + "type": "text", + "text": " 4.2 s (was 2.9 s)" + } + ] + }, + { + "label": "Open problems", + "value": [ + { + "type": "code", + "text": "RETENTION_FAILED" + }, + { + "type": "text", + "text": " since 21 Sep 10:13" + } + ] + } + ] + }, + { + "type": "chart", + "label": "Tool calls per 12 hours", + "values": [ + 10, + 5, + 0, + 0, + 0, + 5, + 20, + 90, + 480, + 1060, + 1525, + 1400, + 950, + 1200 + ], + "start": 1789395200000, + "step_ms": 43200000, + "unit": "calls" + }, + { + "type": "heading", + "text": "Top errors" + }, + { + "type": "table", + "columns": [ + "Error", + "Tool", + "Count", + "Sessions" + ], + "rows": [ + [ + [ + { + "type": "code", + "text": "NAVIGATION_TIMEOUT" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "217" + } + ], + [ + { + "type": "text", + "text": "4" + } + ] + ], + [ + [ + { + "type": "code", + "text": "ELEMENT_NOT_FOUND" + } + ], + [ + { + "type": "code", + "text": "click" + } + ], + [ + { + "type": "text", + "text": "154" + } + ], + [ + { + "type": "text", + "text": "6" + } + ] + ], + [ + [ + { + "type": "code", + "text": "CAPTCHA_DETECTED" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "105" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "heading", + "text": "By harness" + }, + { + "type": "table", + "columns": [ + "Harness", + "Sessions", + "Tool calls", + "Errors" + ], + "rows": [ + [ + [ + { + "type": "text", + "text": "Claude Code" + } + ], + [ + { + "type": "text", + "text": "56" + } + ], + [ + { + "type": "text", + "text": "16,870" + } + ], + [ + { + "type": "text", + "text": "357" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Cursor" + } + ], + [ + { + "type": "text", + "text": "21" + } + ], + [ + { + "type": "text", + "text": "6,160" + } + ], + [ + { + "type": "text", + "text": "105" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Unknown" + } + ], + [ + { + "type": "text", + "text": "7" + } + ], + [ + { + "type": "text", + "text": "854" + } + ], + [ + { + "type": "text", + "text": "14" + } + ] + ] + ] + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "14 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?since=1789395200000&until=1790000000000" + } + ], + "entities": {}, + "privacy": { + "level": "titles", + "has_image": false + }, + "report": { + "window": { + "since": 1789395200000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/goldens/notifications/webhook/digest.json b/packages/core/test/goldens/notifications/webhook/digest.json new file mode 100644 index 0000000..736623a --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/digest.json @@ -0,0 +1,388 @@ +{ + "kind": "webhook", + "variant": "digest", + "mode": null, + "requests": [ + { + "method": "POST", + "path": "https://hooks.example.net/bh", + "encoding": "json", + "body": { + "schema": 1, + "event": "notification", + "op": "send", + "delivered_at": 1790000000000, + "channel": null, + "links": { + "overview": "https://bh.example.net/overview?since=1789913600000&until=1790000000000" + }, + "local_links": false, + "message": { + "schema": 1, + "id": "n-sample000001", + "revision": 1, + "thread": "digest:sample:1790000000000", + "kind": "digest.daily", + "category": "reports", + "severity": "info", + "state": "final", + "alert": true, + "at": { + "created": 1790000000000, + "updated": 1790000000000 + }, + "title": "Daily digest · Mon 21 Sep", + "summary": "12 sessions (2 live) · 3,412 tool calls · 68 errors (2%)", + "blocks": [ + { + "type": "fields", + "items": [ + { + "label": "Sessions", + "value": [ + { + "type": "text", + "text": "12 started · 2 live now" + } + ] + }, + { + "label": "Tool calls", + "value": [ + { + "type": "text", + "text": "3,412 · 68 errors (2%)" + }, + { + "type": "text", + "text": " · was 1.2%" + } + ] + }, + { + "label": "Attention", + "value": [ + { + "type": "text", + "text": "4 requests · 3 answered (median 1m 36s) · 1 timed out" + } + ] + }, + { + "label": "Vault fills", + "value": [ + { + "type": "text", + "text": "9 · 8 ok · 1 origin mismatch" + } + ] + }, + { + "label": "Blocked requests", + "value": [ + { + "type": "text", + "text": "27" + }, + { + "type": "text", + "text": " · top " + }, + { + "type": "code", + "text": "*.doubleclick.net" + }, + { + "type": "text", + "text": " (19)" + } + ] + }, + { + "label": "Slowest tool (p95)", + "value": [ + { + "type": "code", + "text": "navigate" + }, + { + "type": "text", + "text": " 4.2 s (was 2.9 s)" + } + ] + }, + { + "label": "Open problems", + "value": [ + { + "type": "code", + "text": "RETENTION_FAILED" + }, + { + "type": "text", + "text": " since 21 Sep 10:13" + } + ] + } + ] + }, + { + "type": "chart", + "label": "Tool calls per hour", + "values": [ + 2, + 1, + 0, + 0, + 0, + 1, + 4, + 18, + 96, + 212, + 305, + 280, + 190, + 240, + 330, + 412, + 380, + 260, + 150, + 120, + 88, + 60, + 40, + 23 + ], + "start": 1789913600000, + "step_ms": 3600000, + "unit": "calls" + }, + { + "type": "heading", + "text": "Top errors" + }, + { + "type": "table", + "columns": [ + "Error", + "Tool", + "Count", + "Sessions" + ], + "rows": [ + [ + [ + { + "type": "code", + "text": "NAVIGATION_TIMEOUT" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "31" + } + ], + [ + { + "type": "text", + "text": "4" + } + ] + ], + [ + [ + { + "type": "code", + "text": "ELEMENT_NOT_FOUND" + } + ], + [ + { + "type": "code", + "text": "click" + } + ], + [ + { + "type": "text", + "text": "22" + } + ], + [ + { + "type": "text", + "text": "6" + } + ] + ], + [ + [ + { + "type": "code", + "text": "CAPTCHA_DETECTED" + } + ], + [ + { + "type": "code", + "text": "navigate" + } + ], + [ + { + "type": "text", + "text": "15" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "heading", + "text": "By harness" + }, + { + "type": "table", + "columns": [ + "Harness", + "Sessions", + "Tool calls", + "Errors" + ], + "rows": [ + [ + [ + { + "type": "text", + "text": "Claude Code" + } + ], + [ + { + "type": "text", + "text": "8" + } + ], + [ + { + "type": "text", + "text": "2,410" + } + ], + [ + { + "type": "text", + "text": "51" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Cursor" + } + ], + [ + { + "type": "text", + "text": "3" + } + ], + [ + { + "type": "text", + "text": "880" + } + ], + [ + { + "type": "text", + "text": "15" + } + ] + ], + [ + [ + { + "type": "text", + "text": "Unknown" + } + ], + [ + { + "type": "text", + "text": "1" + } + ], + [ + { + "type": "text", + "text": "122" + } + ], + [ + { + "type": "text", + "text": "2" + } + ] + ] + ] + }, + { + "type": "footer", + "content": [ + { + "type": "text", + "text": "20 Sep 16:13 → 21 Sep 16:13 · Europe/Berlin" + } + ] + } + ], + "actions": [ + { + "kind": "open", + "id": "overview", + "label": "Open Overview", + "style": "primary", + "path": "/overview?since=1789913600000&until=1790000000000" + } + ], + "entities": {}, + "privacy": { + "level": "titles", + "has_image": false + }, + "report": { + "window": { + "since": 1789913600000, + "until": 1790000000000 + }, + "time_zone": "Europe/Berlin", + "late": false, + "skipped": 0, + "manual": false + } + } + }, + "headers": {}, + "file": null + } + ] +} diff --git a/packages/core/test/helpers/fake-channel.ts b/packages/core/test/helpers/fake-channel.ts index 658b6ce..932e81e 100644 --- a/packages/core/test/helpers/fake-channel.ts +++ b/packages/core/test/helpers/fake-channel.ts @@ -14,6 +14,7 @@ export function capabilities(overrides: Partial = {}): Chan return { richBlocks: true, tables: false, + charts: false, images: true, actButtons: false, openLinks: true, diff --git a/packages/core/test/helpers/http-fakes.ts b/packages/core/test/helpers/http-fakes.ts index a8a81da..80b87df 100644 --- a/packages/core/test/helpers/http-fakes.ts +++ b/packages/core/test/helpers/http-fakes.ts @@ -20,9 +20,13 @@ import type { Desktop, RevealResult } from '../../src/ports/desktop.ts'; import type { ActivityQuery, AnalyticsQueries, + ReportWindow, TimelineItem, TimelineQuery, + ToolLatencyRow, ToolMetricsQuery, + TopErrorRow, + WindowCounts, } from '../../src/ports/persistence/analytics.ts'; import type { Page } from '../../src/ports/persistence/queries.ts'; import type { IdempotencyRecord } from '../../src/ports/persistence/records.ts'; @@ -111,6 +115,65 @@ export class FakeAnalytics implements AnalyticsQueries { async topDomains() { return this.repos.pages.topDomains({}); } + + async windowCounts(window: ReportWindow): Promise { + const inside = (ts: number) => ts >= window.since && ts < window.until; + const calls = [...this.repos.toolCalls.rows.values()].filter((r) => inside(r.ts)); + return { + sessionsStarted: [...this.repos.sessions.rows.values()].filter((r) => inside(r.createdAt)) + .length, + toolCalls: calls.length, + errors: calls.filter((r) => r.errorCode !== null).length, + blocked: [...this.repos.blocklistAudit.rows.values()].filter((r) => inside(r.ts)).length, + attention: 0, + vaultAccess: [...this.repos.vaultAudit.rows.values()].filter((r) => inside(r.ts)).length, + }; + } + + async toolLatency(window: ReportWindow): Promise { + const byTool = new Map(); + for (const r of this.repos.toolCalls.rows.values()) { + if (r.ts < window.since || r.ts >= window.until) continue; + const entry = byTool.get(r.tool) ?? { durations: [], errors: 0 }; + entry.durations.push(r.durationMs); + if (r.errorCode !== null) entry.errors += 1; + byTool.set(r.tool, entry); + } + return [...byTool.entries()].map(([tool, e]) => { + const sorted = [...e.durations].sort((a, b) => a - b); + const index = Math.max(0, Math.ceil(0.95 * sorted.length) - 1); + return { tool, calls: sorted.length, errors: e.errors, p95Ms: sorted[index] ?? 0 }; + }); + } + + async topErrors(window: ReportWindow, limit: number): Promise { + const groups = new Map< + string, + { errorCode: string; tool: string; count: number; sessions: Set } + >(); + for (const r of this.repos.toolCalls.rows.values()) { + if (r.errorCode === null || r.ts < window.since || r.ts >= window.until) continue; + const key = `${r.errorCode}::${r.tool}`; + const g = groups.get(key) ?? { + errorCode: r.errorCode, + tool: r.tool, + count: 0, + sessions: new Set(), + }; + g.count += 1; + if (r.sessionId !== null) g.sessions.add(r.sessionId); + groups.set(key, g); + } + return [...groups.values()] + .sort((a, b) => b.count - a.count || a.errorCode.localeCompare(b.errorCode)) + .slice(0, limit) + .map((g) => ({ + errorCode: g.errorCode, + tool: g.tool, + count: g.count, + sessions: g.sessions.size, + })); + } } /** A configured blocklist with one pattern. */ diff --git a/packages/core/test/helpers/http-kit.ts b/packages/core/test/helpers/http-kit.ts index d957d54..466cfa7 100644 --- a/packages/core/test/helpers/http-kit.ts +++ b/packages/core/test/helpers/http-kit.ts @@ -13,6 +13,10 @@ import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts import { ChannelService } from '../../src/app/notifications/channel-service.ts'; import { createLocalLinkBuilder } from '../../src/app/notifications/links.ts'; import { PublicUrlChecker } from '../../src/app/notifications/public-url.ts'; +import { ReportScheduler } from '../../src/app/notifications/report-scheduler.ts'; +import { ReportService } from '../../src/app/notifications/report-service.ts'; +import { ReportSettingsStore } from '../../src/app/notifications/report-settings.ts'; +import { sampleAnomalyFacts, sampleDigestFacts } from '../../src/app/notifications/samples.ts'; import { sessionDirLayout } from '../../src/app/sessions/profile-dir.ts'; import { SessionService } from '../../src/app/sessions/session-service.ts'; import { FakeSessionDirFs, testConfig } from '../../src/app/sessions/test-support.ts'; @@ -154,7 +158,26 @@ export async function createHttpKit(options: HttpKitOptions = {}) { }), }); await channelRegistry.load(); + const reportSettings = new ReportSettingsStore(repos.notificationCursors); + const reports = new ReportScheduler({ + settings: reportSettings, + registry: channelRegistry, + facts: { + digest: async (window, rule) => sampleDigestFacts(window.until, rule), + anomaly: async (now) => sampleAnomalyFacts(now), + }, + uow: new InMemoryUnitOfWork(repos), + repos, + outbox: { plan: () => [], kick: () => undefined }, + clock, + ids: auth.ids, + logger, + hostZone: () => 'UTC', + }); const channels = new ChannelService({ + reports, + cursors: repos.notificationCursors, + hostZone: () => 'UTC', repos, uow: new InMemoryUnitOfWork(repos), registry: channelRegistry, @@ -200,6 +223,12 @@ export async function createHttpKit(options: HttpKitOptions = {}) { auth: authService, blocklist, notifications: fakeNotifications(repos.notifications), + reports: new ReportService({ + repo: repos.notifications, + settings: reportSettings, + scheduler: reports, + clock, + }), preferences: fakePreferences(), logs, logLevel: { set: (spec) => spec }, diff --git a/packages/core/test/helpers/http-route-cases.ts b/packages/core/test/helpers/http-route-cases.ts index 772a09f..3098519 100644 --- a/packages/core/test/helpers/http-route-cases.ts +++ b/packages/core/test/helpers/http-route-cases.ts @@ -1,5 +1,7 @@ /** @module test/helpers/http-route-cases — one success request and one validation-failure request per `/api/v1` operation (spec 09 §3.2). */ +import { buildDigest, reportMessage } from '../../src/app/notifications/reports.ts'; +import { sampleDigestFacts } from '../../src/app/notifications/samples.ts'; import { ARCHIVED_ID, CLOSED_ID, EVENT_OK } from './http-fixtures.ts'; import type { HttpKit } from './http-kit.ts'; import { PASSWORD } from './http-kit.ts'; @@ -47,6 +49,54 @@ async function webhookChannel(ctx: CaseContext): Promise { ctx.state['channel'] = body.channel?.channel_id ?? 'nc-placeholder01'; } +/** An in-app digest copy (D-45) for the report routes. */ +async function inAppReport(ctx: CaseContext): Promise { + const rule = { every: 'day', at: '09:00' } as const; + const content = buildDigest(sampleDigestFacts(Date.UTC(2026, 8, 29, 9), rule), rule, { + zone: 'UTC', + level: 'full', + scheduledAt: Date.UTC(2026, 8, 29, 9), + late: false, + skipped: 0, + manual: false, + quiet: false, + }); + const id = 'n-report000001'; + const thread = 'report:digest:day@09:00@UTC:1:2'; + const message = reportMessage(content, { + id, + thread, + revision: 1, + createdAt: 1, + updatedAt: 1, + level: 'full', + }); + await ctx.kit.repos.notifications.insert({ + notificationId: id, + principalId: null, + type: 'lifecycle', + title: message.title, + body: message.summary, + sessionId: null, + target: `/notifications/reports/${id}`, + sourceEventId: null, + createdAt: 1, + updatedAt: 1, + count: 1, + groupKey: null, + readAt: 1, + dismissedAt: null, + kind: message.kind, + category: 'reports', + severity: message.severity, + state: message.state, + revision: 1, + thread, + messageJson: JSON.stringify(message), + }); + ctx.state['report'] = id; +} + async function testedChannel(ctx: CaseContext): Promise { await webhookChannel(ctx); const response = await ctx.kit.request( @@ -630,6 +680,37 @@ export const ROUTE_CASES: readonly RouteCase[] = [ success: { path: api('/notifications'), status: 200 }, invalid: { path: api('/notifications?read=maybe') }, }, + { + operationId: 'listReports', + setup: inAppReport, + success: { path: api('/notifications/reports?kind=digest.daily&channel=in-app'), status: 200 }, + invalid: { path: api('/notifications/reports?kind=digest.hourly') }, + }, + { + operationId: 'getReport', + setup: inAppReport, + success: { path: api('/notifications/reports/n-report000001'), status: 200 }, + invalid: { path: api('/notifications/reports/bad') }, + }, + { + operationId: 'getReportSettings', + success: { path: api('/notifications/report-settings'), status: 200 }, + invalid: null, + }, + { + operationId: 'putReportSettings', + success: { + method: 'PUT', + path: api('/notifications/report-settings'), + body: { settings: { digest: { every: 'week', at: '17:00', day: 'fri' }, anomaly: {} } }, + status: 200, + }, + invalid: { + method: 'PUT', + path: api('/notifications/report-settings'), + body: { settings: { digest: { every: 'hourly' } } }, + }, + }, { operationId: 'markNotificationRead', success: { method: 'POST', path: api('/notifications/n-000000000001/read'), status: 200 }, @@ -865,6 +946,17 @@ export const ROUTE_CASES: readonly RouteCase[] = [ }, invalid: { method: 'POST', path: api('/channels/bad/test') }, }, + { + operationId: 'sendChannelDigest', + setup: webhookChannel, + success: { + method: 'POST', + path: (ctx) => api(`/channels/${ctx.state['channel'] ?? ''}/digest`), + body: { send: false }, + status: 200, + }, + invalid: { method: 'POST', path: api('/channels/bad/digest'), body: { send: 'yes' } }, + }, { operationId: 'getPublicUrlStatus', success: { path: api('/system/public-url?refresh=true'), status: 200 }, diff --git a/packages/core/test/helpers/in-memory-repos-facts.ts b/packages/core/test/helpers/in-memory-repos-facts.ts index ab07242..81f1b34 100644 --- a/packages/core/test/helpers/in-memory-repos-facts.ts +++ b/packages/core/test/helpers/in-memory-repos-facts.ts @@ -4,6 +4,7 @@ import type { BlockedRequestListRow, BlocklistAuditRepository, } from '../../src/ports/persistence/blocklist-audit.ts'; +import type { VaultAccessResult } from '../../src/ports/persistence/enums.ts'; import type { DomainCount, PageFacets, @@ -42,6 +43,8 @@ import type { VaultAuditRepository, } from '../../src/ports/persistence/vault-audit.ts'; +import { countResults } from './in-memory-vault-repos.ts'; + /** Pages a whole list (no cursor support: tests read everything). */ export function pageOf(items: readonly T[], query: PageQuery): Page { const limit = query.limit ?? 50; @@ -223,6 +226,13 @@ export class InMemoryVaultAuditRepository implements VaultAuditRepository { query, ); } + + async countByResult(window: { + readonly since: number; + readonly until: number; + }): Promise { + return countResults(this.rows.values(), window); + } } /** `blocked_requests` in memory. */ diff --git a/packages/core/test/helpers/in-memory-repos-operations.ts b/packages/core/test/helpers/in-memory-repos-operations.ts index 96d189c..260e35c 100644 --- a/packages/core/test/helpers/in-memory-repos-operations.ts +++ b/packages/core/test/helpers/in-memory-repos-operations.ts @@ -1,10 +1,14 @@ /** @module test/helpers/in-memory-repos-operations — Map-backed system_events and notifications repositories for app tests. */ -import type { NotificationRepository } from '../../src/ports/persistence/notifications.ts'; +import type { + NotificationRepository, + ReportChannelRow, +} from '../../src/ports/persistence/notifications.ts'; import type { SystemEventRepository } from '../../src/ports/persistence/operations.ts'; import type { NotificationListQuery, Page, + ReportListQuery, SystemEventListQuery, } from '../../src/ports/persistence/queries.ts'; import type { @@ -75,8 +79,22 @@ export class InMemorySystemEventRepository implements SystemEventRepository { } /** `notifications` in memory. */ +/** What `reportChannels` joins: the delivery rows and the channels (set by the bundle). */ +export interface ReportJoin { + deliveries(): readonly { + readonly seq: number; + readonly channelId: string; + readonly notificationId: string; + readonly status: string; + readonly reason: string | null; + }[]; + channel(channelId: string): { readonly name: string; readonly kind: string } | undefined; +} + export class InMemoryNotificationRepository implements NotificationRepository { readonly rows = new Map(); + /** Set by `InMemoryRepositories`; without it no report reached a channel. */ + join: ReportJoin | undefined; async insert(record: NotificationRecord): Promise { if (!this.rows.has(record.notificationId)) this.rows.set(record.notificationId, record); @@ -160,7 +178,12 @@ export class InMemoryNotificationRepository implements NotificationRepository { query, ) .filter((r) => query.principalId === undefined || r.principalId === query.principalId) - .filter((r) => query.types === undefined || query.types.includes(r.type)) + .filter((r) => { + const types = query.types ?? []; + const categories = query.categories ?? []; + if (types.length === 0 && categories.length === 0) return true; + return types.includes(r.type) || categories.includes(r.category); + }) .filter( (r) => (query.read ?? 'all') === 'all' || (query.read === 'read') === (r.readAt !== null), ) @@ -169,6 +192,59 @@ export class InMemoryNotificationRepository implements NotificationRepository { return pageOf(rows, query); } + async listReports(query: ReportListQuery): Promise> { + const copies = (id: string) => + [...this.rows.values()].filter((c) => c.category === 'reports' && c.sourceEventId === id); + const reached = (id: string) => { + const ids = new Set(copies(id).map((c) => c.notificationId)); + return (this.join?.deliveries() ?? []).filter((d) => ids.has(d.notificationId)); + }; + const rows = inWindow( + [...this.rows.values()].map((r) => ({ ...r, ts: r.createdAt })), + query, + ) + .filter((r) => r.category === 'reports' && (r.thread ?? '').startsWith('report:')) + .filter((r) => query.kinds === undefined || query.kinds.includes(r.kind)) + .filter( + (r) => + query.channelId === undefined || + reached(r.notificationId).some((d) => d.channelId === query.channelId), + ) + .filter((r) => query.inAppOnly !== true || copies(r.notificationId).length === 0) + .sort((a, b) => b.ts - a.ts || b.notificationId.localeCompare(a.notificationId)) + .map(({ ts: _ts, ...rest }) => rest); + return pageOf(rows, query); + } + + async reportChannels( + reportIds: readonly string[], + ): Promise> { + const out = new Map(); + const deliveries = [...(this.join?.deliveries() ?? [])].sort((a, b) => a.seq - b.seq); + for (const id of reportIds) { + const ids = new Set( + [...this.rows.values()] + .filter((c) => c.category === 'reports' && c.sourceEventId === id) + .map((c) => c.notificationId), + ); + const byChannel = new Map(); + for (const d of deliveries) { + if (!ids.has(d.notificationId)) continue; + const channel = this.join?.channel(d.channelId); + if (channel === undefined) continue; + byChannel.set(d.channelId, { + channelId: d.channelId, + name: channel.name, + kind: channel.kind, + status: d.status, + reason: d.reason, + }); + } + if (byChannel.size > 0) out.set(id, [...byChannel.values()]); + } + return out; + } + async unreadCount(principalId: string | null): Promise { return [...this.rows.values()].filter((r) => r.principalId === principalId && r.readAt === null) .length; diff --git a/packages/core/test/helpers/in-memory-repos.ts b/packages/core/test/helpers/in-memory-repos.ts index d787b7f..15251a9 100644 --- a/packages/core/test/helpers/in-memory-repos.ts +++ b/packages/core/test/helpers/in-memory-repos.ts @@ -334,6 +334,10 @@ export class InMemoryRepositories implements Repositories { this.screenshots = new InMemoryScreenshotRepository(this.toolCalls); this.vaultAudit = new InMemoryVaultAuditRepository(slugOf); this.blocklistAudit = new InMemoryBlocklistAuditRepository(slugOf); + this.notifications.join = { + deliveries: () => this.notificationDeliveries.rows, + channel: (id) => this.notificationChannels.rows.get(id), + }; } private countsFor(sessionId: string): SessionListRow['counts'] { diff --git a/packages/core/test/helpers/in-memory-vault-repos.ts b/packages/core/test/helpers/in-memory-vault-repos.ts index 32297d2..80d034f 100644 --- a/packages/core/test/helpers/in-memory-vault-repos.ts +++ b/packages/core/test/helpers/in-memory-vault-repos.ts @@ -1,13 +1,15 @@ /** @module test/helpers/in-memory-vault-repos — Map-backed repositories for the vault and operator-request ports (spec 09 §4). */ +import { windowStatsOf } from '../../src/infra/persistence/repositories/operator-requests.ts'; import { AppError } from '../../src/kernel/errors/app-error.ts'; -import type { OperatorRequestKind } from '../../src/ports/persistence/enums.ts'; +import type { OperatorRequestKind, VaultAccessResult } from '../../src/ports/persistence/enums.ts'; import type { OperatorActionRepository } from '../../src/ports/persistence/operations.ts'; import type { OperatorRequestFacets, OperatorRequestListRow, OperatorRequestRepository, OperatorRequestResolution, + OperatorRequestWindowStats, } from '../../src/ports/persistence/operator-requests.ts'; import type { AuditListQuery, @@ -167,6 +169,13 @@ export class InMemoryVaultAuditRepository implements VaultAuditRepository { return Promise.resolve(page(items)); } + countByResult(window: { + readonly since: number; + readonly until: number; + }): Promise { + return Promise.resolve(countResults(this.rows, window)); + } + /** Rows for one session. */ forSession(sessionId: string): VaultAccessRecord[] { return this.rows.filter((r) => r.sessionId === sessionId); @@ -268,6 +277,38 @@ export class InMemoryOperatorRequestRepository implements OperatorRequestReposit countOpen(kind?: OperatorRequestKind): Promise { return this.open(kind).then((rows) => rows.length); } + + windowStats( + kind: OperatorRequestKind, + window: { readonly since: number; readonly until: number }, + ): Promise { + const rows = [...this.rows.values()] + .filter((r) => r.kind === kind && r.createdAt >= window.since && r.createdAt < window.until) + .map((r) => ({ + status: r.status, + waited: r.resolvedAt === null ? null : r.resolvedAt - r.createdAt, + })); + return Promise.resolve(windowStatsOf(rows)); + } +} + +/** + * Vault accesses by result over `[since, until)`, most first (shared by the in-memory doubles). + * + * @returns The counts. + */ +export function countResults( + rows: Iterable, + window: { readonly since: number; readonly until: number }, +): { result: VaultAccessResult; count: number }[] { + const counts = new Map(); + for (const r of rows) { + if (r.ts >= window.since && r.ts < window.until) + counts.set(r.result, (counts.get(r.result) ?? 0) + 1); + } + return [...counts.entries()] + .map(([result, count]) => ({ result, count })) + .sort((a, b) => b.count - a.count || a.result.localeCompare(b.result)); } function toRow(r: OperatorRequestRecord): OperatorRequestListRow { diff --git a/packages/core/test/integration/notifications/ntfy-live.test.ts b/packages/core/test/integration/notifications/ntfy-live.test.ts index e489c27..8f00e61 100644 --- a/packages/core/test/integration/notifications/ntfy-live.test.ts +++ b/packages/core/test/integration/notifications/ntfy-live.test.ts @@ -73,6 +73,32 @@ describe.skipIf(SERVER === undefined)('ntfy adapter against a real server', () = expect(events.at(-1)).toMatchObject({ event: 'message_delete', sequence_id: 'n-sample000001' }); }); + it('carries a digest and an anomaly alert, then replaces the alert with "Back to normal" (D-43, D-44)', async () => { + const server = (SERVER ?? '').replace(/\/+$/, ''); + const topic = `bh-ci-${randomBytes(6).toString('hex')}`; + const channel = createNtfyChannel(platformRecord('ntfy', { target: { server, topic } }), { + token: null, + topic: null, + images: SAMPLE_IMAGES, + }); + await channel.send(delivery('digest', NTFY_CAPABILITIES, { late: { skipped: 1 } })); + let events = (await poll(server, topic)).filter((e) => e.event === 'message'); + expect(events[0]?.title).toMatch(/^Daily digest · /); + expect(events[0]?.message).toContain('Sent late: BrowserHive was not running'); + expect(events[0]?.message).toContain('Tool calls per hour '); + expect(events[0]?.priority).toBe(3); + expect(events[0]?.tags).toEqual(['bar_chart']); + const alert = await channel.send(delivery('anomaly', NTFY_CAPABILITIES)); + await channel.edit?.(alert.ref, delivery('anomaly', NTFY_CAPABILITIES, { resolved: true })); + events = (await poll(server, topic)).filter((e) => e.event === 'message'); + expect(events.map((e) => e.title)).toEqual([ + events[0]?.title, + 'Something looks off: 2 checks', + 'Back to normal', + ]); + expect(events[2]?.priority).toBe(2); + }); + it('refuses a fourth action like ntfy does, by never sending more than three', async () => { const server = (SERVER ?? '').replace(/\/+$/, ''); const topic = `bh-ci-${randomBytes(6).toString('hex')}`; diff --git a/packages/core/test/notifications/channel-service.test.ts b/packages/core/test/notifications/channel-service.test.ts index 323a513..dce2f50 100644 --- a/packages/core/test/notifications/channel-service.test.ts +++ b/packages/core/test/notifications/channel-service.test.ts @@ -4,6 +4,8 @@ import type { DomainEvents } from '../../src/app/events/catalog.ts'; import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; import { ChannelService, targetHint } from '../../src/app/notifications/channel-service.ts'; import { createPublicLinkBuilder } from '../../src/app/notifications/links.ts'; +import { ReportScheduler } from '../../src/app/notifications/report-scheduler.ts'; +import { sampleAnomalyFacts, sampleDigestFacts } from '../../src/app/notifications/samples.ts'; import { CHANNEL_RENDERERS, channelFactories } from '../../src/infra/notifications/index.ts'; import { AppError } from '../../src/kernel/errors/app-error.ts'; import type { TelegramSetup } from '../../src/ports/notification-channel.ts'; @@ -71,7 +73,24 @@ async function kit(options: { readonly startup?: boolean } = {}): Promise { }; }, }; + const reports = new ReportScheduler({ + registry, + facts: { + digest: async (window, rule) => sampleDigestFacts(window.until, rule), + anomaly: async (now) => sampleAnomalyFacts(now), + }, + uow: new InMemoryUnitOfWork(repos), + repos, + outbox: { plan: () => [], kick: () => undefined }, + clock, + ids, + logger, + hostZone: () => 'Europe/Berlin', + }); const service = new ChannelService({ + reports, + cursors: repos.notificationCursors, + hostZone: () => 'Europe/Berlin', repos, uow: new InMemoryUnitOfWork(repos), registry, @@ -303,6 +322,41 @@ describe('ChannelService test send, preview and the delivery log', () => { expect(result.delivery).toMatchObject({ status: 'dead', reason: 'auth' }); }); + it('previews a crash without an image on a masking channel, like a real crash', async () => { + const { service } = await kit(); + const rules = { content: 'full' as const, images: { problems: true } }; + const shown = (mask: boolean) => + service + .preview({ kind: 'telegram', rules: { ...rules, mask_images: mask }, sample: 'crash' }) + .message.blocks.some((b) => b.type === 'image'); + expect(shown(false)).toBe(true); + expect(shown(true)).toBe(false); + }); + + it('republishes every row of a notification, so a superseded row does not look stale', async () => { + const { service, repos, bus } = await kit(); + const view = await service.create({ + name: 'hook', + kind: 'webhook', + target: { url: fakes.webhookUrl }, + secret_refs: {}, + rules: {}, + }); + await service.test(view.channel_id); + const [row] = repos.notificationDeliveries.rows; + if (row === undefined) throw new Error('no row'); + await repos.notificationDeliveries.enqueue([ + { ...row, revision: 2, op: 'edit', status: 'pending', reason: null, nextAttemptAt: 1 }, + ]); + bus.published.length = 0; + service.onDeliveryChange(row.notificationId, view.channel_id); + await new Promise((r) => setTimeout(r, 20)); + const seqs = bus.published + .filter((e) => e.name === 'delivery.updated') + .map((e) => (e.payload as { delivery: { revision: number } }).delivery.revision); + expect(seqs).toEqual([1, 2]); + }); + it('previews a draft and a saved channel with variable names in place of secrets', async () => { const { service } = await kit(); const draft = service.preview({ kind: 'discord', sample: 'attention' }); @@ -407,3 +461,103 @@ describe('ChannelService env check and Telegram connect', () => { expect(code(() => service.telegramConnectStatus('unknownid1'))).toBe('NOT_FOUND'); }); }); + +describe('ChannelService reports (D-43, D-44)', () => { + async function hook(k: Kit, rules: Parameters[0]['rules']) { + return k.service.create({ + name: 'hook', + kind: 'webhook', + target: { url: fakes.webhookUrl }, + secret_refs: {}, + rules, + }); + } + + it('shows the schedule, the zone and the host zone', async () => { + const k = await kit(); + const view = await hook(k, { digest: { every: 'week', at: '08:30', day: 'fri' } }); + expect(view.reports).toMatchObject({ + time_zone: 'Europe/Berlin', + host_zone: true, + digest: { every: 'week', at: '08:30', day: 'fri', last_until: null }, + anomaly: null, + }); + expect(k.service.hostTimeZone()).toBe('Europe/Berlin'); + const plain = await k.service.update(view.channel_id, { rules: { time_zone: 'Asia/Tokyo' } }); + expect(plain.reports).toEqual({ + time_zone: 'Asia/Tokyo', + host_zone: false, + digest: null, + anomaly: null, + }); + }); + + it('refuses an unknown time zone and a weekday on a daily digest', async () => { + const k = await kit(); + expect(await codeOf(hook(k, { time_zone: 'Mars/Olympus' }))).toBe('VALIDATION_FAILED'); + expect(await codeOf(hook(k, { digest: { every: 'day', at: '09:00', day: 'mon' } }))).toBe( + 'VALIDATION_FAILED', + ); + }); + + it('previews the digest of the period ending now, then sends it as a manual report', async () => { + const k = await kit(); + const view = await hook(k, { digest: { every: 'day', at: '09:00' } }); + const preview = await k.service.digest(view.channel_id, false); + expect(preview).toMatchObject({ sent: false, ok: true, empty: false, delivery: null }); + expect(preview.window.until - preview.window.since).toBe(24 * 3_600_000); + expect(preview.preview.message.kind).toBe('digest.daily'); + expect(fakes.of('webhook')).toHaveLength(0); + const sent = await k.service.digest(view.channel_id, true); + expect(sent.ok).toBe(true); + expect(sent.delivery).toMatchObject({ + status: 'sent', + reason: 'manual', + notification_kind: 'digest.daily', + report: { manual: true, late: false, time_zone: 'Europe/Berlin' }, + }); + const [post] = fakes.of('webhook'); + const body = post?.json as { + message: { report: { manual: boolean }; blocks: { type: string }[] }; + }; + expect(body.message.report.manual).toBe(true); + // The generic webhook receives the chart as data. + expect(body.message.blocks.some((b) => b.type === 'chart')).toBe(true); + // The channel row is kept out of the inbox; the inbox gets the on-demand copy (D-45). + const rows = [...k.repos.notifications.rows.values()].filter((r) => r.kind === 'digest.daily'); + const channelRow = rows.find((r) => !(r.thread ?? '').startsWith('report:')); + const copy = rows.find((r) => (r.thread ?? '').startsWith('report:')); + expect(channelRow?.dismissedAt).not.toBeNull(); + expect(copy).toMatchObject({ dismissedAt: null, readAt: expect.any(Number) }); + expect(channelRow?.sourceEventId).toBe(copy?.notificationId ?? 'missing'); + }); + + it('renders the report samples at the channel level and zone', async () => { + const k = await kit(); + const digest = k.service.preview({ + kind: 'webhook', + rules: { content: 'counts' }, + sample: 'digest', + }); + expect(digest.message.privacy.level).toBe('counts'); + expect(JSON.stringify(digest.message)).not.toContain('NAVIGATION_TIMEOUT'); + expect(digest.notes.some((n) => n.includes('made-up figures'))).toBe(true); + const anomaly = k.service.preview({ kind: 'telegram', sample: 'anomaly' }); + expect(anomaly.message.kind).toBe('report.anomaly'); + expect(anomaly.capabilities.charts).toBe(false); + }); + + it('removes the channel cursors with the channel', async () => { + const k = await kit(); + const view = await hook(k, { anomaly: {} }); + for (const key of [ + `ntfy:${view.channel_id}`, + `digest:${view.channel_id}`, + `anomaly:${view.channel_id}`, + ]) { + await k.repos.notificationCursors.set(key, '{}', 1); + } + await k.service.remove(view.channel_id); + expect(k.repos.notificationCursors.rows.size).toBe(0); + }); +}); diff --git a/packages/core/test/notifications/helpers.ts b/packages/core/test/notifications/helpers.ts index 53c3e6c..5bce5f2 100644 --- a/packages/core/test/notifications/helpers.ts +++ b/packages/core/test/notifications/helpers.ts @@ -1,7 +1,7 @@ /** @module test/notifications/helpers — deliveries built from the preview samples through the real pipeline (content level, degrade), link builders and an in-memory screenshot reader for the adapter suites. */ import type { NotificationContentLevel } from '@browserhive/contracts/enums'; -import type { PreviewSample } from '@browserhive/contracts/notifications'; +import type { DigestRule, PreviewSample } from '@browserhive/contracts/notifications'; import { restrictContent } from '../../src/app/notifications/content-level.ts'; import { degrade } from '../../src/app/notifications/degrade.ts'; import { SAMPLE_IMAGE_REF, sampleMessage } from '../../src/app/notifications/samples.ts'; @@ -43,6 +43,10 @@ export interface DeliveryOptions { readonly level?: NotificationContentLevel; readonly links?: LinkBuilder; readonly replyTo?: PlatformMessageRef | null; + /** Report samples: the digest schedule, a late send, the anomaly's resolved revision. */ + readonly digest?: DigestRule; + readonly late?: { readonly skipped: number }; + readonly resolved?: boolean; } /** @@ -55,7 +59,13 @@ export function delivery( capabilities: ChannelCapabilities, options: DeliveryOptions = {}, ): ChannelDelivery { - const message = sampleMessage(sample, { image: options.image ?? 'none' }); + const message = sampleMessage(sample, { + image: options.image ?? 'none', + ...(options.level !== undefined && { level: options.level }), + ...(options.digest !== undefined && { digest: options.digest }), + ...(options.late !== undefined && { late: options.late }), + ...(options.resolved === true && { resolved: true }), + }); return { message: degrade(restrictContent(message, options.level ?? 'full'), capabilities), links: options.links ?? PUBLIC_LINKS, diff --git a/packages/core/test/notifications/render.golden.test.ts b/packages/core/test/notifications/render.golden.test.ts index e75eb86..6b68617 100644 --- a/packages/core/test/notifications/render.golden.test.ts +++ b/packages/core/test/notifications/render.golden.test.ts @@ -4,7 +4,11 @@ import { describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { NotificationContentLevel } from '@browserhive/contracts/enums'; -import { PREVIEW_SAMPLES, type PreviewSample } from '@browserhive/contracts/notifications'; +import { + type DigestRule, + PREVIEW_SAMPLES, + type PreviewSample, +} from '@browserhive/contracts/notifications'; import { CHANNEL_RENDERERS, telegramClassicRenderer } from '../../src/infra/notifications/index.ts'; import type { ChannelRenderer, @@ -68,6 +72,10 @@ interface Variant { readonly edit?: boolean; /** Act buttons on (D-41). */ readonly act?: boolean; + /** Report samples (D-43, D-44). */ + readonly digest?: DigestRule; + readonly late?: { readonly skipped: number }; + readonly resolved?: boolean; } function variants(kind: string): Variant[] { @@ -85,6 +93,14 @@ function variants(kind: string): Variant[] { edit: true, }, ); + out.push( + { name: 'digest-weekly', sample: 'digest', digest: { every: 'week', at: '09:00', day: 'mon' } }, + { name: 'digest-late', sample: 'digest', late: { skipped: 2 } }, + { name: 'digest-counts', sample: 'digest', level: 'counts' }, + { name: 'digest-full', sample: 'digest', level: 'full' }, + { name: 'anomaly-counts', sample: 'anomaly', level: 'counts' }, + { name: 'anomaly-resolved-edit', sample: 'anomaly', resolved: true, edit: true }, + ); out.push( { name: 'attention-act', sample: 'attention', act: true }, { name: 'vault-confirm-act', sample: 'vault-confirm', act: true }, @@ -131,6 +147,9 @@ describe('renderer goldens', () => { ...(v.image !== undefined && { image: v.image }), links: v.links ?? PUBLIC_LINKS, ...(v.level !== undefined && { level: v.level }), + ...(v.digest !== undefined && { digest: v.digest }), + ...(v.late !== undefined && { late: v.late }), + ...(v.resolved === true && { resolved: true }), }); const context: RenderContext = { mode, diff --git a/packages/core/test/notifications/report-redaction.property.test.ts b/packages/core/test/notifications/report-redaction.property.test.ts new file mode 100644 index 0000000..78afc07 --- /dev/null +++ b/packages/core/test/notifications/report-redaction.property.test.ts @@ -0,0 +1,159 @@ +/** @module test/notifications/report-redaction.property.test — the redaction invariant over scheduled reports (spec 10 §9, D-43, D-44): a sentinel registered in the `SecretRegistry` and planted in every string a digest or an anomaly alert copies from the database (error codes, tool names, blocklist patterns and domains, harness slugs, degradation codes and messages, session slugs) never appears in the stored message, the delivery rows, or any renderer's request at any content level. Seeded, 120 cases. */ + +import { describe, expect, it } from 'bun:test'; +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; +import { degrade } from '../../src/app/notifications/degrade.ts'; +import { decodeMessage } from '../../src/app/notifications/message.ts'; +import { ReportScheduler } from '../../src/app/notifications/report-scheduler.ts'; +import type { AnomalyFacts, DigestFacts } from '../../src/app/notifications/reports.ts'; +import { planDeliveries } from '../../src/app/notifications/routing.ts'; +import { sampleAnomalyFacts, sampleDigestFacts } from '../../src/app/notifications/samples.ts'; +import { CHANNEL_RENDERERS } from '../../src/infra/notifications/index.ts'; +import { createRedactor, SecretRegistry } from '../../src/kernel/redact.ts'; +import { CollectingLogger } from '../helpers/collecting-logger.ts'; +import { capabilities, channelRecord, FakeChannel } from '../helpers/fake-channel.ts'; +import { FakeClock } from '../helpers/fake-clock.ts'; +import { FakeIdGenerator } from '../helpers/fake-id-generator.ts'; +import { InMemoryRepositories, InMemoryUnitOfWork } from '../helpers/in-memory-repos.ts'; +import { PUBLIC_LINKS } from './helpers.ts'; + +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +function sentinelOf(next: () => number): string { + let out = 'zq'; + const length = 12 + Math.floor(next() * 12); + for (let i = 0; i < length; i++) out += ALPHABET[Math.floor(next() * ALPHABET.length)]; + return out; +} + +function digestFacts(until: number, secret: string): DigestFacts { + const base = sampleDigestFacts(until, { every: 'day', at: '09:00' }); + return { + ...base, + blocked: { + count: 3, + topPattern: { pattern: `*.${secret}.example`, count: 2 }, + topDomain: { domain: `${secret}.example.net`, count: 2 }, + }, + slowest: { tool: `tool_${secret}`, p95Ms: 1200, previousP95Ms: 900 }, + topErrors: [{ errorCode: `E_${secret}`, tool: `t_${secret}`, count: 3, sessions: 1 }], + degradations: [ + { + code: `D_${secret}`, + severity: 'error', + message: `failed at ${secret} now`, + since: until - 60_000, + }, + ], + harnesses: [{ harness: `h-${secret}`.slice(0, 32), sessions: 1, toolCalls: 3, errors: 1 }], + }; +} + +function anomalyFacts(now: number, secret: string): AnomalyFacts { + return { + ...sampleAnomalyFacts(now), + attentionWaiting: [{ sessionSlug: `s-${secret}`, waitedMs: 60 * 60_000 }], + degradations: [{ code: `D_${secret}`, message: `failed at ${secret}`, since: now - 60_000 }], + }; +} + +const LEVELS: readonly NotificationContentLevel[] = ['counts', 'titles', 'full']; + +describe('report redaction invariant', () => { + it('a registered sentinel never reaches a stored report, a delivery row or a renderer (120 cases)', async () => { + const leaks: string[] = []; + let checked = 0; + for (let seed = 1; seed <= 120; seed++) { + const next = rng(seed); + const secret = sentinelOf(next); + const level = LEVELS[seed % LEVELS.length] ?? 'full'; + const clock = new FakeClock(Date.UTC(2026, 8, 29, 7, 30)); + const secrets = new SecretRegistry({ now: () => clock.now() }); + secrets.add(secret); + const redactor = createRedactor(secrets); + const repos = new InMemoryRepositories(); + const ids = new FakeIdGenerator(); + const logger = new CollectingLogger(); + const channelId = 'nc-000000000001'; + await repos.notificationChannels.upsert( + channelRecord({ + rules: { content: level, anomaly: {}, digest: { every: 'day', at: '09:00' } }, + }), + ); + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + factories: new Map([['fake', () => new FakeChannel(channelId, capabilities())]]), + }); + await registry.load(); + const scheduler = new ReportScheduler({ + registry, + facts: { + digest: async (w) => digestFacts(w.until, secret), + anomaly: async (now) => anomalyFacts(now, secret), + }, + uow: new InMemoryUnitOfWork(repos), + repos, + outbox: { + plan: (m, now, to) => planDeliveries(m, registry.channels(), now, to), + kick: () => undefined, + }, + clock, + ids, + logger, + hostZone: () => 'UTC', + redactor, + }); + const record = registry.get(channelId)?.record; + if (record === undefined) throw new Error('no channel'); + await scheduler.tick(); // the anomaly check fires; the digest arms + const manual = await scheduler.manualDigest(record); + const messages = [ + manual.message, + ...[...repos.notifications.rows.values()].flatMap((r) => { + const m = decodeMessage(r.messageJson); + return m === null ? [] : [m]; + }), + ]; + const stored = JSON.stringify([...repos.notifications.rows.values()]); + const rows = JSON.stringify(repos.notificationDeliveries.rows); + if (stored.includes(secret)) leaks.push(`stored (seed ${seed})`); + if (rows.includes(secret)) leaks.push(`deliveries (seed ${seed})`); + for (const message of messages) { + for (const [kind, renderer] of CHANNEL_RENDERERS) { + const caps = renderer.capabilities({ mode: null, target: {}, secretRefs: {}, rules: {} }); + const requests = renderer.render( + { message: degrade(message, caps), links: PUBLIC_LINKS, replyTo: null }, + { + mode: kind === 'discord' ? 'webhook' : null, + target: { chat_id: '1', topic: 't' }, + op: 'send', + ref: null, + actToken: () => 'bh1:x', + }, + ); + checked++; + if (JSON.stringify(requests).includes(secret)) { + leaks.push(`${kind}/${message.kind}/${level} (seed ${seed})`); + } + } + } + } + expect(leaks).toEqual([]); + expect(checked).toBeGreaterThanOrEqual(120 * 8); + }); +}); diff --git a/packages/core/test/notifications/reports.sqlite.test.ts b/packages/core/test/notifications/reports.sqlite.test.ts new file mode 100644 index 0000000..115990e --- /dev/null +++ b/packages/core/test/notifications/reports.sqlite.test.ts @@ -0,0 +1,205 @@ +/** @module test/notifications/reports.sqlite.test — scheduled reports end to end on SQLite through each real adapter against the platform fakes (spec 03 §9.7, D-43, D-44): recorded activity → the report facts → the anomaly alert (send, then the silent "back to normal" edit) and the daily digest (send) → the platform requests; an empty day is logged `suppressed: empty` and never reaches the platform. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; +import { NotificationOutbox } from '../../src/app/notifications/outbox.ts'; +import { createReportFacts } from '../../src/app/notifications/report-facts.ts'; +import { ReportScheduler } from '../../src/app/notifications/report-scheduler.ts'; +import { channelFactories } from '../../src/infra/notifications/index.ts'; +import type { NotificationChannelRecord } from '../../src/ports/persistence/records.ts'; +import { CollectingLogger } from '../helpers/collecting-logger.ts'; +import { FakeIdGenerator } from '../helpers/fake-id-generator.ts'; +import { FAKE_TG_TOKEN, FakePlatforms, type RecordedRequest } from '../helpers/fake-platforms.ts'; +import { sessionRecord, toolCallRecord } from '../persistence/helpers.ts'; +import { openMemory, type TestDb } from '../persistence/setup.ts'; +import { PUBLIC_LINKS, platformRecord, SAMPLE_IMAGES } from './helpers.ts'; + +const HOUR = 3_600_000; +/** 28 Sep 2026 12:00 UTC. */ +const START = Date.UTC(2026, 8, 28, 12); +/** The digest time on 29 Sep (09:00 UTC). */ +const NINE = Date.UTC(2026, 8, 29, 9); + +let t: TestDb; +let fakes: FakePlatforms; +beforeEach(async () => { + t = await openMemory(); + t.clock.set(START); + fakes = new FakePlatforms().start(); +}); +afterEach(async () => { + await fakes.stop(); + await t.close(); +}); + +const RULES = { + time_zone: 'UTC', + digest: { every: 'day' as const, at: '09:00' }, + anomaly: { error_rate: 20, min_calls: 10 }, +}; + +/** A busy, failing hour before START (the anomaly) inside the digest window. */ +async function activity(): Promise { + await t.repos.sessions.insert(sessionRecord({ createdAt: START - 2 * HOUR })); + for (let i = 0; i < 20; i++) { + await t.repos.toolCalls.insert( + toolCallRecord({ + eventId: `e-${i}`, + seq: i + 1, + ts: START - 30 * 60_000 + i * 1000, + tool: i % 2 === 0 ? 'navigate' : 'click', + durationMs: 100 + i * 50, + ...(i % 2 === 0 && { ok: false, errorCode: 'NAVIGATION_TIMEOUT', errorMessage: 'slow' }), + }), + ); + } +} + +async function wire(record: NotificationChannelRecord, env: Record) { + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + await t.repos.notificationChannels.upsert({ ...record, rules: { ...record.rules, ...RULES } }); + const registry = new ChannelRegistry({ + repo: t.repos.notificationChannels, + clock: t.clock, + ids, + logger, + factories: channelFactories({ + images: SAMPLE_IMAGES, + apiBases: { telegram: fakes.telegramBase }, + }), + env: (name) => env[name], + }); + await registry.load(); + const outbox = new NotificationOutbox({ + uow: t.uow, + repos: t.repos, + registry, + links: PUBLIC_LINKS, + clock: t.clock, + logger, + }); + const reports = new ReportScheduler({ + registry, + facts: createReportFacts({ + analytics: t.analytics, + repos: t.repos, + capacity: () => ({ live: 1, max: 10 }), + }), + uow: t.uow, + repos: t.repos, + outbox: { plan: (m, now, to) => outbox.plan(m, now, to), kick: () => undefined }, + clock: t.clock, + ids, + logger, + hostZone: () => 'UTC', + }); + return { outbox, reports }; +} + +async function episode(w: Awaited>): Promise { + await w.reports.tick(); // the anomaly check fires; the digest schedule arms + await w.outbox.tick(); + t.clock.set(NINE + 30_000); // the next morning: the failures are out of the last hour + await w.reports.tick(); // the digest, and the anomaly back to normal + await w.outbox.tick(); + t.clock.advance(5_000); + await w.outbox.tick(); + const log = await t.repos.notificationDeliveries.list({}); + return [...log].reverse().map((d) => [d.op, String(d.revision), d.status, d.reason ?? '']); +} + +const calls = (requests: readonly RecordedRequest[]) => + requests.map((r) => `${r.method} ${r.path}`); +const EPISODE = [ + ['send', '1', 'sent', ''], // anomaly alert + ['send', '1', 'sent', ''], // digest + ['edit', '2', 'sent', ''], // back to normal +]; + +describe('scheduled reports through the real adapters', () => { + it('telegram: an anomaly alert with its table, the digest, then back to normal', async () => { + await activity(); + const w = await wire( + platformRecord('telegram', { + target: { chat_id: '-100123' }, + secretRefs: { token: 'BH_TG_TOKEN' }, + }), + { BH_TG_TOKEN: FAKE_TG_TOKEN }, + ); + expect(await episode(w)).toEqual(EPISODE); + expect(calls(fakes.of('telegram'))).toEqual([ + 'POST sendRichMessage', + 'POST sendRichMessage', + 'POST editMessageText', + ]); + const [alert, digest, normal] = fakes + .of('telegram') + .map((r) => r.json as { rich_message: { html: string }; disable_notification: boolean }); + expect(alert?.rich_message.html).toContain('Something looks off'); + expect(alert?.rich_message.html).toContain(''); + expect(digest?.rich_message.html).toContain('Daily digest · Tue 29 Sep'); + expect(digest?.rich_message.html).toContain('NAVIGATION_TIMEOUT'); + expect(normal?.rich_message.html).toContain('Back to normal'); + }); + + it('discord: the same episode as embeds', async () => { + await activity(); + const w = await wire( + platformRecord('discord', { secretRefs: { webhook: 'BH_DISCORD_WEBHOOK' } }), + { + BH_DISCORD_WEBHOOK: fakes.discordWebhook, + }, + ); + expect(await episode(w)).toEqual(EPISODE); + expect(calls(fakes.of('discord'))).toEqual(['POST ', 'POST ', 'PATCH messages/101']); + const digest = fakes.of('discord')[1]?.json as { + embeds: { title: string; description: string }[]; + }; + expect(digest.embeds[0]?.title).toContain('Daily digest'); + expect(digest.embeds[0]?.description).toContain('Tool calls per hour'); + }); + + it('ntfy: the same episode as plain notifications, replaced by sequence id', async () => { + await activity(); + const w = await wire( + platformRecord('ntfy', { target: { server: fakes.ntfyServer, topic: 'bh-reports' } }), + {}, + ); + expect(await episode(w)).toEqual(EPISODE); + const bodies = fakes.of('ntfy').map((r) => r.json as { title: string; sequence_id: string }); + expect(bodies.map((b) => b.title)).toEqual([ + 'Something looks off: 50% of tool calls failed in the last hour', + 'Daily digest · Tue 29 Sep', + 'Back to normal', + ]); + expect(bodies[2]?.sequence_id).toBe(bodies[0]?.sequence_id); + }); + + it('webhook: the contract with the report window and the chart as data', async () => { + await activity(); + const w = await wire(platformRecord('webhook', { target: { url: fakes.webhookUrl } }), {}); + expect(await episode(w)).toEqual(EPISODE); + const digest = fakes.of('webhook')[1]?.json as { + message: { + kind: string; + report: { window: { since: number; until: number } }; + blocks: { type: string }[]; + }; + }; + expect(digest.message.kind).toBe('digest.daily'); + expect(digest.message.report.window).toEqual({ since: NINE - 24 * HOUR, until: NINE }); + expect(digest.message.blocks.some((b) => b.type === 'chart')).toBe(true); + }); + + it('an empty day is logged suppressed: empty and never reaches the platform', async () => { + const w = await wire(platformRecord('webhook', { target: { url: fakes.webhookUrl } }), {}); + await w.reports.tick(); + t.clock.set(NINE + 30_000); + await w.reports.tick(); + await w.outbox.tick(); + const log = await t.repos.notificationDeliveries.list({}); + expect(log.map((d) => [d.status, d.reason])).toEqual([['suppressed', 'empty']]); + expect(fakes.of('webhook')).toHaveLength(0); + }); +}); diff --git a/packages/core/test/persistence/conformance-notifications.test.ts b/packages/core/test/persistence/conformance-notifications.test.ts index 9d5aab2..7c892f5 100644 --- a/packages/core/test/persistence/conformance-notifications.test.ts +++ b/packages/core/test/persistence/conformance-notifications.test.ts @@ -399,6 +399,113 @@ for (const [name, open] of adapters) { expect(await r.notificationChannelMessages.dueForDelete(100, 10)).toEqual([]); }); + it('lists the in-app report copies with the channels they reached (D-45)', async () => { + const report = (id: string, overrides: Partial = {}) => + notification({ + notificationId: id, + type: 'lifecycle', + kind: 'digest.daily', + category: 'reports', + severity: 'info', + state: 'final', + sourceEventId: null, + ...overrides, + }); + await r.notificationChannels.upsert( + channelRecord({ channelId: 'nc-000000000002', name: 'team' }), + ); + // Two in-app copies: one reached both channels, one reached none. + await r.notifications.insert( + report('n-inapp000001', { thread: 'report:digest:a:1:2', createdAt: 20, readAt: 20 }), + ); + await r.notifications.insert( + report('n-inapp000002', { + thread: 'report:anomaly:x:3', + kind: 'report.anomaly', + type: 'system', + createdAt: 30, + dismissedAt: 31, + }), + ); + // The channel copies name the first one. + for (const [id, channel] of [ + ['n-copy0000001', 'nc-000000000001'], + ['n-copy0000002', 'nc-000000000002'], + ] as const) { + await r.notifications.insert( + report(id, { + thread: `digest:${channel}:2`, + sourceEventId: 'n-inapp000001', + readAt: 20, + dismissedAt: 20, + }), + ); + await r.notificationDeliveries.enqueue([ + job({ channelId: channel, notificationId: id, status: 'pending' }), + ]); + } + await r.notificationDeliveries.enqueue([ + job({ + channelId: 'nc-000000000002', + notificationId: 'n-copy0000002', + revision: 2, + op: 'edit', + status: 'suppressed', + reason: 'quiet_hours', + }), + ]); + const ids = async (q: Parameters[0]) => + (await r.notifications.listReports(q)).items.map((n) => n.notificationId); + // Dismissed or not, newest first; channel copies never listed. + expect(await ids({ limit: 10 })).toEqual(['n-inapp000002', 'n-inapp000001']); + expect(await ids({ limit: 10, kinds: ['report.anomaly'] })).toEqual(['n-inapp000002']); + expect(await ids({ limit: 10, channelId: 'nc-000000000002' })).toEqual(['n-inapp000001']); + expect(await ids({ limit: 10, inAppOnly: true })).toEqual(['n-inapp000002']); + expect(await ids({ limit: 10, since: 25 })).toEqual(['n-inapp000002']); + const page = await r.notifications.listReports({ limit: 1, total: true }); + expect(page.total).toBe(2); + const channels = await r.notifications.reportChannels(['n-inapp000001', 'n-inapp000002']); + expect(channels.get('n-inapp000001')).toEqual([ + { + channelId: 'nc-000000000001', + name: 'phone', + kind: 'fake', + status: 'pending', + reason: null, + }, + { + channelId: 'nc-000000000002', + name: 'team', + kind: 'fake', + status: 'suppressed', + reason: 'quiet_hours', + }, + ]); + expect(channels.get('n-inapp000002')).toBeUndefined(); + }); + + it('treats type and category as one facet (D-45)', async () => { + await r.notifications.insert( + notification({ + notificationId: 'n-inapp000001', + type: 'lifecycle', + kind: 'digest.daily', + category: 'reports', + thread: 'report:digest:a:1:2', + createdAt: 20, + updatedAt: 20, + }), + ); + const ids = async (q: Parameters[0]) => + (await r.notifications.list(q)).items.map((n) => n.notificationId).sort(); + expect(await ids({ categories: ['reports'] })).toEqual(['n-inapp000001']); + expect(await ids({ types: ['attention'], categories: ['reports'] })).toEqual([ + 'n-000000000001', + 'n-inapp000001', + ]); + expect(await ids({ types: ['attention'] })).toEqual(['n-000000000001']); + }); + it('removing a channel removes its deliveries and messages', async () => { await r.notificationDeliveries.enqueue([job()]); await r.notificationChannelMessages.upsert({ diff --git a/packages/core/test/persistence/conformance-reports.test.ts b/packages/core/test/persistence/conformance-reports.test.ts new file mode 100644 index 0000000..227cfd0 --- /dev/null +++ b/packages/core/test/persistence/conformance-reports.test.ts @@ -0,0 +1,260 @@ +/** @module test/persistence/conformance-reports.test — the report queries (spec 03 §7.2, §9.7): `windowStats`, `countByResult` on SQLite and their in-memory doubles; `windowCounts`, `toolLatency` (the same p95 as `toolMetrics`), `topErrors` on SQLite and the in-memory analytics fake; the report facts gathered over a real database. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { createReportFacts } from '../../src/app/notifications/report-facts.ts'; +import type { VaultAccessResult } from '../../src/ports/persistence/enums.ts'; +import type { OperatorRequestRepository } from '../../src/ports/persistence/operator-requests.ts'; +import type { NewOperatorRequest } from '../../src/ports/persistence/records.ts'; +import type { VaultAuditRepository } from '../../src/ports/persistence/vault-audit.ts'; +import { FakeAnalytics } from '../helpers/http-fakes.ts'; +import { InMemoryRepositories } from '../helpers/in-memory-repos.ts'; +import { createVaultRepos } from '../helpers/in-memory-vault-repos.ts'; +import { + blockedRequestRecord, + sessionRecord, + toolCallRecord, + vaultAccessRecord, +} from './helpers.ts'; +import { openMemory, type TestDb } from './setup.ts'; + +const WINDOW = { since: 1_000, until: 2_000 }; + +function request(id: string, createdAt: number): NewOperatorRequest { + return { + requestId: id, + kind: 'attention', + sessionId: 'shop-a1b2c3d4', + owner: 'local', + reason: 'captcha', + mode: 'takeover', + entryName: null, + tool: 'navigate', + toolEventId: null, + pageUrl: 'https://x/', + options: null, + idempotencyKey: null, + createdAt, + deadlineAt: null, + }; +} + +async function seedRequests(repo: OperatorRequestRepository): Promise { + await repo.insert(request('a-000000000001', 900)); // before the window + await repo.insert(request('a-000000000002', 1_000)); + await repo.insert(request('a-000000000003', 1_100)); + await repo.insert(request('a-000000000004', 1_200)); + await repo.insert(request('a-000000000005', 1_300)); + await repo.insert(request('a-000000000006', 1_400)); // stays pending + await repo.insert(request('a-000000000007', 2_000)); // at `until`: outside + await repo.resolve('a-000000000001', { status: 'resolved', at: 950 }); + await repo.resolve('a-000000000002', { status: 'resolved', at: 1_060 }); // waited 60 + await repo.resolve('a-000000000003', { status: 'rejected', at: 1_200 }); // waited 100 + await repo.resolve('a-000000000004', { status: 'resolved', at: 1_500 }); // waited 300 + await repo.resolve('a-000000000005', { status: 'timeout', at: 1_900 }); +} + +const EXPECTED_STATS = { + created: 5, + resolved: 2, + rejected: 1, + timedOut: 1, + cancelled: 0, + pending: 1, + medianWaitMs: 100, +}; + +async function seedVault(repo: VaultAuditRepository): Promise { + const rows = [ + ['v1', 1_000, 'success'], + ['v2', 1_500, 'success'], + ['v3', 1_600, 'origin_mismatch'], + ['v4', 2_000, 'denied'], // at `until`: outside + ['v5', 999, 'denied'], // before + ] as const; + for (const [eventId, ts, result] of rows) { + await repo.insert(vaultAccessRecord({ eventId, ts, result })); + } +} + +const EXPECTED_VAULT: { result: VaultAccessResult; count: number }[] = [ + { result: 'success', count: 2 }, + { result: 'origin_mismatch', count: 1 }, +]; + +describe('OperatorRequestRepository.windowStats', () => { + let t: TestDb; + beforeEach(async () => { + t = await openMemory(); + await t.repos.sessions.insert(sessionRecord()); + }); + afterEach(async () => { + await t.close(); + }); + + it('counts outcomes of the requests created in [since, until) on SQLite', async () => { + await seedRequests(t.repos.operatorRequests); + expect(await t.repos.operatorRequests.windowStats('attention', WINDOW)).toEqual(EXPECTED_STATS); + expect(await t.repos.operatorRequests.windowStats('vault_confirm', WINDOW)).toMatchObject({ + created: 0, + medianWaitMs: null, + }); + }); + + it('matches the in-memory double', async () => { + const repo = createVaultRepos().requests; + await seedRequests(repo); + expect(await repo.windowStats('attention', WINDOW)).toEqual(EXPECTED_STATS); + }); +}); + +describe('VaultAuditRepository.countByResult', () => { + let t: TestDb; + beforeEach(async () => { + t = await openMemory(); + await t.repos.sessions.insert(sessionRecord()); + }); + afterEach(async () => { + await t.close(); + }); + + it('counts accesses by result in [since, until), most first, on SQLite', async () => { + await seedVault(t.repos.vaultAudit); + expect(await t.repos.vaultAudit.countByResult(WINDOW)).toEqual(EXPECTED_VAULT); + }); + + it('matches both in-memory doubles', async () => { + const vault = createVaultRepos().audit; + await seedVault(vault); + expect(await vault.countByResult(WINDOW)).toEqual(EXPECTED_VAULT); + const facts = new InMemoryRepositories().vaultAudit; + await seedVault(facts); + expect(await facts.countByResult(WINDOW)).toEqual(EXPECTED_VAULT); + }); +}); + +describe('AnalyticsQueries report additions', () => { + let t: TestDb; + beforeEach(async () => { + t = await openMemory(); + await t.repos.sessions.insert(sessionRecord({ createdAt: 1_500 })); + }); + afterEach(async () => { + await t.close(); + }); + + async function seedCalls(insert: (r: ReturnType) => Promise) { + const durations = [10, 20, 30, 40, 50, 60, 70, 80, 90, 1000]; + let seq = 0; + for (const [i, ms] of durations.entries()) { + seq += 1; + await insert( + toolCallRecord({ + eventId: `n-${i}`, + seq, + ts: 1_100 + i, + tool: 'navigate', + durationMs: ms, + ...(i < 3 && { ok: false, errorCode: 'NAVIGATION_TIMEOUT', errorMessage: 'x' }), + }), + ); + } + seq += 1; + await insert( + toolCallRecord({ + eventId: 'c-1', + seq, + ts: 1_200, + tool: 'click', + durationMs: 5, + ok: false, + errorCode: 'ELEMENT_NOT_FOUND', + errorMessage: 'x', + }), + ); + seq += 1; + await insert(toolCallRecord({ eventId: 'late', seq, ts: 2_000, tool: 'click', durationMs: 5 })); + } + + it('counts one window in one statement', async () => { + await seedCalls((r) => t.repos.toolCalls.insert(r)); + await t.repos.blocklistAudit.insert(blockedRequestRecord({ eventId: 'b-1', ts: 1_300 })); + await t.repos.blocklistAudit.insert(blockedRequestRecord({ eventId: 'b-2', ts: 2_500 })); + await seedVault(t.repos.vaultAudit); + await seedRequests(t.repos.operatorRequests); + expect(await t.analytics.windowCounts(WINDOW)).toEqual({ + sessionsStarted: 1, + toolCalls: 11, + errors: 4, + blocked: 1, + attention: 5, + vaultAccess: 3, + }); + }); + + it('computes the p95 in the database, equal to toolMetrics', async () => { + await seedCalls((r) => t.repos.toolCalls.insert(r)); + const latency = await t.analytics.toolLatency(WINDOW); + const metrics = await t.analytics.toolMetrics({ + ...WINDOW, + until: WINDOW.until - 1, + groupBy: 'tool', + }); + expect(latency).toEqual([ + { tool: 'navigate', calls: 10, errors: 3, p95Ms: 1000 }, + { tool: 'click', calls: 1, errors: 1, p95Ms: 5 }, + ]); + for (const row of latency) { + expect(metrics.find((m) => m.tool === row.tool)?.p95Ms).toBe(row.p95Ms); + } + }); + + it('lists the top errors with their sessions', async () => { + await seedCalls((r) => t.repos.toolCalls.insert(r)); + expect(await t.analytics.topErrors(WINDOW, 5)).toEqual([ + { errorCode: 'NAVIGATION_TIMEOUT', tool: 'navigate', count: 3, sessions: 1 }, + { errorCode: 'ELEMENT_NOT_FOUND', tool: 'click', count: 1, sessions: 1 }, + ]); + }); + + it('matches the in-memory analytics fake', async () => { + const repos = new InMemoryRepositories(); + await seedCalls((r) => repos.toolCalls.insert(r)); + await seedCalls((r) => t.repos.toolCalls.insert(r)); + const fake = new FakeAnalytics(repos); + const sqlite = await t.analytics.toolLatency(WINDOW); + const memory = await fake.toolLatency(WINDOW); + expect([...memory].sort((a, b) => a.tool.localeCompare(b.tool))).toEqual( + [...sqlite].sort((a, b) => a.tool.localeCompare(b.tool)), + ); + expect(await fake.topErrors(WINDOW, 5)).toEqual(await t.analytics.topErrors(WINDOW, 5)); + }); + + it('gathers a digest and the anomaly facts over a real database', async () => { + await seedCalls((r) => t.repos.toolCalls.insert(r)); + await seedVault(t.repos.vaultAudit); + await seedRequests(t.repos.operatorRequests); + const facts = createReportFacts({ + analytics: t.analytics, + repos: t.repos, + capacity: () => ({ live: 1, max: 4 }), + }); + const digest = await facts.digest(WINDOW, { every: 'day', at: '09:00' }); + expect(digest).toMatchObject({ + sessionsStarted: 1, + sessionsLive: 1, + toolCalls: 11, + errors: 4, + // Waiting now counts every pending request, also one created after the window. + attention: { ...EXPECTED_STATS, pending: 2 }, + vault: EXPECTED_VAULT, + slowest: { tool: 'navigate', p95Ms: 1000, previousP95Ms: null }, + }); + expect(digest.topErrors[0]?.errorCode).toBe('NAVIGATION_TIMEOUT'); + const anomaly = await facts.anomaly(2_000); + expect(anomaly).toMatchObject({ live: 1, maxSessions: 4, toolCalls: 11, errors: 4 }); + expect(anomaly.attentionWaiting).toEqual([ + { sessionSlug: 'shop', waitedMs: 600 }, + { sessionSlug: 'shop', waitedMs: 0 }, + ]); + }); +}); diff --git a/packages/core/test/persistence/retention.test.ts b/packages/core/test/persistence/retention.test.ts index b8cc7d1..42a3be1 100644 --- a/packages/core/test/persistence/retention.test.ts +++ b/packages/core/test/persistence/retention.test.ts @@ -178,6 +178,30 @@ async function seed(): Promise { thread: 'notification:n-stale', messageJson: null, }); + // A report read and dismissed 40 days ago stays in the Reports tab's history (D-45). + await r.notifications.insert({ + notificationId: 'n-report', + principalId: null, + type: 'lifecycle', + title: 'Daily digest', + body: null, + sessionId: null, + target: null, + sourceEventId: null, + createdAt: NOW - 40 * DAY, + updatedAt: NOW - 40 * DAY, + count: 1, + groupKey: null, + readAt: NOW - 40 * DAY, + dismissedAt: NOW - 40 * DAY, + kind: 'digest.daily', + category: 'reports', + severity: 'info', + state: 'final', + revision: 1, + thread: 'report:digest:day@09:00@UTC:1:2', + messageJson: null, + }); await r.notifications.insert({ notificationId: 'n-keep', principalId: null, @@ -235,6 +259,7 @@ describe('retentionSweep', () => { expect( (await r.notifications.list({ principalId: null })).items.map((n) => n.notificationId), ).toEqual(['n-keep']); + expect(await r.notifications.get('n-report')).not.toBeNull(); const outbox = await r.artifactOutbox.pending(10); expect(outbox.map((a) => [a.kind, a.path])).toEqual([ ['screenshot', 'sessions/old-00000001/screenshots/tc-old.jpg'], diff --git a/packages/dashboard/src/app/providers/AuthProvider.tsx b/packages/dashboard/src/app/providers/AuthProvider.tsx index bf7406c..01dd483 100644 --- a/packages/dashboard/src/app/providers/AuthProvider.tsx +++ b/packages/dashboard/src/app/providers/AuthProvider.tsx @@ -209,6 +209,15 @@ export function useAuth(): AuthApi { return value; } +/** + * Whether the signed-in principal holds `scope` (a control that needs it is disabled with the + * reason instead of failing with a 403 after the click). Unknown principal: `false`. + */ +export function useHasScope(scope: string): boolean { + const { state } = useAuth(); + return state.principal?.scopes.some((s) => s === scope) ?? false; +} + /** The typed API client. */ export function useApi(): ApiClient { const value = useContext(ApiContext); diff --git a/packages/dashboard/src/app/providers/NotificationsProvider.tsx b/packages/dashboard/src/app/providers/NotificationsProvider.tsx index 003a84f..2abf090 100644 --- a/packages/dashboard/src/app/providers/NotificationsProvider.tsx +++ b/packages/dashboard/src/app/providers/NotificationsProvider.tsx @@ -1,4 +1,4 @@ -/** @module app/providers/NotificationsProvider — server-backed notifications: unread count query, `notifications` topic, toasts only from `notification.created` with a per-type policy: no tool-error toasts by default, titles name the session, a growing group updates its toast in place, a settled request closes it (spec 04 §4.3, D-16) */ +/** @module app/providers/NotificationsProvider — server-backed notifications: unread count query, `notifications` topic, toasts only from `notification.created` with a per-type policy: no tool-error toasts by default, never a digest, anomaly alerts as `system` warnings, titles name the session, a growing group updates its toast in place, a settled request closes it (spec 04 §4.3, D-16, D-45) */ import { NotificationType } from '@browserhive/contracts/enums'; import type { Notification } from '@browserhive/contracts/http'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -47,6 +47,13 @@ export interface ToastPlan { readonly persist: boolean; /** Route for the action button, `null` when there is none (or the operator is already there). */ readonly target: string | null; + /** Label of the action button. */ + readonly actionLabel: string; +} + +/** A digest is a record, not a call to act: it never toasts (D-45). */ +export function isDigest(notification: Pick): boolean { + return notification.kind === 'digest.daily' || notification.kind === 'digest.weekly'; } /** Toast id of a notification (re-issuing it updates the same toast). */ @@ -88,15 +95,17 @@ export function planToast( ): ToastPlan | null { const { preferences } = context; if (preferences?.toasts === false) return null; + if (isDigest(notification)) return null; const types = preferences?.types ?? DEFAULT_TOAST_TYPES; if (!types.includes(notification.type)) return null; const target = notificationTarget(notification); const here = target !== null && isAlreadyAt(context.pathname, target); if (notification.type === 'error' && here) return null; + const anomaly = notification.kind === 'report.anomaly'; const tone = notification.type === 'error' ? 'error' - : notification.type === 'attention' + : notification.type === 'attention' || anomaly ? 'warning' : 'info'; return { @@ -107,6 +116,11 @@ export function planToast( // Only attention requests wait for the operator; errors, lifecycle and vault news fade out. persist: notification.type === 'attention', target: here ? null : target, + actionLabel: anomaly + ? 'Open report' + : notification.type === 'vault' + ? 'Review in vault' + : 'Open session', }; } @@ -209,7 +223,7 @@ export function NotificationsProvider({ children }: { readonly children: ReactNo // No "Open session" button when the operator is already on that page. ...(target !== null && { action: { - label: notification.type === 'vault' ? 'Review in vault' : 'Open session', + label: plan.actionLabel, onClick: () => void navigate({ to: target }), }, }), diff --git a/packages/dashboard/src/app/providers/notifications-policy.test.ts b/packages/dashboard/src/app/providers/notifications-policy.test.ts index 9a6e8d6..a527255 100644 --- a/packages/dashboard/src/app/providers/notifications-policy.test.ts +++ b/packages/dashboard/src/app/providers/notifications-policy.test.ts @@ -55,6 +55,40 @@ describe('toast policy', () => { expect(planToast(error, { pathname: '/overview', preferences: { toasts: false } })).toBeNull(); }); + it('never toasts a digest; an anomaly alert follows the System preference (D-45)', () => { + const digest = notification(4, { + type: 'lifecycle', + kind: 'digest.daily', + category: 'reports', + title: 'Daily digest · Tue 29 Sep', + target: '/notifications/reports/n-000000000004', + }); + expect(planToast(digest, { pathname: '/overview', preferences: undefined })).toBeNull(); + expect( + planToast(digest, { pathname: '/overview', preferences: { types: ['lifecycle'] } }), + ).toBeNull(); + const alert = notification(5, { + type: 'system', + kind: 'report.anomaly', + category: 'reports', + severity: 'warn', + title: 'Something looks off: error rate 34 %', + target: '/notifications/reports/n-000000000005', + }); + expect(planToast(alert, { pathname: '/overview', preferences: undefined })).toMatchObject({ + tone: 'warning', + persist: false, + target: '/notifications/reports/n-000000000005', + actionLabel: 'Open report', + }); + // An operator who switched System toasts off gets it in the bell only. + expect( + planToast(alert, { pathname: '/overview', preferences: { types: ['attention'] } }), + ).toBeNull(); + // Its "back to normal" closes the toast. + expect(toastSettled({ ...alert, state: 'resolved' })).toBe(true); + }); + it('caps the bell badge at 9+', () => { expect(bellBadge(3)).toBe('3'); expect(bellBadge(9)).toBe('9'); diff --git a/packages/dashboard/src/app/shell/NotificationBell.tsx b/packages/dashboard/src/app/shell/NotificationBell.tsx index 08e4fe5..bc676b7 100644 --- a/packages/dashboard/src/app/shell/NotificationBell.tsx +++ b/packages/dashboard/src/app/shell/NotificationBell.tsx @@ -20,7 +20,7 @@ import { } from '@/features/notifications/notification-meta.ts'; import { keys } from '@/lib/api/keys.ts'; import { ICONS } from '@/lib/icons.ts'; -import { NOTIFICATION_TYPE } from '@/lib/status-registry.ts'; +import { notificationEntry } from '@/lib/status-registry.ts'; import { cn } from '@/lib/utils.ts'; /** How many recent notifications the popover fetches (dismissed ones are filtered out). */ @@ -192,7 +192,7 @@ function BellRow({ readonly onMarkRead: () => void; readonly onDismiss: () => void; }) { - const entry = NOTIFICATION_TYPE[n.type]; + const entry = notificationEntry(n); const Icon = ICONS[entry.icon ?? 'notifications']; const unread = n.read_at === null; const meta = notificationMeta(n); diff --git a/packages/dashboard/src/features/notifications/NotificationsNav.tsx b/packages/dashboard/src/features/notifications/NotificationsNav.tsx index 9a75ccd..b8921fb 100644 --- a/packages/dashboard/src/features/notifications/NotificationsNav.tsx +++ b/packages/dashboard/src/features/notifications/NotificationsNav.tsx @@ -1,4 +1,4 @@ -/** @module features/notifications/NotificationsNav — the Notifications area's section nav (Inbox · Channels · Delivery log · Actions): underline tabs made of real links under the page header (spec 04 §12.11.1) */ +/** @module features/notifications/NotificationsNav — the Notifications area's section nav (Inbox · Channels · Delivery log · Actions · Reports): underline tabs made of real links under the page header (spec 04 §12.11.1) */ import { Link, useRouterState } from '@tanstack/react-router'; import { ICONS, type IconName } from '@/lib/icons.ts'; import { cn } from '@/lib/utils.ts'; @@ -12,6 +12,7 @@ const SECTIONS: readonly { { to: '/notifications/channels', label: 'Channels', icon: 'channels' }, { to: '/notifications/log', label: 'Delivery log', icon: 'deliveryLog' }, { to: '/notifications/actions', label: 'Actions', icon: 'actions' }, + { to: '/notifications/reports', label: 'Reports', icon: 'reports' }, ]; /** Which section a pathname belongs to. */ @@ -19,6 +20,7 @@ export function activeSection(pathname: string): string { if (pathname.startsWith('/notifications/channels')) return '/notifications/channels'; if (pathname.startsWith('/notifications/log')) return '/notifications/log'; if (pathname.startsWith('/notifications/actions')) return '/notifications/actions'; + if (pathname.startsWith('/notifications/reports')) return '/notifications/reports'; return '/notifications'; } diff --git a/packages/dashboard/src/features/notifications/NotificationsPage.test.tsx b/packages/dashboard/src/features/notifications/NotificationsPage.test.tsx index 4388bb8..76da4b4 100644 --- a/packages/dashboard/src/features/notifications/NotificationsPage.test.tsx +++ b/packages/dashboard/src/features/notifications/NotificationsPage.test.tsx @@ -12,7 +12,13 @@ import { } from '../../../test/helpers/page-harness.tsx'; import { fireEvent, screen, waitFor, within } from '../../../test/helpers/render.tsx'; import { mergePreferences } from './components/PreferencesForm.tsx'; -import { groupByDay, NotificationsPage, visibleNotifications } from './NotificationsPage.tsx'; +import { + groupByDay, + NotificationsPage, + splitTypeChips, + typeChips, + visibleNotifications, +} from './NotificationsPage.tsx'; import { notificationMeta, notificationOutcome } from './notification-meta.ts'; import { notificationsSearch } from './search.ts'; @@ -144,6 +150,27 @@ describe('notifications helpers', () => { ).toBeNull(); }); + it('marks an on-demand digest in the meta line', () => { + const manual = notification(8, { + kind: 'digest.daily', + category: 'reports', + thread: 'report:digest:now:Europe/Berlin:1:2', + }); + expect(notificationMeta(manual)).toContain('on demand'); + expect( + notificationMeta({ ...manual, thread: 'report:digest:day@09:00@UTC:1:2' }), + ).not.toContain('on demand'); + }); + + it('splits the Type facet into types and the Reports category', () => { + expect(typeChips({ type: ['error'], category: ['reports'] })).toEqual(['error', 'reports']); + expect(splitTypeChips(['reports', 'vault'])).toEqual({ + type: ['vault'], + category: ['reports'], + }); + expect(splitTypeChips([])).toEqual({ type: undefined, category: undefined }); + }); + it('keeps preference keys the form does not edit', () => { const merged = mergePreferences( { saved_views: [], page_defaults: { sessions: { ps: 50 } } }, @@ -251,6 +278,33 @@ describe('NotificationsPage', () => { expect(review.getAttribute('href')).toBe('/vault'); }); + it('adds a Reports chip that asks for category=reports with the types (D-45)', async () => { + const digest = notification(7, { + type: 'lifecycle', + kind: 'digest.daily', + category: 'reports', + title: 'Daily digest · Tue 29 Sep', + read_at: NOW, + target: '/notifications/reports/n-000000000007', + }); + const view = mount('/notifications', { extra: [digest] }); + const row = await screen.findByRole('link', { name: 'Open: Daily digest · Tue 29 Sep' }); + expect(row.getAttribute('href')).toBe('/notifications/reports/n-000000000007'); + fireEvent.click(screen.getByRole('button', { name: 'Reports' })); + await waitFor(() => + expect(view.router.state.location.search).toMatchObject({ category: ['reports'] }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Error' })); + await waitFor(() => { + const last = view.requests.filter((r) => r.path === '/api/v1/notifications').at(-1); + expect(last?.query.get('category')).toBe('reports'); + expect(last?.query.get('type')).toBe('error'); + }); + expect(screen.getByRole('button', { name: 'Reports' }).getAttribute('aria-pressed')).toBe( + 'true', + ); + }); + it('holds live notifications behind a pill while the list is being read', async () => { const view = mount(); await screen.findByText('Tool error · navigate 1'); diff --git a/packages/dashboard/src/features/notifications/NotificationsPage.tsx b/packages/dashboard/src/features/notifications/NotificationsPage.tsx index feac1e0..30a09e4 100644 --- a/packages/dashboard/src/features/notifications/NotificationsPage.tsx +++ b/packages/dashboard/src/features/notifications/NotificationsPage.tsx @@ -35,6 +35,28 @@ import { PreferencesForm } from './components/PreferencesForm.tsx'; import { NotificationsNav } from './NotificationsNav.tsx'; import { NOTIFICATION_RANGES, type NotificationsSearch } from './search.ts'; +/** The Reports chip: one more value of the Type facet, sent as `category=reports` (D-45). */ +export const REPORTS_CHIP = 'reports'; + +/** The Type facet's selected chips: the types, plus Reports when `category=reports`. */ +export function typeChips(search: Pick): string[] { + return [ + ...(search.type ?? []), + ...((search.category ?? []).includes('reports') ? [REPORTS_CHIP] : []), + ]; +} + +/** Splits the Type facet's chips back into the `type` and `category` params. */ +export function splitTypeChips( + values: readonly string[], +): Pick { + const types = NotificationType.options.filter((t) => values.includes(t)); + return { + type: types.length > 0 ? types : undefined, + category: values.includes(REPORTS_CHIP) ? ['reports'] : undefined, + }; +} + /** Visible (not dismissed) notifications, latest activity first (a folded group moves up as it grows). */ export function visibleNotifications(rows: readonly Notification[]): readonly Notification[] { return rows @@ -81,7 +103,14 @@ export function NotificationsPage() { const hold = useLiveHold(rows, listRef, { getId: notificationId, getVersion: notificationVersion, - listKey: JSON.stringify([search.read, search.type, search.range, search.page, search.ps]), + listKey: JSON.stringify([ + search.read, + search.type, + search.category, + search.range, + search.page, + search.ps, + ]), enabled: search.page === 1, }); @@ -94,7 +123,7 @@ export function NotificationsPage() { ) : undefined } - description="Attention requests, tool errors, vault and lifecycle events for your account." + description="Attention requests, tool errors, vault and lifecycle events, digests and anomaly alerts for your account." learnMore="A notification keeps its place when the thing it announces changes: a settled request shows its outcome (resolved, expired) instead of a new row, and a growing group of tool errors updates its count." learnMoreDocs="notifications" tabs={} @@ -170,17 +199,25 @@ export function NotificationsPage() { { param: 'type', label: 'Type', - options: NotificationType.options.map((value) => ({ value, count: 0 })), - selected: search.type ?? [], + options: [...NotificationType.options, REPORTS_CHIP].map((value) => ({ + value, + count: 0, + })), + selected: typeChips(search), counts: false, format: (value) => { + if (value === REPORTS_CHIP) return 'Reports'; const label = NOTIFICATION_TYPE[value as NotificationType]?.label ?? value; return label.charAt(0).toUpperCase() + label.slice(1); }, }, ]} {...(list.data?.page.total !== undefined && { matching: list.data.page.total })} - onChange={(param, value) => set({ [param]: value })} + onChange={(param, value) => + param === 'type' + ? set(splitTypeChips(typeof value === 'string' ? [value] : (value ?? []))) + : set({ [param]: value }) + } onClear={() => clear(['range'])} /> {/* The skeleton holds the list's place from the first paint so the preferences panel below never jumps. */} @@ -203,7 +240,9 @@ export function NotificationsPage() { empty={ void; readonly onDelete: () => void; readonly onEdit: () => void; + /** Opens "Send a digest now" (channels with a digest). */ + readonly onDigestNow?: () => void; readonly busy?: boolean; } +/** + * "Reports": the next digest in the channel's zone (named only when it is not the browser's) and + * the anomaly alerts, watching or with the checks that are off now (D-43, D-44). + */ +function ReportsLine({ + channel, + onDigestNow, +}: { + readonly channel: ChannelView; + readonly onDigestNow?: (() => void) | undefined; +}) { + const r = channel.reports; + if (r.digest === null && r.anomaly === null) return null; + const Digest = ICONS.digest; + const Radar = ICONS.anomaly; + const Warn = ICONS.warn; + const zone = r.time_zone === browserZone() ? '' : ` (${zoneLabel(r.time_zone)})`; + const active = r.anomaly?.active ?? []; + return ( +
+ {r.digest !== null ? ( +
+
+ ) : null} + {r.anomaly !== null ? ( + active.length === 0 ? ( +

+

+ ) : ( +

+

+ ) + ) : null} +
+ ); +} + +/** One active anomaly check in a few words ("error rate 34%"). */ +function anomalyText(check: string, value: number): string { + switch (check) { + case 'error_rate': + return `error rate ${formatNumber(value)}%`; + case 'attention': + return `a request waiting ${formatNumber(value)} min`; + case 'capacity': + return `${formatNumber(value)} sessions, at the limit`; + case 'blocked': + return `${formatNumber(value)} blocked in an hour`; + case 'degraded': + return 'BrowserHive degraded'; + default: + return check; + } +} + /** "Answers from the chat": whether presses reach BrowserHive (only with act buttons on). */ function AnswersLine({ channel, now }: { readonly channel: ChannelView; readonly now: number }) { if (channel.rules.act_buttons !== true) return null; @@ -152,6 +241,7 @@ export function ChannelCard({ onDuplicate, onDelete, onEdit, + onDigestNow, busy = false, }: ChannelCardProps) { const go = useHrefNavigate(); @@ -292,6 +382,10 @@ export function ChannelCard({

) : null} +
    {channel.secrets.map((s) => (
  • { expect(within(card('family')).getByText('Telegram refused the token (401)')).toBeDefined(); }); + it('shows the next digest and the anomaly state, and sends a digest now (D-43, D-44)', async () => { + const family = byName('family'); + const withReports: ChannelView = { + ...family, + rules: { ...family.rules, digest: { every: 'day', at: '09:00' }, anomaly: {} }, + reports: { + time_zone: 'Asia/Tokyo', + host_zone: false, + digest: { + every: 'day', + at: '09:00', + day: null, + weekdays_only: false, + next_at: Date.UTC(2026, 8, 30, 0), + last_until: null, + }, + anomaly: { + next_check_at: Date.UTC(2026, 8, 29, 13), + active: [{ check: 'error_rate', since: 1, value: 34, threshold: 20 }], + }, + }, + }; + const preview = ChannelPreview.parse({ ...CAPTURED.previews.telegramPhoto, sample: 'digest' }); + const window = { since: Date.UTC(2026, 8, 28, 12), until: Date.UTC(2026, 8, 29, 12) }; + const view = mount({ + 'GET /channels': { + ...LIST, + data: LIST.data.map((c) => (c.name === 'family' ? withReports : c)), + }, + [`POST /channels/${family.channel_id}/digest`]: (req: RecordedRequest) => + (req.body as { send: boolean }).send + ? { + preview, + window, + empty: false, + sent: true, + ok: true, + delivery: { ...CAPTURED.deliveries.data[0], duration_ms: 312 }, + error: null, + } + : { preview, window, empty: true, sent: false, ok: true, delivery: null, error: null }, + }); + await until(() => findCard('family') !== null); + const c = card('family'); + expect(within(c).getByText('Daily digest')).toBeDefined(); + expect(within(c).getByText(/next Wed 30 Sep, 09:00 \(Asia\/Tokyo\)/)).toBeDefined(); + expect(within(c).getByText(/error rate 34%/)).toBeDefined(); + await act(async () => { + fireEvent.click(within(c).getByRole('button', { name: 'Send now' })); + }); + expect(await screen.findByRole('heading', { name: 'Send a digest now' })).toBeDefined(); + expect(await screen.findByText('Nothing happened in this period')).toBeDefined(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Send now/ })); + }); + expect(await screen.findByText(/Digest sent/)).toBeDefined(); + const posts = view.requests.filter((r) => r.path.endsWith('/digest')); + expect(posts.map((r) => (r.body as { send: boolean }).send)).toEqual([false, true]); + }); + it('asks before deleting and removes the card', async () => { const phone = byName('phone'); mount({ [`DELETE /channels/${phone.channel_id}`]: { ok: true } }); diff --git a/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx b/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx index 7273740..9b8ab6a 100644 --- a/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx +++ b/packages/dashboard/src/features/notifications/channels/ChannelsPage.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils.ts'; import { NotificationsNav } from '../NotificationsNav.tsx'; import { useChannelActions, useChannels, useTestChannel } from './api.ts'; import { ChannelCard, type TestState } from './ChannelCard.tsx'; +import { DigestNowDialog } from './DigestNowDialog.tsx'; import { PLATFORMS, PlatformMark } from './platforms.tsx'; /** The channels, sorted: dashboard channels and startup channels by name. */ @@ -82,6 +83,7 @@ export function ChannelsPage() { const navigate = useNavigate(); const now = useServerNow(30_000); const [tests, setTests] = useState>>({}); + const [digestFor, setDigestFor] = useState(null); useTopic('channels'); const Plus = ICONS.plus; @@ -186,6 +188,7 @@ export function ChannelsPage() { }) } onDelete={() => void remove(channel)} + onDigestNow={() => setDigestFor(channel.channel_id)} />
  • ); @@ -193,6 +196,18 @@ export function ChannelsPage() {
)} + {(() => { + const channel = channels.data?.data.find((c) => c.channel_id === digestFor); + return channel === undefined ? null : ( + { + if (!open) setDigestFor(null); + }} + /> + ); + })()} ); } diff --git a/packages/dashboard/src/features/notifications/channels/DigestNowDialog.tsx b/packages/dashboard/src/features/notifications/channels/DigestNowDialog.tsx new file mode 100644 index 0000000..fedcf89 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/DigestNowDialog.tsx @@ -0,0 +1,145 @@ +/** @module features/notifications/channels/DigestNowDialog — "Send a digest now" (D-43, spec 04 §12.11.1): previews the channel's real digest of the period that ends now in the platform mock (`POST /channels/{id}/digest {send: false}`, pure), says when that period was empty (a scheduled digest would not be sent), then sends it on demand and shows the result; the schedule is untouched */ +import type { ChannelDigestResponse, ChannelView } from '@browserhive/contracts/http'; +import { deliveryReasonText } from '@browserhive/contracts/notifications'; +import { useEffect, useState } from 'react'; +import { Callout } from '@/components/shared/Callout.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog.tsx'; +import { Skeleton } from '@/components/ui/skeleton.tsx'; +import { Spinner } from '@/components/ui/spinner.tsx'; +import { toAppError } from '@/lib/api/errors.ts'; +import { formatMs } from '@/lib/format/time.ts'; +import { ICONS } from '@/lib/icons.ts'; +import { useChannelDigest } from './api.ts'; +import { formatInZone, zoneLabel } from './model.ts'; +import { PlatformPreview } from './preview/PlatformPreview.tsx'; + +/** Props. */ +export interface DigestNowDialogProps { + readonly channel: ChannelView; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +/** The dialog. */ +export function DigestNowDialog({ channel, open, onOpenChange }: DigestNowDialogProps) { + const preview = useChannelDigest(); + const send = useChannelDigest(); + const [sent, setSent] = useState(null); + const zone = channel.reports.time_zone; + const weekly = channel.reports.digest?.every === 'week'; + const { mutate: load, reset: resetPreview } = preview; + const { reset: resetSend } = send; + + useEffect(() => { + if (!open) return; + setSent(null); + resetSend(); + resetPreview(); + load({ id: channel.channel_id, send: false }); + }, [open, channel.channel_id, load, resetPreview, resetSend]); + + const data = sent ?? preview.data ?? null; + const Send = ICONS.sendTest; + const Ok = ICONS.success; + const busy = send.isPending; + const error = preview.error ?? null; + return ( + + + + Send a digest now + + {weekly ? 'The last seven days' : 'The last 24 hours'}, from real activity, exactly as{' '} + {channel.name} receives it. The schedule is not changed. + + + + {data !== null ? ( +

+ {formatInZone(data.window.since, zone)} → {formatInZone(data.window.until, zone)} ·{' '} + {zoneLabel(zone)} +

+ ) : null} + {data?.empty === true ? ( + + A scheduled digest for a period like this is not sent (it is logged as “nothing + happened”). Sending it now shows you what it would say. + + ) : null} + {error !== null ? ( + + {toAppError(error).message} + + ) : data === null ? ( +
+ + +
+ ) : ( + + )} +
+ +
+ {busy ? ( + + Sending… + + ) : sent?.ok === true ? ( + + + ) : sent !== null ? ( + + Not sent: {deliveryReasonText(sent.error?.code ?? 'failed') ?? sent.error?.code} + {sent.error?.message !== undefined ? ( + {sent.error.message} + ) : null} + + ) : send.error !== null ? ( + {toAppError(send.error).message} + ) : channel.status === 'paused' ? ( + Resume the channel to send it. + ) : !channel.ready ? ( + + {channel.problem ?? 'The channel cannot send yet.'} + + ) : null} +
+ + +
+
+
+ ); +} diff --git a/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx index 04d8067..b282cd3 100644 --- a/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx +++ b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.test.tsx @@ -64,8 +64,14 @@ const REFUSED_STARTUP = row({ outcome: 'not_allowed', }); -function mount(rows: readonly ActionRow[], routes: Record = {}, url = '/') { +function mount( + rows: readonly ActionRow[], + routes: Record = {}, + url = '/', + scopes?: readonly string[], +) { return renderPage({ + ...(scopes !== undefined && { scopes }), path: '/', component: ActionsPage, validateSearch: (s) => actionsSearch.parse(s), @@ -136,6 +142,16 @@ describe('ActionsPage', () => { expect(await screen.findByText('Allowed now')).toBeDefined(); }); + it('explains, instead of failing with a 403, when channels:write is missing', async () => { + mount([REFUSED], {}, '/', ['channels:read']); + await waitFor(() => + expect( + screen.getByRole('button', { name: /Allow this person/ }).hasAttribute('disabled'), + ).toBe(true), + ); + expect(screen.getByText('Needs the channels:write permission.')).toBeDefined(); + }); + it('points a startup channel to its allow= parameter', async () => { mount([REFUSED_STARTUP]); const allow = await screen.findByRole('button', { name: /Allow this person/ }); diff --git a/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx index 0a425bf..d8cb4ba 100644 --- a/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx +++ b/packages/dashboard/src/features/notifications/channels/actions/ActionsPage.tsx @@ -2,6 +2,8 @@ import type { ActionRow, ChannelView } from '@browserhive/contracts/http'; import { ACTION_OUTCOME_TEXT } from '@browserhive/contracts/notifications'; import { Link } from '@tanstack/react-router'; +import { useId } from 'react'; +import { useHasScope } from '@/app/providers/AuthProvider.tsx'; import { useConfirm } from '@/app/providers/ConfirmProvider.tsx'; import { useTopic } from '@/app/providers/SocketProvider.tsx'; import { useToast } from '@/app/providers/ToastProvider.tsx'; @@ -87,6 +89,8 @@ function AllowButton({ const confirm = useConfirm(); const toast = useToast(); const allow = useAllowPresser(); + const canWrite = useHasScope('channels:write'); + const reasonId = useId(); if (id === null || channel === undefined) return null; const who = row.actor_name ?? id; const Check = ICONS.check; @@ -119,23 +123,36 @@ function AllowButton({ }, ); }; + const why = startup + ? `${channel.name} comes from --notificationChannel: add allow=${id} to its flag and restart.` + : !canWrite + ? 'Needs the channels:write permission.' + : null; return ( - + + + {why !== null && !startup ? ( + + {why} + + ) : why !== null ? ( + + {why} + + ) : null} + ); } diff --git a/packages/dashboard/src/features/notifications/channels/api.ts b/packages/dashboard/src/features/notifications/channels/api.ts index 3ef1920..286a662 100644 --- a/packages/dashboard/src/features/notifications/channels/api.ts +++ b/packages/dashboard/src/features/notifications/channels/api.ts @@ -107,6 +107,24 @@ export function useChannelActions() { return { pause, resume, remove }; } +/** + * `POST /channels/{id}/digest` (D-43): the digest of the period that ends now, previewed + * (`send: false`, pure) or also sent at once (`send: true`, a manual report in the delivery log). + */ +export function useChannelDigest() { + const api = useApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, send }: { readonly id: string; readonly send: boolean }) => + api.sendChannelDigest({ params: { channel_id: id }, body: { send } }), + onSettled: (_result, _error, input) => { + if (!input.send) return; + void queryClient.invalidateQueries({ queryKey: keys.channels.list() }); + void queryClient.invalidateQueries({ queryKey: keys.channels.deliveryLists() }); + }, + }); +} + /** `POST /channels/{id}/test`: sends a real message now and returns the delivery row. */ export function useTestChannel() { const api = useApi(); diff --git a/packages/dashboard/src/features/notifications/channels/coverage.test.ts b/packages/dashboard/src/features/notifications/channels/coverage.test.ts index 221890c..3e02509 100644 --- a/packages/dashboard/src/features/notifications/channels/coverage.test.ts +++ b/packages/dashboard/src/features/notifications/channels/coverage.test.ts @@ -25,6 +25,7 @@ const OPERATIONS = [ 'startDiscordConnect', 'getDiscordConnect', 'listChannelActions', + 'sendChannelDigest', ] as const; function sources(dir: string): string[] { diff --git a/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx b/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx index 5c5c32f..3774b80 100644 --- a/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx +++ b/packages/dashboard/src/features/notifications/channels/log/DeliveryDetailSheet.tsx @@ -17,6 +17,7 @@ import { formatAbsolute, formatMs } from '@/lib/format/time.ts'; import { DELIVERY_STATUS } from '@/lib/status-registry.ts'; import { cn } from '@/lib/utils.ts'; import { useDelivery, useNotificationDeliveries } from '../api.ts'; +import { formatInZone, zoneLabel } from '../model.ts'; import { PlatformMark } from '../platforms.tsx'; /** "sent", "not sent: quiet hours", … as one sentence for the timeline. */ @@ -129,6 +130,28 @@ export function DeliveryDetailSheet({ seq, onClose }: DeliveryDetailSheetProps) key: 'Latency', value: row.duration_ms === null ? '—' : formatMs(row.duration_ms), }, + ...(row.report !== null + ? [ + { + key: 'Covers', + value: ( + + + {formatInZone(row.report.window.since, row.report.time_zone)} →{' '} + {formatInZone(row.report.window.until, row.report.time_zone)} + + + {zoneLabel(row.report.time_zone)} + {row.report.late + ? ` · sent late${row.report.skipped > 0 ? `, ${row.report.skipped} earlier skipped` : ''}` + : ''} + {row.report.manual ? ' · sent on demand' : ''} + + + ), + }, + ] + : []), { key: 'Queued', value: formatAbsolute(row.created_at) }, { key: 'Updated', value: formatAbsolute(row.updated_at) }, ...(row.next_attempt_at !== null && diff --git a/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.test.tsx b/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.test.tsx new file mode 100644 index 0000000..f2e2efc --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.test.tsx @@ -0,0 +1,85 @@ +/** @module features/notifications/channels/log/DeliveryLogPage.test — reports in the delivery log (D-43): the window in the channel's zone, the late pill with the skipped count, the on-demand pill, an empty digest explained in words; axe clean */ +import { describe, expect, it } from 'bun:test'; +import { ChannelsResponse, type DeliveryRow } from '@browserhive/contracts/http'; +import { CAPTURED } from '../../../../../test/fixtures/channels.ts'; +import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; +import { envelope, renderPage } from '../../../../../test/helpers/page-harness.tsx'; +import { screen, within } from '../../../../../test/helpers/render.tsx'; +import { deliveryLogSearch } from '../search.ts'; +import { DeliveryLogPage } from './DeliveryLogPage.tsx'; + +const LIST = ChannelsResponse.parse(CAPTURED.channels); +const BASE = CAPTURED.deliveries.data[0] as DeliveryRow; +const NINE = Date.UTC(2026, 8, 29, 7); + +function row(overrides: Partial): DeliveryRow { + return { ...BASE, ...overrides }; +} + +const ROWS: DeliveryRow[] = [ + row({ + seq: 3, + notification_kind: 'digest.daily', + notification_title: 'Daily digest · Tue 29 Sep', + reason: null, + status: 'sent', + report: { + window: { since: NINE - 86_400_000, until: NINE }, + time_zone: 'Europe/Berlin', + late: true, + skipped: 2, + manual: false, + }, + }), + row({ + seq: 2, + notification_kind: 'digest.daily', + notification_title: 'Daily digest · Mon 28 Sep', + status: 'suppressed', + reason: 'empty', + report: { + window: { since: NINE - 2 * 86_400_000, until: NINE - 86_400_000 }, + time_zone: 'Europe/Berlin', + late: false, + skipped: 0, + manual: false, + }, + }), + row({ + seq: 1, + notification_kind: 'digest.daily', + notification_title: 'Daily digest · Tue 29 Sep', + reason: 'manual', + report: { + window: { since: NINE - 86_400_000, until: NINE }, + time_zone: 'Europe/Berlin', + late: false, + skipped: 0, + manual: true, + }, + }), +]; + +describe('DeliveryLogPage reports', () => { + it('shows the window, the late pill and why an empty digest was not sent', async () => { + const view = renderPage({ + path: '/', + component: DeliveryLogPage, + validateSearch: (s) => deliveryLogSearch.parse(s), + url: '/', + routes: { + 'GET /channels': LIST, + 'GET /channels/deliveries': envelope(ROWS), + }, + }); + const late = await screen.findByText('late'); + const item = late.closest('li'); + if (item === null) throw new Error('no row'); + expect(within(item).getByText('Mon 28 Sep, 09:00 → Tue 29 Sep, 09:00')).toBeDefined(); + expect(screen.getByText('on demand')).toBeDefined(); + expect( + screen.getByText('Nothing happened in the period of this digest, so nothing was sent.'), + ).toBeDefined(); + await expectNoA11yViolations(view.container); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx b/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx index 335038f..5e1782c 100644 --- a/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx +++ b/packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.tsx @@ -19,6 +19,7 @@ import { DELIVERY_STATUS } from '@/lib/status-registry.ts'; import { cn } from '@/lib/utils.ts'; import { NotificationsNav } from '../../NotificationsNav.tsx'; import { useChannels, useDeliveries } from '../api.ts'; +import { formatInZone } from '../model.ts'; import { PlatformMark } from '../platforms.tsx'; import type { DeliveryLogSearch } from '../search.ts'; import { DeliveryDetailSheet } from './DeliveryDetailSheet.tsx'; @@ -36,6 +37,9 @@ const KIND_OPTIONS: readonly NotificationKind[] = [ 'session.crashed', 'session.reaped', 'system.degraded', + 'digest.daily', + 'digest.weekly', + 'report.anomaly', 'test', ]; const KIND_LABEL: Readonly> = { @@ -45,9 +49,42 @@ const KIND_LABEL: Readonly> = { 'session.crashed': 'Crash', 'session.reaped': 'Reaped', 'system.degraded': 'System', + 'digest.daily': 'Daily digest', + 'digest.weekly': 'Weekly digest', + 'report.anomaly': 'Anomaly alert', test: 'Test', }; +/** A report's window, and whether it went out late or on demand (D-43). */ +export function ReportMarks({ report }: { readonly report: NonNullable }) { + const zone = report.time_zone; + return ( + + + {formatInZone(report.window.since, zone)} → {formatInZone(report.window.until, zone)} + + {report.late ? ( + 0 + ? `Sent after BrowserHive started again; ${report.skipped} earlier ${report.skipped === 1 ? 'window was' : 'windows were'} skipped.` + : 'Sent after BrowserHive started again: it was not running at the scheduled time.', + }} + /> + ) : null} + {report.manual ? ( + + ) : null} + + ); +} + function Row({ row, onOpen }: { readonly row: DeliveryRow; readonly onOpen: () => void }) { const entry = DELIVERY_STATUS[row.status]; const reason = row.status === 'sent' ? null : deliveryReasonText(row.reason); @@ -76,6 +113,7 @@ function Row({ row, onOpen }: { readonly row: DeliveryRow; readonly onOpen: () = ? ` · ${KIND_LABEL[row.notification_kind] ?? row.notification_kind}` : ''} + {row.report !== null ? : null} diff --git a/packages/dashboard/src/features/notifications/channels/model.test.ts b/packages/dashboard/src/features/notifications/channels/model.test.ts index 32b843f..3281060 100644 --- a/packages/dashboard/src/features/notifications/channels/model.test.ts +++ b/packages/dashboard/src/features/notifications/channels/model.test.ts @@ -5,6 +5,7 @@ import { applyPreset, channelWhere, cleanRules, + digestText, draftForKind, draftForMode, draftProblems, @@ -12,8 +13,10 @@ import { draftToPatch, EMPTY_DRAFT, envSnippet, + formatInZone, isPrivateUrl, isPublicNtfy, + nextDigestAt, ntfyLinks, presetOf, randomReplyTopic, @@ -22,6 +25,7 @@ import { stepProblems, suggestName, ttlChoices, + zoneLabel, } from './model.ts'; describe('presets', () => { @@ -220,3 +224,51 @@ describe('act buttons and Discord modes', () => { ); }); }); + +describe('reports (D-43, D-44)', () => { + it('switches the digest and the anomaly alerts on with the Daily digest preset', () => { + const rules = applyPreset({ categories: ['needs-you'] }, 'daily-digest'); + expect(rules).toEqual({ + categories: ['reports'], + digest: { every: 'day', at: '09:00' }, + anomaly: {}, + }); + expect(presetOf(rules)).toBe('daily-digest'); + // A schedule already set is kept; other presets leave schedules alone. + expect(applyPreset({ digest: { every: 'week', at: '07:00' } }, 'daily-digest').digest).toEqual({ + every: 'week', + at: '07:00', + }); + expect(applyPreset(rules, 'needs-me').digest).toEqual({ every: 'day', at: '09:00' }); + }); + + it('keeps schedules and switched-off checks in the API body', () => { + expect( + cleanRules({ anomaly: {}, digest: { every: 'day', at: '09:00', day: undefined } }), + ).toEqual({ anomaly: {}, digest: { every: 'day', at: '09:00' } }); + expect(cleanRules({ anomaly: { capacity: false, error_rate: null } })).toEqual({ + anomaly: { capacity: false, error_rate: null }, + }); + }); + + it('writes schedules, times and zones for people', () => { + expect(digestText({ every: 'day', at: '09:00' })).toBe('daily at 09:00'); + expect(digestText({ every: 'week', at: '08:30', day: 'fri' })).toBe('Fridays at 08:30'); + expect(formatInZone(Date.UTC(2026, 8, 29, 7), 'Europe/Berlin')).toBe('Tue 29 Sep, 09:00'); + expect(zoneLabel('America/New_York')).toBe('America/New York'); + expect( + nextDigestAt({ every: 'day', at: '09:00' }, 'Europe/Berlin', Date.UTC(2026, 8, 29, 8)), + ).toBe(Date.UTC(2026, 8, 30, 7)); + }); + + it('summarises schedules outside the Daily digest preset', () => { + expect( + rulesSummary({ + categories: ['needs-you'], + digest: { every: 'day', at: '09:00' }, + anomaly: {}, + }), + ).toBe('Needs me now · daily digest · anomaly alerts'); + expect(rulesSummary(applyPreset({}, 'daily-digest'))).toBe('Daily digest'); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/model.ts b/packages/dashboard/src/features/notifications/channels/model.ts index 12f8e02..3a7b8db 100644 --- a/packages/dashboard/src/features/notifications/channels/model.ts +++ b/packages/dashboard/src/features/notifications/channels/model.ts @@ -8,10 +8,15 @@ import { type ChannelConfigProblem, checkChannelConfig, checkChannelRules, + DEFAULT_DIGEST_AT, + type DigestRule, + digestDay, type NotificationChannelRules, NTFY_DEFAULT_SERVER, + nextOccurrence, type SecretParamSpec, TELEGRAM_TTL_MAX_MS, + type Weekday, } from '@browserhive/contracts/notifications'; import { readStorage, removeStorage, writeStorage } from '@/lib/storage.ts'; @@ -99,7 +104,10 @@ export function presetOf(rules: NotificationChannelRules): string | null { return null; } -/** Rules with the preset's categories (other settings kept). */ +/** + * Rules with the preset's categories (other settings kept). The Daily digest preset also switches + * on the daily digest at 09:00 and the anomaly alerts when they are off (D-43, D-44). + */ export function applyPreset( rules: NotificationChannelRules, presetId: string, @@ -107,7 +115,84 @@ export function applyPreset( const preset = CHANNEL_PRESETS.find((p) => p.id === presetId); if (preset === undefined) return rules; const { categories: _categories, ...rest } = rules; - return preset.categories === null ? rest : { ...rest, categories: [...preset.categories] }; + const next = preset.categories === null ? rest : { ...rest, categories: [...preset.categories] }; + if (preset.reports !== true) return next; + return { + ...next, + digest: next.digest ?? { every: 'day', at: DEFAULT_DIGEST_AT }, + anomaly: next.anomaly ?? {}, + }; +} + +/** Short weekday names of a weekly digest. */ +export const WEEKDAY_LABEL: { readonly [D in Weekday]: string } = { + mon: 'Monday', + tue: 'Tuesday', + wed: 'Wednesday', + thu: 'Thursday', + fri: 'Friday', + sat: 'Saturday', + sun: 'Sunday', +}; + +/** The digest schedule in words ("daily at 09:00", "weekdays at 09:00", "Fridays at 17:00"). */ +export function digestText(rule: DigestRule): string { + if (rule.every === 'week') return `${WEEKDAY_LABEL[digestDay(rule)]}s at ${rule.at}`; + return rule.weekdays_only === true ? `weekdays at ${rule.at}` : `daily at ${rule.at}`; +} + +/** + * A time in a zone for the setup and the cards: `Wed 30 Sep, 09:00`, with the year when it is not + * this year's. + * + * @returns The text. + */ +export function formatInZone(at: number, zone: string): string { + const options: Intl.DateTimeFormatOptions = { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }; + let parts: Intl.DateTimeFormatPart[]; + try { + parts = new Intl.DateTimeFormat('en-GB', { ...options, timeZone: zone }).formatToParts(at); + } catch { + parts = new Intl.DateTimeFormat('en-GB', options).formatToParts(at); + } + const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value; + const month = (get('month') ?? '').replace('Sept', 'Sep'); + return `${get('weekday')} ${get('day')} ${month}, ${get('hour')}:${get('minute')}`; +} + +/** The next digest of a draft or channel, from its rule and zone (the server's own calendar maths). */ +export function nextDigestAt(rule: DigestRule, zone: string, now: number): number { + return nextOccurrence(rule, zone, now); +} + +/** The browser's IANA zone. */ +export function browserZone(): string { + try { + return new Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +/** Every IANA zone the browser knows (for the time zone picker). */ +export function timeZones(): readonly string[] { + try { + return Intl.supportedValuesOf('timeZone'); + } catch { + return ['UTC']; + } +} + +/** A zone as people read it (`America/New York`). */ +export function zoneLabel(zone: string): string { + return zone.replaceAll('_', ' '); } /** One-line summary of what a channel sends ("Needs you, Problems · warn and up · quiet 22:00–07:00"). */ @@ -132,6 +217,11 @@ export function rulesSummary(rules: NotificationChannelRules): string { const ttls = Object.values(rules.ttl_ms ?? {}).filter((v): v is number => typeof v === 'number'); if (ttls.length > 0) parts.push(`self-destruct ${formatTtl(Math.min(...ttls))}`); if (rules.act_buttons === true) parts.push('answer from the chat'); + if (rules.digest !== undefined && presetOf(rules) !== 'daily-digest') { + parts.push(rules.digest.every === 'week' ? 'weekly digest' : 'daily digest'); + } + if (rules.anomaly !== undefined && presetOf(rules) !== 'daily-digest') + parts.push('anomaly alerts'); return parts.join(' · '); } @@ -385,6 +475,12 @@ export function cleanRules(rules: NotificationChannelRules): NotificationChannel for (const [key, value] of Object.entries(rules)) { if (value === undefined) continue; if (Array.isArray(value) && value.length === 0 && key !== 'categories') continue; + if (key === 'digest' || key === 'anomaly') { + // A schedule is kept even when empty (`anomaly: {}` = every check at its default), and a + // check switched off (`capacity: false`, `error_rate: null`) keeps its value. + out[key] = Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)); + continue; + } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const entries = Object.entries(value).filter(([, v]) => v !== undefined && v !== false); if (key !== 'quiet_hours' && entries.length === 0) continue; diff --git a/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx b/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx index ad22e99..97af002 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx +++ b/packages/dashboard/src/features/notifications/channels/preview/TelegramMock.tsx @@ -185,7 +185,15 @@ function renderNode(node: TgNode, k: string, ctx: RenderCtx): ReactNode { node.bordered === true && 'border border-tg-muted/30', )} > -
+
{children}
diff --git a/packages/dashboard/src/features/notifications/channels/preview/discord-markdown.tsx b/packages/dashboard/src/features/notifications/channels/preview/discord-markdown.tsx index ed6ea02..5ff331b 100644 --- a/packages/dashboard/src/features/notifications/channels/preview/discord-markdown.tsx +++ b/packages/dashboard/src/features/notifications/channels/preview/discord-markdown.tsx @@ -155,13 +155,21 @@ function renderToken(token: MdToken, k: string): ReactNode { } } -/** Renders a block of Discord markdown (lines, `> ` quotes, `- ` bullets). */ +/** Renders a block of Discord markdown (lines, `-# ` subtext, `> ` quotes, `- ` bullets). */ export function DiscordMarkdown({ text }: { readonly text: string }) { const lines = text.split('\n'); return ( <> {lines.map((line, n) => { const key = `l${n}`; + if (line.startsWith('-# ')) { + // Subtext: Discord draws it small and muted. + return ( +
+ {renderTokens(parseInline(line.slice(3)), key)} +
+ ); + } if (line.startsWith('> ')) { return (
diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx index 613803d..7334fed 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/ChannelWizardPage.tsx @@ -26,6 +26,7 @@ import { useCreateChannel, useUpdateChannel, } from '../api.ts'; +import { DigestNowDialog } from '../DigestNowDialog.tsx'; import { type ChannelDraft, channelWhere, @@ -154,6 +155,8 @@ interface WizardProps { readonly title: ReactNode; readonly headerActions?: ReactNode; readonly notice?: ReactNode; + /** The zone BrowserHive runs in (`GET /channels` `host_time_zone`), once known. */ + readonly hostZone?: string | undefined; } function Wizard({ @@ -171,6 +174,7 @@ function Wizard({ title, headerActions, notice, + hostZone, }: WizardProps) { const [attempted, setAttempted] = useState>(new Set()); const names = draftEnvNames(draft); @@ -284,6 +288,7 @@ function Wizard({ onRules={(rules: NotificationChannelRules) => setDraft((d) => ({ ...d, rules }))} connection={channel?.connection ?? null} onStep={onStep} + {...(hostZone !== undefined && { hostZone })} /> ); case 'preview': @@ -450,6 +455,7 @@ export function NewChannelPage() { const hasDraft = draft.kind !== null; return ( { if (channel !== null && draft === null) setDraftState(draftFromChannel(channel)); @@ -538,62 +545,75 @@ export function EditChannelPage() { .map((c) => c.name); const Pause = ICONS.pause; const Play = ICONS.play; + const Digest = ICONS.digest; return ( - update.mutate(draftToPatch(draft), { onSuccess: () => setSaved(true) })} - savedId={null} - title={ - - - {channel.name} - - - } - headerActions={ - channel.status === 'active' ? ( - - ) : ( - - ) - } - notice={ - readOnly ? ( - - It was declared with --notificationChannel when - BrowserHive started, so it is read-only here: change the flag and restart to edit it. - You can still preview it, send a test and pause it. - - ) : saved ? ( - - They apply to the next notification. - - ) : undefined - } - /> + <> + update.mutate(draftToPatch(draft), { onSuccess: () => setSaved(true) })} + savedId={null} + title={ + + + {channel.name} + + + } + headerActions={ + <> + {channel.reports.digest !== null ? ( + + ) : null} + {channel.status === 'active' ? ( + + ) : ( + + )} + + } + notice={ + readOnly ? ( + + It was declared with --notificationChannel when + BrowserHive started, so it is read-only here: change the flag and restart to edit it. + You can still preview it, send a test and pause it. + + ) : saved ? ( + + They apply to the next notification. + + ) : undefined + } + /> + + ); } diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.test.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.test.tsx new file mode 100644 index 0000000..a98ca9d --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.test.tsx @@ -0,0 +1,160 @@ +/** @module features/notifications/channels/wizard/ReportsSection.test — the Reports section (D-43, D-44): Off · Every day · Every week, the time and weekday, the next run in the channel's zone, the zone picker defaulting to the host's (and replacing an older quiet-hours zone), the anomaly switch with its checks, the thresholds in Advanced, axe clean */ +import { describe, expect, it } from 'bun:test'; +import type { NotificationChannelRules } from '@browserhive/contracts/notifications'; +import { useState } from 'react'; +import { expectNoA11yViolations } from '../../../../../test/helpers/axe.ts'; +import { fireEvent, render, screen } from '../../../../../test/helpers/render.tsx'; +import { AnomalyThresholds, checkOff, ReportsSection } from './ReportsSection.tsx'; + +/** 29 Sep 2026 12:00 UTC (14:00 in Berlin). */ +const NOW = Date.UTC(2026, 8, 29, 12); + +function Harness({ initial }: { readonly initial: NotificationChannelRules }) { + const [rules, setRules] = useState(initial); + return ( + <> + + {JSON.stringify(rules)} + + ); +} + +const rules = () => JSON.parse(screen.getByTestId('rules').textContent ?? '{}'); + +describe('ReportsSection', () => { + it('schedules a daily digest at 09:00 and shows the next one in the channel zone', async () => { + const view = render(); + expect(screen.getByRole('heading', { name: 'Reports' })).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Every day' })); + expect(rules().digest).toEqual({ every: 'day', at: '09:00' }); + // 14:00 in Berlin: the next 09:00 is tomorrow. + expect(screen.getByText('Wed 30 Sep, 09:00')).toBeDefined(); + fireEvent.change(screen.getByLabelText('At'), { target: { value: '18:30' } }); + expect(rules().digest.at).toBe('18:30'); + expect(screen.getByText('Tue 29 Sep, 18:30')).toBeDefined(); + await expectNoA11yViolations(view.container); + }); + + it('switches to weekly on Friday at 17:00 by default, and off again (D-43)', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Every week' })); + expect(rules().digest).toEqual({ every: 'week', at: '17:00', day: 'fri' }); + // Tue 29 Sep 14:00 in Berlin: this Friday. + expect(screen.getByText('Fri 2 Oct, 17:00')).toBeDefined(); + expect(screen.getByText(/The last seven days, weekend included/)).toBeDefined(); + expect(screen.queryByRole('checkbox', { name: 'Weekdays only' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Off' })); + expect(rules().digest).toBeUndefined(); + }); + + it('runs every day unless weekdays only is ticked; Monday covers the weekend', async () => { + const view = render(); + const box = screen.getByRole('checkbox', { name: 'Weekdays only' }); + expect(box.getAttribute('aria-checked')).toBe('false'); + fireEvent.click(box); + expect(rules().digest).toEqual({ every: 'day', at: '09:00', weekdays_only: true }); + expect(screen.getByText(/on Monday, the whole weekend/)).toBeDefined(); + fireEvent.click(screen.getByRole('checkbox', { name: 'Weekdays only' })); + expect(rules().digest).toEqual({ every: 'day', at: '09:00' }); + await expectNoA11yViolations(view.container); + }); + + it('skips the weekend in the next run with weekdays only', () => { + // Fri 2 Oct 2026 12:00 UTC: the next weekday run is Monday. + render( + undefined} + hostZone="Europe/Berlin" + readOnly={false} + errors={{}} + now={Date.UTC(2026, 9, 2, 12)} + />, + ); + expect(screen.getByText('Mon 5 Oct, 09:00')).toBeDefined(); + }); + + it('speaks of BrowserHive in its in-app form (D-45)', () => { + render( + undefined} + hostZone="Europe/Berlin" + readOnly={false} + errors={{}} + now={NOW} + />, + ); + expect(screen.getByRole('heading', { name: 'Reports in BrowserHive' })).toBeDefined(); + expect(screen.getByRole('group', { name: 'Digest in BrowserHive' })).toBeDefined(); + expect(screen.getByText(/Digest times follow this zone/)).toBeDefined(); + expect(screen.queryByText(/quiet hours/)).toBeNull(); + }); + + it('defaults the zone to the host and names it', () => { + render(); + const picker = screen.getByLabelText(/Time zone/) as HTMLInputElement; + expect(picker.value).toBe('Same as BrowserHive (Europe/Berlin)'); + expect(screen.getByText(/Now: Tue 29 Sep, 14:00 in Europe\/Berlin/)).toBeDefined(); + }); + + it('shows the next run in a chosen zone', () => { + render( + , + ); + // 21:00 in Tokyo: the next 09:00 there. + expect(screen.getByText('Wed 30 Sep, 09:00')).toBeDefined(); + expect(screen.getByText(/in Asia\/Tokyo/)).toBeDefined(); + }); + + it('switches anomaly alerts on and lists what is checked', async () => { + const view = render(); + const list = screen.getByRole('list', { name: 'What is checked' }); + expect(list.textContent).toContain('Many tool calls failing'); + expect(list.textContent).toContain('(off)'); + fireEvent.click(screen.getByRole('switch', { name: 'Tell me when something looks off' })); + expect(rules().anomaly).toBeUndefined(); + fireEvent.click(screen.getByRole('switch', { name: 'Tell me when something looks off' })); + expect(rules().anomaly).toEqual({}); + await expectNoA11yViolations(view.container); + }); +}); + +describe('AnomalyThresholds', () => { + function Thresholds() { + const [rule, setRule] = useState>({}); + return ( + <> + + {JSON.stringify(rule)} + + ); + } + const rule = () => JSON.parse(screen.getByTestId('rule').textContent ?? '{}'); + + it('tunes a threshold, switches a check off, and falls back to the default when emptied', async () => { + const view = render(); + const rate = screen.getByLabelText( + 'Many tool calls failing: Alert at (% failed)', + ) as HTMLInputElement; + expect(rate.placeholder).toBe('20'); + fireEvent.blur(rate, { target: { value: '10' } }); + expect(rule().error_rate).toBe(10); + fireEvent.blur(rate, { target: { value: '' } }); + expect(rule().error_rate).toBeUndefined(); + fireEvent.click(screen.getByRole('switch', { name: 'Sessions at the limit (maxSessions)' })); + expect(rule().capacity).toBe(false); + fireEvent.click(screen.getByRole('switch', { name: 'An attention request waiting too long' })); + expect(rule().attention_minutes).toBeNull(); + expect(checkOff(rule(), 'attention')).toBe(true); + await expectNoA11yViolations(view.container); + }); +}); diff --git a/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.tsx b/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.tsx new file mode 100644 index 0000000..b3f6f37 --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.tsx @@ -0,0 +1,455 @@ +/** @module features/notifications/channels/wizard/ReportsSection — "Reports" in the rules step (D-43, D-44) and, in its in-app form, on the Reports tab (D-45): the digest (Off · Every day · Every week; every day at 09:00, optionally weekdays only; every week on Friday at 17:00; a 24-hour time, a weekday) with the next run in the zone, the time zone (default the host's; for a channel it also applies to quiet hours), and "Tell me when something looks off" with its checks in plain words; a channel's thresholds live in Advanced ({@link AnomalyThresholds}) */ +import { + ANOMALY_CHECK_TEXT, + ANOMALY_CHECKS, + ANOMALY_DEFAULTS, + type AnomalyRule, + DEFAULT_DIGEST_DAY, + defaultDigest, + type NotificationChannelRules, + WEEKDAYS, + type Weekday, +} from '@browserhive/contracts/notifications'; +import { useId } from 'react'; +import { Checkbox } from '@/components/ui/checkbox.tsx'; +import { Input } from '@/components/ui/input.tsx'; +import { SimpleSelect } from '@/components/ui/select.tsx'; +import { Switch } from '@/components/ui/switch.tsx'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group.tsx'; +import { ICONS } from '@/lib/icons.ts'; +import { useServerNow } from '@/lib/server-now.ts'; +import { cn } from '@/lib/utils.ts'; +import { formatInZone, nextDigestAt, WEEKDAY_LABEL, zoneLabel } from '../model.ts'; +import { Field } from './fields.tsx'; +import { TimeZonePicker } from './TimeZonePicker.tsx'; + +type Frequency = 'off' | 'day' | 'week'; + +/** Props. */ +export interface ReportsSectionProps { + readonly rules: NotificationChannelRules; + readonly onRules: (rules: NotificationChannelRules) => void; + /** The zone BrowserHive runs in (`host_time_zone`). */ + readonly hostZone: string; + readonly readOnly: boolean; + readonly errors: Readonly>; + /** Now, for the next run (injectable for tests). */ + readonly now?: number; + /** `in-app`: the Reports tab's own schedule (D-45), with its own words and no quiet hours. */ + readonly variant?: 'channel' | 'in-app'; +} + +/** The digest, the zone and the anomaly switch. */ +export function ReportsSection({ + rules, + onRules, + hostZone, + readOnly, + errors, + now: nowProp, + variant = 'channel', +}: ReportsSectionProps) { + const inApp = variant === 'in-app'; + // On the Reports tab the section is a page section (h2); in the wizard it sits under a step (h3). + const Heading = inApp ? 'h2' : 'h3'; + const serverNow = useServerNow(30_000); + const now = nowProp ?? serverNow; + const id = useId(); + const digest = rules.digest; + const frequency: Frequency = digest === undefined ? 'off' : digest.every; + const zone = rules.time_zone ?? hostZone; + const anomaly = rules.anomaly !== undefined; + const on = digest !== undefined || anomaly; + const Digest = ICONS.digest; + const Radar = ICONS.anomaly; + const Globe = ICONS.globe; + const set = (patch: Partial) => { + const next: NotificationChannelRules = { ...rules, ...patch }; + // One zone per channel: an older quiet-hours zone gives way to the channel's. + if ('time_zone' in patch && next.quiet_hours?.time_zone !== undefined) { + const { time_zone: _old, ...hours } = next.quiet_hours; + onRules({ ...next, quiet_hours: hours }); + return; + } + onRules(next); + }; + // Each frequency starts from its own default: every day at 09:00, every week on Friday at 17:00. + const setFrequency = (f: Frequency) => { + if (f === 'off') return set({ digest: undefined }); + if (digest?.every === f) return; + set({ digest: defaultDigest(f) }); + }; + const next = digest === undefined ? null : nextDigestAt(digest, zone, now); + + return ( +
+
+ +
+ + {inApp ? 'Reports in BrowserHive' : 'Reports'} + +

+ {inApp + ? 'A digest in your inbox on your schedule, and an alert when something looks off, with no external channel needed. Digests arrive quietly (no pop-up, no badge); anomaly alerts follow your toast preferences.' + : 'A summary on your schedule, and a heads-up when something looks off. They come to this channel whatever its categories, and nothing is sent when there is nothing to tell.'} +

+
+
+ +
+
+ + {inApp ? 'Digest in BrowserHive' : 'Digest'} + + { + const v = nextValue[0]; + if (v === 'off' || v === 'day' || v === 'week') setFrequency(v); + }} + className="w-fit" + > + Off + Every day + Every week + + {digest !== undefined ? ( +
+ {digest.every === 'week' ? ( + + ({ value: d, label: WEEKDAY_LABEL[d] }))} + onValueChange={(v) => set({ digest: { ...digest, day: v as Weekday } })} + /> + + ) : null} + + { + if (/^\d{2}:\d{2}$/.test(e.target.value)) { + set({ digest: { ...digest, at: e.target.value } }); + } + }} + /> + + {digest.every === 'day' ? ( +
+ { + const { weekdays_only: _off, ...rest } = digest; + set({ digest: checked === true ? { ...rest, weekdays_only: true } : rest }); + }} + /> + +
+ ) : null} + {next !== null ? ( +

+ Next digest: {formatInZone(next, zone)} +

+ ) : null} +
+ ) : null} + {digest !== undefined ? ( +

+ {digest.every === 'week' + ? 'The last seven days, weekend included,' + : digest.weekdays_only === true + ? 'The last working day (on Monday, the whole weekend)' + : 'The last 24 hours'}{' '} + in numbers: sessions, tool calls and errors, attention requests, vault fills, blocked + requests, the slowest tool, the top errors and open problems. If BrowserHive was off + at that time, the digest comes when it starts again, marked late. +

+ ) : null} + {errors['rules.digest.day'] !== undefined ? ( +

+ {errors['rules.digest.day']} +

+ ) : null} +
+ + + + +
+
+
+
+ ); +} + +/** Whether one anomaly check is switched off in a rule. */ +export function checkOff(rule: AnomalyRule, check: (typeof ANOMALY_CHECKS)[number]): boolean { + switch (check) { + case 'error_rate': + return rule.error_rate === null; + case 'attention': + return rule.attention_minutes === null; + case 'blocked': + return rule.blocked_spike === null; + case 'capacity': + return rule.capacity === false; + case 'degraded': + return rule.degraded === false; + } +} + +/** The anomaly thresholds (Advanced): each with its default as placeholder, and a switch per check. */ +export function AnomalyThresholds({ + rule, + onChange, + readOnly, +}: { + readonly rule: AnomalyRule; + readonly onChange: (rule: AnomalyRule) => void; + readonly readOnly: boolean; +}) { + const id = useId(); + const number = ( + key: 'error_rate' | 'min_calls' | 'attention_minutes' | 'blocked_spike' | 'blocked_min', + raw: string, + ) => { + const value = raw.trim() === '' ? undefined : Number(raw); + const next = { ...rule }; + if (value === undefined || !Number.isFinite(value)) delete next[key]; + else next[key] = value; + onChange(next); + }; + const rows: readonly { + readonly check: (typeof ANOMALY_CHECKS)[number]; + readonly fields: readonly { + readonly key: + | 'error_rate' + | 'min_calls' + | 'attention_minutes' + | 'blocked_spike' + | 'blocked_min'; + readonly label: string; + readonly unit: string; + readonly step: number; + }[]; + }[] = [ + { + check: 'error_rate', + fields: [ + { key: 'error_rate', label: 'Alert at', unit: '% failed', step: 1 }, + { key: 'min_calls', label: 'With at least', unit: 'calls an hour', step: 1 }, + ], + }, + { + check: 'attention', + fields: [ + { key: 'attention_minutes', label: 'Alert after', unit: 'minutes waiting', step: 5 }, + ], + }, + { + check: 'blocked', + fields: [ + { key: 'blocked_spike', label: 'Alert at', unit: '× the usual hourly count', step: 0.5 }, + { key: 'blocked_min', label: 'And at least', unit: 'blocked an hour', step: 10 }, + ], + }, + { check: 'capacity', fields: [] }, + { check: 'degraded', fields: [] }, + ]; + const toggle = (check: (typeof ANOMALY_CHECKS)[number], on: boolean) => { + const next = { ...rule }; + switch (check) { + case 'error_rate': + if (on) delete next.error_rate; + else next.error_rate = null; + break; + case 'attention': + if (on) delete next.attention_minutes; + else next.attention_minutes = null; + break; + case 'blocked': + if (on) delete next.blocked_spike; + else next.blocked_spike = null; + break; + case 'capacity': + if (on) delete next.capacity; + else next.capacity = false; + break; + case 'degraded': + if (on) delete next.degraded; + else next.degraded = false; + break; + } + onChange(next); + }; + return ( +
+ Anomaly checks +

+ Each check alerts once when it crosses, and clears only well below its threshold, so it does + not flap. Empty fields use the default. +

+
+ {rows.map(({ check, fields }) => { + const off = checkOff(rule, check); + return ( +
+
+ toggle(check, checked)} + /> + +
+ {fields.length > 0 && !off ? ( +
+ {fields.map((f) => ( + + {f.label} + number(f.key, e.target.value)} + /> + {f.unit} + + ))} +
+ ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx b/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx index 624d417..1c7d0fb 100644 --- a/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx +++ b/packages/dashboard/src/features/notifications/channels/wizard/StepRules.tsx @@ -1,11 +1,11 @@ -/** @module features/notifications/channels/wizard/StepRules — step 4: the channel's name and what it sends: preset cards (Needs me now, Problems, Wrap-ups, Everything) and an Advanced disclosure with categories, minimum severity, session globs, harness, quiet hours with a time zone, content level, screenshots per category with masking (need Full; the ntfy.sh warning), self-destruct per category (Never by default, Telegram at most 47 h) and delete-when-resolved (off by default) (D-35, D-36) */ +/** @module features/notifications/channels/wizard/StepRules — step 4: the channel's name and what it sends: preset cards (Needs me now, Problems, Wrap-ups, Everything, Daily digest), the Reports section (digest, time zone, anomaly alerts; D-43, D-44), Answer from the chat, and an Advanced disclosure with categories, minimum severity, session globs, harness, quiet hours in the channel's zone, content level, the anomaly thresholds, screenshots per category with masking (need Full; the ntfy.sh warning), self-destruct per category (Never by default, Telegram at most 47 h) and delete-when-resolved (off by default) (D-35, D-36) */ import type { NotificationCategory, NotificationContentLevel } from '@browserhive/contracts/enums'; import type { ChannelConnection } from '@browserhive/contracts/http'; import { CHANNEL_PRESETS, type NotificationChannelRules, } from '@browserhive/contracts/notifications'; -import { useId, useMemo, useState } from 'react'; +import { useId, useState } from 'react'; import { Callout } from '@/components/shared/Callout.tsx'; import { Checkbox } from '@/components/ui/checkbox.tsx'; import { Input } from '@/components/ui/input.tsx'; @@ -15,16 +15,22 @@ import { docsUrl } from '@/lib/links.ts'; import { cn } from '@/lib/utils.ts'; import { applyPreset, + browserZone, CATEGORIES, type ChannelDraft, isPublicNtfy, presetOf, ttlChoices, + zoneLabel, } from '../model.ts'; import { ActButtonsSection } from './ActButtonsSection.tsx'; import { Field, SwitchField } from './fields.tsx'; +import { AnomalyThresholds, ReportsSection } from './ReportsSection.tsx'; import { RadioCard } from './StepPlatform.tsx'; +/** The categories that arrive as they happen (reports come on their schedule, D-43). */ +const INSTANT = CATEGORIES.filter((c) => c.id !== 'reports'); + const SEVERITIES = [ { value: 'info', label: 'Everything (info and up)' }, { value: 'warn', label: 'Warnings and up' }, @@ -54,14 +60,6 @@ const CONTENT: readonly { }, ]; -function timeZones(): readonly string[] { - try { - return Intl.supportedValuesOf('timeZone'); - } catch { - return ['UTC']; - } -} - /** Props. */ export interface StepRulesProps { readonly draft: ChannelDraft; @@ -73,6 +71,8 @@ export interface StepRulesProps { readonly connection?: ChannelConnection | null; /** Opens another wizard step (the act-button blockers point at Platform or Connect). */ readonly onStep?: (step: 'platform' | 'connect') => void; + /** The zone BrowserHive runs in (`GET /channels` `host_time_zone`); default the browser's. */ + readonly hostZone?: string; } function PerCategorySwitches({ @@ -123,13 +123,20 @@ export function StepRules({ readOnly, connection = null, onStep, + hostZone = browserZone(), }: StepRulesProps) { const rules = draft.rules; const preset = presetOf(rules); const [advanced, setAdvanced] = useState( preset === null || Object.keys(rules).some( - (k) => k !== 'categories' && k !== 'act_buttons' && k !== 'allow_list', + (k) => + k !== 'categories' && + k !== 'act_buttons' && + k !== 'allow_list' && + k !== 'digest' && + k !== 'anomaly' && + k !== 'time_zone', ), ); const nameId = useId(); @@ -138,8 +145,7 @@ export function StepRules({ const harnessId = useId(); const severityId = useId(); const tzId = useId(); - const zones = useMemo(timeZones, []); - const hostZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const zone = rules.quiet_hours?.time_zone ?? rules.time_zone ?? hostZone; const set = (patch: Partial) => onRules({ ...rules, ...patch }); const Chevron = advanced ? ICONS.chevronUp : ICONS.chevronDown; const content = rules.content ?? 'titles'; @@ -190,6 +196,14 @@ export function StepRules({ ) : null} + + Advanced - Severity, sessions, quiet hours, content, screenshots and self-destruct. + Severity, sessions, quiet hours, content, screenshots, self-destruct + {rules.anomaly !== undefined ? ' and the anomaly checks' : ''}.
+ ); +} diff --git a/packages/dashboard/src/features/notifications/reports/api.ts b/packages/dashboard/src/features/notifications/reports/api.ts new file mode 100644 index 0000000..2f3301b --- /dev/null +++ b/packages/dashboard/src/features/notifications/reports/api.ts @@ -0,0 +1,75 @@ +/** @module features/notifications/reports/api — the reports history (cursor-paged, window resolved per request), one report, and the in-app report settings query + mutation (spec 03 §4.8, D-45) */ +import type { ReportSettings } from '@browserhive/contracts/notifications'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useApi } from '@/app/providers/AuthProvider.tsx'; +import { useToast } from '@/app/providers/ToastProvider.tsx'; +import { useCursorPager } from '@/components/shared/use-cursor-pages.ts'; +import { useWindowAnchor } from '@/features/overview/api.ts'; +import { toAppError } from '@/lib/api/errors.ts'; +import { keys, stableParams } from '@/lib/api/keys.ts'; +import { rangeWindow } from '@/lib/search/time-range.ts'; +import type { ReportsSearch } from './search.ts'; + +/** `GET /notifications/reports` query (without cursor) for the URL search. */ +export function reportsQuery(search: ReportsSearch, anchor: number) { + const window = rangeWindow(search.range, anchor); + return { + limit: search.ps, + total: true, + ...(search.kind !== undefined && { kind: search.kind }), + ...(search.channel !== undefined && { channel: search.channel }), + ...(window.since !== undefined && { since: window.since }), + } as const; +} + +/** The page of reports for the current search. */ +export function useReportList(search: ReportsSearch) { + const api = useApi(); + const anchor = useWindowAnchor(); + const pager = useCursorPager(); + const query = reportsQuery(search, anchor); + const filterKey = JSON.stringify(stableParams(query)); + return useQuery({ + queryKey: keys.notifications.reports({ ...query, page: search.page }), + queryFn: () => + pager.resolve(filterKey, search.page, (cursor) => + api.listReports({ query: { ...query, ...(cursor !== undefined && { cursor }) } }), + ), + placeholderData: keepPreviousData, + }); +} + +/** One report. */ +export function useReport(notificationId: string) { + const api = useApi(); + return useQuery({ + queryKey: keys.notifications.report(notificationId), + queryFn: () => api.getReport({ params: { notification_id: notificationId as never } }), + }); +} + +/** `GET /notifications/report-settings`. */ +export function useReportSettings() { + const api = useApi(); + return useQuery({ + queryKey: keys.notifications.reportSettings(), + queryFn: () => api.getReportSettings(), + }); +} + +/** `PUT /notifications/report-settings`. */ +export function useSaveReportSettings() { + const api = useApi(); + const toast = useToast(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (settings: ReportSettings) => api.putReportSettings({ body: { settings } }), + onSuccess: (data) => { + queryClient.setQueryData(keys.notifications.reportSettings(), data); + toast.success({ title: 'Reports in BrowserHive saved' }); + }, + onError: (error) => { + toast.fromError(toAppError(error), 'Reports settings not saved'); + }, + }); +} diff --git a/packages/dashboard/src/features/notifications/reports/model.ts b/packages/dashboard/src/features/notifications/reports/model.ts new file mode 100644 index 0000000..62e2f8e --- /dev/null +++ b/packages/dashboard/src/features/notifications/reports/model.ts @@ -0,0 +1,95 @@ +/** @module features/notifications/reports/model — the Reports tab's pure helpers (D-45): kind labels, the window in its zone, the anomaly alert's outcome pill, the Overview period of a report and the words for a channel's delivery */ +import type { Notification, ReportItem } from '@browserhive/contracts/http'; +import type { NotificationMessage } from '@browserhive/contracts/notifications'; +import type { StatusEntry } from '@/lib/status-registry.ts'; +import { formatInZone, zoneLabel } from '../channels/model.ts'; + +const HOUR = 3_600_000; + +/** Kinds in the order the filter shows them, with their labels. */ +export const REPORT_KINDS = [ + { value: 'digest.daily', label: 'Daily digest' }, + { value: 'digest.weekly', label: 'Weekly digest' }, + { value: 'report.anomaly', label: 'Anomaly alert' }, +] as const; + +/** The label of a report kind. */ +export function reportKindLabel(kind: string): string { + return REPORT_KINDS.find((k) => k.value === kind)?.label ?? kind; +} + +/** Whether a notification is a digest (never toasts, arrives read). */ +export function isDigestKind(kind: string): boolean { + return kind === 'digest.daily' || kind === 'digest.weekly'; +} + +/** + * The window of a report in its zone: `Mon 28 Sep, 09:00 → Tue 29 Sep, 09:00 · Europe/Berlin`. + * + * @returns The text, or `null` without a report. + */ +export function reportWindowText(report: ReportItem['report']): string | null { + if (report === null) return null; + const { since, until } = report.window; + return `${formatInZone(since, report.time_zone)} → ${formatInZone(until, report.time_zone)} · ${zoneLabel(report.time_zone)}`; +} + +/** + * The pill of an anomaly alert: active while open, back to normal once resolved, closed when it + * was superseded or no longer checked. Digests have none. + * + * @returns The entry, or `null`. + */ +export function anomalyOutcome(n: Pick): StatusEntry | null { + if (n.kind !== 'report.anomaly') return null; + if (n.state === 'open') return { label: 'active', tone: 'warn' }; + if (n.state === 'resolved') return { label: 'back to normal', tone: 'success' }; + return { label: 'closed', tone: 'muted' }; +} + +/** + * The period "Open Overview for this period" opens: a digest's window; for an anomaly alert, from + * the start of the hour it first checked to its latest update. + * + * @returns Epoch-ms bounds, or `null` without a report. + */ +export function overviewPeriod( + n: Pick, + message: Pick, +): { readonly since: number; readonly until: number } | null { + const report = message.report; + if (report === undefined) return null; + if (n.kind !== 'report.anomaly') return report.window; + return { + since: Math.min(report.window.since, n.created_at - HOUR), + until: Math.max(report.window.until, message.at.updated), + }; +} + +/** A channel's delivery of a report in words, for the "Sent to" list. */ +export function deliveryWords(status: string, reason: string | null): string { + switch (status) { + case 'sent': + case 'superseded': + return 'sent'; + case 'pending': + case 'sending': + case 'retrying': + return 'sending'; + case 'dead': + return 'failed'; + case 'suppressed': + return reason === 'empty' + ? 'not sent: nothing happened' + : reason === 'channel_paused' + ? 'not sent: paused' + : 'not sent'; + default: + return status; + } +} + +/** Whether a delivery status reads as a problem. */ +export function deliveryFailed(status: string): boolean { + return status === 'dead'; +} diff --git a/packages/dashboard/src/features/notifications/reports/search.ts b/packages/dashboard/src/features/notifications/reports/search.ts new file mode 100644 index 0000000..3bf680c --- /dev/null +++ b/packages/dashboard/src/features/notifications/reports/search.ts @@ -0,0 +1,20 @@ +/** @module features/notifications/reports/search — `/notifications/reports` search params: kind csv (daily digest, weekly digest, anomaly alert), channel (a channel id, or `in-app` for reports that reached no channel), range (7d|30d|all, default 30d), page, ps (spec 04 §12.11.2, D-45) */ +import { ReportKind } from '@browserhive/contracts/http'; +import { z } from 'zod'; +import { csvParam, pageParam, pageSizeParam, TABLE_SEARCH_DEFAULTS } from '@/lib/search/table.ts'; + +/** The history's window vocabulary. */ +export const REPORT_RANGES = ['7d', '30d', 'all'] as const; + +/** Search schema. */ +export const reportsSearch = z.object({ + kind: csvParam(ReportKind), + channel: z.string().max(80).optional().catch(undefined), + range: z.enum(REPORT_RANGES).catch('30d').default('30d'), + page: pageParam, + ps: pageSizeParam, +}); +/** Parsed search. */ +export type ReportsSearch = z.infer; +/** Defaults omitted from the URL. */ +export const REPORTS_DEFAULTS = { ...TABLE_SEARCH_DEFAULTS, range: '30d' } as const; diff --git a/packages/dashboard/src/features/notifications/search.ts b/packages/dashboard/src/features/notifications/search.ts index f903eb0..af865c3 100644 --- a/packages/dashboard/src/features/notifications/search.ts +++ b/packages/dashboard/src/features/notifications/search.ts @@ -1,5 +1,5 @@ -/** @module features/notifications/search — `/notifications` search params: read (all|unread|read), type csv, range (24h|7d|30d|all, default 7d), page, ps (spec 04 §12.11) */ -import { NotificationType } from '@browserhive/contracts/enums'; +/** @module features/notifications/search — `/notifications` search params: read (all|unread|read), type csv, category csv (the Reports chip, D-45), range (24h|7d|30d|all, default 7d), page, ps (spec 04 §12.11) */ +import { NotificationCategory, NotificationType } from '@browserhive/contracts/enums'; import { z } from 'zod'; import { csvParam, pageParam, pageSizeParam, TABLE_SEARCH_DEFAULTS } from '@/lib/search/table.ts'; @@ -10,6 +10,7 @@ export const NOTIFICATION_RANGES = ['24h', '7d', '30d', 'all'] as const; export const notificationsSearch = z.object({ read: z.enum(['all', 'unread', 'read']).catch('all').default('all'), type: csvParam(NotificationType), + category: csvParam(NotificationCategory), range: z.enum(NOTIFICATION_RANGES).catch('7d').default('7d'), page: pageParam, ps: pageSizeParam, diff --git a/packages/dashboard/src/lib/api/keys.ts b/packages/dashboard/src/lib/api/keys.ts index 20b39dc..37ba2fa 100644 --- a/packages/dashboard/src/lib/api/keys.ts +++ b/packages/dashboard/src/lib/api/keys.ts @@ -109,6 +109,10 @@ export const keys = { lists: () => ['notifications', 'list'] as const, list: (params?: KeyParams) => ['notifications', 'list', stableParams(params)] as const, unreadCount: () => ['notifications', 'unread-count'] as const, + reportLists: () => ['notifications', 'reports'] as const, + reports: (params?: KeyParams) => ['notifications', 'reports', stableParams(params)] as const, + report: (id: string) => ['notifications', 'report', id] as const, + reportSettings: () => ['notifications', 'report-settings'] as const, }, channels: { all: ['channels'] as const, diff --git a/packages/dashboard/src/lib/api/operations.ts b/packages/dashboard/src/lib/api/operations.ts index e7d94f0..9f387d9 100644 --- a/packages/dashboard/src/lib/api/operations.ts +++ b/packages/dashboard/src/lib/api/operations.ts @@ -21,6 +21,8 @@ import { BulkVaultConfirmRequest, ChangePasswordRequest, ChangePasswordResponse, + ChannelDigestRequest, + ChannelDigestResponse, ChannelEnvQuery, ChannelEnvResponse, ChannelIdParams, @@ -84,11 +86,17 @@ import { PutGroupPolicyResponse, PutPreferencesRequest, PutPreferencesResponse, + PutReportSettingsRequest, PutVaultBindingRequest, PutVaultBindingResponse, RecentPagesQuery, RecentPagesResponse, ReloadBlocklistResponse, + ReportDetailResponse, + ReportIdParams, + ReportSettingsResponse, + ReportsPage, + ReportsQuery, RequestIdParams, ResolveAttentionRequest, ResolveBindingsRequest, @@ -305,6 +313,10 @@ export const OPERATIONS = { markAllNotificationsRead: { response: NotificationsUpdatedResponse }, dismissNotification: { params: NotificationIdParams, response: NotificationAckResponse }, dismissAllNotifications: { response: NotificationsUpdatedResponse }, + listReports: { query: ReportsQuery, response: ReportsPage }, + getReport: { params: ReportIdParams, response: ReportDetailResponse }, + getReportSettings: { response: ReportSettingsResponse }, + putReportSettings: { body: PutReportSettingsRequest, response: ReportSettingsResponse }, // §4.8.1 notification channels listChannels: { response: ChannelsResponse }, createChannel: { body: ChannelInput, response: ChannelResponse }, @@ -325,6 +337,11 @@ export const OPERATIONS = { pauseChannel: { params: ChannelIdParams, response: ChannelResponse }, resumeChannel: { params: ChannelIdParams, response: ChannelResponse }, testChannel: { params: ChannelIdParams, response: ChannelTestResponse }, + sendChannelDigest: { + params: ChannelIdParams, + body: ChannelDigestRequest, + response: ChannelDigestResponse, + }, getPreferences: { response: PreferencesResponse }, putPreferences: { body: PutPreferencesRequest, response: PutPreferencesResponse }, search: { query: SearchQuery, response: SearchResponse }, diff --git a/packages/dashboard/src/lib/icons.ts b/packages/dashboard/src/lib/icons.ts index f170f51..9de8e6b 100644 --- a/packages/dashboard/src/lib/icons.ts +++ b/packages/dashboard/src/lib/icons.ts @@ -16,6 +16,7 @@ import { BookOpen, Braces, Bug, + CalendarClock, Camera, Check, CheckCheck, @@ -43,6 +44,7 @@ import { ExternalLink, Eye, EyeOff, + FileChartColumn, FileQuestion, FileText, Folder, @@ -94,6 +96,7 @@ import { Power, Proportions, QrCode, + Radar, RefreshCw, Reply, Rocket, @@ -185,6 +188,10 @@ export const ICONS = { quietHours: MoonStar, attachment: Paperclip, deliveryLog: ListTree, + // Reports (N3) + digest: CalendarClock, + anomaly: Radar, + reports: FileChartColumn, // Act buttons (N2) actions: MousePointerClick, allowList: UserCheck, diff --git a/packages/dashboard/src/lib/links.ts b/packages/dashboard/src/lib/links.ts index 20dbcae..6ea734a 100644 --- a/packages/dashboard/src/lib/links.ts +++ b/packages/dashboard/src/lib/links.ts @@ -39,6 +39,7 @@ export const DOCS_PAGES = { attention: { page: 'guide/attention' }, notifications: { page: 'guide/notifications' }, notificationChannels: { page: 'guide/notifications', anchor: 'channels' }, + notificationReports: { page: 'guide/notifications', anchor: 'reports-in-browserhive' }, channelTelegram: { page: 'guide/notifications', anchor: 'telegram' }, channelDiscord: { page: 'guide/notifications', anchor: 'discord' }, discordBotTroubleshooting: { diff --git a/packages/dashboard/src/lib/status-registry.ts b/packages/dashboard/src/lib/status-registry.ts index 9e2afb0..0b554c5 100644 --- a/packages/dashboard/src/lib/status-registry.ts +++ b/packages/dashboard/src/lib/status-registry.ts @@ -161,6 +161,27 @@ export const NOTIFICATION_TYPE: { readonly [K in NotificationType]: StatusEntry system: { label: 'system', tone: 'info', icon: 'system' }, }; +/** Report kinds shown with their own icon and tone in the inbox, the bell and the Reports tab (D-45). */ +export const REPORT_KIND: { + readonly 'digest.daily': StatusEntry; + readonly 'digest.weekly': StatusEntry; + readonly 'report.anomaly': StatusEntry; +} = { + 'digest.daily': { label: 'daily digest', tone: 'accent', icon: 'digest' }, + 'digest.weekly': { label: 'weekly digest', tone: 'accent', icon: 'digest' }, + 'report.anomaly': { label: 'anomaly alert', tone: 'warn', icon: 'anomaly' }, +}; + +/** The entry a notification row shows: its report kind's, else its type's. */ +export function notificationEntry(n: { + readonly type: NotificationType; + readonly kind: string; +}): StatusEntry { + return n.kind in REPORT_KIND + ? REPORT_KIND[n.kind as keyof typeof REPORT_KIND] + : NOTIFICATION_TYPE[n.type]; +} + /** * Notification lifecycle (D-32). The inbox shows a pill only once a request's notification is no * longer open (`acted`, `resolved`, `expired`, and `final` as "closed" for a cancelled request); diff --git a/packages/dashboard/src/routeTree.gen.ts b/packages/dashboard/src/routeTree.gen.ts index aa9b9ec..3e4e263 100644 --- a/packages/dashboard/src/routeTree.gen.ts +++ b/packages/dashboard/src/routeTree.gen.ts @@ -28,10 +28,12 @@ import { Route as PublicLoginRouteImport } from './routes/_public/login'; import { Route as AuthNotificationsActionsRouteImport } from './routes/_auth/notifications_.actions'; import { Route as AuthNotificationsChannelsRouteImport } from './routes/_auth/notifications_.channels'; import { Route as AuthNotificationsLogRouteImport } from './routes/_auth/notifications_.log'; +import { Route as AuthNotificationsReportsRouteImport } from './routes/_auth/notifications_.reports'; import { Route as AuthSessionsIdRouteImport } from './routes/_auth/sessions_.$id'; import { Route as AuthVaultLogRouteImport } from './routes/_auth/vault_.log'; import { Route as AuthNotificationsChannelsChannelIdRouteImport } from './routes/_auth/notifications_.channels_.$channelId'; import { Route as AuthNotificationsChannelsNewRouteImport } from './routes/_auth/notifications_.channels_.new'; +import { Route as AuthNotificationsReportsNotificationIdRouteImport } from './routes/_auth/notifications_.reports_.$notificationId'; import { Route as AuthSessionsIdLiveRouteImport } from './routes/_auth/sessions_.$id_.live'; const IndexRoute = IndexRouteImport.update({ @@ -129,6 +131,12 @@ const AuthNotificationsLogRoute = AuthNotificationsLogRouteImport.update({ path: '/notifications/log', getParentRoute: () => AuthRoute, } as any); +const AuthNotificationsReportsRoute = + AuthNotificationsReportsRouteImport.update({ + id: '/notifications_/reports', + path: '/notifications/reports', + getParentRoute: () => AuthRoute, + } as any); const AuthSessionsIdRoute = AuthSessionsIdRouteImport.update({ id: '/sessions_/$id', path: '/sessions/$id', @@ -151,6 +159,12 @@ const AuthNotificationsChannelsNewRoute = path: '/notifications/channels/new', getParentRoute: () => AuthRoute, } as any); +const AuthNotificationsReportsNotificationIdRoute = + AuthNotificationsReportsNotificationIdRouteImport.update({ + id: '/notifications_/reports_/$notificationId', + path: '/notifications/reports/$notificationId', + getParentRoute: () => AuthRoute, + } as any); const AuthSessionsIdLiveRoute = AuthSessionsIdLiveRouteImport.update({ id: '/sessions_/$id_/live', path: '/sessions/$id/live', @@ -175,10 +189,12 @@ export interface FileRoutesByFullPath { '/notifications/actions': typeof AuthNotificationsActionsRoute; '/notifications/channels': typeof AuthNotificationsChannelsRoute; '/notifications/log': typeof AuthNotificationsLogRoute; + '/notifications/reports': typeof AuthNotificationsReportsRoute; '/sessions/$id': typeof AuthSessionsIdRoute; '/vault/log': typeof AuthVaultLogRoute; '/notifications/channels/$channelId': typeof AuthNotificationsChannelsChannelIdRoute; '/notifications/channels/new': typeof AuthNotificationsChannelsNewRoute; + '/notifications/reports/$notificationId': typeof AuthNotificationsReportsNotificationIdRoute; '/sessions/$id/live': typeof AuthSessionsIdLiveRoute; } export interface FileRoutesByTo { @@ -199,10 +215,12 @@ export interface FileRoutesByTo { '/notifications/actions': typeof AuthNotificationsActionsRoute; '/notifications/channels': typeof AuthNotificationsChannelsRoute; '/notifications/log': typeof AuthNotificationsLogRoute; + '/notifications/reports': typeof AuthNotificationsReportsRoute; '/sessions/$id': typeof AuthSessionsIdRoute; '/vault/log': typeof AuthVaultLogRoute; '/notifications/channels/$channelId': typeof AuthNotificationsChannelsChannelIdRoute; '/notifications/channels/new': typeof AuthNotificationsChannelsNewRoute; + '/notifications/reports/$notificationId': typeof AuthNotificationsReportsNotificationIdRoute; '/sessions/$id/live': typeof AuthSessionsIdLiveRoute; } export interface FileRoutesById { @@ -226,10 +244,12 @@ export interface FileRoutesById { '/_auth/notifications_/actions': typeof AuthNotificationsActionsRoute; '/_auth/notifications_/channels': typeof AuthNotificationsChannelsRoute; '/_auth/notifications_/log': typeof AuthNotificationsLogRoute; + '/_auth/notifications_/reports': typeof AuthNotificationsReportsRoute; '/_auth/sessions_/$id': typeof AuthSessionsIdRoute; '/_auth/vault_/log': typeof AuthVaultLogRoute; '/_auth/notifications_/channels_/$channelId': typeof AuthNotificationsChannelsChannelIdRoute; '/_auth/notifications_/channels_/new': typeof AuthNotificationsChannelsNewRoute; + '/_auth/notifications_/reports_/$notificationId': typeof AuthNotificationsReportsNotificationIdRoute; '/_auth/sessions_/$id_/live': typeof AuthSessionsIdLiveRoute; } export interface FileRouteTypes { @@ -252,10 +272,12 @@ export interface FileRouteTypes { | '/notifications/actions' | '/notifications/channels' | '/notifications/log' + | '/notifications/reports' | '/sessions/$id' | '/vault/log' | '/notifications/channels/$channelId' | '/notifications/channels/new' + | '/notifications/reports/$notificationId' | '/sessions/$id/live'; fileRoutesByTo: FileRoutesByTo; to: @@ -276,10 +298,12 @@ export interface FileRouteTypes { | '/notifications/actions' | '/notifications/channels' | '/notifications/log' + | '/notifications/reports' | '/sessions/$id' | '/vault/log' | '/notifications/channels/$channelId' | '/notifications/channels/new' + | '/notifications/reports/$notificationId' | '/sessions/$id/live'; id: | '__root__' @@ -302,10 +326,12 @@ export interface FileRouteTypes { | '/_auth/notifications_/actions' | '/_auth/notifications_/channels' | '/_auth/notifications_/log' + | '/_auth/notifications_/reports' | '/_auth/sessions_/$id' | '/_auth/vault_/log' | '/_auth/notifications_/channels_/$channelId' | '/_auth/notifications_/channels_/new' + | '/_auth/notifications_/reports_/$notificationId' | '/_auth/sessions_/$id_/live'; fileRoutesById: FileRoutesById; } @@ -451,6 +477,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthNotificationsLogRouteImport; parentRoute: typeof AuthRoute; }; + '/_auth/notifications_/reports': { + id: '/_auth/notifications_/reports'; + path: '/notifications/reports'; + fullPath: '/notifications/reports'; + preLoaderRoute: typeof AuthNotificationsReportsRouteImport; + parentRoute: typeof AuthRoute; + }; '/_auth/sessions_/$id': { id: '/_auth/sessions_/$id'; path: '/sessions/$id'; @@ -479,6 +512,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthNotificationsChannelsNewRouteImport; parentRoute: typeof AuthRoute; }; + '/_auth/notifications_/reports_/$notificationId': { + id: '/_auth/notifications_/reports_/$notificationId'; + path: '/notifications/reports/$notificationId'; + fullPath: '/notifications/reports/$notificationId'; + preLoaderRoute: typeof AuthNotificationsReportsNotificationIdRouteImport; + parentRoute: typeof AuthRoute; + }; '/_auth/sessions_/$id_/live': { id: '/_auth/sessions_/$id_/live'; path: '/sessions/$id/live'; @@ -503,10 +543,12 @@ interface AuthRouteChildren { AuthNotificationsActionsRoute: typeof AuthNotificationsActionsRoute; AuthNotificationsChannelsRoute: typeof AuthNotificationsChannelsRoute; AuthNotificationsLogRoute: typeof AuthNotificationsLogRoute; + AuthNotificationsReportsRoute: typeof AuthNotificationsReportsRoute; AuthSessionsIdRoute: typeof AuthSessionsIdRoute; AuthVaultLogRoute: typeof AuthVaultLogRoute; AuthNotificationsChannelsChannelIdRoute: typeof AuthNotificationsChannelsChannelIdRoute; AuthNotificationsChannelsNewRoute: typeof AuthNotificationsChannelsNewRoute; + AuthNotificationsReportsNotificationIdRoute: typeof AuthNotificationsReportsNotificationIdRoute; AuthSessionsIdLiveRoute: typeof AuthSessionsIdLiveRoute; } @@ -524,11 +566,14 @@ const AuthRouteChildren: AuthRouteChildren = { AuthNotificationsActionsRoute: AuthNotificationsActionsRoute, AuthNotificationsChannelsRoute: AuthNotificationsChannelsRoute, AuthNotificationsLogRoute: AuthNotificationsLogRoute, + AuthNotificationsReportsRoute: AuthNotificationsReportsRoute, AuthSessionsIdRoute: AuthSessionsIdRoute, AuthVaultLogRoute: AuthVaultLogRoute, AuthNotificationsChannelsChannelIdRoute: AuthNotificationsChannelsChannelIdRoute, AuthNotificationsChannelsNewRoute: AuthNotificationsChannelsNewRoute, + AuthNotificationsReportsNotificationIdRoute: + AuthNotificationsReportsNotificationIdRoute, AuthSessionsIdLiveRoute: AuthSessionsIdLiveRoute, }; diff --git a/packages/dashboard/src/routes/_auth/notifications_.reports.tsx b/packages/dashboard/src/routes/_auth/notifications_.reports.tsx new file mode 100644 index 0000000..9d3dbe4 --- /dev/null +++ b/packages/dashboard/src/routes/_auth/notifications_.reports.tsx @@ -0,0 +1,17 @@ +/** @module routes/_auth/notifications_.reports — `/notifications/reports`: reports in BrowserHive and their history (filters in the URL; spec 04 §12.11.2, D-45) */ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; +import { ReportsPage } from '@/features/notifications/reports/ReportsPage.tsx'; +import { REPORTS_DEFAULTS, reportsSearch } from '@/features/notifications/reports/search.ts'; + +/** Reports. */ +export const Route = createFileRoute('/_auth/notifications_/reports')({ + component: ReportsPage, + validateSearch: reportsSearch, + search: { middlewares: [stripSearchParams(REPORTS_DEFAULTS)] }, + staticData: { + title: 'Reports', + palette: { + keywords: ['digest', 'daily digest', 'weekly digest', 'anomaly', 'report', 'summary'], + }, + }, +}); diff --git a/packages/dashboard/src/routes/_auth/notifications_.reports_.$notificationId.tsx b/packages/dashboard/src/routes/_auth/notifications_.reports_.$notificationId.tsx new file mode 100644 index 0000000..a68d0b7 --- /dev/null +++ b/packages/dashboard/src/routes/_auth/notifications_.reports_.$notificationId.tsx @@ -0,0 +1,12 @@ +/** @module routes/_auth/notifications_.reports_.$notificationId — `/notifications/reports/$notificationId`: one report drawn natively (spec 04 §12.11.2, D-45) */ +import { createFileRoute } from '@tanstack/react-router'; +import { ReportPage } from '@/features/notifications/reports/ReportPage.tsx'; + +/** One report. */ +export const Route = createFileRoute('/_auth/notifications_/reports_/$notificationId')({ + component: ReportPage, + staticData: { + title: 'Report', + crumb: () => 'Report', + }, +}); diff --git a/packages/dashboard/test/e2e/channels.e2e.ts b/packages/dashboard/test/e2e/channels.e2e.ts index f410681..b3409a4 100644 --- a/packages/dashboard/test/e2e/channels.e2e.ts +++ b/packages/dashboard/test/e2e/channels.e2e.ts @@ -1,4 +1,4 @@ -/** @module dashboard/test/e2e/channels.e2e — the notification channels journey against a running daemon: add a webhook channel through the wizard (pointed at a receiver this test starts, answering from the chat switched on), preview it, save, send a real test and see it arrive, find it in the delivery log, the empty Actions audit, delete it; skips cleanly without a daemon */ +/** @module dashboard/test/e2e/channels.e2e — the notification channels journey against a running daemon: add a webhook channel through the wizard (pointed at a receiver this test starts, answering from the chat switched on), preview it, save, send a real test and see it arrive, find it in the delivery log, the empty Actions audit, delete it; a digest sent on demand found again through the inbox's Reports chip, its report page and the Overview of its period; the in-app report settings saved and switched off (D-45); skips cleanly without a daemon */ import { createServer, type IncomingMessage, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { expect, type Page, test } from '@playwright/test'; @@ -129,4 +129,113 @@ test.describe('notification channels', () => { await new Promise((resolve) => receiver.server.close(() => resolve())); } }); + + test('reports in BrowserHive: a weekly digest on Friday at 17:00, saved, then off', async ({ + page, + }) => { + await signIn(page); + // Start from the default (off), whatever an earlier project left. + const reset = await page.request.put('/api/v1/notifications/report-settings', { + data: { settings: {} }, + headers: { origin: baseUrl ?? '' }, + }); + expect(reset.ok()).toBe(true); + await page.goto('/notifications/reports'); + const form = page.getByRole('form', { name: 'Reports in BrowserHive' }); + await expect(form.getByRole('button', { name: 'Off' })).toHaveAttribute('aria-pressed', 'true'); + await form.getByRole('button', { name: 'Every week' }).click(); + await expect(form.getByLabel('At')).toHaveValue('17:00'); + await expect(form.getByText(/Next digest: Fri /)).toBeVisible(); + await form.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('heading', { name: 'Reports in BrowserHive saved' })).toBeVisible(); + await page.reload(); + await expect(form.getByRole('button', { name: 'Every week' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await form.getByRole('button', { name: 'Off' }).click(); + await form.getByRole('button', { name: 'Save' }).click(); + await expect(form.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + test('schedule a daily digest in a time zone, send one now, see it in the log', async ({ + page, + }, testInfo) => { + const receiver = await startReceiver(); + const name = `e2e-digest-${testInfo.project.name}`.slice(0, 32); + try { + await signIn(page); + await page.goto('/notifications/channels/new'); + await page.locator('label').filter({ hasText: 'POSTs the notification' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByLabel('URL', { exact: true }).fill(receiver.url); + await page.getByRole('button', { name: 'Continue' }).click(); + // 4. What to send: the Daily digest preset switches the digest and the anomaly alerts on. + await page.getByLabel('Name', { exact: true }).fill(name); + await page.locator('label').filter({ hasText: 'A summary every morning' }).click(); + await expect(page.getByRole('button', { name: 'Every day' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(page.getByText('Next digest:')).toBeVisible(); + await expect( + page.getByRole('switch', { name: 'Tell me when something looks off' }), + ).toBeChecked(); + // A zone of its own: type to search. + const zone = page.getByLabel(/Time zone/); + await zone.fill('Tokyo'); + await page.getByRole('option', { name: 'Asia/Tokyo' }).click(); + await expect(page.getByText(/in Asia\/Tokyo/)).toBeVisible(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Save channel' }).click(); + await expect(page.getByText(`${name} is saved`)).toBeVisible(); + + // The card shows the next digest and sends one on demand. + await page.goto('/notifications/channels'); + const card = page.locator('article').filter({ has: page.getByRole('heading', { name }) }); + await expect(card.getByText('Daily digest', { exact: true }).last()).toBeVisible(); + await expect(card.getByText(/\(Asia\/Tokyo\)/)).toBeVisible(); + await expect(card.getByText('Watching for anomalies')).toBeVisible(); + await card.getByRole('button', { name: 'Send now' }).click(); + const dialog = page.getByRole('dialog', { name: 'Send a digest now' }); + await expect(dialog.locator('[data-platform="webhook"]')).toBeVisible(); + await dialog.getByRole('button', { name: /Send now/ }).click(); + await expect(dialog.getByText(/Digest sent/)).toBeVisible(); + await expect.poll(() => receiver.bodies.length).toBeGreaterThan(0); + const body = receiver.bodies.at(-1) as { + message?: { kind?: string; report?: { manual?: boolean; time_zone?: string } }; + }; + expect(body.message?.kind).toBe('digest.daily'); + expect(body.message?.report).toMatchObject({ manual: true, time_zone: 'Asia/Tokyo' }); + await dialog.getByRole('button', { name: 'Done' }).click(); + + // The delivery log marks it as sent on demand. + await page.goto('/notifications/log'); + await expect(page.getByText('on demand').first()).toBeVisible(); + + // The inbox has its in-app copy (D-45): the Reports chip, the report page, the Overview. + await page.goto('/notifications'); + await page.getByRole('button', { name: 'Reports', exact: true }).click(); + await expect(page).toHaveURL(/category=reports/); + const row = page.getByRole('link', { name: /^Open: Daily digest/ }).first(); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(/\/notifications\/reports\/n-/); + await expect(page.getByRole('heading', { level: 2, name: /Daily digest/ })).toBeVisible(); + await expect(page.getByText('on demand').first()).toBeVisible(); + await expect(page.getByText(/Asia\/Tokyo/).first()).toBeVisible(); + await expect(page.getByRole('img', { name: /Tool calls per hour/ })).toBeVisible(); + await page.getByRole('link', { name: 'Open Overview for this period' }).click(); + await expect(page).toHaveURL(/\/overview\?.*since=\d+.*until=\d+/); + + await page.goto('/notifications/channels'); + await card.getByRole('button', { name: `More actions for ${name}` }).click(); + await page.getByRole('menuitem', { name: /Delete/ }).click(); + await page.getByRole('button', { name: 'Delete channel' }).click(); + await expect(page.getByRole('heading', { name })).toHaveCount(0); + } finally { + await new Promise((resolve) => receiver.server.close(() => resolve())); + } + }); }); diff --git a/packages/dashboard/test/fixtures/channels.ts b/packages/dashboard/test/fixtures/channels.ts index f0f98b2..7ae5732 100644 --- a/packages/dashboard/test/fixtures/channels.ts +++ b/packages/dashboard/test/fixtures/channels.ts @@ -9,6 +9,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -170,6 +171,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -314,6 +316,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -525,6 +528,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: true, open_links: true, @@ -750,6 +754,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -882,6 +887,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -1060,6 +1066,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: true, + charts: true, images: false, act_buttons: false, open_links: true, @@ -1243,6 +1250,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -1271,6 +1279,7 @@ export const CAPTURED = { last_status: 'dead', }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, { channel_id: 'nc-y8ZbVpnQBhd4', @@ -1312,6 +1321,7 @@ export const CAPTURED = { last_status: null, }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, { channel_id: 'nc-pN3x-pHacQLB', @@ -1330,6 +1340,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: true, + charts: true, images: false, act_buttons: false, open_links: true, @@ -1360,6 +1371,7 @@ export const CAPTURED = { last_status: 'dead', }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, { channel_id: 'nc-wSL3LAc8pVPI', @@ -1381,6 +1393,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -1409,6 +1422,7 @@ export const CAPTURED = { last_status: 'sent', }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, { channel_id: 'nc-hEYoKcho1yzA', @@ -1443,6 +1457,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -1471,6 +1486,7 @@ export const CAPTURED = { last_status: 'sent', }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, { channel_id: 'nc-iK652M_vsrPO', @@ -1492,6 +1508,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -1520,6 +1537,7 @@ export const CAPTURED = { last_status: 'sent', }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, { channel_id: 'nc-R5_-8E_3n2IM', @@ -1547,6 +1565,7 @@ export const CAPTURED = { capabilities: { rich_blocks: true, tables: false, + charts: false, images: true, act_buttons: false, open_links: true, @@ -1575,9 +1594,11 @@ export const CAPTURED = { last_status: 'dead', }, connection: null, + reports: { time_zone: 'Europe/Berlin', host_zone: true, digest: null, anomaly: null }, }, ], now: 1790643735409, + host_time_zone: 'Europe/Berlin', }, deliveries: { data: [ @@ -1603,6 +1624,7 @@ export const CAPTURED = { }, created_at: 1790643709288, updated_at: 1790643709295, + report: null, }, { seq: 39, @@ -1623,6 +1645,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643373695, updated_at: 1790643373695, + report: null, }, { seq: 38, @@ -1643,6 +1666,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643373695, updated_at: 1790643373708, + report: null, }, { seq: 37, @@ -1666,6 +1690,7 @@ export const CAPTURED = { }, created_at: 1790643373695, updated_at: 1790643373706, + report: null, }, { seq: 36, @@ -1686,6 +1711,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643373695, updated_at: 1790643373695, + report: null, }, { seq: 35, @@ -1706,6 +1732,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643373695, updated_at: 1790643373695, + report: null, }, { seq: 34, @@ -1726,6 +1753,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643373695, updated_at: 1790643373701, + report: null, }, { seq: 33, @@ -1746,6 +1774,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643354651, updated_at: 1790643359110, + report: null, }, { seq: 32, @@ -1766,6 +1795,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643354651, updated_at: 1790643354651, + report: null, }, { seq: 31, @@ -1789,6 +1819,7 @@ export const CAPTURED = { }, created_at: 1790643354651, updated_at: 1790643354962, + report: null, }, { seq: 30, @@ -1810,6 +1841,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643354651, updated_at: 1790643358081, + report: null, }, { seq: 29, @@ -1830,6 +1862,7 @@ export const CAPTURED = { message_ref: null, created_at: 1790643354651, updated_at: 1790643354651, + report: null, }, ], page: { diff --git a/packages/dashboard/test/fixtures/reports.ts b/packages/dashboard/test/fixtures/reports.ts new file mode 100644 index 0000000..46aa32a --- /dev/null +++ b/packages/dashboard/test/fixtures/reports.ts @@ -0,0 +1,451 @@ +/** @module test/fixtures/reports — report messages as the server builds them (generated from the core samples at the `full` level: a daily digest, a late weekly digest, an anomaly alert) and report items for the Reports tab tests (D-45) */ +import type { Notification, ReportItem } from '@browserhive/contracts/http'; +import type { NotificationMessage } from '@browserhive/contracts/notifications'; + +/** A daily digest (Tue 29 Sep, Europe/Berlin). */ +export const DIGEST_MESSAGE = { + schema: 1, + id: 'n-digest000001', + revision: 1, + thread: 'report:digest:day@09:00@Europe/Berlin:1790578800000:1790665200000', + kind: 'digest.daily', + category: 'reports', + severity: 'info', + state: 'final', + alert: false, + at: { created: 1790665200000, updated: 1790665200000 }, + title: 'Daily digest \u00b7 Tue 29 Sep', + summary: '12 sessions (2 live) \u00b7 3,412 tool calls \u00b7 68 errors (2%)', + blocks: [ + { + type: 'fields', + items: [ + { label: 'Sessions', value: [{ type: 'text', text: '12 started \u00b7 2 live now' }] }, + { + label: 'Tool calls', + value: [ + { type: 'text', text: '3,412 \u00b7 68 errors (2%)' }, + { type: 'text', text: ' \u00b7 was 1.2%' }, + ], + }, + { + label: 'Attention', + value: [ + { + type: 'text', + text: '4 requests \u00b7 3 answered (median 1m 36s) \u00b7 1 timed out', + }, + ], + }, + { + label: 'Vault fills', + value: [{ type: 'text', text: '9 \u00b7 8 ok \u00b7 1 origin mismatch' }], + }, + { + label: 'Blocked requests', + value: [ + { type: 'text', text: '27' }, + { type: 'text', text: ' \u00b7 top ' }, + { type: 'code', text: '*.doubleclick.net' }, + { type: 'text', text: ' (19)' }, + { type: 'text', text: ' \u00b7 most blocked ' }, + { type: 'code', text: 'ads.example.net' }, + { type: 'text', text: ' (12)' }, + ], + }, + { + label: 'Slowest tool (p95)', + value: [ + { type: 'code', text: 'navigate' }, + { type: 'text', text: ' 4.2 s (was 2.9 s)' }, + ], + }, + { + label: 'Open problems', + value: [ + { type: 'code', text: 'RETENTION_FAILED' }, + { type: 'text', text: ' since 29 Sep 03:00' }, + ], + }, + ], + }, + { + type: 'chart', + label: 'Tool calls per hour', + values: [ + 2, 1, 0, 0, 0, 1, 4, 18, 96, 212, 305, 280, 190, 240, 330, 412, 380, 260, 150, 120, 88, 60, + 40, 23, + ], + start: 1790578800000, + step_ms: 3600000, + unit: 'calls', + }, + { type: 'heading', text: 'Top errors' }, + { + type: 'table', + columns: ['Error', 'Tool', 'Count', 'Sessions'], + rows: [ + [ + [{ type: 'code', text: 'NAVIGATION_TIMEOUT' }], + [{ type: 'code', text: 'navigate' }], + [{ type: 'text', text: '31' }], + [{ type: 'text', text: '4' }], + ], + [ + [{ type: 'code', text: 'ELEMENT_NOT_FOUND' }], + [{ type: 'code', text: 'click' }], + [{ type: 'text', text: '22' }], + [{ type: 'text', text: '6' }], + ], + [ + [{ type: 'code', text: 'CAPTCHA_DETECTED' }], + [{ type: 'code', text: 'navigate' }], + [{ type: 'text', text: '15' }], + [{ type: 'text', text: '2' }], + ], + ], + }, + { type: 'heading', text: 'By harness' }, + { + type: 'table', + columns: ['Harness', 'Sessions', 'Tool calls', 'Errors'], + rows: [ + [ + [{ type: 'text', text: 'Claude Code' }], + [{ type: 'text', text: '8' }], + [{ type: 'text', text: '2,410' }], + [{ type: 'text', text: '51' }], + ], + [ + [{ type: 'text', text: 'Cursor' }], + [{ type: 'text', text: '3' }], + [{ type: 'text', text: '880' }], + [{ type: 'text', text: '15' }], + ], + [ + [{ type: 'text', text: 'Unknown' }], + [{ type: 'text', text: '1' }], + [{ type: 'text', text: '122' }], + [{ type: 'text', text: '2' }], + ], + ], + }, + { + type: 'list', + ordered: false, + items: [ + [ + { type: 'bold', text: 'RETENTION_FAILED' }, + { type: 'text', text: ' since 29 Sep 03:00: retention sweep failed: database is locked' }, + ], + ], + }, + { + type: 'footer', + content: [{ type: 'text', text: '28 Sep 09:00 \u2192 29 Sep 09:00 \u00b7 Europe/Berlin' }], + }, + ], + actions: [ + { + kind: 'open', + id: 'overview', + label: 'Open Overview', + style: 'primary', + path: '/overview?since=1790578800000&until=1790665200000', + }, + ], + entities: {}, + privacy: { level: 'full', has_image: false }, + report: { + window: { since: 1790578800000, until: 1790665200000 }, + time_zone: 'Europe/Berlin', + late: false, + skipped: 0, + manual: false, + }, +} as unknown as NotificationMessage; + +/** A weekly digest, sent late with two skipped. */ +export const WEEKLY_MESSAGE = { + schema: 1, + id: 'n-weekly000001', + revision: 1, + thread: 'report:digest:week:fri@17:00@Europe/Berlin:1:2', + kind: 'digest.weekly', + category: 'reports', + severity: 'info', + state: 'final', + alert: false, + at: { created: 1790665200000, updated: 1790665200000 }, + title: 'Weekly digest \u00b7 22\u201329 Sep', + summary: '84 sessions (2 live) \u00b7 23,884 tool calls \u00b7 476 errors (2%)', + blocks: [ + { + type: 'text', + content: [ + { type: 'text', text: 'Sent late: BrowserHive was not running at 09:00 (Tue 29 Sep).' }, + { type: 'text', text: ' 2 earlier weekly digests were skipped while BrowserHive was off.' }, + ], + }, + { + type: 'fields', + items: [ + { label: 'Sessions', value: [{ type: 'text', text: '84 started \u00b7 2 live now' }] }, + { + label: 'Tool calls', + value: [ + { type: 'text', text: '23,884 \u00b7 476 errors (2%)' }, + { type: 'text', text: ' \u00b7 was 1.2%' }, + ], + }, + { + label: 'Attention', + value: [ + { + type: 'text', + text: '28 requests \u00b7 21 answered (median 1m 36s) \u00b7 7 timed out', + }, + ], + }, + { + label: 'Vault fills', + value: [{ type: 'text', text: '63 \u00b7 56 ok \u00b7 7 origin mismatch' }], + }, + { + label: 'Blocked requests', + value: [ + { type: 'text', text: '189' }, + { type: 'text', text: ' \u00b7 top ' }, + { type: 'code', text: '*.doubleclick.net' }, + { type: 'text', text: ' (133)' }, + { type: 'text', text: ' \u00b7 most blocked ' }, + { type: 'code', text: 'ads.example.net' }, + { type: 'text', text: ' (84)' }, + ], + }, + { + label: 'Slowest tool (p95)', + value: [ + { type: 'code', text: 'navigate' }, + { type: 'text', text: ' 4.2 s (was 2.9 s)' }, + ], + }, + { + label: 'Open problems', + value: [ + { type: 'code', text: 'RETENTION_FAILED' }, + { type: 'text', text: ' since 29 Sep 03:00' }, + ], + }, + ], + }, + { + type: 'chart', + label: 'Tool calls per 12 hours', + values: [10, 5, 0, 0, 0, 5, 20, 90, 480, 1060, 1525, 1400, 950, 1200], + start: 1790060400000, + step_ms: 43200000, + unit: 'calls', + }, + { type: 'heading', text: 'Top errors' }, + { + type: 'table', + columns: ['Error', 'Tool', 'Count', 'Sessions'], + rows: [ + [ + [{ type: 'code', text: 'NAVIGATION_TIMEOUT' }], + [{ type: 'code', text: 'navigate' }], + [{ type: 'text', text: '217' }], + [{ type: 'text', text: '4' }], + ], + [ + [{ type: 'code', text: 'ELEMENT_NOT_FOUND' }], + [{ type: 'code', text: 'click' }], + [{ type: 'text', text: '154' }], + [{ type: 'text', text: '6' }], + ], + [ + [{ type: 'code', text: 'CAPTCHA_DETECTED' }], + [{ type: 'code', text: 'navigate' }], + [{ type: 'text', text: '105' }], + [{ type: 'text', text: '2' }], + ], + ], + }, + { type: 'heading', text: 'By harness' }, + { + type: 'table', + columns: ['Harness', 'Sessions', 'Tool calls', 'Errors'], + rows: [ + [ + [{ type: 'text', text: 'Claude Code' }], + [{ type: 'text', text: '56' }], + [{ type: 'text', text: '16,870' }], + [{ type: 'text', text: '357' }], + ], + [ + [{ type: 'text', text: 'Cursor' }], + [{ type: 'text', text: '21' }], + [{ type: 'text', text: '6,160' }], + [{ type: 'text', text: '105' }], + ], + [ + [{ type: 'text', text: 'Unknown' }], + [{ type: 'text', text: '7' }], + [{ type: 'text', text: '854' }], + [{ type: 'text', text: '14' }], + ], + ], + }, + { + type: 'list', + ordered: false, + items: [ + [ + { type: 'bold', text: 'RETENTION_FAILED' }, + { type: 'text', text: ' since 29 Sep 03:00: retention sweep failed: database is locked' }, + ], + ], + }, + { + type: 'footer', + content: [{ type: 'text', text: '22 Sep 09:00 \u2192 29 Sep 09:00 \u00b7 Europe/Berlin' }], + }, + ], + actions: [ + { + kind: 'open', + id: 'overview', + label: 'Open Overview', + style: 'primary', + path: '/overview?since=1790060400000&until=1790665200000', + }, + ], + entities: {}, + privacy: { level: 'full', has_image: false }, + report: { + window: { since: 1790060400000, until: 1790665200000 }, + time_zone: 'Europe/Berlin', + late: true, + skipped: 2, + manual: false, + }, +} as unknown as NotificationMessage; + +/** An anomaly alert, open. */ +export const ANOMALY_MESSAGE = { + schema: 1, + id: 'n-anomaly00001', + revision: 1, + thread: 'report:anomaly:abcd1234:1790683200000', + kind: 'report.anomaly', + category: 'reports', + severity: 'warn', + state: 'open', + alert: true, + at: { created: 1790683200000, updated: 1790683200000 }, + title: 'Something looks off: 2 checks', + summary: + '34% of tool calls failed in the last hour \u00b7 An attention request has waited 47 min', + blocks: [ + { + type: 'table', + columns: ['Check', 'Now', 'Threshold', 'Since'], + rows: [ + [ + [ + { type: 'text', text: 'Tool-call error rate' }, + { type: 'text', text: ' ' }, + { type: 'bold', text: 'new' }, + ], + [{ type: 'text', text: '34%' }], + [{ type: 'text', text: '\u2265 20%' }], + [{ type: 'text', text: '14:00' }], + ], + [ + [ + { type: 'text', text: 'Attention waiting' }, + { type: 'text', text: ' ' }, + { type: 'bold', text: 'new' }, + ], + [{ type: 'text', text: '47 min' }], + [{ type: 'text', text: '\u2265 30 min' }], + [{ type: 'text', text: '14:00' }], + ], + ], + }, + { + type: 'text', + content: [ + { type: 'text', text: 'Waiting: ' }, + { type: 'code', text: 'checkout' }, + ], + }, + { + type: 'footer', + content: [{ type: 'text', text: 'Checked 13:00\u201314:00 \u00b7 Europe/Berlin' }], + }, + ], + actions: [ + { + kind: 'open', + id: 'overview', + label: 'Open Overview', + style: 'primary', + path: '/overview?range=24h', + }, + ], + entities: {}, + privacy: { level: 'full', has_image: false }, + report: { + window: { since: 1790679600000, until: 1790683200000 }, + time_zone: 'Europe/Berlin', + late: false, + skipped: 0, + manual: false, + }, +} as unknown as NotificationMessage; + +/** The in-app row of a report message. */ +export function reportNotification( + message: NotificationMessage, + overrides: Partial = {}, +): Notification { + const digest = message.kind !== 'report.anomaly'; + return { + notification_id: message.id as Notification['notification_id'], + principal_id: null, + type: digest ? 'lifecycle' : 'system', + title: message.title, + body: message.summary, + session_id: null, + session_slug: null, + target: `/notifications/reports/${message.id}`, + source_event_id: null, + created_at: message.at.created, + updated_at: message.at.created, + count: 1, + read_at: digest ? message.at.created : null, + dismissed_at: null, + kind: message.kind, + category: 'reports', + severity: message.severity, + state: message.state, + revision: message.revision, + thread: message.thread, + ...overrides, + }; +} + +/** A report item of the history. */ +export function reportItem( + message: NotificationMessage, + channels: ReportItem['channels'] = [], + overrides: Partial = {}, +): ReportItem { + return { + notification: reportNotification(message, overrides), + report: message.report ?? null, + channels, + }; +} diff --git a/packages/dashboard/test/helpers/page-harness.tsx b/packages/dashboard/test/helpers/page-harness.tsx index 2dc0a24..c00e3cc 100644 --- a/packages/dashboard/test/helpers/page-harness.tsx +++ b/packages/dashboard/test/helpers/page-harness.tsx @@ -1,4 +1,5 @@ /** @module dashboard/test/helpers/page-harness — render a page route inside the real provider tree (auth, confirm, router, socket, notifications) with a scripted fetch and a FakeSocket store */ +import { Scope } from '@browserhive/contracts/enums'; import '../setup.ts'; import { type AnyRoute, @@ -48,15 +49,18 @@ export function problem(status: number, code: string, title = code) { return { status, body: { type: 'about:blank', title, status, code, retryable: 'never' } }; } -const ME = { - principal: { - subject: 'operator', - kind: 'operator', - display: 'admin', - scopes: [], - must_change_password: false, - }, -}; +/** The signed-in operator: every scope, like the single operator of a real install (D-09). */ +function me(scopes: readonly string[] = Scope.options) { + return { + principal: { + subject: 'operator', + kind: 'operator', + display: 'admin', + scopes: [...scopes], + must_change_password: false, + }, + }; +} /** An empty collection envelope. */ export function envelope(data: readonly T[], extra: Record = {}) { @@ -70,7 +74,7 @@ export function envelope(data: readonly T[], extra: Record = } /** Scripted fetch: routes keyed by `METHOD path`; unknown routes 404 and are recorded. */ -export function fakeFetch(routes: Record) { +export function fakeFetch(routes: Record, scopes?: readonly string[]) { const requests: RecordedRequest[] = []; const fetchImpl: FetchLike = async (input, init) => { const url = new URL(String(input), 'http://localhost:9876'); @@ -78,7 +82,7 @@ export function fakeFetch(routes: Record) { const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; const request = { method, path: url.pathname, query: url.searchParams, body }; requests.push(request); - if (url.pathname.endsWith('/auth/me')) return json(ME); + if (url.pathname.endsWith('/auth/me')) return json(me(scopes)); const key = `${method} ${url.pathname.replace(/^\/api\/v1/, '')}`; if (!(key in routes)) return json(problem(404, 'NOT_FOUND').body, 404); const route = routes[key]; @@ -106,13 +110,15 @@ export interface PageHarnessOptions { readonly url: string; /** Extra routes (e.g. a redirect route under test). */ readonly extra?: (root: AnyRoute) => AnyRoute[]; + /** The principal's scopes; default every scope. */ + readonly scopes?: readonly string[]; } /** Render a page; returns the router, sockets, recorded requests and helpers to push feed events. */ export function renderPage(options: PageHarnessOptions) { const sockets: FakeSocket[] = []; const timers = new FakeTimers(); - const { fetchImpl, requests } = fakeFetch(options.routes); + const { fetchImpl, requests } = fakeFetch(options.routes, options.scopes); const createStore = (storeOptions: ConstructorParameters[0]) => new SocketStore({ ...storeOptions, diff --git a/scripts/notify-live.ts b/scripts/notify-live.ts index 04bf3c6..a9e25e7 100644 --- a/scripts/notify-live.ts +++ b/scripts/notify-live.ts @@ -40,6 +40,9 @@ const IMAGES: NotificationImageReader = { : null, }; /** Links point at the public website so every platform accepts them as buttons. */ +/** Notification ids of the report checks (distinct from the samples' id: ntfy replaces by id). */ +const DIGEST_ID = 'n-livedigest01'; +const ANOMALY_ID = 'n-liveanomaly1'; const LINKS: LinkBuilder = { local: false, url: (path) => `https://browserhive.ai${path}` }; type Outcome = { platform: string; status: 'passed' | 'failed' | 'skipped'; detail: string }; @@ -68,9 +71,18 @@ function deliveryOf( channel: NotificationChannel, sample: PreviewSample, image: boolean, + options: { readonly id?: string; readonly resolved?: boolean } = {}, ): ChannelDelivery { - const message = sampleMessage(sample, { now: Date.now(), image: image ? 'masked' : 'none' }); - const marked = { ...message, title: `Live check · ${message.title}`.slice(0, 120) }; + const message = sampleMessage(sample, { + now: Date.now(), + image: image ? 'masked' : 'none', + ...(options.resolved === true && { resolved: true }), + }); + const marked = { + ...message, + ...(options.id !== undefined && { id: options.id }), + title: `Live check · ${message.title}`.slice(0, 120), + }; return { message: degrade(restrictContent(marked, 'full'), channel.capabilities), links: LINKS, @@ -117,10 +129,22 @@ async function telegram(): Promise { check(text.ref['rich'] === 1, 'the text send was a Rich Message'); await channel.edit?.(text.ref, deliveryOf(channel, 'tool-errors', false)); await channel.delete?.(text.ref); + // Reports (D-43, D-44): the digest with its tables and chart, and an anomaly alert edited to + // "Back to normal". + const digest = await channel.send(deliveryOf(channel, 'digest', false, { id: DIGEST_ID })); + check(digest.ref['rich'] === 1, 'the digest was a Rich Message'); + const alert = await channel.send(deliveryOf(channel, 'anomaly', false, { id: ANOMALY_ID })); + await channel.edit?.( + alert.ref, + deliveryOf(channel, 'anomaly', false, { id: ANOMALY_ID, resolved: true }), + ); + await channel.delete?.(digest.ref); + await channel.delete?.(alert.ref); return { platform: 'Telegram', status: 'passed', - detail: 'Rich Messages with a screenshot and act buttons: send, edit (buttons removed), delete', + detail: + 'Rich Messages with a screenshot and act buttons: send, edit (buttons removed), delete; a digest and an anomaly alert edited to back to normal', }; } @@ -133,7 +157,13 @@ async function discord(): Promise { images: IMAGES, }); const read = async (id: string | number) => { - const response = await fetch(`${webhook.replace(/\/+$/, '')}/messages/${id}`); + let response = await fetch(`${webhook.replace(/\/+$/, '')}/messages/${id}`); + // A burst of calls can hit the webhook's rate limit: wait as Discord asks, then read again. + for (let i = 0; i < 3 && response.status === 429; i++) { + const wait = Number(response.headers.get('retry-after') ?? '1'); + await Bun.sleep(Math.min(10, Math.max(0.5, wait)) * 1000); + response = await fetch(`${webhook.replace(/\/+$/, '')}/messages/${id}`); + } return { status: response.status, body: (await response.json().catch(() => null)) as Record | null, @@ -164,10 +194,35 @@ async function discord(): Promise { await channel.delete?.(ref); back = await read(id); check(back.status === 404, 'the message is gone after delete'); + const digest = await channel.send(deliveryOf(channel, 'digest', false, { id: DIGEST_ID })); + const readDigest = await read(digest.ref['message_id'] ?? ''); + const digestEmbed = ( + (readDigest.body?.['embeds'] ?? []) as { title?: string; description?: string }[] + )[0]; + check((digestEmbed?.title ?? '').startsWith('📊'), 'the digest embed title'); + check( + (digestEmbed?.description ?? '').includes('**Sessions:**') && + (digestEmbed?.description ?? '').includes('Tool calls per hour'), + 'the digest facts and chart in the description', + ); + const alert = await channel.send(deliveryOf(channel, 'anomaly', false, { id: ANOMALY_ID })); + await channel.edit?.( + alert.ref, + deliveryOf(channel, 'anomaly', false, { id: ANOMALY_ID, resolved: true }), + ); + const readAlert = await read(alert.ref['message_id'] ?? ''); + const alertEmbed = ((readAlert.body?.['embeds'] ?? []) as { title?: string }[])[0]; + check( + (alertEmbed?.title ?? '').includes('Back to normal'), + 'the anomaly alert edited to back to normal', + ); + await channel.delete?.(digest.ref); + await channel.delete?.(alert.ref); return { platform: 'Discord', status: 'passed', - detail: 'send, read back, edit (screenshot kept), delete', + detail: + 'send, read back, edit (screenshot kept), delete; a digest (read back) and an anomaly alert edited to back to normal', }; } @@ -240,12 +295,15 @@ async function discordBot(): Promise { await channel.delete?.(ref); back = await read(); check(back.status === 404, 'the message is gone after delete'); + const digest = await channel.send(deliveryOf(channel, 'digest', false, { id: DIGEST_ID })); + check(typeof digest.ref['message_id'] === 'string', 'the bot sent the digest'); + await channel.delete?.(digest.ref); stop?.(); return { platform: 'Discord bot', status: 'passed', detail: - 'gateway Ready (intents 0); send with screenshot and interactive buttons, read back, edit (buttons removed, screenshot kept), delete', + 'gateway Ready (intents 0); send with screenshot and interactive buttons, read back, edit (buttons removed, screenshot kept), delete; a digest', }; } finally { gateway.stop(); @@ -302,6 +360,30 @@ async function ntfy(): Promise { await until('the delete event', (events) => events.some((e) => e.event === 'message_delete' && e.sequence_id === sequence), ); + const digest = await channel.send(deliveryOf(channel, 'digest', false, { id: DIGEST_ID })); + await until('the digest', (events) => + events.some( + (e) => + e.event === 'message' && + e.sequence_id === String(digest.ref['sequence_id']) && + (e.message ?? '').includes('Tool calls per hour'), + ), + ); + const alert = await channel.send(deliveryOf(channel, 'anomaly', false, { id: ANOMALY_ID })); + await channel.edit?.( + alert.ref, + deliveryOf(channel, 'anomaly', false, { id: ANOMALY_ID, resolved: true }), + ); + await until('the anomaly alert replaced by back to normal', (events) => + events.some( + (e) => + e.event === 'message' && + e.sequence_id === String(alert.ref['sequence_id']) && + (e.message ?? '').includes('back under its threshold'), + ), + ); + await channel.delete?.(digest.ref); + await channel.delete?.(alert.ref); // The reply topic (D-42): a throwaway topic B; post like the phone's `http` action does and // check the subscription receives the token. const reply = `bh-live-${randomBytes(9).toString('hex')}`; @@ -325,7 +407,7 @@ async function ntfy(): Promise { return { platform: 'ntfy', status: 'passed', - detail: `send with screenshot, replace, delete and a reply-topic round trip on ${new URL(server).host}`, + detail: `send with screenshot, replace, delete, a digest, an anomaly alert replaced by back to normal, and a reply-topic round trip on ${new URL(server).host}`, }; } diff --git a/specs/00-decisions.md b/specs/00-decisions.md index 67220d2..0cc7411 100644 --- a/specs/00-decisions.md +++ b/specs/00-decisions.md @@ -405,6 +405,7 @@ Details in `04-admin-frontend.md`. - Tool errors are **grouped per session**: one row per group (`" · N tool errors"`) grows while it is unread, has been idle for less than 5 minutes and is younger than 60 minutes; `notification.updated` carries the full row and clients upsert by id; lists sort by `updated_at`. Session-less caller mistakes (codes whose retry guidance is "different arguments") produce no notification. A failing agent would otherwise flood the inbox and toasts with one row per call, none naming the session. - `/me/preferences` stores the notification toast preferences (`notifications.toasts`, `notifications.types`), which follow the operator across devices; sidebar state and page size are per-device or per-URL. - External channels (Telegram, Discord, ntfy, a generic webhook, later more) implement the `NotificationChannel` port and receive the contract through the delivery outbox (D-34). The in-app inbox is itself a channel on that port, delivered inline. +- Scheduled reports (a daily or weekly digest, D-43) and anomaly alerts (D-44) are produced per channel from the analytics read model, never from a single event, and are addressed to the channel that schedules them. The inbox gets one in-app copy per report period (D-45): a digest arrives already read and never toasts; an anomaly alert counts toward the badge and toasts like a `system` notification. **Consequences.** - Read state survives reloads and is shared across tabs. @@ -644,8 +645,8 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA - **Producers own it; consumers only render it.** Producers are pure, table-driven functions from observed bus events (spec 03 §9). A platform adapter receives the contract and nothing else: it never reads domain events or the database. **Agents never author notifications**: every message derives from facts BrowserHive observed (D-09, D-12); there is no `notify` tool. - **Full-state revisions.** A notification keeps its `id` for life; every state change is `revision + 1` and the message is complete at every revision, so a re-send or re-edit is always correct and adapters are idempotent. - **Redaction happens before the contract** (spec 10 §9): every string a producer copies from an event goes through the `Redactor` (registered secrets and credential patterns) and URLs through `sanitizeUrl`. Content levels (`counts` < `titles` < `full`) are applied by the core per channel, never by an adapter. -- **Versioning.** Additive changes (a new optional field, a new kind, block, inline or command) keep `schema: 1`; consumers MUST ignore what they do not know (an unknown block renders as nothing, an unknown action is skipped). Removing or re-typing a field bumps `schema`, and the generic webhook announces the version it sends. -- A shared, pure `degrade(message, capabilities)` adapts a message to what a renderer supports (tables → lists, images dropped or linked, `act` → `open`, truncation with "… Open in BrowserHive"); renderers never implement fallbacks themselves. +- **Versioning.** Additive changes (a new optional field, a new kind, block, inline or command) keep `schema: 1` (N3 added the kind `digest.weekly`, the `chart` block and the optional `report` field this way); consumers MUST ignore what they do not know (an unknown block renders as nothing, an unknown action is skipped). Removing or re-typing a field bumps `schema`, and the generic webhook announces the version it sends. +- A shared, pure `degrade(message, capabilities)` adapts a message to what a renderer supports (tables → lists, charts → a line of text bars, images dropped or linked, `act` → `open`, truncation with "… Open in BrowserHive"); renderers never implement fallbacks themselves. A new block type is added only when `degrade` can turn it into something every renderer already draws. **Consequences.** Adding a platform is a renderer plus a transport against a fixed input, testable with golden files. The contract is a public compatibility surface: its JSON Schema is diffed in review. The in-app `Notification` DTO keeps its shape and gains the contract's classification fields (`kind`, `category`, `severity`, `state`, `revision`, `thread`) additively. Rows from before schema v5 have no stored message (`message_json` NULL): nothing is fabricated for them. @@ -686,6 +687,7 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA - **Backlog.** After an outage only the latest revision per notification is sent, and more than 20 pending `info` sends on one channel collapse into the newest one with a "you missed N" note. - **Suppressed deliveries are logged** with a reason (`filtered`, `quiet_hours`, `throttled`, `channel_paused`, `content_blocked`, `image_blocked`, `edit_unsupported`, `delete_unsupported`, `collapsed`), so "why didn't I get it?" always has an answer. - With no external channel configured nothing is enqueued, no worker timer runs and the only cost is one indexed read of `notification_channels` at startup. +- **Addressed notifications.** A scheduled report (D-43, D-44) is planned for the one channel it was produced for, and only that channel's paused/adapter state applies; every other notification is planned for every channel, filtered by its rules. **Consequences.** Delivery rows are telemetry-class (30 days, spec 03 §7.1); channels are configuration and never pruned. `browserhive.notifications.deliveries{channel_kind,status}` counts outcomes and every platform call is a span (spec 10). A per-principal routing model is not built: channels are instance-wide and deliveries are enqueued once per produced notification, matching the single shared inbox (spec 03 §9). @@ -784,12 +786,13 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA - **Checks, in order.** The token is known; it belongs to the channel the press came from, and the press came from that channel's chat (Telegram chat id, Discord channel id); act buttons are still on and the channel is active; the token is unused and unexpired; the notification is still `open`; the presser's platform user id is on the channel's **allow-list** (`rules.allow_list`; by default the person who connected the chat in the setup: the Telegram `/start`, or the Discord "This is me" button; more ids can be added). ntfy has no per-user identity (D-42). Then the command runs through the same application service as the dashboard route: `attention.resolve` (resolve or reject) and `vault.confirm.resolve` (approve or deny), the two ops producers put on buttons. `session.extend_lease` and `session.close` are reserved in the contract but no producer offers them (there is no operator-side lease extension), so a press of one is refused. - **Actor and audit.** The actor is `telegram:`, `discord:` or `ntfy:topic-b`; it is the request's `resolved_by`, so the revised message says who answered and from where. Every press of a known token writes one `notification_actions` row (audit class): when, channel, notification, action, op, actor and display name, outcome (`done`, `failed`, `not_allowed`, `used`, `expired`, `stale`, `wrong_channel`, `disabled`) and detail. A press with an unknown token is answered and counted, not stored, so a stranger cannot fill the audit table. The presser gets a short answer in the chat (a Telegram toast, an ephemeral Discord reply); a refusal names the reason, and a refused allow-list check names the presser's id so the operator can add it. - **The message follows the state.** A successful command settles the request; the settlement is a revision (03 §9.1) and the outbox's silent edit removes every button and shows the outcome and the actor. A press made while BrowserHive was stopped is processed at the next start if the platform kept it (Telegram 24 h, ntfy's cache 12 h) and refused as `stale` or `expired` when the request no longer waits (the startup reconcile settles orphaned requests first). +- **No second confirmation.** A press is the answer: Approve or Reject from the chat acts at once, with no "are you sure?" round trip (confirmed after the N2 review). The safeguards are the ones above: the opt-in, the allow-list, the chat binding, a single-use token and a request that must still be open. - **Scopes.** A press acts with the authority of the operator who enabled act buttons (the single operator holds every scope, D-09); each op is tied to the scope of its dashboard route (`attention:resolve`, `vault:confirm`). - **Presses arrive only over outbound connections**: Telegram `getUpdates` long polling (one poller per bot token, shared with the setup's `/start` wait, its offset persisted so a press is handled once), the Discord gateway (D-38), an ntfy subscription (D-42). Their connection state shows on the channel card. **Consequences.** Tokens never appear in logs, the delivery log, previews (which show `bh1:preview-`) or the API. Deleting a channel deletes its tokens; its audit rows keep the channel's name. `notification_actions` follows `auditRetentionDays`; used or expired tokens are pruned a day after they expire. -**Alternatives considered.** *An HMAC-keyed token* (the plan's first wording): with 66 random bits, a 24-hour life and single use, a keyed hash protects nothing a plain SHA-256 does not, and it adds a key to generate, store and back up. *A signed URL on the dashboard* for the buttons to call: needs a public URL and exposes a callback to the internet (D-33). *Telegram webhooks*: a public HTTPS endpoint. *Act buttons on by default*: a chat is shared more casually than a dashboard login. +**Alternatives considered.** *A second confirmation in the chat* ("Approve? Yes / No"): every answer from a phone would take two taps and two messages, and it adds nothing the allow-list and the single-use token do not already check. *An HMAC-keyed token* (the plan's first wording): with 66 random bits, a 24-hour life and single use, a keyed hash protects nothing a plain SHA-256 does not, and it adds a key to generate, store and back up. *A signed URL on the dashboard* for the buttons to call: needs a public URL and exposes a callback to the internet (D-33). *Telegram webhooks*: a public HTTPS endpoint. *Act buttons on by default*: a chat is shared more casually than a dashboard login. ## D-42 ntfy answers through a second, private topic @@ -807,3 +810,74 @@ OS defaults: `~/Library/Application Support/BrowserHive` (macOS), `%LOCALAPPDATA **Consequences.** Two-way ntfy needs no BrowserHive endpoint. Presses made while BrowserHive was stopped for more than ntfy's cache time are lost (the request is settled by then anyway). **Alternatives considered.** *Open links only on ntfy*: the fallback if the spike had failed. *Putting an access token in the `http` action's headers*: anyone who reads topic A would get a write token. + +## D-43 Scheduled reports: addressed to each channel, in its time zone, sent late once, never empty + +**Status:** Accepted + +**Implementation:** N3: `ReportScheduler` and the report producers (`app/notifications/reports*.ts`), the `digest` rule, `POST /channels/{id}/digest`, the wizard's Reports section, `--notificationChannel … digest=…`. + +**Context.** A daily summary is the notification people keep when they do not want to be interrupted. "09:00" means the operator's wall clock, which moves with daylight saving time and differs between the phone a channel reaches and the host BrowserHive runs on. BrowserHive is a local daemon: it is stopped, the laptop sleeps, a container restarts. A report must neither be lost because the daemon was off at 09:00 nor arrive five times after a long weekend, and a report that says "nothing happened" is noise. + +**Decision.** +- **Per channel, addressed.** A channel opts in with `rules.digest` (`every: 'day'|'week'`, `at: 'HH:MM'`, `day` for weekly, `weekdays_only` for daily). Every report is its own notification, addressed only to that channel (the outbox plans it for no other channel), because the window, the time zone, the thresholds and the content level are the channel's. The schedule is the opt-in: the channel's category, severity, session and harness filters do not apply to its reports. The channel's copy is stored read and dismissed (like a test send), so it never shows in the inbox; the delivery log keeps its record, and the inbox and the Reports tab get **one in-app copy per period** instead (D-45). +- **Defaults.** "Every day" runs every day, weekends included, at 09:00; an optional **weekdays only** runs Monday to Friday, and Monday's digest then covers the whole weekend (its window starts at Friday's run). "Every week" runs on **Friday at 17:00** in the channel's zone and covers the full seven days before it, weekends included. Both are changeable; the startup flag uses the same defaults (`digest=weekly` alone is Friday 17:00). +- **Time zone.** `rules.time_zone` (an IANA name) is the channel's zone for its reports and its quiet hours (`quiet_hours.time_zone`, when set, still wins for quiet hours). Absent, it is **the host's zone, read at each evaluation** (so a moved host follows). The window of a report is the local period that ends at the scheduled time: yesterday 09:00 to today 09:00 (23 or 25 hours across a DST change), or the previous week. A local time that does not exist on a day (spring forward) fires at the same wall time shifted by the gap; a local time that occurs twice (fall back) fires once, at its first occurrence. +- **Durable, exactly once per window.** The last handled occurrence and the end of the last window are kept in `notification_cursors` (`digest:`), written in the same transaction as the report's notification and delivery row. Changing a schedule re-arms it from the moment of the change: an edit never causes a late report. +- **Late, once.** When the daemon starts (or wakes) after one or more scheduled times passed, the **most recent** missed window is produced and marked late ("Sent late: BrowserHive was not running at 09:00"); older missed windows are skipped and counted in one line ("2 earlier digests were skipped while BrowserHive was off"). A report is late when it is produced more than 5 minutes after its scheduled time. At most one late report per schedule, and that is final (confirmed after the N3 review): the skipped windows are never produced later or merged into the next digest; the dashboard's Overview covers them. +- **Never empty.** A window with no session started, no tool call, no attention request, no vault access, no blocked request and no open degradation produces no message: the notification is stored and its delivery row is `suppressed` with the reason `empty`, so "why didn't I get a digest?" has an answer. "Send a digest now" (the dashboard, `POST /channels/{id}/digest`) sends even an empty one. +- **Quiet hours.** A digest is sent at the time the operator chose even inside the channel's quiet hours, but **silently** there (`alert: false`: no sound, no vibration). Quiet hours hold back alerts; a digest scheduled into them is still wanted. +- **Content levels.** A report is built at the channel's content level: `counts` carries numbers and fixed labels only; `titles` (the default) adds BrowserHive's own vocabulary (tool names, error codes, harness slugs, vault results, degradation codes, blocklist patterns, session slugs); `full` adds degradation messages and the most blocked domain. Every copied string passes the `Redactor` (spec 10 §9) like any other notification. +- **Structure.** Reports use tables and the additive `chart` block (D-32) and carry the optional `report` field (window, time zone, `late`, skipped windows, `manual`), so the generic webhook's consumers and the delivery log read the window without parsing text. +- With no channel scheduling a report, no timer runs and no query is made. + +**Consequences.** Reports cost one scheduler tick a minute while any channel schedules one, a handful of indexed queries per report, and no table. The delivery log shows every report with its window and the late marker. A report addressed to a channel that was paused when it fell due is logged `suppressed: channel_paused` and not re-sent after the resume. + +**Alternatives considered.** *Reports in UTC*: testable, but "09:00" would move twice a year and differ from the operator's clock. *One shared digest for every channel*: the window and level differ per channel. *Sending every missed window*: a long weekend would arrive as a burst of stale messages. *Skipping missed windows silently*: data loss with no trace. *Merging missed windows into the next digest*: a 24-hour digest that suddenly covers four days reads as a mistake. *No record of empty digests*: "why didn't I get one?" would have no answer. + +## D-44 Anomaly alerts: hourly checks with thresholds and hysteresis, silent unless something crosses + +**Status:** Accepted + +**Implementation:** N3: `evaluateAnomalies` (pure), the hourly check in `ReportScheduler`, the `anomaly` rule, `--notificationChannel … anomaly=on`. + +**Context.** Individual notifications already cover each attention request, crash and degradation. What they miss is a trend: a fleet whose tool calls start failing, a blocklist suddenly hit hundreds of times, a queue of requests nobody answers, sessions pinned at the limit. A check that reports on every tick is ignored within a day; one that flaps around a threshold is worse. + +**Decision.** +- A channel opts in with `rules.anomaly` (each check can be tuned or switched off: `null`). Once an hour (at the top of the hour; after downtime one check runs at once, and nothing is reported late), BrowserHive computes the facts of the trailing 60 minutes once and evaluates each channel's checks: + +| Check | Fires when (defaults) | Clears when | +|---|---|---| +| `error_rate` | ≥ 20 % of tool calls failed, with at least `min_calls` (20) calls | below half the threshold, or fewer than half the minimum calls | +| `attention` | an attention request has waited ≥ `attention_minutes` (30) | no request waits that long | +| `capacity` | live sessions ≥ `maxSessions` | below 90 % of `maxSessions` (at least one below) | +| `blocked` | blocked requests ≥ `blocked_spike` (3) × the hourly average of the 24 hours before, and ≥ `blocked_min` (50) | below half of both | +| `degraded` | an unresolved error-severity system event exists | none is unresolved | + +- **Hysteresis and state.** Each channel's active checks, when each became active and the open alert are kept in `notification_cursors` (`anomaly:`), so a restart neither repeats nor forgets an episode. A check that becomes active is a **crossing**: it produces a new, alerting message that lists every active check (the new ones first) with its value and threshold. A change without a crossing (one of several checks clears) is a silent edit of the open alert. When every check has cleared, the alert is revised to `resolved` with a silent edit ("Back to normal since 15:00"). The resolution stays silent by design (confirmed after the N3 review): good news edits the alert in place and rings nothing, on a platform or in the dashboard. Nothing is sent while nothing crosses. +- **Quiet hours.** No check runs for a channel during its quiet hours; the first check after them reports what is still wrong (held, not lost). +- Severity `warn`, `error` while `degraded` or `capacity` is active; addressed to the channel like any report (D-43), at its content level (the checks' names and numbers are fixed labels, so every level carries them; `full` adds the degradation messages). + +**Consequences.** The check is five indexed counts and one list per hour, shared by every channel. The thresholds are per channel (advanced settings, `anomaly.*` flag parameters). The anomaly alert complements, and does not replace, the per-event notifications. + +**Alternatives considered.** *A statistical baseline for every metric*: opaque ("why did this fire?") and noisy on a small fleet; fixed, visible thresholds with one relative check (blocked) are explainable. *A check every minute*: faster, but an hourly window is what makes a rate meaningful on a small fleet. *Re-alerting while a check stays active*: that is the flapping the hysteresis removes. + +## D-45 Reports in the dashboard: one in-app copy per period, an in-app schedule, digests never ring + +**Status:** Accepted + +**Implementation:** N3 (follow-up on the same PR): the in-app copies and the anomaly watches in `ReportScheduler`, `GET /notifications/reports`, `GET /notifications/reports/{notification_id}`, `GET`/`PUT /notifications/report-settings`, the inbox's Reports chip, the Notifications → Reports tab and the report page. + +**Context.** D-43 and D-44 addressed every report to a channel and kept its row out of the inbox, pointing at the Overview. In review the owner asked for the reports in BrowserHive itself: someone who reads the dashboard every morning wants the digest there, a history of reports to look back at, and reports without setting up any external channel. Two channels on the same schedule must not put the same digest in the inbox twice, and a daily summary must not behave like an alarm. + +**Decision.** +- **One in-app copy per period.** Every report a schedule produces also has an **in-app copy**: an ordinary inbox row (principal `null`, like every produced notification) built at the `full` content level (the dashboard is the operator's own screen; content levels protect the platforms, D-43), in the report's time zone, whose target is its report page (`/notifications/reports/`). Channels that share a **period** share one copy. A digest's period is its schedule identity (`scheduleKey`: frequency, weekday, time, weekdays-only, time zone) plus its window `[since, until)`; two channels with "every day at 09:00, Europe/Berlin" produce the same period and one copy, while 09:00 Berlin and 08:00 London are two periods even when the instants coincide, because the text is written in different zones. The copy's thread is `report:digest:::`: the first schedule to produce the period writes it, the others find it by thread in their own transaction. An on-demand digest ("Send a digest now") is a period of its own (the window that ends at the press, `report:digest:now:::`). An empty digest has no in-app copy (nothing to tell; its channel rows still say `suppressed: empty`). +- **Channel copies stay out of the inbox.** A channel's own row (its content level, its window text, its delivery rows) keeps being stored read and dismissed, and names its in-app copy in `source_event_id`, so the Reports tab can say which channels a report reached. Nothing else changes for channels. +- **Anomaly watches.** The in-app anomaly alerts come from **watches**: one per distinct set of effective thresholds among the channels with anomaly alerts, plus the defaults when the in-app switch is on. Each watch is evaluated once an hour with the same facts and hysteresis as D-44 (no quiet hours: the inbox rings nothing that the toast preferences do not allow), and its crossings, silent revisions and "Back to normal" apply to its in-app copy (thread `report:anomaly::`). Two channels with the same thresholds therefore produce one in-app alert per episode. A channel's own alert names the open alert of its watch. When a watch is no longer wanted (the last channel with those thresholds changed or went), its open alert is closed silently ("No longer checked."). +- **Badge and toasts.** A digest is a record, not a call to act: its in-app copy is stored **already read** (it never counts toward the bell's badge or the Unread filter) and it **never toasts**, whatever the preferences. An anomaly alert is stored unread (it counts toward the badge) and has type `system`, so it follows the existing toast preferences: it toasts, in the warning tone and fading, exactly when the operator's toast types include System (the default); its revisions (superseded, "Back to normal") are silent edits that close its toast. +- **In-app schedule.** The Reports tab's settings (`notification_cursors['settings:in-app-reports']`, server-wide, like the channels) hold a digest schedule (off by default; every day, optionally weekdays only, or every week; a time; a time zone, default the host's) and the anomaly switch (off by default). The same `ReportScheduler` evaluates them as a schedule without a channel (`digest:in-app`), so reports work with no external channel at all, and they share periods with the channels like any other schedule. +- **Finding them.** The inbox gains a **Reports** filter chip (`category=reports`; `type` and `category` together are one facet, a row matching either). The Notifications area gains a **Reports** tab: the full history of in-app copies (a dismissed row leaves the inbox, not the history) until notifications retention prunes it, filtered by kind, by where it went (a channel, or "BrowserHive only": reached no channel) and by period; each opens a report page that renders the message natively (facts, chart, tables) with its window, zone, late and on-demand markers, the channels it reached, and "Open Overview for this period". + +**Consequences.** No migration: the copies are notification rows, the links reuse `source_event_id` and `thread`, the settings and watches live in `notification_cursors`. A report reaching three channels is four rows (the in-app copy and one per channel). The anomaly facts are still gathered once per hour, shared by every watch and channel. + +**Alternatives considered.** *A new notification type `report`*: the natural chip, but `notifications.type` has a `CHECK` constraint, so it needs a table rebuild for a filter the `category` column already expresses. *Showing each channel's copy in the inbox*: the same digest once per channel, at levels chosen for platforms. *A separate reports table*: a migration for data the notification rows already hold. *Toasting digests*: a summary scheduled at 09:00 would interrupt like an alarm. *Deduplicating by window only*: two zones would share a copy written in only one of them. diff --git a/specs/02-mcp-and-tools.md b/specs/02-mcp-and-tools.md index 50031b9..774a3b5 100644 --- a/specs/02-mcp-and-tools.md +++ b/specs/02-mcp-and-tools.md @@ -367,4 +367,4 @@ await server.stop({ deadlineMs?: 20_000 }); // idempotent; unwinds even after a - `launch_options.chromiumSandbox`: `false` is refused (`UNSAFE_LAUNCH_ARG`, spec 11 §4); `true` is accepted in every `sandbox` mode because it only strengthens the posture, and makes the sandbox a requirement for that session (a host that cannot give it answers `SANDBOX_UNAVAILABLE`, never `INTERNAL_ERROR`). - `list_saved_auths` scopes by the `owner` field in `.meta.json`; a manifest without it (for example one written by hand) is treated as owned by `local`. - `instructions` on the server and `title` on tools are additive; the golden generator normalizes key order so an SDK reordering does not produce a false diff. -- `resolved_by` of an attention outcome names who answered: the operator principal from the dashboard or the API, or, when the operator answered with an act button in a chat (03 §9.6, D-41), the platform alone (`telegram`, `discord`, `ntfy`): the operator's views and the audit keep the full actor (`telegram:`), but the agent is untrusted (D-09) and never learns the operator's chat identity. There is no tool that sends a notification, and none will be added to the core catalog: notifications derive only from facts BrowserHive observed (D-32). `request_attention` is the agent's way to reach a human, and it already produces the `attention.requested` notification (03 §9) that external channels deliver. +- `resolved_by` of an attention outcome names who answered: the operator principal from the dashboard or the API, or, when the operator answered with an act button in a chat (03 §9.6, D-41), the platform alone (`telegram`, `discord`, `ntfy`): the operator's views and the audit keep the full actor (`telegram:`), but the agent is untrusted (D-09) and never learns the operator's chat identity. There is no tool that sends a notification, and none will be added to the core catalog: notifications derive only from facts BrowserHive observed (D-32). Scheduled digests and anomaly alerts (D-43, D-44) are computed from the recorded facts on the operator's schedule; no tool can trigger or suppress one. `request_attention` is the agent's way to reach a human, and it already produces the `attention.requested` notification (03 §9) that external channels deliver. diff --git a/specs/03-admin-backend.md b/specs/03-admin-backend.md index c93f9d5..8b049f5 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -282,8 +282,12 @@ One broker (D-15) backs two resource views; paths stay recognizable. | Method | Path | Auth | Request | Response | |---|---|---|---|---| -| GET | `/notifications` | S | `read` (`all|unread|read`), `type[]`, `since`, `until`, `sort` (`updated_at` default \| `created_at`), cursor | `Page` + `unread_count`. `Notification` = `notification_id, principal_id, type, title, body, session_id, session_slug, target, source_event_id, created_at, updated_at, count, read_at, dismissed_at, kind, category, severity, state, revision, thread` (the last six classify the row by the notification contract, §9; rows from before schema v5 read values derived from `type`); `session_slug` is `null` without a session; `count ≥ 1` is the number of folded occurrences; `updated_at` is the latest occurrence (= `created_at` when `count` is 1). `since`/`until` filter on the sort column and cursors are bound to it, so a growing group moves to the top. `unread_count` counts rows (a group of 12 errors counts 1) | +| GET | `/notifications` | S | `read` (`all|unread|read`), `type[]`, `category[]` (`type` and `category` are one facet: a row matches when its type or its category is selected; either alone filters as usual; the inbox's Reports chip is `category=reports`, D-45), `since`, `until`, `sort` (`updated_at` default \| `created_at`), cursor | `Page` + `unread_count`. `Notification` = `notification_id, principal_id, type, title, body, session_id, session_slug, target, source_event_id, created_at, updated_at, count, read_at, dismissed_at, kind, category, severity, state, revision, thread` (the last six classify the row by the notification contract, §9; rows from before schema v5 read values derived from `type`); `session_slug` is `null` without a session; `count ≥ 1` is the number of folded occurrences; `updated_at` is the latest occurrence (= `created_at` when `count` is 1). `since`/`until` filter on the sort column and cursors are bound to it, so a growing group moves to the top. `unread_count` counts rows (a group of 12 errors counts 1) | | POST | `/notifications/{notification_id}/read` | S | — | `{ok:true}` | +| GET | `/notifications/reports` | S (`notifications:read`) | `kind[]` (`digest.daily`, `digest.weekly`, `report.anomaly`), `channel` (a channel id: reports with a delivery to it; `in-app`: reports that reached no channel), `since`, `until` (on `created_at`), cursor, `limit`, `total` | `Page` newest first — the in-app copies of reports (§9.7, D-45) whatever their inbox state: `ReportItem = {notification: Notification, report: {window: {since, until}, time_zone, late, skipped, manual} \| null, channels: [{channel_id, name, kind, status, reason}]}` (`channels`: the channels its channel copies were addressed to, from their delivery rows, with the latest status of each; a channel removed since, or delivery rows pruned after 30 days, leave it out) | +| GET | `/notifications/reports/{notification_id}` | S (`notifications:read`) | — | `{report: ReportItem, message: NotificationMessage}` (the in-app copy's current message, `full` level) | 404 `REPORT_NOT_FOUND` (unknown, pruned, or not an in-app report copy) | +| GET | `/notifications/report-settings` | S (`notifications:read`) | — | `{settings: ReportSettings, host_time_zone, reports: ChannelReports}` — `ReportSettings = {digest?: DigestRule, anomaly?: AnomalyRule, time_zone?: IANA}` (the in-app schedule, D-45; `{}` = off, the default); `reports` is the same view as a channel's (`next_at`, the watch's `next_check_at` and active checks) | +| PUT | `/notifications/report-settings` | S (`channels:write`) | `{settings: ReportSettings}` (validated like channel rules: a known zone, `day` only for weekly, `weekdays_only` only for daily) | the GET body. A changed schedule re-arms from now (no late digest from an edit) | 400 `VALIDATION_FAILED` | | POST | `/notifications/read-all` | S | — | `{ok:true, updated:n}` | | DELETE | `/notifications/{notification_id}` | S | — | `{ok:true}` (dismiss) | | POST | `/notifications/dismiss-all` | S | — | `{ok:true, updated:n}` | @@ -291,13 +295,13 @@ One broker (D-15) backs two resource views; paths stay recognizable. | PUT | `/me/preferences` | S (`preferences:write`) | `{preferences}` (≤ 64 KiB, zod-validated known keys, unknown keys rejected) | `{ok:true, updated_at}` | | GET | `/search` | S | `q` (≥ 2 chars), `limit` ≤ 20 | `{sessions:[{session_id, slug}], tools:[name], vault_handles:[handle], patterns:[pattern]}` — command-palette entity search | -### 4.8.1 Notification channels (D-33, D-37, D-38, D-39, D-41, D-42; §9.5, §9.6) +### 4.8.1 Notification channels (D-33, D-37, D-38, D-39, D-41, D-42, D-43, D-44; §9.5, §9.6, §9.7) -`ChannelView` = `channel_id, name, kind, mode, source ('db'|'startup'), status ('active'|'paused'|'broken'), target (non-secret coordinates, per kind §9.5), target_hint (a short, lossy rendering for lists: "chat …3456", "ntfy.sh/bh-…", "discord webhook"), secret_refs ({param: ENV_NAME}), secrets ([{param, env, set}]: whether each named variable is set, never its value), rules (NotificationChannelRules), capabilities, ready (the adapter could be built), problem (why not: "BH_TG_TOKEN is not set"), failure_count, last_error, last_ok_at, last_failure_at, created_at, updated_at, stats {sent_24h, failed_24h, suppressed_24h, pending, last_delivery_at, last_status}, connection` — `connection` is the state of the channel's press listener (§9.6: `{state: 'connecting'|'connected'|'reconnecting'|'offline', since, detail}`), `null` when the channel receives no presses (act buttons off, or a platform without them). No response ever carries a secret value; requests carry only environment variable names (`SecretEnvName`: not `BROWSERHIVE_*`), and a body that looks like it holds a secret value where a name belongs is a 400 `VALIDATION_FAILED` that never echoes it. +`ChannelView` = `channel_id, name, kind, mode, source ('db'|'startup'), status ('active'|'paused'|'broken'), target (non-secret coordinates, per kind §9.5), target_hint (a short, lossy rendering for lists: "chat …3456", "ntfy.sh/bh-…", "discord webhook"), secret_refs ({param: ENV_NAME}), secrets ([{param, env, set}]: whether each named variable is set, never its value), rules (NotificationChannelRules), capabilities, ready (the adapter could be built), problem (why not: "BH_TG_TOKEN is not set"), failure_count, last_error, last_ok_at, last_failure_at, created_at, updated_at, stats {sent_24h, failed_24h, suppressed_24h, pending, last_delivery_at, last_status}, connection, reports` — `reports` is `{time_zone (the effective IANA zone: rules.time_zone or the host's), host_zone (whether it is the host's), digest: {every, at, day, next_at (the next scheduled time, epoch ms), last_until (the end of the last window handled, or null)} | null, anomaly: {next_check_at, active: [{check, since, value, threshold}]} | null}` (§9.7); `connection` is the state of the channel's press listener (§9.6: `{state: 'connecting'|'connected'|'reconnecting'|'offline', since, detail}`), `null` when the channel receives no presses (act buttons off, or a platform without them). No response ever carries a secret value; requests carry only environment variable names (`SecretEnvName`: not `BROWSERHIVE_*`), and a body that looks like it holds a secret value where a name belongs is a 400 `VALIDATION_FAILED` that never echoes it. | Method | Path | Auth | Request | Response | Errors | |---|---|---|---|---|---| -| GET | `/channels` | S (`channels:read`) | — | `{data: ChannelView[], now}` (dashboard channels and startup channels, by name) | — | +| GET | `/channels` | S (`channels:read`) | — | `{data: ChannelView[], now, host_time_zone}` (dashboard channels and startup channels, by name; `host_time_zone` is the zone a channel without `rules.time_zone` uses, §9.7) | — | | POST | `/channels` | S (`channels:write`) | `ChannelInput {name, kind, mode?, target, secret_refs, rules?}` (kinds `telegram`, `discord` (mode `webhook` or `bot`), `ntfy`, `webhook`) | 201 `{channel: ChannelView}` | 400 `VALIDATION_FAILED` (a Telegram TTL above 47 h, an unknown target key, a missing required secret, a secret of the other Discord mode, act buttons on Discord webhook mode or on ntfy without a reply topic, an allow-list entry that is not a numeric user id, an allow-list on ntfy), 409 `CHANNEL_NAME_TAKEN`, 400 `CHANNEL_KIND_UNAVAILABLE` (the reserved platforms, until they ship) | | GET | `/channels/{channel_id}` | S (`channels:read`) | — | `{channel: ChannelView}` | 404 `CHANNEL_NOT_FOUND` | | PATCH | `/channels/{channel_id}` | S (`channels:write`) | partial `ChannelInput` (not `kind`). The allow-list is edited here, as `rules.allow_list` (a full rules object replaces the stored one); switching the Discord `mode` sends the other mode's `secret_refs` and `target` and keeps the rules | `{channel}` | 404, 409 `CHANNEL_READ_ONLY` (a startup channel: it is edited with its flag), 409 `CHANNEL_NAME_TAKEN` | @@ -305,8 +309,9 @@ One broker (D-15) backs two resource views; paths stay recognizable. | POST | `/channels/{channel_id}/pause` | S (`channels:write`) | — | `{channel}` (pending jobs become `suppressed: channel_paused`; allowed on startup channels, and the pause survives restarts) | 404 | | POST | `/channels/{channel_id}/resume` | S (`channels:write`) | — | `{channel}` (`active`, consecutive failures reset; also how a `broken` channel is retried) | 404 | | POST | `/channels/{channel_id}/test` | S (`channels:write`) | — | `{ok, delivery: DeliveryRow, error?: {code, message}}` — sends a `test` notification (system · info) through the adapter now, outside the outbox queue, and records it in the delivery log; the message carries an "Open dashboard" link (the human `publicUrl` proof). Rate-limited 10/min | 404, 409 `CHANNEL_NOT_READY` (no adapter: a variable is unset) | -| POST | `/channels/preview` | S (`channels:read`) | `{channel_id}` or a draft `{kind, mode?, target?, secret_refs? (variable names only; anything else is ignored), rules?}`, plus `sample` (`attention`, `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`) | `ChannelPreview {kind, mode, sample, capabilities, message (as the channel receives it: content level, image rule, degrade), requests: [{method, path, body}] (the platform request(s) the renderer produces, with every secret replaced by its variable name), notes[]}` — **pure, sends nothing**; the dashboard's mocks draw from `requests` | 404 | -| GET | `/channels/deliveries` | S (`channels:read`) | filters `channel_id`, `notification_id`, `status[]`, `op[]`, `kind[]` (notification kind), cursor (`seq`), `limit` | `Page` newest first — `seq, channel_id, channel_name, channel_kind, notification_id, notification_kind, notification_title, revision, op, status, reason, attempts, next_attempt_at, last_error, duration_ms, message_ref, created_at, updated_at` | — | +| POST | `/channels/{channel_id}/digest` | S (`channels:write`) | `{send: boolean}` (default `false`) | `{preview: ChannelPreview, window: {since, until}, empty: boolean, sent: boolean, ok: boolean, delivery: DeliveryRow \| null, error: {code, message} \| null}` — builds the channel's report for the period that ends now (a day, or a week for a weekly digest) from real data at the channel's content level and time zone; `send: false` only previews it (pure); `send: true` also sends it at once through the adapter, outside the queue, as a `manual` report (even when the period is empty; the schedule and its cursor are not touched) and records it in the delivery log. Rate-limited 12 calls a minute (a preview and a send count one each) | 404, 409 `CHANNEL_NOT_READY` (sending without an adapter) | +| POST | `/channels/preview` | S (`channels:read`) | `{channel_id}` or a draft `{kind, mode?, target?, secret_refs? (variable names only; anything else is ignored), rules?}`, plus `sample` (`attention`, `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`, `digest`, `anomaly`; the two report samples use fixed sample figures, built like a real report at the channel's content level, time zone and schedule) | `ChannelPreview {kind, mode, sample, capabilities, message (as the channel receives it: content level, image rule, degrade), requests: [{method, path, body}] (the platform request(s) the renderer produces, with every secret replaced by its variable name), notes[]}` — **pure, sends nothing**; the dashboard's mocks draw from `requests` | 404 | +| GET | `/channels/deliveries` | S (`channels:read`) | filters `channel_id`, `notification_id`, `status[]`, `op[]`, `kind[]` (notification kind), cursor (`seq`), `limit` | `Page` newest first — `seq, channel_id, channel_name, channel_kind, notification_id, notification_kind, notification_title, revision, op, status, reason, attempts, next_attempt_at, last_error, duration_ms, message_ref, created_at, updated_at, report` (`report` is the report's `{window: {since, until}, time_zone, late, skipped, manual}` for digests and anomaly alerts, else `null`) | — | | GET | `/channels/deliveries/{seq}` | S (`channels:read`) | — | `{delivery: DeliveryRow, message: NotificationMessage | null}` — the notification's current message as this channel is shown it (content level and degrade applied: the redacted payload) | 404 `DELIVERY_NOT_FOUND` | | GET | `/channels/env` | S (`channels:read`) | `names` (csv of `SecretEnvName`, ≤ 16) | `{vars: [{name, set}]}` — whether each variable is set and non-empty in the server's environment; never a value | 400 | | POST | `/channels/telegram/connect` | S (`channels:write`) | `{token_env, thread?}` | `{connect_id, bot_username, link: 'https://t.me/?start=', group_link: 'https://t.me/?startgroup=', expires_at}` — checks the token with `getMe`, then long-polls `getUpdates` for 2 minutes waiting for `/start ` in a private chat or a group; one connect per token at a time (a new one cancels the old) | 400, 409 `CHANNEL_NOT_READY` (the variable is unset), 502 `CHANNEL_PLATFORM_ERROR` (Telegram refused the token) | @@ -636,8 +641,8 @@ CREATE TABLE notification_actions ( -- audit: every press of CREATE INDEX idx_notification_actions_at ON notification_actions(at); CREATE INDEX idx_notification_actions_channel ON notification_actions(channel_id, seq); CREATE INDEX idx_notification_actions_notification ON notification_actions(notification_id, seq) WHERE notification_id IS NOT NULL; -CREATE TABLE notification_cursors ( -- where each press listener resumes: a Telegram update offset per bot, the last ntfy message id per channel - cursor_key TEXT PRIMARY KEY, -- telegram: | ntfy:; never a token or a topic name +CREATE TABLE notification_cursors ( -- where each press listener resumes (a Telegram update offset per bot, the last ntfy message id per channel) and each report schedule stands (§9.7) + cursor_key TEXT PRIMARY KEY, -- telegram: | ntfy: | digest: | anomaly: | digest:in-app | anomaly:in-app (the anomaly watches) | settings:in-app-reports (D-45); never a token or a topic name value TEXT NOT NULL, updated_at INTEGER NOT NULL ) WITHOUT ROWID; @@ -659,10 +664,10 @@ CREATE TABLE resource_samples (ts INTEGER NOT NULL, session_id TEXT REFERENCES s | sessions | `sessions` rows | deleted only when all children are gone and `closed_at < now - retentionDays`; **archived sessions exempt** | | artifacts | `trace.zip`, `sessions//`, downloads | follow their session; deletion via `artifact_outbox` (row delete and outbox insert in one transaction; sweeper unlinks with retries; orphan scan weekly) | | connections | `mcp_connections` (with its IP, `User-Agent` and meta bag) | closed rows with `closed_at < now - retentionDays` that no remaining `sessions` row references (a session keeps its client metadata as long as it lives, archived ones included); open rows never. A pruned row's tool calls are older than it, so they are pruned first; a session's own harness survives in `sessions.harness` | -| notifications | `notifications` | 30 d after `dismissed_at`/`read_at`, 90 d otherwise (a pruned row takes its deliveries and channel messages with it) | +| notifications | `notifications` | 30 d after `dismissed_at`/`read_at`, 90 d otherwise; reports (`category = 'reports'`: the in-app copies and the channel copies, D-45) 90 d after `created_at` whatever their read and dismissed state, so the Reports tab keeps its history (a pruned row takes its deliveries and channel messages with it) | | notification deliveries | `notification_deliveries` (terminal rows: `sent`, `dead`, `suppressed`, `superseded`), `notification_channel_messages` (deleted, or without a TTL) | telemetry-like, own window: 30 d after `updated_at`; never byte-pruned; `pending`, `sending` and `retrying` jobs and messages with a pending TTL are never pruned | | action tokens | `notification_action_tokens` | pruned one day after `expires_at` (used or not); a channel or notification delete takes its tokens | -| configuration | `notification_channels`, `notification_cursors`, `vault_bindings`, `vault_group_policies`, `preferences` | never pruned; `purge` lists them with the other tables (deleting the database loses configured channels and bindings) | +| configuration | `notification_channels`, `notification_cursors`, `vault_bindings`, `vault_group_policies`, `preferences` | never pruned; `purge` lists them with the other tables (deleting the database loses configured channels and bindings); a channel's cursors (`ntfy:`, `digest:`, `anomaly:`) are removed with the channel (a dashboard delete, or a startup channel no longer declared) | | backups | `backups/*.db` | keep last 5 | `retentionDays` must be ≥ 1 (`0` is rejected at config time, see 08 — "keep forever" is expressed by the per-class exemptions below and a large value). The sweep runs every 6 h (`retentionIntervalMs`), catches per-item failures, records a `system.degraded` on repeated failure, never throws, and never runs `VACUUM`: the DB is opened with `auto_vacuum=INCREMENTAL` and the sweep issues `PRAGMA incremental_vacuum(N)` in bounded chunks. `/system.retention` exposes the last run. @@ -674,14 +679,14 @@ interface SessionRepository { insert(row); update(id, patch); get(id); list(quer interface ToolCallRepository { insert(row); get(eventId); listBySession(id, query); listAll(query /* hasSession? */); } interface PageRepository { insert(row); list(query); facets(query): {categories}; recent(limit); topDomains(query); } interface ScreenshotRepository { insert(row); get(eventId); listBySession(id, query); } -interface VaultAuditRepository { insert(row); list(query); } +interface VaultAuditRepository { insert(row); list(query); countByResult(window): [{result, count}] /* [since, until) */; } interface BlocklistAuditRepository { insert(row); list(query); stats(query); } -interface OperatorRequestRepository { insert(row); resolve(id, status, at, message, by, reason); open(kind?); get(id); listHistory(query); facets(query): {status, mode}; } +interface OperatorRequestRepository { insert(row); resolve(id, status, at, message, by, reason); open(kind?); get(id); listHistory(query); facets(query): {status, mode}; windowStats(kind, window): {created, resolved, rejected, timedOut, cancelled, pending, medianWaitMs} /* requests created in [since, until) */; } interface EventLogRepository { append(event); replay(afterSeq, limit); } interface PrincipalRepository / CredentialRepository / AuthSessionRepository / GrantRepository / AuthEventRepository interface VaultBindingRepository { list(query); get(handle); upsert(binding, ifVersion?); remove(handle); exportAll(); importAll(doc, mode); } interface VaultGroupPolicyRepository { list(); get(groupKey); upsert(policy, ifVersion?); } -interface NotificationRepository { …; list(query /* sort: updated_at|created_at */); findOpenGroup(principalId, groupKey); updateGroup(id, patch /* applies only while unread and undismissed; carries revision + message */); findLatestByThread(principalId, thread); revise(id, patch /* state, severity, revision, message; never title/body/updated_at */); } +interface NotificationRepository { …; list(query /* sort: updated_at|created_at; types ∪ categories */); listReports(query /* in-app report copies: kinds, channel id | 'in-app', since, until; newest first */); reportChannels(ids) /* per in-app copy: the channels of its channel copies, from their delivery rows */; findOpenGroup(principalId, groupKey); updateGroup(id, patch /* applies only while unread and undismissed; carries revision + message */); findLatestByThread(principalId, thread); revise(id, patch /* state, severity, revision, message; never title/body/updated_at */); } interface NotificationChannelRepository { list(); get(id); getByName(name); upsert(row); remove(id); setStatus(id, status, at); recordSuccess(id, at); recordFailure(id, at, error): {failureCount}; } interface NotificationDeliveryRepository { enqueue(rows) /* ignores duplicates of (channel, notification, revision, op) */; due(now, limit); claim(seq, at): boolean /* pending|retrying → sending */; finish(seq, patch); supersedeOlder(channelId, notificationId, revision, at); recoverSending(at): number; pendingByChannel(channelId, severity); list(query); get(seq); } interface NotificationChannelMessageRepository { get(channelId, notificationId); upsert(row); firstInThread(channelId, thread); dueForDelete(now, limit) /* expired, not deleted, no delete job yet */; markDeleted(channelId, notificationId, at); } @@ -691,7 +696,7 @@ interface NotificationCursorRepository { get(key); set(key, value, at); remove(k interface PreferenceRepository / SystemEventRepository / IdempotencyRepository / ArtifactOutboxRepository interface McpConnectionRepository { insert(row); update(id, patch); get(id); listOpen(); listRecent(limit) /* live first, with session counts */; closeAll(at); } interface UnitOfWork { transaction(fn: (repos: Repositories) => Promise): Promise; } -interface AnalyticsQueries { activity(query); toolMetrics(query); harnessMetrics(window); timeline(sessionId, query); summary(now, window); databaseSize(); } +interface AnalyticsQueries { activity(query); toolMetrics(query); harnessMetrics(window); timeline(sessionId, query); summary(now, window); databaseSize(); windowCounts(window): {sessionsStarted, toolCalls, errors, blocked, attention, vaultAccess} /* one statement */; toolLatency(window): [{tool, calls, errors, p95Ms}] /* p95 computed in SQLite with a window function; one row per tool */; topErrors(window, limit): [{errorCode, tool, count, sessions}]; } interface MaintenanceService { migrate(); backup(): Promise; retentionSweep(); incrementalVacuum(); inventory(): Promise; integrityCheck(); } ``` @@ -713,7 +718,7 @@ Writes are enqueued (FIFO, one transaction per drain, statements prepared once); ## 9. Notifications (D-16, D-32, D-34) -Producer (`app/notifications`) subscribes to the bus and writes every row to **one shared operator inbox** (`principal_id` NULL): v1 has a single operator, the list, unread count and read/dismiss routes are not filtered by principal, and per-operator inboxes wait for multi-user (D-25). `NotificationService`'s `recipients` hook (default `[null]`) is the seam they plug into; composition does not set it. External channels are instance-wide: deliveries are enqueued once per produced notification, for the first recipient's row. +Producer (`app/notifications`) subscribes to the bus and writes every row to **one shared operator inbox** (`principal_id` NULL): v1 has a single operator, the list, unread count and read/dismiss routes are not filtered by principal, and per-operator inboxes wait for multi-user (D-25). `NotificationService`'s `recipients` hook (default `[null]`) is the seam they plug into; composition does not set it. External channels are instance-wide: deliveries are enqueued once per produced notification, for the first recipient's row. Scheduled reports (§9.7) are the exception: each is produced for one channel and addressed to it alone. ### 9.1 Producer rules @@ -729,8 +734,10 @@ Producer (`app/notifications`) subscribes to the bus and writes every row to **o | `system.degraded` (severity error) | `system.degraded` | system · error · open | type `system`, message, target `/system` | | `system.recovered` | revision | state `resolved` | as above | | `notification.channel.changed` to `broken` (internal) | `channel.broken` | system · error · final | type `system`, "Notification channel {name} is failing", target `/system`; **in-app only** (§9.4) | +| the report scheduler, a channel's digest time (§9.7) | `digest.daily` / `digest.weekly` | reports · info · final | type `lifecycle`, "Daily digest · Tue 29 Sep", target `/overview?since=…&until=…`; stored read and dismissed; **addressed** to that channel | +| the report scheduler, a crossing of a channel's anomaly check (§9.7) | `report.anomaly` | reports · warn (error while `degraded` or `capacity` is active) · open, later `resolved` | type `system`, "Something looks off: …", target `/overview`; stored read and dismissed; **addressed** to that channel | -Reserved kinds without a producer yet: `session.finished`, `vault.filled` (wrap-ups · info), `digest.daily` (reports · info), `report.anomaly` (reports · warn), `test` (system · info). The kind → category map is fixed in `contracts/notifications`; severity is set per producer. +Reserved kinds without a producer yet: `session.finished`, `vault.filled` (wrap-ups · info). `test` (system · info) is produced only by the test send. The kind → category map is fixed in `contracts/notifications`; severity is set per producer. **Tool-error grouping** (`app/notifications/producers.ts`): group key `tool-errors:`. A new failure grows the existing row of its group when that row is unread, not dismissed, its `updated_at` is < 5 min ago (`NOTIFICATION_GROUP_IDLE_MS`) and its `created_at` is < 60 min ago (`NOTIFICATION_GROUP_MAX_AGE_MS`): `count` +1, `title`, `body`, `updated_at` and `source_event_id` follow the latest occurrence, the revision grows by one, and `notification.updated` carries the full row. Otherwise a new row (`count` 1) is created with `notification.created`. Marking read or dismissing therefore starts a fresh group, and a failure run longer than an hour resurfaces hourly. Session-less failures: a caller mistake (an error code with `retryable: 'different_args'`, e.g. `INVALID_ARGUMENTS`, `SESSION_NOT_FOUND`) produces no notification; any other (e.g. `launch_session` → `BROWSER_NOT_INSTALLED`) is grouped under "No session · {n} tool errors" with `session_id`, `session_slug` and `target` null. Every row carries `session_slug` when it has a session. @@ -738,25 +745,25 @@ Deliberately silent (no new notification): `session.opened`, `page.visited`, `se ### 9.2 The message contract -Every produced or revised notification also stores its current `NotificationMessage` (`message_json`, `@browserhive/contracts/notifications`, JSON Schema in `docs/reference/notification-message.schema.json`, D-32): `schema: 1`, `id` (= `notification_id`), `revision`, `thread`, `kind`, `category`, `severity`, `state`, `alert` (whether this revision should make noise: true for the first revision, false for lifecycle revisions and group growth), `at {created, updated}`, `title` (≤ 120), `summary` (≤ 240), `blocks` (text, heading, fields, quote, list, table, image, code, divider, footer; inline text, bold, italic, code, dashboard-path link, time), `actions` (≤ 5: `act` with a `command {op, args}` and an `open` fallback, or `open` with a dashboard `path`; act ops `attention.resolve`, `vault.confirm.resolve`, `session.extend_lease`, `session.close`), `entities` (`session_id`, `session_slug`, `harness`, `owner`, `tool`, `error_code`, `domain`, `request_id`) and `privacy {level, has_image}`. Field names are snake_case like every wire shape (D-05). +Every produced or revised notification also stores its current `NotificationMessage` (`message_json`, `@browserhive/contracts/notifications`, JSON Schema in `docs/reference/notification-message.schema.json`, D-32): `schema: 1`, `id` (= `notification_id`), `revision`, `thread`, `kind`, `category`, `severity`, `state`, `alert` (whether this revision should make noise: true for the first revision, false for lifecycle revisions and group growth), `at {created, updated}`, `title` (≤ 120), `summary` (≤ 240), `blocks` (text, heading, fields, quote, list, table, image, code, divider, footer, chart; inline text, bold, italic, code, dashboard-path link, time), `actions` (≤ 5: `act` with a `command {op, args}` and an `open` fallback, or `open` with a dashboard `path`; act ops `attention.resolve`, `vault.confirm.resolve`, `session.extend_lease`, `session.close`), `entities` (`session_id`, `session_slug`, `harness`, `owner`, `tool`, `error_code`, `domain`, `request_id`), `privacy {level, has_image}` and, on reports only, the optional `report {window: {since, until}, time_zone, late, skipped, manual}` (§9.7). A `chart` block is `{label, values (1–48 non-negative numbers), start, step_ms, unit}`: bars over equal steps from `start` (a digest's tool calls per hour). Field names are snake_case like every wire shape (D-05). - Producers are pure (`buildMessage(draft, …)`); every copied string passes the `Redactor` and URLs pass `sanitizeUrl` before it becomes part of the message. The in-app title and body are the message's `title` and `summary` at creation; lifecycle revisions change the message only. - Act buttons exist only while `state = open`, and a lifecycle revision out of `open` carries no actions at all: the buttons disappear with a silent edit. A one-shot fact (`final` from its first revision, e.g. a crash) keeps its open links. - Links are paths (`/sessions/{id}?live=1`); a `LinkBuilder` port turns them into absolute URLs for external channels (`publicUrl`, D-37). The in-app channel needs none. -- `restrictContent(message, level)` derives the lower content levels per channel: `titles` keeps title, summary, `fields` and `footer` blocks and the actions; `counts` keeps only a fixed per-kind title (with the group count), the session slug and the actions. -- `degrade(message, capabilities)` adapts a message to a renderer (D-32); both are pure and tested table-driven. +- `restrictContent(message, level)` derives the lower content levels per channel: `titles` keeps title, summary, `fields` and `footer` blocks and the actions; `counts` keeps only a fixed per-kind title (with the group count), the session slug and the actions. A message already at the target level is returned unchanged: reports are built at their channel's level by their producer (§9.7), with the tables and charts that level allows. +- `degrade(message, capabilities)` adapts a message to a renderer (D-32): among other steps, a `chart` becomes a text line where `charts` is false (`label: ▁▂▅▇█▃ · peak 412`); both are pure and tested table-driven. Rows whose classification columns are NULL (written by an older reader in the compatibility window) are read with values derived from `type` as migration v5 backfills them, except `state`, which reads `open` for tool-error groups and `final` otherwise (`classifyLegacy`). ### 9.3 Channels and the registry -`NotificationChannel` (`ports/notification-channel.ts`) is the platform seam: `id`, `name`, `kind`, `capabilities` (rich blocks, tables, images, act buttons, open links, edit, delete, replies, delete window, max title/text length, max buttons), `send(delivery) → {ref}`, `edit(ref, delivery) → {ref}`, `delete(ref)`, and optionally `presses` (a press listener, §9.6). A delivery is the restricted, degraded message plus the `LinkBuilder`, where the platform supports replies the ref of the first message of the thread, and, where act buttons are on, `actTokens` (action id → `bh1:`, minted by the outbox just before the call). The capabilities of a channel depend on its setup (mode, target, secret names, rules): `actButtons` is true only where presses can arrive and `rules.act_buttons` is on (§9.6). A platform failure is a `ChannelSendError` (`retryable`, `retryAfterMs`, `code`: `rate_limited`, `unavailable`, `timeout`, `auth`, `rejected`, `message_gone`, `too_old`). The in-app channel (`kind: in-app`) implements the same port and is delivered **inline after the commit**: the row is the delivery, so it has no outbox rows. +`NotificationChannel` (`ports/notification-channel.ts`) is the platform seam: `id`, `name`, `kind`, `capabilities` (rich blocks, tables, charts, images, act buttons, open links, edit, delete, replies, delete window, max title/text length, max buttons; `charts` is true only for the generic webhook, every other renderer receives charts as text), `send(delivery) → {ref}`, `edit(ref, delivery) → {ref}`, `delete(ref)`, and optionally `presses` (a press listener, §9.6). A delivery is the restricted, degraded message plus the `LinkBuilder`, where the platform supports replies the ref of the first message of the thread, and, where act buttons are on, `actTokens` (action id → `bh1:`, minted by the outbox just before the call). The capabilities of a channel depend on its setup (mode, target, secret names, rules): `actButtons` is true only where presses can arrive and `rules.act_buttons` is on (§9.6). A platform failure is a `ChannelSendError` (`retryable`, `retryAfterMs`, `code`: `rate_limited`, `unavailable`, `timeout`, `auth`, `rejected`, `message_gone`, `too_old`). The in-app channel (`kind: in-app`) implements the same port and is delivered **inline after the commit**: the row is the delivery, so it has no outbox rows. `ChannelRegistry` (`app/notifications/channel-registry.ts`) holds the configured channels (`notification_channels`) and builds an adapter for each through factories registered per kind by composition (`telegram`, `discord`, `ntfy` and `webhook` since N1, §9.5). A channel whose adapter cannot be built (no factory for its kind, or a secret variable that is unset) keeps its row, reports why as `problem` in the API, and its jobs are suppressed with reason `no_adapter`. At start it projects the startup channels (`--notificationChannel`, 08 §5.7, D-39) into rows with `source = 'startup'`: configuration columns rewritten, status and failure counters kept, rows no longer declared removed; a name that a `source = 'db'` channel already uses stops startup with `CONFIG_INVALID` (exit 64). Channel rows store environment variable names only (D-33). ### 9.4 The outbox (D-34) -- **Enqueue.** In the same transaction as the notification insert, growth or revision, `planDeliveries` writes one `notification_deliveries` row per external channel: `pending` with `op = send` for the first revision and `edit` for later ones, or `suppressed` with its reason when the channel's rules filter it (`channel_paused` for a paused or broken channel, `filtered` for category, minimum severity, session glob or harness rules, `quiet_hours` outside the channel's hours unless `critical`, `edit_unsupported` for a silent revision on a platform that cannot edit; an alerting revision there becomes a new `send`). `channel.broken` is never enqueued for an external channel: the degradation loop is cut by kind. With no external channel nothing is written. +- **Enqueue.** In the same transaction as the notification insert, growth or revision, `planDeliveries` writes one `notification_deliveries` row per external channel: `pending` with `op = send` for the first revision and `edit` for later ones, or `suppressed` with its reason when the channel's rules filter it (`channel_paused` for a paused or broken channel, `filtered` for category, minimum severity, session glob or harness rules, `quiet_hours` outside the channel's hours unless `critical`, `edit_unsupported` for a silent revision on a platform that cannot edit; an alerting revision there becomes a new `send`). `channel.broken` is never enqueued for an external channel: the degradation loop is cut by kind. With no external channel nothing is written. An **addressed** notification (a report, §9.7) is planned only for its channel, and only `channel_paused`, `no_adapter` and `edit_unsupported` apply to it; its producer may also plan it `suppressed: empty` (an empty digest). - **Worker** (`app/notifications/outbox.ts`, injected clock, interval scheduler and jitter; like `RetentionScheduler`). It runs only while at least one external channel exists: a tick every second plus a kick after each enqueue. Each tick claims due jobs (`pending`/`retrying` with `next_attempt_at <= now`, oldest first) one at a time: `claim` moves a job to `sending` and counts the attempt; the adapter call runs outside any transaction; the result is written in one transaction (delivery row, channel message, channel counters). - **Coalescing and supersede.** A job renders the notification's current message. When the channel message's `last_revision` already covers the job's revision the job is `superseded`; claiming a job supersedes older pending jobs of the same notification and channel. An edit is deferred (not an attempt) until 3 s after the message's last update. - **Send, edit, delete.** `send` stores the ref in `notification_channel_messages` with `last_revision` and `expires_at` (from the channel's TTL for the category; never by default, D-35) and sets `expires_at = now` on a resolved notification when "delete when resolved" is on. `edit` addresses the stored ref; `message_gone` turns an alerting revision into a new `send` and marks the rest `superseded`. `delete` jobs are enqueued by the TTL sweep for expired, undeleted messages; a platform that cannot delete gives `suppressed: delete_unsupported`; `too_old` ends `dead` with reason `could_not_delete: too_old`; a delete more than a minute past its deadline is logged as late. @@ -804,6 +811,35 @@ Act buttons let the operator answer from the chat. They are **off by default** p - **Listeners.** `NotificationActionListeners` follows the registry: a channel with act buttons on and an adapter that receives presses is listened to; removing, pausing or turning act buttons off stops it (a shared poller or gateway connection closes 5 s after its last channel). Listener states (`connecting`, `connected`, `reconnecting`, `offline` with a reason such as "the token was refused" or "another program is polling this bot") are shown as `ChannelView.connection` and re-published with `channel.changed`. Transient failures back off 1 s → 30 s (Discord: → 60 s, resuming the gateway session where Discord allows it); a refused token is `offline` and retried every 5 minutes; a Telegram bot polled by another program or with a webhook set (409) is `offline` with that reason and retried every 30 s. - **Secrets.** Bot tokens pass the `SecretRegistry`; gateway frames, update payloads and callback data are never logged; a token appears only in the platform message and, hashed, in the database. +### 9.7 Reports: digests and anomaly alerts (D-43, D-44, D-45) + +A channel schedules reports in its rules: `digest {every: 'day'|'week', at: 'HH:MM', day?: 'mon'…'sun' (weekly; default `fri`), weekdays_only?: boolean (daily; default `false`)}` (the dashboard and the startup flag default a daily digest to 09:00 and a weekly one to Friday 17:00) and `anomaly {error_rate?, min_calls?, attention_minutes?, blocked_spike?, blocked_min?, capacity?, degraded?}` (each check's number, or `null`/`false` to switch it off; absent = the default), in the channel's `time_zone` (IANA; absent = the host's zone, read at each evaluation). `ReportScheduler` (`app/notifications/report-scheduler.ts`, injected clock, interval scheduler and host-zone reader, like `RetentionScheduler`) ticks every 60 s **only while at least one channel or the in-app settings schedule a report** and follows registry reloads and settings changes; the report producers (`reports.ts`) are pure and table-driven; the facts come from `ReportFacts` (`report-facts.ts`) over `AnalyticsQueries` and the repositories (§7.2), never raw SQL in the app layer. + +- **Occurrences.** `schedule.ts` computes the scheduled instants of a rule in its zone: each local day (weekly: each local `day`; weekdays only: each local Monday to Friday) at `at`. With weekdays only, Monday's window starts at Friday's occurrence, so the weekend is in Monday's digest. A local time skipped by a DST change is shifted by the gap (02:30 on a spring-forward night fires at 03:30); a repeated one fires once, at its first occurrence. The window of an occurrence is `[previous occurrence, occurrence)` (23 or 25 hours across a change), starting no earlier than the end of the channel's last window. +- **Cursor.** `notification_cursors['digest:'] = {spec, last, until}`: the rule it belongs to (`scheduleKey`: `day[-weekdays]` or `week:`, the time and the zone), the last handled occurrence (or when the rule was armed) and the end of the last window. The in-app schedule (D-45) has the same cursor under `digest:in-app`. A tick handles the occurrences in `(last, now]`: none → nothing; otherwise the newest is produced and the older ones are skipped (`skipped: n`). It is late when produced more than `LATE_AFTER_MS` (5 min) after its time. The notification, its delivery row and the new cursor are written in one transaction, so a window is produced exactly once across crashes and restarts. A new channel, a changed rule (`spec` differs) or a missing cursor arms at `now` and produces nothing; the next occurrence after it is the first report. +- **Digest facts** for `[since, until)` and the period before it: sessions started (`windowCounts`) and live now; tool calls, errors and the error rate (and the previous period's rate); attention requests created, resolved, rejected, timed out and still pending, with the median wait of answered ones (`windowStats`); vault accesses by result (`countByResult`); blocked requests with the top pattern and domain (`BlocklistAuditRepository.stats`); the slowest tool (the highest p95 among tools with at least 5 calls, `toolLatency`) against its p95 in the previous period; the top errors (`topErrors`, 3); open degradations (`SystemEventRepository.open`, severity warn and error); calls per harness (`harnessMetrics`); tool calls per hour (per 12 hours for a weekly digest) for the chart (`activity`). +- **Empty.** A period with no session started, no tool call, no attention request, no vault access, no blocked request and no open degradation is empty: the notification is written with its delivery row `suppressed: empty` and nothing is sent. +- **Digest message** (`digest.daily` / `digest.weekly`, reports · info · final, thread `digest::`), built at the channel's content level (the in-app copy at `full`): + +| Part | `counts` | `titles` (default) | `full` | +|---|---|---|---| +| title | "Daily digest · Tue 29 Sep" / "Weekly digest · 22–29 Sep" (dates in the channel's zone) | same | same | +| summary | "12 sessions (2 live) · 3 412 tool calls · 68 errors (2.0 %)" | same | same | +| fields | sessions; tool calls with the error rate and its change; attention (created · resolved with the median wait · timed out · waiting); vault fills (total · failed); blocked requests (total) | + vault results by name, the top blocked pattern, the slowest tool (name, p95, previous p95) | + the most blocked domain | +| chart | "Tool calls per hour" | same | same | +| tables | — | top errors (error code · tool · count · sessions); harnesses (harness · sessions · tool calls · errors), both only when non-empty | same | +| degradations | "N open problems" | each open degradation's code and since when | + its message | +| footer | the window ("28 Sep 09:00 → 29 Sep 09:00 · Europe/Berlin"); when late, "Sent late: BrowserHive was not running at 09:00."; when windows were skipped, "N earlier digests were skipped while BrowserHive was off." | same | same | +| action | open "Open Overview" → `/overview?since=&until=` | same | same | + + `alert` is false when the scheduled time is inside the channel's quiet hours (a silent send). The message carries `report {window, time_zone, late, skipped, manual}`. +- **Anomaly checks.** `notification_cursors['anomaly:'] = {last, active: {check: {since, value, threshold}}, notification_id}`. At the first tick after each top of the hour (UTC-aligned), and once at start when a check is due, the facts of the trailing hour are computed once (tool calls and errors, blocked requests and the 24 hours before, pending attention requests with their wait, live sessions and `maxSessions`, unresolved error-severity system events) and each channel's checks are evaluated with `evaluateAnomalies(facts, thresholds, previous)` (the table of D-44). A channel in its quiet hours is skipped without moving `last`, so the first tick after them checks. Outcomes: a **crossing** (a check became active) → a new `report.anomaly` notification (alert, open, thread `anomaly:`; the previous open one is revised `final` silently) listing every active check, new ones first, with value, threshold and since when; a change without a crossing → a silent revision of the open alert; every check cleared → a silent revision to `resolved` ("Back to normal since 15:00 · it lasted 2h 05m"); no change → nothing. The cursor is written in the notification's transaction. +- **In-app copies (D-45).** With each channel digest (and each digest of the in-app schedule) the same transaction looks up the period's in-app copy by thread (`report:digest:::`) and inserts it when missing: built from the same facts at `full`, in the schedule's zone, `late`/`skipped`/`manual` as produced, `target` `/notifications/reports/`, `read_at = created_at` (a digest never counts as unread), `dismissed_at` null; the channel copy's `source_event_id` names it. An empty digest writes no in-app copy. A new in-app copy is announced with `notification.created` on the `notifications` topic after the commit (the dashboard never toasts a digest). A manual digest that is sent has its own copy (`report:digest:now:::`); a preview writes nothing. +- **Anomaly watches (D-45).** `notification_cursors['anomaly:in-app'] = {watches: {: {last, active, notification_id}}}`, one watch per distinct effective thresholds (`anomalyThresholds` serialised) among the channels with `rules.anomaly` and, when the in-app switch is on, its rule. Each watch is checked at its hourly slot before the channels, with the same facts, `evaluateAnomalies` and outcomes as a channel, but without quiet hours and at `full`, in the in-app settings' zone (else the host's); its alerts are unread `system` rows (they count toward the badge; the dashboard toasts them per the toast preferences) with thread `report:anomaly::` and target the report page; revisions publish `notification.updated`. A channel alert written in the same pass (or later in the episode) names the watch's open alert in `source_event_id`. A watch no longer wanted is dropped from the cursor and its open alert revised `final` silently ("No longer checked."). +- **"Send a digest now"** (`POST /channels/{id}/digest`): the same producer for the period ending now, `manual: true`, never late and never suppressed as empty; sent through the adapter outside the queue like the test send (a delivery row with reason `manual`), without touching the cursor. +- **Deleting a channel** removes its cursors (its watch goes at the next check when no other schedule shares its thresholds). A paused or broken channel's due report is produced and logged `suppressed: channel_paused`; its cursor moves on, so a resume sends nothing stale. +- **Cost.** With no channel and no in-app setting scheduling a report no timer runs. A digest is a dozen indexed queries once a day per channel; the anomaly facts are gathered once per hour for all channels. + ## 10. Design notes - `POST /sessions/{id}/input` exists so takeover can be scripted without a WebSocket client; it shares the attention gate and audit path with the WS `input` command. diff --git a/specs/04-admin-frontend.md b/specs/04-admin-frontend.md index e36d8a6..51f9544 100644 --- a/specs/04-admin-frontend.md +++ b/specs/04-admin-frontend.md @@ -138,6 +138,7 @@ Server-backed (D-16): `useQuery` for the page and the bell's unread count; the W - Only `notification.created` raises a toast. `notification.updated` (a growing group, a lifecycle revision, or read/dismiss elsewhere) updates a toast this tab raised, in place, or closes it once the row is read or dismissed or it shows an outcome (an attention request or vault confirmation resolved, rejected, timed out or cancelled elsewhere); it never raises a new one. - Types that toast come from `/me/preferences` `notifications.types`; with none stored, `DEFAULT_TOAST_TYPES` applies, which excludes `error` (tool errors go to the bell only). `notifications.toasts === false` silences all. +- Reports (D-45): a digest (`digest.*`) never toasts, whatever the preferences (its row arrives already read, so it never touches the badge either); an anomaly alert (`report.anomaly`, type `system`) toasts when `system` is among the toast types, in the warning tone, auto-dismissing, with an **Open report** action; its silent revisions close that toast once it is `resolved` and otherwise update it in place. - An `error` for the session the operator is already viewing never toasts. The title is prefixed with `session_slug` unless it already contains it. - Only `attention` toasts persist; the rest auto-dismiss. "Open session" / "Review in vault" is omitted when the operator is already at the target (`isAlreadyAt`). @@ -203,7 +204,7 @@ Never: module-level mutable singletons holding server state. - Right: a search field-button ("Search sessions, pages…" + `Ctrl K`/`⌘K` by platform) that opens the palette (an icon button under 768 px), `HealthPill`, `NotificationBell`, `ThemeMenu`, `PrincipalMenu` (display name, Change password, Keyboard shortcuts, Log out). - `document.title` = `${title} · BrowserHive`. - `HealthPill` is a ghost `Button` opening a popover with Realtime (WS `connected | connecting | offline` + reason), REST, and daemon version. -- `NotificationBell`: badge anchored top-right, capped at "9+"; rows show the session slug (unless the title has it), `updated_at`, "first …" for grouped rows and the same outcome pill as the inbox (§12.11); "View all" → `/notifications`. +- `NotificationBell`: badge anchored top-right, capped at "9+" (the server's `unread_count`: digests arrive read and never count, anomaly alerts do, D-45); rows show the session slug (unless the title has it), `updated_at`, "first …" for grouped rows and the same outcome pill as the inbox (§12.11); "View all" → `/notifications`. - 404 (`NotFoundPage`) renders inside `AuthGate` and the shell and sets its title ("Page not found · BrowserHive"). ### 6.2 Route table as data @@ -559,27 +560,36 @@ Search: `tab` (`status|tokens|config`, default `status`), `key` (config filter). ### 12.11 `/notifications` -Search: `read` (`all|unread|read`, default `all`), `type` (csv), `range` (`24h|7d|30d|all`, default `7d`), `page`, `ps`. Reachable from the bell's "View all" and the palette. +Search: `read` (`all|unread|read`, default `all`), `type` (csv), `category` (csv; the Reports chip), `range` (`24h|7d|30d|all`, default `7d`), `page`, `ps`. Reachable from the bell's "View all" and the palette. - Header: accent "N unread" pill, description with a Learn more popover (a notification keeps its place while its outcome changes; docs link to the Notifications guide), Dismiss all (ghost, confirm), Mark all read. -- `FilterBar`: labelled "Show" segmented control + "Period" range in the first row, type chips (no counts) in the second, "N matching" + Clear all. -- List: day groups ("Today", "Yesterday", `Mon D`) of `Panel`s with `LinkRow`s ordered and grouped by `updated_at`. **The whole row is a real link to its target and opening it (including middle/ctrl-click) marks it read.** Row: tinted type icon (type also in sr-only text), title (semibold + accent dot while unread), 2-line body, meta (session slug with a session icon unless the title leads with it; "first