From 260aad2d9d0df65dc551109b4b4721152756b891 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 00:59:05 -0400 Subject: [PATCH 01/28] docs(specs): scheduled digests and anomaly alerts (D-43, D-44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports are addressed to the channel that schedules them, in its time zone (default the host's), produced exactly once per window with a durable cursor, sent late once after downtime with the skipped count, never sent when empty (logged suppressed: empty), and silent inside quiet hours. Anomaly alerts run hourly with fixed thresholds and hysteresis and stay silent unless a check crosses. Specs 00 (D-43, D-44; D-16, D-32, D-34 amended), 02, 03 (API, ports, §9.1-§9.4, new §9.7), 04, 08, 09 and 10. --- specs/00-decisions.md | 56 +++++++++++++++++++- specs/02-mcp-and-tools.md | 2 +- specs/03-admin-backend.md | 66 +++++++++++++++++------- specs/04-admin-frontend.md | 12 ++--- specs/08-cli-arguments-and-config.md | 6 ++- specs/09-testing.md | 1 + specs/10-error-handling-and-telemetry.md | 3 +- 7 files changed, 116 insertions(+), 30 deletions(-) diff --git a/specs/00-decisions.md b/specs/00-decisions.md index 67220d2..666e68e 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. **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). @@ -807,3 +809,53 @@ 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). 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 in-app row is stored read and dismissed (like a test send): the inbox is not repeated, the delivery log keeps its record, and the dashboard's Overview is the in-app equivalent. +- **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; the dashboard's Overview covers the rest. +- **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"). 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. 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..7cabdfd 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -291,13 +291,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 +305,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. Sends are rate-limited 6/min | 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 +637,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:; never a token or a topic name value TEXT NOT NULL, updated_at INTEGER NOT NULL ) WITHOUT ROWID; @@ -662,7 +663,7 @@ CREATE TABLE resource_samples (ts INTEGER NOT NULL, session_id TEXT REFERENCES s | notifications | `notifications` | 30 d after `dismissed_at`/`read_at`, 90 d otherwise (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,9 +675,9 @@ 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); } @@ -691,7 +692,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 +714,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 +730,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 +741,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 +807,33 @@ 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) + +A channel schedules reports in its rules: `digest {every: 'day'|'week', at: 'HH:MM', day?: 'mon'…'sun' (weekly; default `mon`)}` 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 schedules a report** and follows registry reloads; 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`) at `at`. 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, the last handled occurrence (or when the rule was armed) and the end of the last window. 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 6 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: + +| 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. +- **"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. 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 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..16fe2c9 100644 --- a/specs/04-admin-frontend.md +++ b/specs/04-admin-frontend.md @@ -567,19 +567,19 @@ Search: `read` (`all|unread|read`, default `all`), `type` (csv), `range` (`24h|7 - Live inserts and group updates hold while reading (`useLiveHold` with `getVersion = updated_at`). - **Toast preferences** panel (mounted after the list loads): pop-up toasts switch, "Toast for" type checkboxes (default `DEFAULT_TOAST_TYPES`, disabled while toasts are off), Save enabled only with changes; other stored preference keys are preserved. -### 12.11.1 `/notifications/channels` — channels (D-33, D-35, D-36, D-37, D-38, D-39, D-41, D-42) +### 12.11.1 `/notifications/channels` — channels (D-33, D-35, D-36, D-37, D-38, D-39, D-41, D-42, D-43, D-44) The Notifications area has four sibling pages reached from a segmented header (Inbox · Channels · Delivery log · Actions); the sidebar keeps one Notifications entry. -- **List.** One card per channel (responsive grid): platform mark and name, "from startup" badge for startup channels (read-only: no Edit, Duplicate or Delete, a tooltip names the flag), status (`active` success dot, `paused` muted, `broken` danger with the last error), where it sends (`target_hint`), the secrets line (each variable with set ✓ / missing ✗), last delivery (relative time and status), 24 h counts (sent, failed, suppressed) and pending, and — when act buttons are on — an "Answers from the chat" line with the press listener's state (`connection`: connected (success), connecting/reconnecting (warning, with the time since), offline (danger, with the reason)) and the Discord mode. Actions: Send test (shows the result inline, with the classified error), Pause/Resume, Edit, Duplicate, Delete (confirm; "its delivery log goes with it"). The whole card opens the channel. Empty state: what channels are, the four platforms and "Add channel". Live through the `channels` WS topic. +- **List.** One card per channel (responsive grid): platform mark and name, "from startup" badge for startup channels (read-only: no Edit, Duplicate or Delete, a tooltip names the flag), status (`active` success dot, `paused` muted, `broken` danger with the last error), where it sends (`target_hint`), the secrets line (each variable with set ✓ / missing ✗), last delivery (relative time and status), 24 h counts (sent, failed, suppressed) and pending, and — when act buttons are on — an "Answers from the chat" line with the press listener's state (`connection`: connected (success), connecting/reconnecting (warning, with the time since), offline (danger, with the reason)) and the Discord mode; when the channel schedules reports, a **Reports** line: the next digest in the channel's zone ("Daily digest · next Wed 09:00 (Europe/Berlin)", with the zone named only when it is not the browser's), and "Watching for anomalies" or the active checks as a warning ("Something looks off: error rate 34 %"). Actions: Send test (shows the result inline, with the classified error), **Send a digest now** (channels with a digest: a dialog that previews the real digest of the period ending now in the platform mock (`POST /channels/{id}/digest {send: false}`), then Send; the result inline, rate-limited), Pause/Resume, Edit, Duplicate, Delete (confirm; "its delivery log goes with it"). The whole card opens the channel. Empty state: what channels are, the four platforms and "Add channel". Live through the `channels` WS topic. - **Add channel** (`/notifications/channels/new`, also the edit form at `/notifications/channels/$channelId`): a wizard with a step rail; the draft is kept in `localStorage` (`bh.channelDraft`), so it survives a reload and the BrowserHive restart that setting a variable needs, and is cleared on save. 1. **Platform**: cards for Telegram, Discord, ntfy and Webhook (and the upcoming ones, disabled). Discord asks for the mode (webhook, the default, or bot) and opens **What's the difference?**: a panel comparing the modes (setup time, Approve/Reject buttons in the chat, the connection BrowserHive keeps open; D-38) and the two message styles drawn side by side from the preview endpoint (`mode: webhook` and `mode: bot`) with the current theme; a documented slot (`features/notifications/channels/discord-shots.ts`) accepts real screenshots of BrowserHive's own messages later, never images copied from Discord or the web. 2. **Credentials**: a suggested variable name (`BH_TELEGRAM_TOKEN`, `BH_DISCORD_WEBHOOK`, `BH_DISCORD_BOT_TOKEN` in bot mode with the Developer Portal click-path: New Application → Bot → Reset Token, `BH_NTFY_TOKEN`, `BH_WEBHOOK_SECRET`; never `BROWSERHIVE_*`, which the config loader reserves, and editable), the exact line for how BrowserHive runs, in tabs: shell (`export …`), systemd (`Environment=`/`EnvironmentFile=`), Docker (`-e` / compose `environment:`), and the config file (`{env:NAME}` is for config keys; channels read the variable directly, which the tab explains), with copy buttons; the live **set ✓ / missing ✗** state (`GET /channels/env`, polled every 3 s while the step is open), and "Restart BrowserHive after setting it" when missing. 3. **Connect**: Telegram: the bot's name from the token, a one-tap `t.me/?start=` link and QR code (and "Add to a group" `startgroup` link), a 2-minute countdown while the server waits, then the captured chat (title, type) and the person who connected it; a manual chat id field as the fallback. Discord: nothing more in webhook mode; in bot mode the bot's name, an **Invite the bot** link (minimal permissions, opens Discord), a server picker and a channel picker filled from the bot API (with Refresh after inviting), then **Link your Discord account**: the bot posts a "This is me" button in the chosen channel and the page waits up to 2 minutes (countdown) for the press, then shows "Connected as " and puts that account first in the allow-list. ntfy: server (default `https://ntfy.sh`) and topic (a random suggestion `bh-`, or from a variable), a QR code and link that subscribe the phone app, the ntfy.sh warning when screenshots are on, and an optional **reply topic** for answering from the notification (a random suggestion, or from a variable, plus an optional token variable to read it) with the security note of D-42 (whoever can read the topic can press its buttons). Webhook: the URL (or its variable), the signature secret variable, and the SSRF note for private addresses. - 4. **What to send**: presets as cards — *Needs me now* (needs-you), *Problems* (needs-you + problems), *Wrap-ups* (wrap-ups), *Everything* — then an Advanced disclosure: categories, minimum severity, session globs, harness, quiet hours with a time zone picker (default the browser's), content level (`counts`/`titles`/`full` with what each sends), screenshots per category (off by default; enabling one requires `full`; "Mask form fields" on by default; the ntfy.sh warning), TTL per category (Never by default; 15 min…7 d; Telegram capped at 47 h with the reason; the honest note that a lock-screen preview cannot be taken back), delete when resolved per category (off), and **Answer from the chat** (act buttons): off by default; a switch with what it does ("Approve, Reject and Mark resolved work from the chat; everything else opens BrowserHive"), disabled with the reason where the setup cannot receive presses (Discord webhook mode: "switch to bot mode"; ntfy without a reply topic); below it the **allow-list** (Telegram and Discord): the person who connected the chat first (name and id, marked "connected in setup"), then added ids, each removable, and an input that accepts a numeric user id with how to find one (Telegram: the id the bot names when someone not on the list presses; Discord: Developer Mode → Copy User ID). The name field lives here. - 5. **Preview and test**: sample picker (attention, resolved, vault confirm, tool errors, crash, degraded, test) and a **near-exact mock** of the platform (Telegram Rich Message bubble with heading, table, photo and inline keyboard, callback buttons tinted by style; Discord embed with colour bar, fields, image, link buttons and, in bot mode, interactive buttons; ntfy Android-style notification with priority, tags and actions, `http` actions marked as answering) drawn only from `POST /channels/preview` `requests`, so the mock and a real send share the renderer. **Save** creates the channel; **Send test** then delivers a real message whose "Open dashboard" link is the `publicUrl` check from the phone, and shows the result. -- **Delivery log** (`/notifications/log`): a table newest first (time, channel, notification title and kind, revision, op, status pill with the reason in words, attempts, latency), filters (channel, status, op, kind) in the URL, live through the `channels` topic with the live-hold pill; a row opens a side sheet with the timeline of that notification on every channel ("why wasn't this sent?": every suppressed or failed row explained in a sentence from the reason code), the last error, the message ref, and the redacted message as the channel was shown it (`GET /channels/deliveries/{seq}`). -- **Actions** (`/notifications/actions`): the audit of act-button presses (D-41), newest first: time, channel (platform mark and name), the notification (title, linking to the delivery log filtered by it), the button (label and op), who pressed (display name and `telegram:`/`discord:`/`ntfy:topic-b`), and the outcome as a pill with its sentence (done — success; not allowed, used, expired, stale, wrong channel, disabled — warning; failed — danger, with the detail). A `not_allowed` row of a Telegram or Discord presser offers **Allow this person** (confirm first; `channels:write`): it appends the presser's id to the channel's `rules.allow_list` through `PATCH /channels/{id}`; disabled, with the `allow=` flag named, for a startup channel. Filters: channel and outcome, in the URL (`?channel`, `?outcome`, `?page`, `?ps`). Live through `action.recorded` on the `channels` topic with the live-hold pill. Empty state: what act buttons are and where to switch them on. + 4. **What to send**: presets as cards — *Needs me now* (needs-you), *Problems* (needs-you + problems), *Wrap-ups* (wrap-ups), *Everything*, *Daily digest* (reports only: switches the daily digest at 09:00 and the anomaly alerts on) — then a **Reports** section, always visible (D-43, D-44): **Daily digest** (Off · Every day · Every week; a time picker, 24-hour, default 09:00; a weekday picker for weekly; one line on what it contains and that empty days send nothing), **Time zone** (a searchable picker of IANA zones, default "Same as BrowserHive ()" from `host_time_zone`, with the browser's zone offered first when it differs; it also applies to quiet hours), and **Tell me when something looks off** (a switch with one line per check in plain words); the next run is shown under the schedule ("Next digest: Wed 30 Sep, 09:00"). Advanced adds the anomaly thresholds (error rate %, minimum calls, attention wait in minutes, blocked spike factor and minimum, capacity and degradations on/off), each with its default as placeholder. Then an Advanced disclosure: categories, minimum severity, session globs, harness, quiet hours (in the channel's time zone above; a digest scheduled inside them arrives silently), content level (`counts`/`titles`/`full` with what each sends), screenshots per category (off by default; enabling one requires `full`; "Mask form fields" on by default; the ntfy.sh warning), TTL per category (Never by default; 15 min…7 d; Telegram capped at 47 h with the reason; the honest note that a lock-screen preview cannot be taken back), delete when resolved per category (off), and **Answer from the chat** (act buttons): off by default; a switch with what it does ("Approve, Reject and Mark resolved work from the chat; everything else opens BrowserHive"), disabled with the reason where the setup cannot receive presses (Discord webhook mode: "switch to bot mode"; ntfy without a reply topic); below it the **allow-list** (Telegram and Discord): the person who connected the chat first (name and id, marked "connected in setup"), then added ids, each removable, and an input that accepts a numeric user id with how to find one (Telegram: the id the bot names when someone not on the list presses; Discord: Developer Mode → Copy User ID). The name field lives here. + 5. **Preview and test**: sample picker (attention, resolved, vault confirm, tool errors, crash, degraded, test, daily digest, anomaly alert — the report samples follow the draft's schedule, zone and content level) and a **near-exact mock** of the platform (Telegram Rich Message bubble with heading, table, photo and inline keyboard, callback buttons tinted by style; Discord embed with colour bar, fields, image, link buttons and, in bot mode, interactive buttons; ntfy Android-style notification with priority, tags and actions, `http` actions marked as answering) drawn only from `POST /channels/preview` `requests`, so the mock and a real send share the renderer. **Save** creates the channel; **Send test** then delivers a real message whose "Open dashboard" link is the `publicUrl` check from the phone, and shows the result. +- **Delivery log** (`/notifications/log`): a table newest first (time, channel, notification title and kind — reports add their window in the channel's zone and a **late** pill (warning tone, with the skipped count in its tooltip) or **manual** pill; an empty digest reads "suppressed · nothing happened" — revision, op, status pill with the reason in words, attempts, latency), filters (channel, status, op, kind) in the URL, live through the `channels` topic with the live-hold pill; a row opens a side sheet with the timeline of that notification on every channel ("why wasn't this sent?": every suppressed or failed row explained in a sentence from the reason code), the last error, the message ref, and the redacted message as the channel was shown it (`GET /channels/deliveries/{seq}`). +- **Actions** (`/notifications/actions`): the audit of act-button presses (D-41), newest first: time, channel (platform mark and name), the notification (title, linking to the delivery log filtered by it), the button (label and op), who pressed (display name and `telegram:`/`discord:`/`ntfy:topic-b`), and the outcome as a pill with its sentence (done — success; not allowed, used, expired, stale, wrong channel, disabled — warning; failed — danger, with the detail). A `not_allowed` row of a Telegram or Discord presser offers **Allow this person** (confirm first; `channels:write`): it appends the presser's id to the channel's `rules.allow_list` through `PATCH /channels/{id}`; disabled, with the `allow=` flag named, for a startup channel; disabled with "Needs the channels:write permission" for a principal without that scope (never a 403 after the click). Filters: channel and outcome, in the URL (`?channel`, `?outcome`, `?page`, `?ps`). Live through `action.recorded` on the `channels` topic with the live-hold pill. Empty state: what act buttons are and where to switch them on. ### 12.12 `/login`, `/change-password` (public layout) diff --git a/specs/08-cli-arguments-and-config.md b/specs/08-cli-arguments-and-config.md index 122d5c3..fc377da 100644 --- a/specs/08-cli-arguments-and-config.md +++ b/specs/08-cli-arguments-and-config.md @@ -330,6 +330,8 @@ A repeatable flag that declares a notification channel for this run (D-39, 03 § --notificationChannel "telegram:name=phone,token=env:BH_TG_TOKEN,chat=123456,actButtons=true,allow=123456" --notificationChannel "discord:name=ops-bot,mode=bot,token=env:BH_DISCORD_BOT_TOKEN,channel=112233445566778899,actButtons=true,allow=998877665544332211" --notificationChannel "ntfy:name=pager,topic=env:BH_NTFY_TOPIC,reply=env:BH_NTFY_REPLY,actButtons=true" +--notificationChannel "telegram:name=morning,token=env:BH_TG_TOKEN,chat=123456,categories=reports,digest=daily@08:30,tz=Europe/Berlin,anomaly=on" +--notificationChannel "ntfy:name=weekly,topic=env:BH_NTFY_TOPIC,categories=reports,digest=weekly:mon@09:00,anomaly=on,anomaly.errorRate=10,anomaly.blocked=off" ``` - **Grammar.** `kind` is a `NotificationChannelKind` with a startup adapter (`telegram`, `discord`, `ntfy`, `webhook` in the first release). Parameters are `name=value` pairs separated by `,`; a list value joins its items with `+`; a value cannot contain `,` (percent-encode it as `%2C`; `%` itself is `%25`). `name` is required, `[a-z0-9][a-z0-9-]{0,31}`, and unique across all startup channels. A parameter given twice is a usage error. @@ -343,7 +345,7 @@ A repeatable flag that declares a notification channel for this run (D-39, 03 § | `webhook` | `url` (absolute `http(s)` URL, or `env:NAME`) | `secret` (the HMAC key) | `secret`, and `url` when written `env:NAME` | - **Secrets are environment variable names.** Every secret-bearing parameter (`token`, `webhook`, `secret`, `password`, and a Discord bot `token`) takes `env:NAME`, where `NAME` matches `[A-Za-z_][A-Za-z0-9_]*` and does not start with `BROWSERHIVE_`. An inline value is a usage error, exit 64, and the message never echoes the value: `browserhive: --notificationChannel 'phone': token must name an environment variable (token=env:NAME), never contain the secret: other users of this machine can read process arguments.` A referenced variable that is unset or empty is a usage error naming the variable. An ntfy `topic` on a public server acts as a credential too; a literal topic is accepted with a `warn` recommending `topic=env:NAME`. -- **Rules.** Optional parameters mirror the channel rules of the dashboard: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info|warn|error|critical`), `sessions` (slug globs), `harness` (harness slugs), `content` (`counts|titles|full`, default `titles`), `quiet` (`HH:MM-HH:MM`) with `tz` (IANA name, default the host's), `ttl.` (a duration; default never, D-35; a Telegram TTL above `47h` is a usage error because Telegram lets a bot delete its messages for 48 hours only), `deleteWhenResolved` (`true|false` for every category, or a `+` list of categories; default `false`), `images` (`+` list of categories whose notifications carry a screenshot, D-36; default none), `maskImages` (`true|false`, default `false`), `actButtons` (`true|false`, default `false`: answer from the chat, D-41; a usage error on Discord webhook mode and on ntfy without `reply`) and `allow` (the Telegram or Discord user ids allowed to press them, joined with `+`; numeric; not accepted for ntfy, which has no user identity). A startup channel has no setup flow, so its allow-list is exactly `allow`; a press by anyone else is refused with a message naming the presser's id. Unknown parameters are usage errors with a "did you mean". Every problem of every flag is reported together, like the other usage errors. +- **Rules.** Optional parameters mirror the channel rules of the dashboard: `categories` (`needs-you+problems+wrap-ups+reports+system`), `min` (`info|warn|error|critical`), `sessions` (slug globs), `harness` (harness slugs), `content` (`counts|titles|full`, default `titles`), `quiet` (`HH:MM-HH:MM`), `tz` (the channel's IANA time zone for its quiet hours and reports, D-43; default the host's; an unknown zone is a usage error), `digest` (D-43: `daily@HH:MM`, `weekly:@HH:MM`, or `daily`/`weekly` alone for 09:00 and Monday; default none), `anomaly` (`on|off`, default `off`: the hourly anomaly alert, D-44) with its thresholds `anomaly.errorRate` (percent, 1–100, or `off`; default 20), `anomaly.minCalls` (default 20), `anomaly.attention` (minutes, or `off`; default 30), `anomaly.blocked` (the spike factor, ≥ 1.5, or `off`; default 3), `anomaly.blockedMin` (default 50), `anomaly.capacity` and `anomaly.degraded` (`on|off`, default `on`) — an `anomaly.*` parameter without `anomaly=on` is a usage error, `ttl.` (a duration; default never, D-35; a Telegram TTL above `47h` is a usage error because Telegram lets a bot delete its messages for 48 hours only), `deleteWhenResolved` (`true|false` for every category, or a `+` list of categories; default `false`), `images` (`+` list of categories whose notifications carry a screenshot, D-36; default none), `maskImages` (`true|false`, default `false`), `actButtons` (`true|false`, default `false`: answer from the chat, D-41; a usage error on Discord webhook mode and on ntfy without `reply`) and `allow` (the Telegram or Discord user ids allowed to press them, joined with `+`; numeric; not accepted for ntfy, which has no user identity). A startup channel has no setup flow, so its allow-list is exactly `allow`; a press by anyone else is refused with a message naming the presser's id. Unknown parameters are usage errors with a "did you mean". Every problem of every flag is reported together, like the other usage errors. - **Where it is accepted.** `serve` (the default command) and `doctor` (which checks the channels without starting them). The programmatic API takes the same strings as `notificationChannels: string[]` (§9); a programmatic caller passes secrets through `env` as well. - **Read-only.** A startup channel is projected into `notification_channels` with `source = 'startup'` at each start; the dashboard and API show it with a "from startup" badge and refuse to edit or delete it; pausing and resuming are allowed, and a pause survives restarts like the breaker state. A startup channel whose `name` a dashboard channel already uses stops startup with `CONFIG_INVALID` (exit 64): `browserhive: [CONFIG_INVALID] notification channel 'phone' is defined by --notificationChannel and in the dashboard. Rename one of them.` @@ -428,7 +430,7 @@ Positional rules: the first argument is the command if it is a known command wor **`admin tokens list | create | revoke `** — manages agent bearer tokens for `auth=token` (D-09). Tokens are stored hashed, so `create` is the only time the plaintext is shown; `list` shows principal, public prefix, created/last-used timestamps; `revoke` is immediate. Works against the database directly (refuses while the server is running, lock file) or via the REST API when a server is up (`--url`, cookie/bearer). Output is a table, `--json` for scripts. -**`channels list | test | preview [--sample ]`** — notification channels (03 §9.5). `list` prints name, platform (with the Discord mode: `discord (bot)`), status (with "from startup" for startup channels), where it sends (`target_hint`), whether its secret variables are set, the answer state when act buttons are on (`answers: connected`, `reconnecting`, `offline ()`), the last delivery and the 24 h counts. `test ` sends a real test message through the channel and exits 0 when the platform accepted it, 1 otherwise (printing the classified error). `preview ` renders a sample notification exactly as the channel would send it and sends nothing; `--sample` is one of `attention` (default), `attention-resolved`, `vault-confirm`, `tool-errors`, `crash`, `degraded`, `test`; text mode prints the platform request (method, path without secrets, body), `--json` the whole `ChannelPreview`. All three talk to a running server over the REST API: `--url` (default `http://127.0.0.1:` from the resolved configuration) with `--token` (an operator bearer with `channels:read`/`channels:write`) or `--cookie`; without a reachable server they fail with exit 1 and say so. `--json` everywhere. +**`channels list | test | preview [--sample ]`** — notification channels (03 §9.5). `list` prints name, platform (with the Discord mode: `discord (bot)`), status (with "from startup" for startup channels), where it sends (`target_hint`), whether its secret variables are set, the answer state when act buttons are on (`answers: connected`, `reconnecting`, `offline ()`), the next report (`daily 09:00 → next Wed 30 Sep 09:00 Europe/Berlin`, plus `anomaly alerts` or the active checks), the last delivery and the 24 h counts. `test ` sends a real test message through the channel and exits 0 when the platform accepted it, 1 otherwise (printing the classified error). `preview ` renders a sample notification exactly as the channel would send it and sends nothing; `--sample` is one of `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); text mode prints the platform request (method, path without secrets, body), `--json` the whole `ChannelPreview`. All three talk to a running server over the REST API: `--url` (default `http://127.0.0.1:` from the resolved configuration) with `--token` (an operator bearer with `channels:read`/`channels:write`) or `--cookie`; without a reachable server they fail with exit 1 and say so. `--json` everywhere. **`admin reset-password`** — generates a new seed password, marks `must_change_password`, prints it once, refuses while the server is running (lock file), writes `admin/credentials.txt` (0600). diff --git a/specs/09-testing.md b/specs/09-testing.md index 2b27e92..341d3e8 100644 --- a/specs/09-testing.md +++ b/specs/09-testing.md @@ -108,6 +108,7 @@ Domain and application (no I/O, fakes only): - Notifications (`app/notifications/*.test.ts`): producer rules from the bus; persistence; read/dismiss; WS topic emission. The message contract (D-32): producers table-driven event → `NotificationMessage` (kind, category, severity, state, thread, blocks, actions, entities) validated against the contract schema; lifecycle revisions (attention and vault confirm resolved/rejected/timeout/cancelled, degradation recovered, tool-error group growth = new revision with `alert: false`); `restrictContent` per level; `degrade()` table-driven (tables → lists, images dropped or linked, `act` → `open`, button cap, truncation with the "Open in BrowserHive" footer, act buttons dropped outside `open`). The outbox (`outbox.test.ts`, fake clock, manual intervals, fixed jitter, a scripted fake channel): enqueue in the same transaction as the notification; zero channels → no rows, no timer; send → channel message with `last_revision`; retry with backoff and `retry_after`; dead after 8 attempts and after 24 h; non-retryable → dead; coalescing and supersede; the 3 s edit spacing; `message_gone` → re-send or supersede; breaker at 5 consecutive failures with the `channel.broken` notification delivered in-app only and **no degradation reported** (the loop test); backlog collapse; crash recovery of `sending`; the TTL sweep enqueues deletes, `delete_unsupported`, `too_old`, late deletes; routing rules (category, severity, session globs, harness, quiet hours across DST). The registry: startup projection, removal of undeclared startup rows, name clash → `CONFIG_INVALID`. **Invariant** (`redaction.property.test.ts`, seeded generator, 1 000 cases): a sentinel secret registered in the `SecretRegistry` and routed through every producer input never appears in `message_json`, the in-app row or payload, or any delivery row. - Notification channels (N1, 03 §9.5): the renderers (`infra/notifications/-render.test.ts`) against **golden files** per platform × sample kind × revision (`packages/core/test/goldens/notifications//.json`, regenerated only with `UPDATE_GOLDENS=1`); escaping and length property tests (Telegram HTML never contains an unescaped `<`, `>` or `&` from message text, never exceeds 4096 characters, or 1024 as a caption; Discord embed limits; ntfy header-safe values); the startup-flag parser (`--notificationChannel`) table-driven with the exact exit-64 texts, including the inline-secret refusal that never echoes the value; the channel service (CRUD, read-only startup channels, `SecretEnvName` refusals, Telegram TTL cap, pause/resume, test send, preview is pure); the per-channel image rule (the masked/unmasked variant kept, the image dropped when `images[category]` is off or the level is below `full`); the `publicUrl` link builder and the host/origin trust; the `publicUrl` check outcomes against a `Bun.serve` fake (`ok`, `elsewhere`, `login` for a 302 or 401/403 or an HTML page, `unreachable`). **Adapters against fakes** (`packages/core/test/integration/notifications/*.test.ts`, no secrets): `Bun.serve` fakes of the Telegram Bot API, the Discord webhook API and ntfy (`packages/core/test/helpers/fake-platforms.ts`) script 429 with `retry_after`, 5xx, timeouts, "message to edit not found" and "message can't be deleted"; the full path event → outbox → send → edit (resolution) → TTL delete runs through each. The redaction sentinel suite renders through every adapter and asserts the sentinel is absent from every request path and body, the preview, the delivery log and the webhook body. - Act buttons and Rich Messages (N2, 03 §9.6, D-40..D-42): command tokens table-driven on a fake clock (mint and re-use across edits, 24 h expiry, single use including a concurrent double press, wrong channel, wrong chat, act buttons off or channel paused, stale notification, presser not on the allow-list naming their id, unknown token not audited, the executors' `ATTENTION_NOT_OPEN`/`CONFIRM_NOT_OPEN` → `stale`, `session.extend_lease` refused), the audit rows and `action.recorded`, and the actor in the revised summary; the allow-list and act-button checks shared by the API and the flag parser; the Telegram Rich Message renderer against goldens (`telegram-rich/.json`, with image, table, act buttons) and the classic fallback goldens, the fallback on a rejected rich call (sticky after a 404) and classic edits of classic refs; the ntfy renderer's `http` actions (three at most, act first) and the reply-topic placeholder; the Discord bot renderer and transport. **Against fakes**: the Telegram poller (`getUpdates` offsets persisted and resumed after a restart with presses made while stopped, a press processed once when an update is re-delivered, `answerCallbackQuery` texts, 409 → offline, the setup's `/start` wait sharing the poller); a fake Discord gateway speaking Hello/Identify/Ready/heartbeat/ACK/Resume/Reconnect/Invalid Session and `INTERACTION_CREATE`, asserting a resume after a dropped socket, a zombie connection detected by a missing ACK, and the 3-second answer (an immediate ephemeral reply, or a deferred reply edited later for a slow command); the Discord bot REST (send, edit keeping the attachment, delete, guild and channel listing, the "This is me" claim); the full path event → outbox → send with tokens → press → attention resolved with `resolved_by` → silent edit without buttons, per platform. **Real ntfy** (`ntfy-live.test.ts`): the reply-topic round trip (publish to topic A with `http` actions, a phone-style POST to topic B, BrowserHive resolves the request and replaces topic A), and catch-up with `since=` after a restart. The sentinel suite covers the press audit and asserts no token reaches a log line, a delivery row or the preview. +- Reports (N3, 03 §9.7, D-43, D-44): `schedule.test.ts` on fixed instants — occurrences in a zone across spring-forward (a skipped 02:30 fires at 03:30) and fall-back (a repeated 02:30 fires once), weekly days, windows of 23/24/25 hours, the host zone versus a channel zone, the next run; `report-scheduler.test.ts` on a fake clock with manual intervals and in-memory repositories — on-time digests, a restart after downtime producing the newest missed window once, marked late, with the skipped count, the cap, no double send across two ticks or a crash between ticks (cursor and notification in one transaction), a rule change re-arming without a late send, empty periods stored `suppressed: empty`, silent sends inside quiet hours, anomaly crossings with hysteresis (fire, stay, clear, re-fire), a silent revision when one of several checks clears, `resolved` when all clear, no check during quiet hours, zero timers without a schedule, cursors removed with the channel; `reports.test.ts` table-driven producers over fixture facts at each content level (what `counts`, `titles` and `full` carry; no name at `counts`), the anomaly table of D-44; the analytics additions (`windowCounts`, `toolLatency` p95 equal to `toolMetrics`, `topErrors`) on SQLite and the repository additions (`windowStats`, `countByResult`) in the SQLite + in-memory conformance suites; renderer goldens for the `digest`, `digest-weekly`, `digest-late` and `anomaly` samples on every platform (the chart as text bars, tables native on Telegram and as lists elsewhere); the sentinel suite routes a secret through every copied digest string (error codes, patterns, domains, degradation messages); against fakes and the real ntfy container, a digest and an anomaly alert travel the full path; the startup flag's `digest`, `tz` and `anomaly.*` parameters. - Real ntfy (`packages/core/test/integration/notifications/ntfy-live.test.ts`): runs only when `BHDEV_NTFY_URL` points at a real ntfy server (CI starts `binwiederhier/ntfy` as a service container in the `ntfy` job); publishes, reads back with `GET //json?poll=1`, uploads an attachment, replaces by sequence id and deletes. - Event bus (`app/events/bus.test.ts`): every domain event has a versioned name and a zod payload in contracts; consumers are isolated (a throwing consumer does not stop others). diff --git a/specs/10-error-handling-and-telemetry.md b/specs/10-error-handling-and-telemetry.md index 0a86c82..47a9d8d 100644 --- a/specs/10-error-handling-and-telemetry.md +++ b/specs/10-error-handling-and-telemetry.md @@ -358,6 +358,7 @@ All spans use `@opentelemetry/api`'s tracer `browserhive`; attributes use the `b | `browserhive.blocklist.hits` | counter | `source` | | `browserhive.retention.pruned_rows` | counter | `table` | | `browserhive.notifications.deliveries` | counter | `channel_kind`, `status` (`sent`, `retrying`, `dead`, `suppressed`, `superseded`) — one increment per finished or rescheduled outbox job (03 §9.4) | +| `browserhive.notifications.reports` | counter | `kind` (`digest.daily`, `digest.weekly`, `report.anomaly`), `outcome` (`sent` for a produced report, `late`, `empty`, `skipped` per skipped window, `manual`, `resolved` for an anomaly alert that cleared) — one increment per report decision of the scheduler (03 §9.7) | | `browserhive.notifications.actions` | counter | `channel_kind`, `outcome` (`done`, `failed`, `not_allowed`, `used`, `expired`, `stale`, `wrong_channel`, `disabled`, `unknown`) — one increment per act-button press (03 §9.6); `unknown` counts presses of tokens BrowserHive never minted, which are not audited | | `browserhive.process.*` | gauges: rss, heap, event-loop lag (sampled) | — | @@ -392,7 +393,7 @@ The same registry backs `/api/v1/system` figures; with `--otel` off, the in-proc - References in config files (D-29, 08 §3.1): the variable *name* is always shown (it is the operator's coordinate); the value and the file's text around a reference never are on a secret key (no `template`, problem messages name the variable only). The key-name heuristics above also apply to reference names: a value that came through `{env:GRAFANA_API_TOKEN}` renders `` on every surface even on a key that is not flagged secret, `GET /api/v1/system/config` marks that key `secret: true` for this run, and the values such references produced are registered as always-on entries in the `observability` phase. Over-redaction is the accepted failure direction. - Error projections run through `toWire` too, so an error message that echoes a typed value cannot carry a secret past the redaction window. - `--screenshotTrace` skips frames while a session's secret window is open; `vault_fill` is excluded from screenshot tracing. -- Notifications are a sink (D-32): producers pass every string they copy from an event (attention reason and message, tool name, error message, entry name, degradation message, URLs) through the `Redactor` and `sanitizeUrl` before it becomes part of the stored `NotificationMessage`, the in-app row or a delivery; adapters and the delivery log only ever see that result, and `last_error` of a delivery is scrubbed too. Channel secrets are never in the database (only environment variable names, D-33). The adapter factories read the variables through `ctx.secret(name)`, which registers each value as an always-on `SecretRegistry` entry before it is used, so a token inside a platform URL (`/bot/…`, a Discord webhook path) is scrubbed from span attributes, log lines and `last_error`. Every renderer output (the platform request bodies and paths), the preview (`POST /channels/preview` replaces a secret in a path by its variable name) and the generic webhook body are sinks of the sentinel test. Act-button command tokens (D-41) exist in the clear only in the platform message: the database stores their SHA-256, the preview shows `bh1:preview-`, and no log line, span attribute, audit row, delivery row or API response carries one; Telegram updates, Discord gateway frames (which carry the bot token in Identify and Resume) and ntfy reply messages are never logged, and a log line about a press names at most the token's first 4 characters. The press audit (`notification_actions`) stores platform user ids and display names (the operator's own chat members), never message text. Screenshots (D-36) are never taken while the session's secret window is open, never when `recordToolResults=none`, and reach a channel only at content level `full` with `images[category]` on; masking uses Playwright's `mask` over form fields. +- Notifications are a sink (D-32): producers pass every string they copy from an event (attention reason and message, tool name, error message, entry name, degradation message, URLs) through the `Redactor` and `sanitizeUrl` before it becomes part of the stored `NotificationMessage`, the in-app row or a delivery; adapters and the delivery log only ever see that result, and `last_error` of a delivery is scrubbed too. Channel secrets are never in the database (only environment variable names, D-33). The adapter factories read the variables through `ctx.secret(name)`, which registers each value as an always-on `SecretRegistry` entry before it is used, so a token inside a platform URL (`/bot/…`, a Discord webhook path) is scrubbed from span attributes, log lines and `last_error`. Scheduled reports (D-43) copy error codes, tool names, harness slugs, blocklist patterns, domains and degradation messages from the database: each passes the `Redactor` before it becomes part of the message, like a producer's copy of an event. Every renderer output (the platform request bodies and paths), the preview (`POST /channels/preview` replaces a secret in a path by its variable name) and the generic webhook body are sinks of the sentinel test. Act-button command tokens (D-41) exist in the clear only in the platform message: the database stores their SHA-256, the preview shows `bh1:preview-`, and no log line, span attribute, audit row, delivery row or API response carries one; Telegram updates, Discord gateway frames (which carry the bot token in Identify and Resume) and ntfy reply messages are never logged, and a log line about a press names at most the token's first 4 characters. The press audit (`notification_actions`) stores platform user ids and display names (the operator's own chat members), never message text. Screenshots (D-36) are never taken while the session's secret window is open, never when `recordToolResults=none`, and reach a channel only at content level `full` with `images[category]` on; masking uses Playwright's `mask` over form fields. - Property test: for every sink (log line, DB row, WS frame, MCP result, problem+json, OTLP payload, export stream, notification message, in-app notification row, delivery log, each platform renderer's request, the generic webhook body and the act-button audit) inject a sentinel secret through every documented path and assert the sentinel never appears. --- From 0dea0fc471938c8ab84163069e209ea7cefb81db Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:28:58 -0400 Subject: [PATCH 02/28] feat(contracts): digest and anomaly report contract, schedules and the digest route Additive, schema 1 unchanged: the digest.weekly kind, the chart block (bars over equal steps; degraded to text where charts is not a capability) and the optional report field (window, time zone, late, skipped, manual). Channel rules gain time_zone, digest and anomaly with their defaults; checkChannelRules validates zones. ChannelView.reports, host_time_zone on GET /channels, DeliveryRow.report, the charts capability, the digest and anomaly preview samples, the Daily digest preset and POST /channels/{id}/digest. --- docs/reference/api.md | 3 +- .../notification-message.schema.json | 103 + docs/reference/websocket.md | 4 +- packages/contracts/generated/openapi.json | 3194 ++++++++++++++++- .../contracts/src/enums/notification-kind.ts | 1 + packages/contracts/src/http/channels.ts | 80 +- packages/contracts/src/http/endpoints.ts | 1 + packages/contracts/src/http/index.ts | 4 + .../contracts/src/notifications/channel.ts | 80 + packages/contracts/src/notifications/index.ts | 13 + .../contracts/src/notifications/message.ts | 40 + .../contracts/src/notifications/platforms.ts | 55 +- .../contracts/src/notifications/taxonomy.ts | 4 + .../test/goldens/ws/ws-protocol.json | 614 +++- 14 files changed, 4122 insertions(+), 74 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 2c66687..76d41c5 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 (107 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`. @@ -187,6 +187,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/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/contracts/generated/openapi.json b/packages/contracts/generated/openapi.json index a68d664..c852d34 100644 --- a/packages/contracts/generated/openapi.json +++ b/packages/contracts/generated/openapi.json @@ -8841,6 +8841,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -9410,6 +9411,88 @@ "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" + ] + } + }, + "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": [ @@ -9512,6 +9595,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -9553,6 +9639,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -9689,6 +9776,125 @@ "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 + ] + }, + "next_at": { + "type": "integer", + "minimum": 0 + }, + "last_until": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "every", + "at", + "day", + "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": [ @@ -9713,18 +9919,23 @@ "created_at", "updated_at", "stats", - "connection" + "connection", + "reports" ] } }, "now": { "type": "integer", "minimum": 0 + }, + "host_time_zone": { + "type": "string" } }, "required": [ "data", - "now" + "now", + "host_time_zone" ] }, "ChannelResponse": { @@ -9881,6 +10092,88 @@ "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" + ] + } + }, + "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": [ @@ -9983,6 +10276,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -10024,6 +10320,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -10160,6 +10457,125 @@ "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 + ] + }, + "next_at": { + "type": "integer", + "minimum": 0 + }, + "last_until": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "every", + "at", + "day", + "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": [ @@ -10184,7 +10600,8 @@ "created_at", "updated_at", "stats", - "connection" + "connection", + "reports" ] } }, @@ -10296,42 +10713,124 @@ "end" ] }, - "content": { + "time_zone": { "type": "string", - "enum": [ - "counts", - "titles", - "full" - ] + "minLength": 1, + "maxLength": 64 }, - "images": { + "digest": { "type": "object", "properties": { - "needs-you": { - "type": "boolean" - }, - "problems": { - "type": "boolean" - }, - "wrap-ups": { - "type": "boolean" + "every": { + "type": "string", + "enum": [ + "day", + "week" + ] }, - "reports": { - "type": "boolean" + "at": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" }, - "system": { - "type": "boolean" + "day": { + "type": "string", + "enum": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] } - } - }, - "mask_images": { - "type": "boolean" + }, + "required": [ + "every", + "at" + ] }, - "ttl_ms": { + "anomaly": { "type": "object", "properties": { - "needs-you": { - "type": "integer", + "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": { @@ -10421,7 +10920,9 @@ "tool-errors", "crash", "degraded", - "test" + "test", + "digest", + "anomaly" ] }, "capabilities": { @@ -10433,6 +10934,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -10474,6 +10978,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -10521,6 +11026,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -11645,6 +12151,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 +12408,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": [ @@ -12056,6 +12654,88 @@ "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" + ] + } + }, + "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": [ @@ -12155,7 +12835,9 @@ "tool-errors", "crash", "degraded", - "test" + "test", + "digest", + "anomaly" ], "default": "attention" } @@ -12209,6 +12891,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test", null @@ -12297,6 +12980,53 @@ "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": [ @@ -12317,7 +13047,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ] } }, @@ -12482,6 +13213,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test", null @@ -12570,6 +13302,53 @@ "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": [ @@ -12590,7 +13369,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ] }, "message": { @@ -12631,6 +13411,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -13755,28 +14536,76 @@ "type", "content" ] - } - ] - }, - "maxItems": 50 - }, - "actions": { - "type": "array", - "items": { - "oneOf": [ + }, { "type": "object", "properties": { - "kind": { + "type": { "type": "string", "enum": [ - "act" + "chart" ] }, - "id": { + "label": { "type": "string", - "pattern": "^[a-z][a-z0-9-]{0,31}$", - "description": "Stable within the message (`resolve`, `open-session`)." + "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", @@ -13964,6 +14793,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": [ @@ -14641,6 +15514,88 @@ "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" + ] + } + }, + "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": [ @@ -14717,26 +15672,1944 @@ } } }, - "act_buttons": { + "act_buttons": { + "type": "boolean" + }, + "allow_list": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "maxItems": 32 + } + } + } + }, + "additionalProperties": false + }, + "ChannelTestResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "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", + "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": [ + "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" + ] + }, + "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": [ + "heading" + ] + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ] + }, + { + "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" + ] + }, + { + "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" + ] + }, + { + "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" + ] + }, + "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": { + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + } + }, + "required": [ + "name", + "content_type" + ] + } + }, + "required": [ + "method", + "path", + "encoding", + "body", + "headers", + "file" + ] + } + }, + "local_links": { "type": "boolean" }, - "allow_list": { + "notes": { "type": "array", "items": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "maxItems": 32 + "type": "string" + } } - } - } - }, - "additionalProperties": false - }, - "ChannelTestResponse": { - "type": "object", - "properties": { + }, + "required": [ + "kind", + "mode", + "sample", + "capabilities", + "message", + "requests", + "local_links", + "notes" + ] + }, + "window": { + "type": "object", + "properties": { + "since": { + "type": "integer", + "minimum": 0 + }, + "until": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "since", + "until" + ] + }, + "empty": { + "type": "boolean" + }, + "sent": { + "type": "boolean" + }, "ok": { "type": "boolean" }, @@ -14785,6 +17658,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test", null @@ -14873,6 +17747,53 @@ "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": [ @@ -14893,7 +17814,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ] }, "error": { @@ -14916,11 +17838,25 @@ } }, "required": [ + "preview", + "window", + "empty", + "sent", "ok", "delivery", "error" ] }, + "ChannelDigestRequest": { + "type": "object", + "properties": { + "send": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, "SearchResponse": { "type": "object", "properties": { @@ -26078,6 +29014,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -27812,6 +30749,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/http/channels.ts b/packages/contracts/src/http/channels.ts index abf953c..d349a70 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,46 @@ 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(), + /** 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 +152,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 +188,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 +224,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 +326,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..d69e73b 100644 --- a/packages/contracts/src/http/endpoints.ts +++ b/packages/contracts/src/http/endpoints.ts @@ -154,6 +154,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..a7f8a44 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, diff --git a/packages/contracts/src/notifications/channel.ts b/packages/contracts/src/notifications/channel.ts index 72f6f51..f2b5e4c 100644 --- a/packages/contracts/src/notifications/channel.ts +++ b/packages/contracts/src/notifications/channel.ts @@ -45,6 +45,76 @@ 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 digest is sent when none is chosen. */ +export const DEFAULT_DIGEST_AT = '09:00'; + +/** + * A scheduled digest (D-43): every day or every week (on `day`, default Monday) at `at`, in the + * channel's time zone. The report covers the period that ends at that time. + */ +export const DigestRule = z.object({ + every: z.enum(['day', 'week']), + at: ClockTime, + day: Weekday.optional(), +}); +/** A scheduled digest. */ +export type DigestRule = z.infer; + +/** + * 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 +130,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 +200,7 @@ export const SUPPRESSION_REASONS = [ 'edit_unsupported', 'delete_unsupported', 'no_adapter', + 'empty', ] as const; /** A suppression reason. */ export type SuppressionReason = (typeof SUPPRESSION_REASONS)[number]; diff --git a/packages/contracts/src/notifications/index.ts b/packages/contracts/src/notifications/index.ts index 5596e2d..24d6215 100644 --- a/packages/contracts/src/notifications/index.ts +++ b/packages/contracts/src/notifications/index.ts @@ -1,7 +1,12 @@ /** @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, + DigestRule, + isValidTimeZone, NotificationChannelName, NotificationChannelRules, NotificationChannelSecretRefs, @@ -12,6 +17,8 @@ export { StartupNotificationChannel, SUPPRESSION_REASONS, type SuppressionReason, + WEEKDAYS, + Weekday, } from './channel.ts'; export { NOTIFICATION_MESSAGE_SCHEMA_ID, notificationMessageJsonSchema } from './json-schema.ts'; export { @@ -19,6 +26,7 @@ export { ActionStyle, Block, type BlockType, + ChartBlock, CodeBlock, DashboardPath, DividerBlock, @@ -37,6 +45,7 @@ export { ListBlock, NOTIFICATION_ACTIONS_MAX, NOTIFICATION_BLOCKS_MAX, + NOTIFICATION_CHART_POINTS_MAX, NOTIFICATION_LABEL_MAX, NOTIFICATION_SCHEMA_VERSION, NOTIFICATION_SUMMARY_MAX, @@ -47,6 +56,7 @@ export { NotificationEntities, NotificationMessage, NotificationPrivacy, + NotificationReport, OpenAction, QuoteBlock, TableBlock, @@ -58,6 +68,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, 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..261841a 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; @@ -495,6 +495,24 @@ export function checkChannelRules(input: { : `${input.kind} cannot receive button presses.`, }); } + for (const [field, zone] of [ + ['rules.time_zone', input.rules.time_zone], + ['rules.quiet_hours.time_zone', input.rules.quiet_hours?.time_zone], + ] as const) { + if (zone !== undefined && !isValidTimeZone(zone)) { + problems.push({ + field, + message: `'${zone.slice(0, 64)}' is not a time zone; use an IANA name such as Europe/Berlin.`, + }); + } + } + const digest = input.rules.digest; + if (digest !== undefined && digest.every === 'day' && digest.day !== undefined) { + problems.push({ + field: 'rules.digest.day', + message: 'a weekday applies to a weekly digest only.', + }); + } const allow = input.rules.allow_list ?? []; if (allow.length > 0 && !hasPresserIdentity(input.kind)) { problems.push({ @@ -535,6 +553,8 @@ export const PREVIEW_SAMPLES = [ 'crash', 'degraded', 'test', + 'digest', + 'anomaly', ] as const; /** A preview sample. */ export const PreviewSample = z.enum(PREVIEW_SAMPLES); @@ -550,6 +570,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 +580,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 +607,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 +668,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/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/goldens/ws/ws-protocol.json b/packages/contracts/test/goldens/ws/ws-protocol.json index 1db57f6..f26e413 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,102 @@ ], "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" + ] + } + }, + "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 +3686,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -3637,6 +3738,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -3811,6 +3913,148 @@ "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" + } + ] + }, + "next_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_until": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "every", + "at", + "day", + "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 +4079,8 @@ "created_at", "updated_at", "stats", - "connection" + "connection", + "reports" ], "additionalProperties": false } @@ -3912,6 +4157,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -4021,6 +4267,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 +4343,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ], "additionalProperties": false } @@ -7266,6 +7569,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -7459,6 +7763,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -7700,6 +8005,102 @@ ], "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" + ] + } + }, + "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 +8188,9 @@ "tables": { "type": "boolean" }, + "charts": { + "type": "boolean" + }, "images": { "type": "boolean" }, @@ -7836,6 +8240,7 @@ "required": [ "rich_blocks", "tables", + "charts", "images", "act_buttons", "open_links", @@ -8010,6 +8415,148 @@ "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" + } + ] + }, + "next_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "last_until": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "every", + "at", + "day", + "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 +8581,8 @@ "created_at", "updated_at", "stats", - "connection" + "connection", + "reports" ], "additionalProperties": false } @@ -8111,6 +8659,7 @@ "system.degraded", "channel.broken", "digest.daily", + "digest.weekly", "report.anomaly", "test" ] @@ -8220,6 +8769,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 +8845,8 @@ "duration_ms", "message_ref", "created_at", - "updated_at" + "updated_at", + "report" ], "additionalProperties": false } From a6f3d45c90adbc9128410b3fe98842e755908d8f Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:29:10 -0400 Subject: [PATCH 03/28] docs(specs): the digest route counts previews and sends in one limit --- specs/03-admin-backend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/03-admin-backend.md b/specs/03-admin-backend.md index 7cabdfd..bd6f7f5 100644 --- a/specs/03-admin-backend.md +++ b/specs/03-admin-backend.md @@ -305,7 +305,7 @@ 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/{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. Sends are rate-limited 6/min | 404, 409 `CHANNEL_NOT_READY` (sending without an adapter) | +| 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` | From c2c72d982b4f7b98a20b91d409b0d534d8fa3d7d Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:29:10 -0400 Subject: [PATCH 04/28] feat(notifications): scheduled digests and anomaly alerts in core ReportScheduler ticks every 60 s only while a channel schedules a report: per channel it keeps a durable cursor in notification_cursors, produces the newest missed digest window once (late after 5 min, with the skipped count), stores empty days as suppressed: empty, sends silently inside quiet hours, and runs the hourly anomaly checks with hysteresis (a new alert on a crossing, a silent edit on a change, a silent resolved edit when all clear). Reports are addressed notifications planned for their channel only, written with their delivery rows and cursor in one transaction. Pure producers (reports.ts) build the digest and the alert at the channel's content level; schedule.ts does the zone and DST maths. Additive indexed queries: windowCounts, toolLatency (p95 in SQLite), topErrors, windowStats, countByResult. degrade turns the chart block into text bars; restrictContent keeps a message already at its level. ChannelService gains the report views, Send a digest now and the report samples; deleting a channel removes its cursors. The Discord renderer escapes list markers only at line starts. --- .../config/notification-channel-flag.test.ts | 57 +- .../app/config/notification-channel-flag.ts | 119 ++- .../src/app/notifications/channel-registry.ts | 3 + .../src/app/notifications/channel-service.ts | 249 ++++-- .../src/app/notifications/content-level.ts | 5 +- .../src/app/notifications/degrade.test.ts | 55 +- .../core/src/app/notifications/degrade.ts | 50 +- .../src/app/notifications/in-app-channel.ts | 1 + packages/core/src/app/notifications/index.ts | 50 ++ .../core/src/app/notifications/message.ts | 26 + packages/core/src/app/notifications/outbox.ts | 4 +- .../src/app/notifications/report-facts.ts | 153 ++++ .../notifications/report-scheduler.test.ts | 460 ++++++++++ .../src/app/notifications/report-scheduler.ts | 774 +++++++++++++++++ .../src/app/notifications/reports.test.ts | 367 ++++++++ .../core/src/app/notifications/reports.ts | 796 ++++++++++++++++++ .../src/app/notifications/routing.test.ts | 71 ++ .../core/src/app/notifications/routing.ts | 32 +- .../core/src/app/notifications/samples.ts | 175 +++- .../src/app/notifications/schedule.test.ts | 172 ++++ .../core/src/app/notifications/schedule.ts | 328 ++++++++ .../core/src/infra/notifications/discord.ts | 29 +- packages/core/src/infra/notifications/ntfy.ts | 2 + .../core/src/infra/notifications/telegram.ts | 5 + .../core/src/infra/notifications/webhook.ts | 1 + .../core/src/infra/persistence/analytics.ts | 89 ++ .../repositories/operator-requests.ts | 47 ++ .../persistence/repositories/vault-audit.ts | 17 + packages/core/src/infra/telemetry/metrics.ts | 10 + .../src/interface/http/routes/channels.ts | 21 +- packages/core/src/interface/http/services.ts | 2 + .../core/src/ports/notification-channel.ts | 2 + .../core/src/ports/persistence/analytics.ts | 41 + packages/core/src/ports/persistence/index.ts | 4 + .../ports/persistence/operator-requests.ts | 17 + .../core/src/ports/persistence/vault-audit.ts | 6 + packages/core/src/public/server.ts | 5 + .../notifications/discord/anomaly-counts.json | 45 + .../discord/anomaly-resolved-edit.json | 33 + .../notifications/discord/anomaly.json | 45 + .../notifications/discord/digest-counts.json | 77 ++ .../notifications/discord/digest-full.json | 82 ++ .../notifications/discord/digest-late.json | 82 ++ .../notifications/discord/digest-weekly.json | 82 ++ .../goldens/notifications/discord/digest.json | 82 ++ .../notifications/ntfy/anomaly-counts.json | 34 + .../ntfy/anomaly-resolved-edit.json | 25 + .../goldens/notifications/ntfy/anomaly.json | 34 + .../notifications/ntfy/digest-counts.json | 34 + .../notifications/ntfy/digest-full.json | 34 + .../notifications/ntfy/digest-late.json | 34 + .../notifications/ntfy/digest-weekly.json | 34 + .../goldens/notifications/ntfy/digest.json | 34 + .../telegram-classic/anomaly-counts.json | 34 + .../anomaly-resolved-edit.json | 26 + .../telegram-classic/anomaly.json | 34 + .../telegram-classic/digest-counts.json | 34 + .../telegram-classic/digest-full.json | 34 + .../telegram-classic/digest-late.json | 34 + .../telegram-classic/digest-weekly.json | 34 + .../telegram-classic/digest.json | 34 + .../telegram/anomaly-counts.json | 33 + .../telegram/anomaly-resolved-edit.json | 25 + .../notifications/telegram/anomaly.json | 33 + .../notifications/telegram/digest-counts.json | 33 + .../notifications/telegram/digest-full.json | 33 + .../notifications/telegram/digest-late.json | 33 + .../notifications/telegram/digest-weekly.json | 33 + .../notifications/telegram/digest.json | 33 + .../notifications/webhook/anomaly-counts.json | 156 ++++ .../webhook/anomaly-resolved-edit.json | 67 ++ .../notifications/webhook/anomaly.json | 169 ++++ .../notifications/webhook/digest-counts.json | 173 ++++ .../notifications/webhook/digest-full.json | 416 +++++++++ .../notifications/webhook/digest-late.json | 401 +++++++++ .../notifications/webhook/digest-weekly.json | 392 +++++++++ .../goldens/notifications/webhook/digest.json | 388 +++++++++ packages/core/test/helpers/fake-channel.ts | 1 + packages/core/test/helpers/http-fakes.ts | 63 ++ packages/core/test/helpers/http-kit.ts | 19 + .../core/test/helpers/http-route-cases.ts | 11 + .../test/helpers/in-memory-repos-facts.ts | 10 + .../test/helpers/in-memory-vault-repos.ts | 43 +- .../notifications/ntfy-live.test.ts | 25 + .../notifications/channel-service.test.ts | 115 +++ packages/core/test/notifications/helpers.ts | 14 +- .../test/notifications/render.golden.test.ts | 21 +- .../report-redaction.property.test.ts | 159 ++++ .../test/notifications/reports.sqlite.test.ts | 205 +++++ .../persistence/conformance-reports.test.ts | 259 ++++++ 90 files changed, 8564 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/app/notifications/report-facts.ts create mode 100644 packages/core/src/app/notifications/report-scheduler.test.ts create mode 100644 packages/core/src/app/notifications/report-scheduler.ts create mode 100644 packages/core/src/app/notifications/reports.test.ts create mode 100644 packages/core/src/app/notifications/reports.ts create mode 100644 packages/core/src/app/notifications/schedule.test.ts create mode 100644 packages/core/src/app/notifications/schedule.ts create mode 100644 packages/core/test/goldens/notifications/discord/anomaly-counts.json create mode 100644 packages/core/test/goldens/notifications/discord/anomaly-resolved-edit.json create mode 100644 packages/core/test/goldens/notifications/discord/anomaly.json create mode 100644 packages/core/test/goldens/notifications/discord/digest-counts.json create mode 100644 packages/core/test/goldens/notifications/discord/digest-full.json create mode 100644 packages/core/test/goldens/notifications/discord/digest-late.json create mode 100644 packages/core/test/goldens/notifications/discord/digest-weekly.json create mode 100644 packages/core/test/goldens/notifications/discord/digest.json create mode 100644 packages/core/test/goldens/notifications/ntfy/anomaly-counts.json create mode 100644 packages/core/test/goldens/notifications/ntfy/anomaly-resolved-edit.json create mode 100644 packages/core/test/goldens/notifications/ntfy/anomaly.json create mode 100644 packages/core/test/goldens/notifications/ntfy/digest-counts.json create mode 100644 packages/core/test/goldens/notifications/ntfy/digest-full.json create mode 100644 packages/core/test/goldens/notifications/ntfy/digest-late.json create mode 100644 packages/core/test/goldens/notifications/ntfy/digest-weekly.json create mode 100644 packages/core/test/goldens/notifications/ntfy/digest.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/anomaly-counts.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/anomaly-resolved-edit.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/anomaly.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/digest-counts.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/digest-full.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/digest-late.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json create mode 100644 packages/core/test/goldens/notifications/telegram-classic/digest.json create mode 100644 packages/core/test/goldens/notifications/telegram/anomaly-counts.json create mode 100644 packages/core/test/goldens/notifications/telegram/anomaly-resolved-edit.json create mode 100644 packages/core/test/goldens/notifications/telegram/anomaly.json create mode 100644 packages/core/test/goldens/notifications/telegram/digest-counts.json create mode 100644 packages/core/test/goldens/notifications/telegram/digest-full.json create mode 100644 packages/core/test/goldens/notifications/telegram/digest-late.json create mode 100644 packages/core/test/goldens/notifications/telegram/digest-weekly.json create mode 100644 packages/core/test/goldens/notifications/telegram/digest.json create mode 100644 packages/core/test/goldens/notifications/webhook/anomaly-counts.json create mode 100644 packages/core/test/goldens/notifications/webhook/anomaly-resolved-edit.json create mode 100644 packages/core/test/goldens/notifications/webhook/anomaly.json create mode 100644 packages/core/test/goldens/notifications/webhook/digest-counts.json create mode 100644 packages/core/test/goldens/notifications/webhook/digest-full.json create mode 100644 packages/core/test/goldens/notifications/webhook/digest-late.json create mode 100644 packages/core/test/goldens/notifications/webhook/digest-weekly.json create mode 100644 packages/core/test/goldens/notifications/webhook/digest.json create mode 100644 packages/core/test/notifications/report-redaction.property.test.ts create mode 100644 packages/core/test/notifications/reports.sqlite.test.ts create mode 100644 packages/core/test/persistence/conformance-reports.test.ts 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..d68559b 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,58 @@ 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' }, + }); + expect(rules('digest=weekly')).toEqual({ digest: { every: 'week', at: '09:00', day: 'mon' } }); + 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 or weekly:@HH:MM, like daily@09:00 or weekly:mon@08:30.", + ]); + 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..dd9f237 100644 --- a/packages/core/src/app/config/notification-channel-flag.ts +++ b/packages/core/src/app/config/notification-channel-flag.ts @@ -7,18 +7,21 @@ import { NotificationSeverity, } from '@browserhive/contracts/enums'; import { + type AnomalyRule, AVAILABLE_CHANNEL_KINDS, AvailableChannelKind, CHANNEL_KIND_SPECS, type ChannelKindSpec, checkChannelConfig, checkChannelRules, + DEFAULT_DIGEST_AT, 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 +52,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 +114,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 +353,94 @@ function categoriesOf( return items.filter(isCategory); } +const DIGEST_RE = + /^(daily|weekly)(?::(mon|tue|wed|thu|fri|sat|sun))?(?:@([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 or weekly:@HH:MM, like daily@09:00 or weekly:mon@08:30.`, + ); + } else if (m[1] === 'daily' && m[2] !== undefined) { + problems.push(`${label}: a weekday applies to a weekly digest only (weekly:${m[2]}@…).`); + } else { + const at = m[3] === undefined ? DEFAULT_DIGEST_AT : `${m[3]}:${m[4]}`; + rules.digest = + m[1] === 'weekly' + ? { every: 'week', at, day: WEEKDAYS.find((d) => d === m[2]) ?? 'mon' } + : { every: 'day', at }; + } + } + 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 +480,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..d053e2f 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,12 @@ 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; + /** 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 +143,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 +180,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 +298,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 +388,8 @@ export class ChannelService { last_status: s?.lastStatus ?? null, }, connection: this.deps.connection?.(r.channelId) ?? null, + reports: + this.deps.reports?.view(r) ?? reportsView(r, this.deps.clock.now(), this.hostTimeZone()), }; } @@ -503,6 +531,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 +607,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 +639,89 @@ 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', + ); + const base = { preview, window: built.window, empty: built.empty }; + if (!send) return { ...base, sent: false, ok: true, delivery: null, error: null }; + const sent = await this.sendNow( + this.readyEntry(channelId), + built.message, + built.record, + '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 +731,7 @@ export class ChannelService { revision: 1, op: 'send', status: 'pending', - reason: 'test', + reason, nextAttemptAt: null, createdAt: now, }, @@ -640,17 +740,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 +758,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 +776,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 +791,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 +812,68 @@ 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 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 rules = setup.rules; const now = this.deps.clock.now(); - const plain = sampleMessage(request.sample, { 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); const withImage = wantsImages(rules, plain.category) ? sampleMessage(request.sample, { - now, + ...options, image: rules.mask_images === true ? 'masked' : 'unmasked', }) : plain; - const shown = this.shape(withImage, rules, capabilities); + 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, + ): 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 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,19 +897,12 @@ export class ChannelService { return { kind: parsedKind.data, mode, - sample: request.sample, + sample, capabilities: capabilitiesDto(capabilities), message: shown, requests, local_links: this.deps.links.local, - notes: this.notes( - parsedKind.data, - rules, - capabilities, - plain.category, - request.sample, - target, - ), + notes: this.notes(parsedKind.data, rules, capabilities, message.category, sample, target), }; } @@ -822,6 +937,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 +1040,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, }); } 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..5882739 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 8 calls' }, + ], + }, + ]); + 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..38bb486 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,45 @@ 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(''); +} + +/** 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 ? '' : ` ${block.unit}`; + return { + type: 'text', + content: [ + bold(block.label), + text(' '), + code(sparkline(block.values)), + text(` peak ${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 +92,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 +109,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 +141,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 +200,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..be73da6 100644 --- a/packages/core/src/app/notifications/index.ts +++ b/packages/core/src/app/notifications/index.ts @@ -103,6 +103,38 @@ export { publicUrlHost, publicUrlOrigin, } from './public-url.ts'; +export { + createReportFacts, + type ReportFacts, + type ReportFactsDeps, +} from './report-facts.ts'; +export { + anomalyCursorKey, + channelCursorKeys, + digestCursorKey, + forgetChannelCursors, + LATE_AFTER_MS, + REPORT_TICK_MS, + type ReportCounter, + type ReportPass, + ReportScheduler, + type ReportSchedulerDeps, + reportsView, +} from './report-scheduler.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 +142,7 @@ export { inQuietHours, localMinutes, planDeliveries, + quietHoursOf, type RoutableChannel, type RouteDecision, route, @@ -119,6 +152,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/report-facts.ts b/packages/core/src/app/notifications/report-facts.ts new file mode 100644 index 0000000..69a7a9e --- /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' ? 6 * 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.test.ts b/packages/core/src/app/notifications/report-scheduler.test.ts new file mode 100644 index 0000000..887c3c2 --- /dev/null +++ b/packages/core/src/app/notifications/report-scheduler.test.ts @@ -0,0 +1,460 @@ +/** @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(); + const reports = () => + [...repos.notifications.rows.values()].filter((r) => r.category === 'reports'); + const deliveries = () => repos.notificationDeliveries.rows; + return { + clock, + repos, + registry, + scheduler, + make, + calls, + intervals, + counted, + reports, + 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(); + expect(t.calls.anomaly).toBe(0); + await t.clock.set(START + HOUR); // 13:00 UTC: quiet hours are over + await t.scheduler.tick(); + expect(t.calls.anomaly).toBe(1); + expect(t.reports()).toHaveLength(1); + }); + + 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..082eb75 --- /dev/null +++ b/packages/core/src/app/notifications/report-scheduler.ts @@ -0,0 +1,774 @@ +/** @module app/notifications/report-scheduler — the scheduled reports (D-43, D-44, spec 03 §9.7): a 60 s tick while any channel schedules a digest or anomaly alerts; per channel, 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; each report an addressed notification written with its delivery row and its cursor in one transaction. */ + +import type { ChannelReports } from '@browserhive/contracts/http'; +import { + ANOMALY_CHECKS, + type AnomalyCheck, + type DigestRule, + KIND_CATEGORY, + KIND_TYPE, + type NotificationChannelRules, + type NotificationMessage, +} 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, RegisteredChannel } from './channel-registry.ts'; +import { scrubMessage } from './message.ts'; +import type { NotificationOutbox } from './outbox.ts'; +import type { ReportFacts } from './report-facts.ts'; +import { + type ActiveCheck, + type AnomalyFacts, + type AnomalyState, + buildAnomaly, + buildDigest, + 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 channel schedules 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; + +/** Cursor key of a channel's digest schedule. */ +export const digestCursorKey = (channelId: string) => `digest:${channelId}`; +/** Cursor key of a channel's anomaly state. */ +export const anomalyCursorKey = (channelId: string) => `anomaly:${channelId}`; + +/** + * 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 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; +} + +/** 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; + 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; +} + +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 readAnomalyCursor(raw: string | null): AnomalyCursor | null { + const v = parseJson<{ last?: unknown; active?: unknown; notification_id?: unknown }>(raw); + if (v === null || 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 writeAnomalyCursor(c: AnomalyCursor): string { + return JSON.stringify({ last: c.last, active: c.active, notification_id: c.notificationId }); +} + +/** + * Produces the scheduled reports. `tick()` is idempotent and serialised: a call while a pass runs + * returns that pass. With no channel 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 started = false; + private current: Promise | undefined; + private readonly digestCache = new Map(); + private readonly anomalyCache = new Map(); + + constructor(private readonly deps: ReportSchedulerDeps) { + this.log = deps.logger.child({ module: 'notifications' }); + this.redactor = deps.redactor ?? createRedactor(); + } + + /** Arms the timer while a channel schedules 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.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.cancel?.(); + this.cancel = undefined; + this.started = false; + } + + private scheduled(): RegisteredChannel[] { + return this.deps.registry + .channels() + .filter((c) => c.record.rules.digest !== undefined || c.record.rules.anomaly !== undefined); + } + + private wanted(): boolean { + return this.scheduled().length > 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 channel's reports use. */ + zoneOf(rules: NotificationChannelRules): string { + const host = this.hostZone(); + return usableZone(rules.time_zone, host); + } + + private hostZone(): string { + return usableZone(this.deps.hostZone(), 'UTC'); + } + + /** + * One pass over every scheduling channel: due digests, then 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; + }); + 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; + for (const entry of this.scheduled()) { + const rules = entry.record.rules; + try { + if (rules.digest !== undefined && (await this.digestTick(entry, rules.digest, now))) { + digests++; + } + } catch (err) { + this.log.error('digest failed', { channel: entry.record.name, err: serializeError(err) }); + } + try { + if (rules.anomaly !== undefined) { + const facts = () => { + anomalyFacts ??= this.deps.facts.anomaly(now); + return anomalyFacts; + }; + if (await this.anomalyTick(entry, now, facts)) anomalies++; + } + } catch (err) { + this.log.error('anomaly check failed', { + channel: entry.record.name, + err: serializeError(err), + }); + } + } + return { digests, anomalies }; + } + + // ----------------------------------------------------------------------------------------------- + // Digests + // ----------------------------------------------------------------------------------------------- + + private async digestCursor(channelId: string): Promise { + const cached = this.digestCache.get(channelId); + if (cached !== undefined) return cached; + const read = readDigestCursor( + await this.deps.repos.notificationCursors.get(digestCursorKey(channelId)), + ); + if (read !== null) this.digestCache.set(channelId, read); + return read; + } + + /** Handles a channel's due digest; `true` when one was produced. */ + private async digestTick(entry: RegisteredChannel, rule: DigestRule, now: number) { + const record = entry.record; + const zone = this.zoneOf(record.rules); + const spec = scheduleKey(rule, zone); + const cursor = await this.digestCursor(record.channelId); + 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( + digestCursorKey(record.channelId), + JSON.stringify(armed), + now, + ); + this.digestCache.set(record.channelId, 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 quietHours = quietHoursOf(record.rules); + const built = await this.buildDigestFor(record, rule, window, now, { + zone, + level: contentLevelOf(record.rules), + scheduledAt: newest, + late, + skipped, + manual: false, + quiet: quietHours !== null && inQuietHours(newest, quietHours), + }); + const next: DigestCursor = { spec, last: newest, until: window.until }; + let jobs = this.deps.outbox.plan(built.message, now, record.channelId); + if (built.empty) { + jobs = jobs.map((j) => + j.status === 'pending' + ? { ...j, status: 'suppressed', reason: 'empty', nextAttemptAt: null } + : j, + ); + } + await this.write( + built.record, + jobs, + digestCursorKey(record.channelId), + JSON.stringify(next), + now, + ); + this.digestCache.set(record.channelId, next); + const kind = built.message.kind; + this.count(kind, built.empty ? 'empty' : late ? 'late' : 'sent'); + if (skipped > 0) this.count(kind, 'skipped', skipped); + this.log.info(built.empty ? 'digest empty' : 'digest produced', { + channel: record.name, + late, + skipped, + }); + return true; + } + + /** + * The digest of a window for a channel, sealed (redacted, validated) with a fresh notification + * id and its in-app row (read and dismissed: reports are channel-only, D-43). + * + * @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 content = buildDigest(facts, rule, ctx); + const thread = `digest:${record.channelId}:${window.until}`; + const built = this.seal(this.messageOf(content, thread, now, 1, ctx.level, null)); + return { + message: built, + record: this.recordOf(built, content.target, now), + window, + empty: isEmptyDigest(facts), + }; + } + + /** + * 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, or `null` when the channel schedules no digest (a daily one is used). + */ + 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, + }); + } + + // ----------------------------------------------------------------------------------------------- + // 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; + } + + /** Runs a channel's check when due; `true` when a notification was written or revised. */ + private async anomalyTick( + entry: RegisteredChannel, + now: number, + factsOf: () => Promise, + ): Promise { + const record = entry.record; + 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 facts = await factsOf(); + const previous: AnomalyState = cursor?.active ?? {}; + const evaluation = evaluateAnomalies(facts, rule, previous, now); + const zone = this.zoneOf(record.rules); + const ctx: ReportContext = { + zone, + level: contentLevelOf(record.rules), + scheduledAt: now, + late: false, + skipped: 0, + manual: false, + quiet: false, + }; + const key = anomalyCursorKey(record.channelId); + 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 message = this.seal( + this.messageOf(content, `anomaly:${record.channelId}`, now, 1, ctx.level, null), + ); + const row = this.recordOf(message, content.target, now); + const next: AnomalyCursor = { + last: now, + active: evaluation.active, + notificationId: message.id, + }; + const jobs = this.deps.outbox.plan(message, now, record.channelId); + const superseded = + openId === null + ? null + : await this.revision(openId, now, (prev) => ({ + ...prev, + state: 'final', + alert: false, + summary: `Superseded by the report of ${formatClock(now, zone)}.`, + actions: [], + })); + await this.write( + row, + jobs, + key, + writeAnomalyCursor(next), + now, + superseded ?? undefined, + record.channelId, + ); + this.anomalyCache.set(record.channelId, next); + this.count('report.anomaly', 'sent'); + this.log.info('anomaly alert', { channel: record.name, fired: evaluation.fired.length }); + return true; + } + 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), + ); + const next: AnomalyCursor = { last: now, active: {}, notificationId: null }; + await this.write( + null, + [], + key, + writeAnomalyCursor(next), + now, + revised ?? undefined, + record.channelId, + ); + this.anomalyCache.set(record.channelId, next); + this.count('report.anomaly', 'resolved'); + this.log.info('anomaly cleared', { channel: record.name }); + return true; + } + 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), + ); + const next: AnomalyCursor = { last: now, active: evaluation.active, notificationId: openId }; + await this.write( + null, + [], + key, + writeAnomalyCursor(next), + now, + revised ?? undefined, + record.channelId, + ); + this.anomalyCache.set(record.channelId, next); + return true; + } + const next: AnomalyCursor = { + last: now, + active: evaluation.active, + notificationId: activeNow ? openId : null, + }; + await this.deps.repos.notificationCursors.set(key, writeAnomalyCursor(next), now); + this.anomalyCache.set(record.channelId, next); + return false; + } + + /** 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<{ record: NotificationRecord; message: NotificationMessage } | null> { + 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, + }, + message, + }; + } + + // ----------------------------------------------------------------------------------------------- + // Shared + // ----------------------------------------------------------------------------------------------- + + private messageOf( + content: ReportContent, + thread: string, + now: number, + revision: number, + level: NotificationMessage['privacy']['level'], + prev: NotificationMessage | null, + ): NotificationMessage { + return reportMessage(content, { + id: prev?.id ?? `n-${this.deps.ids.opaque(12)}`, + 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); + } + } + + private recordOf(message: NotificationMessage, target: string, now: number): 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: now, + dismissedAt: now, + kind: message.kind, + category: KIND_CATEGORY[message.kind], + severity: message.severity, + state: message.state, + revision: message.revision, + thread: message.thread, + messageJson: JSON.stringify(message), + }; + } + + /** + * Writes a new report row (or none), a revision of an earlier one (or none), their delivery rows + * and the cursor in one transaction, then wakes the outbox. + */ + private async write( + row: NotificationRecord | null, + jobs: readonly NewNotificationDelivery[], + cursorKey: string, + cursorValue: string, + now: number, + revised?: { record: NotificationRecord; message: NotificationMessage }, + channelId?: string, + ): Promise { + const revisionJobs = + revised === undefined || channelId === undefined + ? [] + : this.deps.outbox.plan(revised.message, now, channelId); + await this.deps.uow.transaction(async (repos) => { + if (revised !== undefined) { + await repos.notifications.revise(revised.record.notificationId, { + state: revised.message.state, + severity: revised.message.severity, + revision: revised.message.revision, + messageJson: JSON.stringify(revised.message), + }); + } + if (row !== null) await repos.notifications.insert(row); + const all = [...revisionJobs, ...jobs]; + if (all.length > 0) await repos.notificationDeliveries.enqueue(all); + await repos.notificationCursors.set(cursorKey, cursorValue, now); + }); + const touched = [ + ...(revised === undefined ? [] : [revised.record.notificationId]), + ...(row === null ? [] : [row.notificationId]), + ]; + for (const id of touched) { + try { + this.deps.onDeliveryChange?.(id); + } catch (err) { + this.log.warn('delivery feed failed', { err: serializeError(err) }); + } + } + if ([...revisionJobs, ...jobs].some((j) => j.status === 'pending')) this.deps.outbox.kick(); + } + + 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, this.deps.clock.now(), this.hostZone(), { + until: this.digestCache.get(record.channelId)?.until ?? null, + ...(ac !== undefined && { anomaly: { last: ac.last, active: ac.active } }), + }); + } + + /** Reads every scheduling channel's cursors into the cache (the views before the first tick). */ + async load(): Promise { + for (const entry of this.scheduled()) { + await this.digestCursor(entry.record.channelId); + await this.anomalyCursor(entry.record.channelId); + } + } +} + +/** + * The scheduled reports of a channel 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( + record: NotificationChannelRecord, + now: number, + hostZone: string, + state: { + readonly until?: number | null; + readonly anomaly?: { readonly last: number; readonly active: AnomalyState }; + } = {}, +): ChannelReports { + const rules = record.rules; + 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' ? (digest.day ?? 'mon') : null, + 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/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..def741b --- /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; the schedule is unchanged.')); + 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 6 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..845a9f7 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 ? 6 * 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..fb45a7c --- /dev/null +++ b/packages/core/src/app/notifications/schedule.test.ts @@ -0,0 +1,172 @@ +/** @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('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'); + expect(scheduleKey({ every: 'week', at: '09:00' }, 'UTC')).toBe('week:mon@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..10e2a8c --- /dev/null +++ b/packages/core/src/app/notifications/schedule.ts @@ -0,0 +1,328 @@ +/** @module app/notifications/schedule — pure calendar maths of the scheduled reports (D-43, spec 03 §9.7): 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, the hourly anomaly slots, and the dates reports print. */ + +import type { DigestRule, Weekday } from '@browserhive/contracts/notifications'; +import { WEEKDAYS } from '@browserhive/contracts/notifications'; + +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; +} + +/** The weekday a rule fires on (weekly), as an index; `null` for a daily rule. */ +function ruleWeekday(rule: DigestRule): number | null { + if (rule.every !== 'week') return null; + return WEEKDAYS.indexOf(rule.day ?? 'mon'); +} + +/** 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 weekday = ruleWeekday(rule); + 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 (weekday === null || weekdayOf(date) === weekday) { + 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); + const weekday = ruleWeekday(rule); + let date = addDays(wallTime(after, zone), -1); + for (let i = 0; i < 16; i++) { + if (weekday === null || weekdayOf(date) === weekday) { + 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); + const weekday = ruleWeekday(rule); + let date = addDays(wallTime(before, zone), 1); + for (let i = 0; i < 16; i++) { + if (weekday === null || weekdayOf(date) === weekday) { + 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' ? `:${rule.day ?? 'mon'}` : ''; + 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; +} + +/** 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 { + formatter(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..4cb9ca7 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 ''; } } diff --git a/packages/core/src/infra/notifications/ntfy.ts b/packages/core/src/infra/notifications/ntfy.ts index 2fb67b2..2f403c0 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, @@ -122,6 +123,7 @@ function blockText(b: Block): string { case 'table': case 'image': case 'divider': + case 'chart': return ''; } } 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/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/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/services.ts b/packages/core/src/interface/http/services.ts index fc6125f..1aacdd8 100644 --- a/packages/core/src/interface/http/services.ts +++ b/packages/core/src/interface/http/services.ts @@ -298,6 +298,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..3452091 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 { 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/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..02f3944 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,9 @@ export { NotificationService, PublicUrlChecker, publicUrlHost, + type ReportCounter, + ReportScheduler, + 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..ef200d8 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-counts.json @@ -0,0 +1,77 @@ +{ + "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**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, + "fields": [ + { + "name": "Sessions", + "value": "12 started · 2 live now", + "inline": true + }, + { + "name": "Tool calls", + "value": "3,412 · 68 errors \\(2%\\) · was 1.2%", + "inline": true + }, + { + "name": "Attention", + "value": "4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out", + "inline": true + }, + { + "name": "Vault fills", + "value": "9 · 1 failed", + "inline": true + }, + { + "name": "Blocked requests", + "value": "27", + "inline": true + }, + { + "name": "Open problems", + "value": "1", + "inline": true + } + ], + "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..a7ab20b --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-full.json @@ -0,0 +1,82 @@ +{ + "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**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, + "fields": [ + { + "name": "Sessions", + "value": "12 started · 2 live now", + "inline": true + }, + { + "name": "Tool calls", + "value": "3,412 · 68 errors \\(2%\\) · was 1.2%", + "inline": true + }, + { + "name": "Attention", + "value": "4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out", + "inline": true + }, + { + "name": "Vault fills", + "value": "9 · 8 ok · 1 origin mismatch", + "inline": true + }, + { + "name": "Blocked requests", + "value": "27 · top `*.doubleclick.net` \\(19\\) · most blocked `ads.example.net` \\(12\\)", + "inline": true + }, + { + "name": "Slowest tool (p95)", + "value": "`navigate` 4.2 s \\(was 2.9 s\\)", + "inline": true + }, + { + "name": "Open problems", + "value": "`RETENTION_FAILED` since 21 Sep 10:13", + "inline": true + } + ], + "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..53ad59c --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-late.json @@ -0,0 +1,82 @@ +{ + "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**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, + "fields": [ + { + "name": "Sessions", + "value": "12 started · 2 live now", + "inline": true + }, + { + "name": "Tool calls", + "value": "3,412 · 68 errors \\(2%\\) · was 1.2%", + "inline": true + }, + { + "name": "Attention", + "value": "4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out", + "inline": true + }, + { + "name": "Vault fills", + "value": "9 · 8 ok · 1 origin mismatch", + "inline": true + }, + { + "name": "Blocked requests", + "value": "27 · top `*.doubleclick.net` \\(19\\)", + "inline": true + }, + { + "name": "Slowest tool (p95)", + "value": "`navigate` 4.2 s \\(was 2.9 s\\)", + "inline": true + }, + { + "name": "Open problems", + "value": "`RETENTION_FAILED` since 21 Sep 10:13", + "inline": true + } + ], + "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..f9c5537 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest-weekly.json @@ -0,0 +1,82 @@ +{ + "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**Tool calls per 6 hours** `▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁` peak 2,060 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, + "fields": [ + { + "name": "Sessions", + "value": "84 started · 2 live now", + "inline": true + }, + { + "name": "Tool calls", + "value": "23,884 · 476 errors \\(2%\\) · was 1.2%", + "inline": true + }, + { + "name": "Attention", + "value": "28 requests · 21 answered \\(median 1m 36s\\) · 7 timed out", + "inline": true + }, + { + "name": "Vault fills", + "value": "63 · 56 ok · 7 origin mismatch", + "inline": true + }, + { + "name": "Blocked requests", + "value": "189 · top `*.doubleclick.net` \\(133\\)", + "inline": true + }, + { + "name": "Slowest tool (p95)", + "value": "`navigate` 4.2 s \\(was 2.9 s\\)", + "inline": true + }, + { + "name": "Open problems", + "value": "`RETENTION_FAILED` since 21 Sep 10:13", + "inline": true + } + ], + "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..bde2899 --- /dev/null +++ b/packages/core/test/goldens/notifications/discord/digest.json @@ -0,0 +1,82 @@ +{ + "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**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, + "fields": [ + { + "name": "Sessions", + "value": "12 started · 2 live now", + "inline": true + }, + { + "name": "Tool calls", + "value": "3,412 · 68 errors \\(2%\\) · was 1.2%", + "inline": true + }, + { + "name": "Attention", + "value": "4 requests · 3 answered \\(median 1m 36s\\) · 1 timed out", + "inline": true + }, + { + "name": "Vault fills", + "value": "9 · 8 ok · 1 origin mismatch", + "inline": true + }, + { + "name": "Blocked requests", + "value": "27 · top `*.doubleclick.net` \\(19\\)", + "inline": true + }, + { + "name": "Slowest tool (p95)", + "value": "`navigate` 4.2 s \\(was 2.9 s\\)", + "inline": true + }, + { + "name": "Open problems", + "value": "`RETENTION_FAILED` since 21 Sep 10:13", + "inline": true + } + ], + "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..5010d52 --- /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": [ + "information_source" + ], + "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..035eac6 --- /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": [ + "information_source" + ], + "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..c280e83 --- /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": [ + "information_source" + ], + "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..9ddf251 --- /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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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": [ + "information_source" + ], + "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..323eaf6 --- /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": [ + "information_source" + ], + "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..048cead --- /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..e6c3e5b --- /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..c3b9a65 --- /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..2f0895b --- /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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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..4a41c8c --- /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..54238e1 --- /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..c19007c --- /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..9f6300d --- /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..f805e9b --- /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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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..ca2066a --- /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..a706a58 --- /dev/null +++ b/packages/core/test/goldens/notifications/webhook/digest-weekly.json @@ -0,0 +1,392 @@ +{ + "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 6 hours", + "values": [ + 10, + 5, + 0, + 0, + 0, + 5, + 20, + 90, + 480, + 1060, + 1525, + 1400, + 950, + 1200, + 1650, + 2060, + 1900, + 1300, + 750, + 600, + 440, + 300, + 200, + 115, + 10, + 5, + 0, + 0 + ], + "start": 1789395200000, + "step_ms": 21600000, + "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..676cf0c 100644 --- a/packages/core/test/helpers/http-kit.ts +++ b/packages/core/test/helpers/http-kit.ts @@ -13,6 +13,8 @@ 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 { 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 +156,24 @@ export async function createHttpKit(options: HttpKitOptions = {}) { }), }); await channelRegistry.load(); + const reports = new ReportScheduler({ + 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, diff --git a/packages/core/test/helpers/http-route-cases.ts b/packages/core/test/helpers/http-route-cases.ts index 772a09f..1f0c31c 100644 --- a/packages/core/test/helpers/http-route-cases.ts +++ b/packages/core/test/helpers/http-route-cases.ts @@ -865,6 +865,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-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..d9cee1c 100644 --- a/packages/core/test/integration/notifications/ntfy-live.test.ts +++ b/packages/core/test/integration/notifications/ntfy-live.test.ts @@ -73,6 +73,31 @@ 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); + 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..68f2280 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, @@ -407,3 +426,99 @@ 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 row is kept out of the inbox. + const row = [...k.repos.notifications.rows.values()].find((r) => r.kind === 'digest.daily'); + expect(row?.dismissedAt).not.toBeNull(); + }); + + 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..227b39e --- /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-reports.test.ts b/packages/core/test/persistence/conformance-reports.test.ts new file mode 100644 index 0000000..20e1ff7 --- /dev/null +++ b/packages/core/test/persistence/conformance-reports.test.ts @@ -0,0 +1,259 @@ +/** @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 { 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: '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 }, + ]); + }); +}); From a7145759bee2b3977a86b8e6db5b8d8a50d71cd0 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:29:10 -0400 Subject: [PATCH 05/28] feat(cli): report schedules on startup channels and in channels list --notificationChannel accepts digest=daily@HH:MM|weekly:@HH:MM, tz (now the channel's zone) and anomaly=on with anomaly.* thresholds. channels list prints each channel's next digest and its anomaly state; channels preview offers the digest and anomaly samples. Composition builds the report scheduler (capacity from the session service, the reports counter) and starts it after the outbox. --- .../browserhive/src/cli/commands/channels.ts | 42 +++++++++++++++++++ packages/browserhive/src/cli/registry.ts | 2 +- .../browserhive/src/composition/context.ts | 3 ++ .../src/composition/phases/build-domain.ts | 6 +++ .../src/composition/phases/domain-ops.ts | 33 +++++++++++++++ .../src/composition/phases/wire-observers.ts | 5 +++ .../cli/__goldens__/help-channels-preview.txt | 3 +- .../test/cli/__goldens__/help-channels.txt | 2 +- .../browserhive/test/cli/channels.test.ts | 27 +++++++++++- 9 files changed, 119 insertions(+), 4 deletions(-) diff --git a/packages/browserhive/src/cli/commands/channels.ts b/packages/browserhive/src/cli/commands/channels.ts index 3352923..a335db0 100644 --- a/packages/browserhive/src/cli/commands/channels.ts +++ b/packages/browserhive/src/cli/commands/channels.ts @@ -54,6 +54,46 @@ 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 ?? 'mon'}` : '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 +147,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..69435ed 100644 --- a/packages/browserhive/src/composition/context.ts +++ b/packages/browserhive/src/composition/context.ts @@ -42,6 +42,7 @@ import type { PreferenceService, PublicUrlChecker, Recorder, + ReportScheduler, RuntimeFacts, SessionService, SystemStatusService, @@ -120,6 +121,8 @@ 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 `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..99ee74a 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,7 @@ async function buildDomain( notificationOutbox: ops.notificationOutbox, channelService: ops.channelService, actionListeners: ops.actionListeners, + reports: ops.reports, 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..4087cae 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,9 @@ import { PreferenceService, PublicUrlChecker, Recorder, + type ReportCounter, + ReportScheduler, + runtimeZone, } from '@browserhive/core/server'; /** Inputs of {@link buildOps}. */ @@ -90,6 +95,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 +117,8 @@ 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 `publicUrl` check (spec 08 §5.8). */ readonly publicUrl: PublicUrlChecker; readonly preferences: PreferenceService; @@ -126,6 +139,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 +176,21 @@ export function buildOps(input: OpsInput): OpsParts { feed?.onDeliveryChange(notificationId, channelId), actions, }); + const hostZone = input.hostZone ?? runtimeZone; + const reports = new ReportScheduler({ + 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 +208,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 +258,7 @@ export function buildOps(input: OpsInput): OpsParts { channelService, actions, actionListeners, + reports, publicUrl, preferences: new PreferenceService({ repo: repos.preferences, clock, logger }), retention: new RetentionScheduler({ diff --git a/packages/browserhive/src/composition/phases/wire-observers.ts b/packages/browserhive/src/composition/phases/wire-observers.ts index 78b2196..4eb794b 100644 --- a/packages/browserhive/src/composition/phases/wire-observers.ts +++ b/packages/browserhive/src/composition/phases/wire-observers.ts @@ -103,6 +103,10 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.notifications.start(); domain.notificationOutbox.start(); domain.actionListeners.start(); + void 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 +131,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..44503fc 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,31 @@ 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, + 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 +95,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, From 9380ba3977498b879df76c4e144dede7336016ce Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:30:27 -0400 Subject: [PATCH 06/28] refactor(contracts): share the report calendar with the dashboard The zone and DST maths of the schedules move to @browserhive/contracts/notifications so the setup wizard shows the same next run as the server; core keeps the zone fallbacks and the dates reports print. --- packages/contracts/src/notifications/index.ts | 13 + .../contracts/src/notifications/schedule.ts | 238 ++++++++++++++++ .../core/src/app/notifications/schedule.ts | 258 ++---------------- .../test/notifications/reports.sqlite.test.ts | 2 +- .../persistence/conformance-reports.test.ts | 3 +- 5 files changed, 272 insertions(+), 242 deletions(-) create mode 100644 packages/contracts/src/notifications/schedule.ts diff --git a/packages/contracts/src/notifications/index.ts b/packages/contracts/src/notifications/index.ts index 24d6215..4ca96c1 100644 --- a/packages/contracts/src/notifications/index.ts +++ b/packages/contracts/src/notifications/index.ts @@ -97,6 +97,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/schedule.ts b/packages/contracts/src/notifications/schedule.ts new file mode 100644 index 0000000..96130ac --- /dev/null +++ b/packages/contracts/src/notifications/schedule.ts @@ -0,0 +1,238 @@ +/** @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, 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; +} + +/** The weekday a rule fires on (weekly), as an index; `null` for a daily rule. */ +function ruleWeekday(rule: DigestRule): number | null { + if (rule.every !== 'week') return null; + return WEEKDAYS.indexOf(rule.day ?? 'mon'); +} + +/** 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 weekday = ruleWeekday(rule); + 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 (weekday === null || weekdayOf(date) === weekday) { + 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); + const weekday = ruleWeekday(rule); + let date = addDays(wallTime(after, zone), -1); + for (let i = 0; i < 16; i++) { + if (weekday === null || weekdayOf(date) === weekday) { + 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); + const weekday = ruleWeekday(rule); + let date = addDays(wallTime(before, zone), 1); + for (let i = 0; i < 16; i++) { + if (weekday === null || weekdayOf(date) === weekday) { + 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' ? `:${rule.day ?? 'mon'}` : ''; + 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/core/src/app/notifications/schedule.ts b/packages/core/src/app/notifications/schedule.ts index 10e2a8c..73dcd4d 100644 --- a/packages/core/src/app/notifications/schedule.ts +++ b/packages/core/src/app/notifications/schedule.ts @@ -1,242 +1,20 @@ -/** @module app/notifications/schedule — pure calendar maths of the scheduled reports (D-43, spec 03 §9.7): 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, the hourly anomaly slots, and the dates reports print. */ - -import type { DigestRule, Weekday } from '@browserhive/contracts/notifications'; -import { WEEKDAYS } from '@browserhive/contracts/notifications'; - -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; -} - -/** The weekday a rule fires on (weekly), as an index; `null` for a daily rule. */ -function ruleWeekday(rule: DigestRule): number | null { - if (rule.every !== 'week') return null; - return WEEKDAYS.indexOf(rule.day ?? 'mon'); -} - -/** 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 weekday = ruleWeekday(rule); - 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 (weekday === null || weekdayOf(date) === weekday) { - 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); - const weekday = ruleWeekday(rule); - let date = addDays(wallTime(after, zone), -1); - for (let i = 0; i < 16; i++) { - if (weekday === null || weekdayOf(date) === weekday) { - 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); - const weekday = ruleWeekday(rule); - let date = addDays(wallTime(before, zone), 1); - for (let i = 0; i < 16; i++) { - if (weekday === null || weekdayOf(date) === weekday) { - 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' ? `:${rule.day ?? 'mon'}` : ''; - 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; -} +/** @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 { @@ -320,7 +98,7 @@ export function runtimeZone(): string { export function usableZone(zone: string | undefined, fallback: string): string { if (zone === undefined) return fallback; try { - formatter(zone); + new Intl.DateTimeFormat('en-US', { timeZone: zone }); return zone; } catch { return fallback; diff --git a/packages/core/test/notifications/reports.sqlite.test.ts b/packages/core/test/notifications/reports.sqlite.test.ts index 227b39e..115990e 100644 --- a/packages/core/test/notifications/reports.sqlite.test.ts +++ b/packages/core/test/notifications/reports.sqlite.test.ts @@ -106,7 +106,7 @@ async function episode(w: Awaited>): Promise 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 ?? '']); + return [...log].reverse().map((d) => [d.op, String(d.revision), d.status, d.reason ?? '']); } const calls = (requests: readonly RecordedRequest[]) => diff --git a/packages/core/test/persistence/conformance-reports.test.ts b/packages/core/test/persistence/conformance-reports.test.ts index 20e1ff7..227cfd0 100644 --- a/packages/core/test/persistence/conformance-reports.test.ts +++ b/packages/core/test/persistence/conformance-reports.test.ts @@ -2,6 +2,7 @@ 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'; @@ -75,7 +76,7 @@ async function seedVault(repo: VaultAuditRepository): Promise { } } -const EXPECTED_VAULT = [ +const EXPECTED_VAULT: { result: VaultAccessResult; count: number }[] = [ { result: 'success', count: 2 }, { result: 'origin_mismatch', count: 1 }, ]; From b5db65ed5d5f6c0320df9a6b22c049a34a96b3e4 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:58:35 -0400 Subject: [PATCH 07/28] fix(notifications): digests read as digests on every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 📊 mark on Telegram and Discord and the bar_chart tag on ntfy for digests; the real digest of Send a digest now carries no sample note. --- .../core/src/app/notifications/channel-service.ts | 14 ++++++++++++-- packages/core/src/infra/notifications/ntfy.ts | 7 +++++-- .../core/src/infra/notifications/render-common.ts | 7 +++++-- .../notifications/discord/digest-counts.json | 2 +- .../goldens/notifications/discord/digest-full.json | 2 +- .../goldens/notifications/discord/digest-late.json | 2 +- .../notifications/discord/digest-weekly.json | 2 +- .../test/goldens/notifications/discord/digest.json | 2 +- .../goldens/notifications/ntfy/digest-counts.json | 2 +- .../goldens/notifications/ntfy/digest-full.json | 2 +- .../goldens/notifications/ntfy/digest-late.json | 2 +- .../goldens/notifications/ntfy/digest-weekly.json | 2 +- .../test/goldens/notifications/ntfy/digest.json | 2 +- .../telegram-classic/digest-counts.json | 2 +- .../telegram-classic/digest-full.json | 2 +- .../telegram-classic/digest-late.json | 2 +- .../telegram-classic/digest-weekly.json | 2 +- .../notifications/telegram-classic/digest.json | 2 +- .../notifications/telegram/digest-counts.json | 2 +- .../notifications/telegram/digest-full.json | 2 +- .../notifications/telegram/digest-late.json | 2 +- .../notifications/telegram/digest-weekly.json | 2 +- .../goldens/notifications/telegram/digest.json | 2 +- .../integration/notifications/ntfy-live.test.ts | 1 + 24 files changed, 43 insertions(+), 26 deletions(-) diff --git a/packages/core/src/app/notifications/channel-service.ts b/packages/core/src/app/notifications/channel-service.ts index d053e2f..63ced9c 100644 --- a/packages/core/src/app/notifications/channel-service.ts +++ b/packages/core/src/app/notifications/channel-service.ts @@ -666,6 +666,7 @@ export class ChannelService { { 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 }; @@ -865,6 +866,7 @@ export class ChannelService { setup: PreviewSetup, message: NotificationMessage, sample: PreviewSample, + real = false, ): ChannelPreview { const { kind, mode, target, rules, secretRefs } = setup; const renderer = this.deps.renderers.get(kind); @@ -902,7 +904,14 @@ export class ChannelService { message: shown, requests, local_links: this.deps.links.local, - notes: this.notes(parsedKind.data, rules, capabilities, message.category, sample, target), + notes: this.notes( + parsedKind.data, + rules, + capabilities, + message.category, + real ? null : sample, + target, + ), }; } @@ -911,7 +920,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[] = []; diff --git a/packages/core/src/infra/notifications/ntfy.ts b/packages/core/src/infra/notifications/ntfy.ts index 2f403c0..f71d1b4 100644 --- a/packages/core/src/infra/notifications/ntfy.ts +++ b/packages/core/src/infra/notifications/ntfy.ts @@ -87,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': 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/test/goldens/notifications/discord/digest-counts.json b/packages/core/test/goldens/notifications/discord/digest-counts.json index ef200d8..a971d54 100644 --- a/packages/core/test/goldens/notifications/discord/digest-counts.json +++ b/packages/core/test/goldens/notifications/discord/digest-counts.json @@ -27,7 +27,7 @@ ], "embeds": [ { - "title": "ℹ️ Daily digest · Mon 21 Sep", + "title": "📊 Daily digest · Mon 21 Sep", "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\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, diff --git a/packages/core/test/goldens/notifications/discord/digest-full.json b/packages/core/test/goldens/notifications/discord/digest-full.json index a7ab20b..5a4ae08 100644 --- a/packages/core/test/goldens/notifications/discord/digest-full.json +++ b/packages/core/test/goldens/notifications/discord/digest-full.json @@ -27,7 +27,7 @@ ], "embeds": [ { - "title": "ℹ️ Daily digest · Mon 21 Sep", + "title": "📊 Daily digest · Mon 21 Sep", "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\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, diff --git a/packages/core/test/goldens/notifications/discord/digest-late.json b/packages/core/test/goldens/notifications/discord/digest-late.json index 53ad59c..2321f4e 100644 --- a/packages/core/test/goldens/notifications/discord/digest-late.json +++ b/packages/core/test/goldens/notifications/discord/digest-late.json @@ -27,7 +27,7 @@ ], "embeds": [ { - "title": "ℹ️ Daily digest · Mon 21 Sep", + "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**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, diff --git a/packages/core/test/goldens/notifications/discord/digest-weekly.json b/packages/core/test/goldens/notifications/discord/digest-weekly.json index f9c5537..e57bece 100644 --- a/packages/core/test/goldens/notifications/discord/digest-weekly.json +++ b/packages/core/test/goldens/notifications/discord/digest-weekly.json @@ -27,7 +27,7 @@ ], "embeds": [ { - "title": "ℹ️ Weekly digest · 14–21 Sep", + "title": "📊 Weekly digest · 14–21 Sep", "description": "84 sessions \\(2 live\\) · 23,884 tool calls · 476 errors \\(2%\\)\n\n**Tool calls per 6 hours** `▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁` peak 2,060 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, diff --git a/packages/core/test/goldens/notifications/discord/digest.json b/packages/core/test/goldens/notifications/discord/digest.json index bde2899..0f38369 100644 --- a/packages/core/test/goldens/notifications/discord/digest.json +++ b/packages/core/test/goldens/notifications/discord/digest.json @@ -27,7 +27,7 @@ ], "embeds": [ { - "title": "ℹ️ Daily digest · Mon 21 Sep", + "title": "📊 Daily digest · Mon 21 Sep", "description": "12 sessions \\(2 live\\) · 3,412 tool calls · 68 errors \\(2%\\)\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, diff --git a/packages/core/test/goldens/notifications/ntfy/digest-counts.json b/packages/core/test/goldens/notifications/ntfy/digest-counts.json index 5010d52..872c99b 100644 --- a/packages/core/test/goldens/notifications/ntfy/digest-counts.json +++ b/packages/core/test/goldens/notifications/ntfy/digest-counts.json @@ -13,7 +13,7 @@ "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": [ - "information_source" + "bar_chart" ], "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", "actions": [ diff --git a/packages/core/test/goldens/notifications/ntfy/digest-full.json b/packages/core/test/goldens/notifications/ntfy/digest-full.json index 035eac6..32b8144 100644 --- a/packages/core/test/goldens/notifications/ntfy/digest-full.json +++ b/packages/core/test/goldens/notifications/ntfy/digest-full.json @@ -13,7 +13,7 @@ "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": [ - "information_source" + "bar_chart" ], "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", "actions": [ diff --git a/packages/core/test/goldens/notifications/ntfy/digest-late.json b/packages/core/test/goldens/notifications/ntfy/digest-late.json index c280e83..d1079a1 100644 --- a/packages/core/test/goldens/notifications/ntfy/digest-late.json +++ b/packages/core/test/goldens/notifications/ntfy/digest-late.json @@ -13,7 +13,7 @@ "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": [ - "information_source" + "bar_chart" ], "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", "actions": [ diff --git a/packages/core/test/goldens/notifications/ntfy/digest-weekly.json b/packages/core/test/goldens/notifications/ntfy/digest-weekly.json index 9ddf251..c6925b0 100644 --- a/packages/core/test/goldens/notifications/ntfy/digest-weekly.json +++ b/packages/core/test/goldens/notifications/ntfy/digest-weekly.json @@ -13,7 +13,7 @@ "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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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": [ - "information_source" + "bar_chart" ], "click": "https://bh.example.net/overview?since=1789395200000&until=1790000000000", "actions": [ diff --git a/packages/core/test/goldens/notifications/ntfy/digest.json b/packages/core/test/goldens/notifications/ntfy/digest.json index 323eaf6..c42b0d1 100644 --- a/packages/core/test/goldens/notifications/ntfy/digest.json +++ b/packages/core/test/goldens/notifications/ntfy/digest.json @@ -13,7 +13,7 @@ "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": [ - "information_source" + "bar_chart" ], "click": "https://bh.example.net/overview?since=1789913600000&until=1790000000000", "actions": [ diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json b/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json index 048cead..34dc60f 100644 --- a/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-counts.json @@ -22,7 +22,7 @@ ] ] }, - "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", + "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 } diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-full.json b/packages/core/test/goldens/notifications/telegram-classic/digest-full.json index e6c3e5b..ade32d6 100644 --- a/packages/core/test/goldens/notifications/telegram-classic/digest-full.json +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-full.json @@ -22,7 +22,7 @@ ] ] }, - "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", + "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 } diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-late.json b/packages/core/test/goldens/notifications/telegram-classic/digest-late.json index c3b9a65..ad7c74b 100644 --- a/packages/core/test/goldens/notifications/telegram-classic/digest-late.json +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-late.json @@ -22,7 +22,7 @@ ] ] }, - "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", + "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 } diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json b/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json index 2f0895b..adab2ec 100644 --- a/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json +++ b/packages/core/test/goldens/notifications/telegram-classic/digest-weekly.json @@ -22,7 +22,7 @@ ] ] }, - "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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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", + "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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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 } diff --git a/packages/core/test/goldens/notifications/telegram-classic/digest.json b/packages/core/test/goldens/notifications/telegram-classic/digest.json index 4a41c8c..f4410ce 100644 --- a/packages/core/test/goldens/notifications/telegram-classic/digest.json +++ b/packages/core/test/goldens/notifications/telegram-classic/digest.json @@ -22,7 +22,7 @@ ] ] }, - "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", + "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 } diff --git a/packages/core/test/goldens/notifications/telegram/digest-counts.json b/packages/core/test/goldens/notifications/telegram/digest-counts.json index 54238e1..8773502 100644 --- a/packages/core/test/goldens/notifications/telegram/digest-counts.json +++ b/packages/core/test/goldens/notifications/telegram/digest-counts.json @@ -10,7 +10,7 @@ "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
", + "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, diff --git a/packages/core/test/goldens/notifications/telegram/digest-full.json b/packages/core/test/goldens/notifications/telegram/digest-full.json index c19007c..cd85e05 100644 --- a/packages/core/test/goldens/notifications/telegram/digest-full.json +++ b/packages/core/test/goldens/notifications/telegram/digest-full.json @@ -10,7 +10,7 @@ "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
", + "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, diff --git a/packages/core/test/goldens/notifications/telegram/digest-late.json b/packages/core/test/goldens/notifications/telegram/digest-late.json index 9f6300d..18eb04a 100644 --- a/packages/core/test/goldens/notifications/telegram/digest-late.json +++ b/packages/core/test/goldens/notifications/telegram/digest-late.json @@ -10,7 +10,7 @@ "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
", + "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, diff --git a/packages/core/test/goldens/notifications/telegram/digest-weekly.json b/packages/core/test/goldens/notifications/telegram/digest-weekly.json index f805e9b..079e6c6 100644 --- a/packages/core/test/goldens/notifications/telegram/digest-weekly.json +++ b/packages/core/test/goldens/notifications/telegram/digest-weekly.json @@ -10,7 +10,7 @@ "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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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
", + "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 6 hours ▁▁▁▁▁▁▁▁▃▅▆▆▄▅▇█▇▅▄▃▂▂▂▁▁▁▁▁ peak 2,060 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, diff --git a/packages/core/test/goldens/notifications/telegram/digest.json b/packages/core/test/goldens/notifications/telegram/digest.json index ca2066a..6ed4095 100644 --- a/packages/core/test/goldens/notifications/telegram/digest.json +++ b/packages/core/test/goldens/notifications/telegram/digest.json @@ -10,7 +10,7 @@ "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
", + "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, diff --git a/packages/core/test/integration/notifications/ntfy-live.test.ts b/packages/core/test/integration/notifications/ntfy-live.test.ts index d9cee1c..8f00e61 100644 --- a/packages/core/test/integration/notifications/ntfy-live.test.ts +++ b/packages/core/test/integration/notifications/ntfy-live.test.ts @@ -87,6 +87,7 @@ describe.skipIf(SERVER === undefined)('ntfy adapter against a real server', () = 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'); From edc2fae2d2643ba09f0c520ef89172d4fcaa85e2 Mon Sep 17 00:00:00 2001 From: Amir Ghorbani Date: Tue, 29 Sep 2026 01:58:35 -0400 Subject: [PATCH 08/28] feat(dashboard): digest and anomaly settings, next run on the cards, late digests in the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules step gains a Reports section: Off · Every day · Every week with a 24-hour time and a weekday, the next run in the channel's zone, a searchable time zone picker defaulting to BrowserHive's zone (also used by quiet hours), and the anomaly switch with its checks; Advanced holds the thresholds. The Daily digest preset switches both on. Cards show the next digest and the anomaly state with Send now, which opens a preview of the real digest and sends it on demand; the channel page has the same action. The delivery log shows a report's window, a late pill and an on-demand pill. Allow this person is disabled with the reason for a principal without channels:write. --- .../src/app/providers/AuthProvider.tsx | 9 + .../notifications/channels/ChannelCard.tsx | 94 +++- .../channels/ChannelsPage.test.tsx | 63 ++- .../notifications/channels/ChannelsPage.tsx | 15 + .../channels/DigestNowDialog.tsx | 139 ++++++ .../channels/actions/ActionsPage.test.tsx | 18 +- .../channels/actions/ActionsPage.tsx | 49 +- .../features/notifications/channels/api.ts | 18 + .../notifications/channels/coverage.test.ts | 1 + .../channels/log/DeliveryDetailSheet.tsx | 23 + .../channels/log/DeliveryLogPage.test.tsx | 85 ++++ .../channels/log/DeliveryLogPage.tsx | 38 ++ .../notifications/channels/model.test.ts | 52 +++ .../features/notifications/channels/model.ts | 100 ++++- .../channels/preview/TelegramMock.tsx | 10 +- .../channels/wizard/ChannelWizardPage.tsx | 132 +++--- .../channels/wizard/ReportsSection.test.tsx | 112 +++++ .../channels/wizard/ReportsSection.tsx | 422 ++++++++++++++++++ .../channels/wizard/StepRules.tsx | 87 ++-- .../channels/wizard/TimeZonePicker.tsx | 104 +++++ packages/dashboard/src/lib/api/operations.ts | 7 + packages/dashboard/src/lib/icons.ts | 5 + packages/dashboard/test/fixtures/channels.ts | 33 ++ .../dashboard/test/helpers/page-harness.tsx | 30 +- 24 files changed, 1522 insertions(+), 124 deletions(-) create mode 100644 packages/dashboard/src/features/notifications/channels/DigestNowDialog.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/log/DeliveryLogPage.test.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.test.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.tsx create mode 100644 packages/dashboard/src/features/notifications/channels/wizard/TimeZonePicker.tsx 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/features/notifications/channels/ChannelCard.tsx b/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx index 6eac7a3..1f1cbdd 100644 --- a/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx +++ b/packages/dashboard/src/features/notifications/channels/ChannelCard.tsx @@ -1,4 +1,4 @@ -/** @module features/notifications/channels/ChannelCard — one channel as a whole-card link: platform mark, name, status, where it sends, what it sends, whether answers from the chat reach BrowserHive (the press listener's state, D-41), secret variables (set / missing, never values), last delivery and 24 h counts; Send test (result inline), Pause/Resume, and Edit / Duplicate / Delete in a menu (not for startup channels, which are read-only; D-39) */ +/** @module features/notifications/channels/ChannelCard — one channel as a whole-card link: platform mark, name, status, where it sends, what it sends, whether answers from the chat reach BrowserHive (the press listener's state, D-41), the next digest and the anomaly alerts with "Send now" (D-43, D-44), secret variables (set / missing, never values), last delivery and 24 h counts; Send test (result inline), Pause/Resume, and Edit / Duplicate / Delete in a menu (not for startup channels, which are read-only; D-39) */ import type { ChannelTestResponse, ChannelView } from '@browserhive/contracts/http'; import { deliveryReasonText } from '@browserhive/contracts/notifications'; import { isPlainClick, useHrefNavigate } from '@/components/shared/DataTableBody.tsx'; @@ -18,7 +18,7 @@ import { formatMs } from '@/lib/format/time.ts'; import { ICONS } from '@/lib/icons.ts'; import { CHANNEL_STATUS, DELIVERY_STATUS, LISTENER_STATE } from '@/lib/status-registry.ts'; import { cn } from '@/lib/utils.ts'; -import { channelWhere, rulesSummary } from './model.ts'; +import { browserZone, channelWhere, formatInZone, rulesSummary, zoneLabel } from './model.ts'; import { PlatformMark, platformOf } from './platforms.tsx'; /** The outcome of the last test send of a card. */ @@ -40,9 +40,94 @@ export interface ChannelCardProps { readonly onDuplicate: () => 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 +237,7 @@ export function ChannelCard({ onDuplicate, onDelete, onEdit, + onDigestNow, busy = false, }: ChannelCardProps) { const go = useHrefNavigate(); @@ -292,6 +378,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, + 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..534069e --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/DigestNowDialog.tsx @@ -0,0 +1,139 @@ +/** @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} + ) : 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..5a135d7 100644 --- a/packages/dashboard/src/features/notifications/channels/model.ts +++ b/packages/dashboard/src/features/notifications/channels/model.ts @@ -8,10 +8,14 @@ import { type ChannelConfigProblem, checkChannelConfig, checkChannelRules, + DEFAULT_DIGEST_AT, + type DigestRule, 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 +103,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 +114,85 @@ 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", "Mondays at 08:30"). */ +export function digestText(rule: DigestRule): string { + return rule.every === 'week' + ? `${WEEKDAY_LABEL[rule.day ?? 'mon']}s 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/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..03291ae --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.test.tsx @@ -0,0 +1,112 @@ +/** @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 with a weekday, and off again', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Every week' })); + expect(rules().digest).toEqual({ every: 'week', at: '08:00', day: 'mon' }); + expect(screen.getByText('Mon 5 Oct, 08:00')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Off' })); + expect(rules().digest).toBeUndefined(); + }); + + 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..097c63a --- /dev/null +++ b/packages/dashboard/src/features/notifications/channels/wizard/ReportsSection.tsx @@ -0,0 +1,422 @@ +/** @module features/notifications/channels/wizard/ReportsSection — "Reports" in the rules step (D-43, D-44): the digest (Off · Every day · Every week, a 24-hour time, a weekday) with the next run in the channel's zone, the channel's time zone (default the host's, which also applies to quiet hours), and "Tell me when something looks off" with its checks in plain words; the thresholds live in Advanced ({@link AnomalyThresholds}) */ +import { + ANOMALY_CHECK_TEXT, + ANOMALY_CHECKS, + ANOMALY_DEFAULTS, + type AnomalyRule, + DEFAULT_DIGEST_AT, + type NotificationChannelRules, + WEEKDAYS, + type Weekday, +} from '@browserhive/contracts/notifications'; +import { useId } from 'react'; +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 { 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; +} + +/** The digest, the zone and the anomaly switch. */ +export function ReportsSection({ + rules, + onRules, + hostZone, + readOnly, + errors, + now = Date.now(), +}: ReportsSectionProps) { + 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); + }; + const setFrequency = (f: Frequency) => { + if (f === 'off') return set({ digest: undefined }); + const at = digest?.at ?? DEFAULT_DIGEST_AT; + set({ + digest: + f === 'week' ? { every: 'week', at, day: digest?.day ?? 'mon' } : { every: 'day', at }, + }); + }; + const next = digest === undefined ? null : nextDigestAt(digest, zone, now); + + return ( +
+
+ +
+

+ Reports +

+

+ 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. +

+
+
+ +
+
+ + 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 } }); + } + }} + /> + + {next !== null ? ( +

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

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

+ {digest.every === 'week' ? 'The last seven days' : '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' : ''}.