diff --git a/.changeset/notification-contract-outbox.md b/.changeset/notification-contract-outbox.md new file mode 100644 index 0000000..bc1683e --- /dev/null +++ b/.changeset/notification-contract-outbox.md @@ -0,0 +1,11 @@ +--- +"browserhive": minor +--- + +Notifications now follow what they announce, and the groundwork for sending them to your phone is in place. + +- **A notification keeps its place as things change.** When you resolve an attention request or a vault confirmation, reject it, or it times out, its notification shows the outcome (**resolved**, **expired**) as a small pill in the bell and on the Notifications page, instead of staying as if it were still waiting. A toast still on screen for it closes. A recovered subsystem marks its degradation notification resolved the same way. +- **Richer notification data in the API.** `GET /api/v1/notifications` and the `notifications` WebSocket topic add `kind`, `category`, `severity`, `state`, `revision` and `thread` to every notification. Existing fields are unchanged. +- **Safer text.** An agent's attention reason and other text copied into a notification now go through the same redaction as the logs, and page addresses lose their query strings. +- **Foundations for Telegram, Discord and ntfy.** Every notification is also a versioned message document, whose JSON Schema is published in the reference docs. Delivery to chat apps goes through a new outbox in the database, with retries and a circuit breaker, so nothing is lost when a service is down. No channel can be configured yet; with none configured nothing changes and nothing extra runs. +- The database upgrades on start (schema v5: new columns on notifications and three new tables; a backup is written first). Older releases can still open it. Existing notifications are classified from what they already recorded; nothing is invented for them. diff --git a/docs/README.md b/docs/README.md index b52f390..13dc8ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ BrowserHive is a local MCP server that gives AI agents isolated, stealthy Chromi - [Security model](guide/security.md): authentication, bind rules, the vault model, redaction, what is recorded - [Vault](guide/vault.md): Bitwarden setup, folder policies, bindings, confirmations - [Human takeover](guide/attention.md): `request_attention` and the live view +- [Notifications](guide/notifications.md): what is notified, how a notification changes over its life, and how delivery to chat apps works - [Stealth](guide/stealth.md): what it does, what it does not, ceilings, proxies - [Telemetry](guide/telemetry.md): OpenTelemetry export and a local Grafana stack - [Command line](guide/cli.md): every command and exit code @@ -33,6 +34,7 @@ Generated from the source by `scripts/gen-docs.ts`; always in sync with the code - [REST API](reference/api.md): every admin API endpoint with scope and authentication - [WebSocket protocol](reference/websocket.md): envelope, topics, commands, screencast frames - [Config file JSON Schema](reference/config.schema.json) +- [Notification message JSON Schema](reference/notification-message.schema.json) ## Contributing diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index 447d17b..3bde4cd 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -62,6 +62,7 @@ Playwright and Patchright may be imported only under `infra/browsers/`; Kysely a - **Auth:** provider chain (password session cookie, bearer token, short-lived grant), `Authorizer.can(principal, scope)`, ownership on every tool (D-09). - **Vault:** broker with fixed gate order and one audit row per fill, Bitwarden backend, bindings and folder policies in SQLite (D-14). - **Operator requests:** one broker for attention and vault confirmations with deadlines, lease pause, cancellation and restart recovery (D-15). +- **Notifications:** pure producer rules turn bus events into rows plus a versioned `NotificationMessage` (`@browserhive/contracts/notifications`) with full-state revisions; the in-app inbox is delivered inline, external channels through a transactional outbox (`notification_deliveries`) drained by a worker with retries, coalescing, a circuit breaker that never raises a degradation, and a TTL sweep. Platform adapters implement `NotificationChannel` and receive only the contract after `restrictContent` and `degrade` (D-16, D-32, D-34, [spec 03 §9](../../specs/03-admin-backend.md#9-notifications-d-16-d-32-d-34)). - **Observability:** structured logger with per-module levels and a ring buffer, OpenTelemetry API everywhere with exporters opt-in, redaction by construction (D-08, D-20, [spec 10](../../specs/10-error-handling-and-telemetry.md)). - **Event bus:** typed in-process events (`session.opened`, `tool.called`, `page.visited`, `vault.access`, …). Every mutation publishes through it; the WebSocket layer never synthesizes events. @@ -88,7 +89,7 @@ A failure in phase N unwinds phases N-1…1 in reverse and exits with a typed bo ## Extension points -Seams exist for features that are deliberately not built yet: other browser engines (`BrowserDriver` capabilities), managed proxies (`ProxyResolver`, `LaunchSpec.proxy`), other vault backends (`VaultBackend`), security intercepts and approval gates (`OperatorRequestBroker.kind`, `InterceptionChain`), multi-user auth (`AuthenticationProvider`, `tenant_id`), external notification channels (`NotificationChannel`), resource governance (`AdmissionPolicy`). See [spec 01 §9](../../specs/01-overall-architecture.md#9-extension-points-seams-that-exist-without-their-features). +Seams exist for features that are deliberately not built yet: other browser engines (`BrowserDriver` capabilities), managed proxies (`ProxyResolver`, `LaunchSpec.proxy`), other vault backends (`VaultBackend`), security intercepts and approval gates (`OperatorRequestBroker.kind`, `InterceptionChain`), multi-user auth (`AuthenticationProvider`, `tenant_id`), external notification channels (`NotificationChannel` plus a factory per channel kind in `ChannelRegistry`), resource governance (`AdmissionPolicy`). See [spec 01 §9](../../specs/01-overall-architecture.md#9-extension-points-seams-that-exist-without-their-features). ## Working on the code diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 342bb97..c347692 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -85,4 +85,4 @@ Version, transport, uptime, bind address, sessions live versus the cap, open att ## Notifications -Persisted notifications for attention requests, tool errors, crashed sessions and pending vault confirmations, grouped by day. Mark as read or dismiss, individually or all at once. +Persisted notifications for attention requests, tool errors, crashed sessions and pending vault confirmations, grouped by day. Mark as read or dismiss, individually or all at once. Once an attention request or vault confirmation is settled (in the dashboard, by a timeout, or elsewhere), its row keeps its place and shows the outcome as a small pill (**resolved**, **expired**, or **closed** when the agent stopped waiting), and a toast still on screen for it closes. See [Notifications](notifications.md). diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md new file mode 100644 index 0000000..5b7b0fa --- /dev/null +++ b/docs/guide/notifications.md @@ -0,0 +1,51 @@ +# Notifications + +BrowserHive tells you when something needs you or went wrong: an agent asked for help, a vault fill waits for your approval, a session crashed, tools keep failing, or BrowserHive itself is degraded. Notifications are stored in the database, so they survive reloads and restarts, and they appear in the dashboard's bell, as toasts and on the **Notifications** page. + +This page explains what produces a notification, how one changes over its life, and the delivery machinery that sends notifications to chat apps such as Telegram, Discord and ntfy. That delivery is being built in stages: this release lays the foundations, and the first channels arrive next. + +## What BrowserHive notifies about + +| What happened | Kind | Category | Severity | +|---|---|---|---| +| An agent called `request_attention` ([human takeover](attention.md)) | `attention.requested` | needs you | warn | +| A vault fill waits for your confirmation ([vault](vault.md)) | `vault.confirm` | needs you | warn | +| A session crashed | `session.crashed` | problems | error | +| A session was reaped because its lease expired | `session.reaped` | problems | warn | +| Tools failed (grouped per session: "shop · 12 tool errors") | `tool.errors` | problems | warn | +| A subsystem is degraded (for example the retention sweep failed) | `system.degraded` | system | error | +| A notification channel keeps failing | `channel.broken` | system | error | + +Routine events are deliberately silent: a session opening, a page visit, a clean close. Agents cannot send notifications themselves: every notification comes from something BrowserHive observed. The `request_attention` tool is how an agent reaches you. + +## A notification has a life + +A notification keeps its identity while the thing it announces changes. When you resolve an attention request, reject it, or it times out, the same notification moves to **resolved** or **expired** instead of a second one appearing. The dashboard shows that outcome as a small pill on the row (**resolved**, **expired**, or **closed** when the agent stopped waiting) and closes the toast if it is still on screen. The row keeps its place in the list. A growing group of tool errors updates its count in place. + +Every change is a new **revision** of the notification's message. Each revision is complete, so whoever shows it never has to merge changes. That is also what lets a chat message be edited in place later, silently: only a new notification makes noise. + +Notifications from before this release keep working; they get the new fields, derived from what they already recorded, and nothing is invented for them. + +## The message contract + +Every notification is also a `NotificationMessage`: a small, versioned JSON document with a title and summary, structured blocks (text, facts, lists, tables, images, code), up to five buttons, and what it is about (session, tool, error code). Links in it are dashboard paths. BrowserHive owns this contract; every channel renders it and none reads BrowserHive's internals. The JSON Schema is published at [notification-message.schema.json](../reference/notification-message.schema.json), so you can build your own consumer from the generic webhook channel when it arrives. + +Before a message is stored or sent, BrowserHive removes known secrets and credential-shaped text from every field and strips query strings from URLs, the same redaction the logs get ([security](security.md)). A channel can be set to carry less: only titles and facts, or only counts. + +## How delivery to other apps works + +Delivery is designed so that nothing is silently lost and nothing waits on a slow chat service: + +- **An outbox in the database.** When a notification is created or changes, the jobs that deliver it to each channel are written in the same database transaction. A worker sends them afterwards. If BrowserHive stops mid-way, it picks up at the next start. This guarantees at-least-once delivery: an edit or a delete can safely be repeated, and in the rare case of a crash in the middle of sending a new message, that message can arrive twice. +- **Edits instead of spam.** When a notification changes, its chat message is edited in place, at most once every 3 seconds. Edits never make a sound. +- **Retries.** A failed send is retried with growing pauses, honouring the platform's "retry after". It gives up after 8 attempts or 24 hours. +- **A circuit breaker.** After 5 failures in a row, the channel is marked **broken**, you get an in-app notification about it, and its deliveries pause. A failing channel is never reported as a BrowserHive degradation: that report would be sent through the same failing channel. +- **Catching up.** After an outage only the latest state of each notification is sent, and a pile of routine updates collapses into one message that says how many you missed. +- **A log for every decision.** Each delivery is logged, including the ones a channel's rules filtered out and why, so "why didn't I get it?" always has an answer. The log is kept for 30 days. +- **Messages that clean up after themselves.** A channel can delete its messages after a time you choose per category, or once they are resolved. BrowserHive does the deleting, because no chat platform offers a timer for bot messages. The default is to keep everything. + +Your own accounts, no servers: BrowserHive never runs a relay or a shared bot. You create your own Telegram bot, Discord webhook or ntfy topic, and every connection goes out from your machine. Tokens stay in environment variables; BrowserHive stores only the variable names, so a database backup never contains a token. + +## Retention + +Read or dismissed notifications are kept for 30 days, others for 90. Delivery history is kept for 30 days. Configured channels are never pruned; `browserhive purge` lists them with everything else in the database. diff --git a/docs/guide/security.md b/docs/guide/security.md index 67f104e..25a9af6 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -70,6 +70,7 @@ Failures are returned as a status and reason (`origin_mismatch`, `not_authorized - **Traces do not contain typed credentials.** Playwright tracing is stopped before the first credential keystroke and restarted after submit, and the parts are merged when the session closes. The replay has a gap instead of the login POST. - **Pixels are not redacted.** The live view and screenshots show exactly what the browser shows. Screenshot tracing skips frames while a redaction window is open, but operators are trusted with the live view. - Every secret BrowserHive creates or handles (seed password, tokens, cookies, vault session tokens, OTLP headers) is registered with a redactor that scrubs logs, database rows, WebSocket frames, MCP results and OTLP exports. That includes secrets the config file reads from environment variables (`{env:CI_TOKEN}`, see [References](configuration.md#references)): the variable's name is shown so you know what to set, never its value or the file's text around it. +- **Notifications are redacted before they are stored or sent.** Every text a notification copies from an event (an agent's attention reason, a page address, an error) goes through the same redactor, and URLs lose their query strings. A notification channel keeps its tokens in environment variables; BrowserHive stores only the variable names, so a database backup never contains one. See [Notifications](notifications.md). ## What is recorded diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index f01f205..53d607a 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -29,6 +29,8 @@ browserhive db migrate **Each session records whether it ran sandboxed (schema v4).** From 0.2 on, the database stores each session's sandbox state and browser version when its browser launches, so closed sessions keep showing them. The migration only adds columns: an older release still opens the database. Sessions from before the upgrade show "not recorded"; nothing is guessed for them. +**Notifications gain a message contract (schema v5).** The database adds the notification contract's fields to every notification (kind, category, severity, state, revision, thread) and three tables for delivery to chat apps (channels, the delivery outbox and the sent-message index). Existing notifications are classified from what they already recorded; an attention request's notification reads as resolved or expired when the request was. The migration only adds columns and tables: an older release still opens the database. See [Notifications](notifications.md). + Prereleases are published under the `next` tag: `bun add -g browserhive@next`. ## Downgrade diff --git a/docs/reference/notification-message.schema.json b/docs/reference/notification-message.schema.json new file mode 100644 index 0000000..9fcde17 --- /dev/null +++ b/docs/reference/notification-message.schema.json @@ -0,0 +1,1376 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://browserhive.ai/schemas/notification-message.v1.json", + "title": "BrowserHive notification message", + "description": "NotificationMessage, schema 1. Every revision is the complete state; consumers render the whole message and ignore kinds, blocks, inlines and actions they do not know.", + "type": "object", + "properties": { + "schema": { + "type": "number", + "const": 1 + }, + "id": { + "type": "string", + "pattern": "^n-[A-Za-z0-9_-]{12}$" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "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", + "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, + "maximum": 9007199254740991 + }, + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "summary": { + "type": "string", + "maxLength": 240 + }, + "blocks": { + "maxItems": 50, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "content": { + "maxItems": 64, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bold" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "italic" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "time" + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "type", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "heading" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "fields" + }, + "items": { + "minItems": 1, + "maxItems": 12, + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "value": { + "maxItems": 64, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bold" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "italic" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "time" + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "quote" + }, + "content": { + "maxItems": 64, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bold" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "italic" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "time" + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ], + "additionalProperties": false + } + ] + } + }, + "collapsible": { + "type": "boolean" + } + }, + "required": [ + "type", + "content", + "collapsible" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "list" + }, + "ordered": { + "type": "boolean" + }, + "items": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "maxItems": 64, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bold" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "italic" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "time" + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ], + "additionalProperties": false + } + ] + } + } + } + }, + "required": [ + "type", + "ordered", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "table" + }, + "columns": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + } + }, + "rows": { + "maxItems": 20, + "type": "array", + "items": { + "type": "array", + "items": { + "maxItems": 64, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bold" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "italic" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "time" + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ], + "additionalProperties": false + } + ] + } + } + } + } + }, + "required": [ + "type", + "columns", + "rows" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "alt": { + "type": "string", + "maxLength": 4000 + }, + "captured_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "masked": { + "type": "boolean" + }, + "path": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "ref", + "alt", + "captured_at", + "masked", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "language": { + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "text", + "language" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "divider" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "footer" + }, + "content": { + "maxItems": 64, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bold" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "italic" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "code" + }, + "text": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "type", + "text", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "time" + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "style": { + "type": "string", + "enum": [ + "relative", + "absolute" + ] + } + }, + "required": [ + "type", + "at", + "style" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "type", + "content" + ], + "additionalProperties": false + } + ] + } + }, + "actions": { + "maxItems": 5, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "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", + "propertyNames": { + "type": "string", + "maxLength": 64 + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "required": [ + "op", + "args" + ], + "additionalProperties": false + }, + "confirm": { + "anyOf": [ + { + "type": "string", + "maxLength": 240 + }, + { + "type": "null" + } + ] + }, + "fallback": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "path": { + "type": "string", + "maxLength": 2048, + "pattern": "^\\/(?!\\/)\\S*$" + } + }, + "required": [ + "label", + "path" + ], + "additionalProperties": false + } + }, + "required": [ + "kind", + "id", + "label", + "style", + "command", + "confirm", + "fallback" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "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" + ], + "additionalProperties": false + } + ] + } + }, + "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 + } + }, + "additionalProperties": false + }, + "privacy": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "counts", + "titles", + "full" + ] + }, + "has_image": { + "type": "boolean" + } + }, + "required": [ + "level", + "has_image" + ], + "additionalProperties": false + } + }, + "required": [ + "schema", + "id", + "revision", + "thread", + "kind", + "category", + "severity", + "state", + "alert", + "at", + "title", + "summary", + "blocks", + "actions", + "entities", + "privacy" + ], + "additionalProperties": false +} diff --git a/docs/reference/websocket.md b/docs/reference/websocket.md index 7cda009..6a376dd 100644 --- a/docs/reference/websocket.md +++ b/docs/reference/websocket.md @@ -326,14 +326,14 @@ Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`sessio | Field | Type | Required | Constraints | |---|---|---|---| -| `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at` | +| `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at`, `kind`, `category`, `severity`, `state`, `revision`, `thread` | ### `notification.updated` | Field | Type | Required | Constraints | |---|---|---|---| -| `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at` | +| `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at`, `kind`, `category`, `severity`, `state`, `revision`, `thread` | ### `log.record` diff --git a/packages/browserhive/src/cli/commands/purge.ts b/packages/browserhive/src/cli/commands/purge.ts index 789cb16..169d37c 100644 --- a/packages/browserhive/src/cli/commands/purge.ts +++ b/packages/browserhive/src/cli/commands/purge.ts @@ -30,7 +30,7 @@ export const ALL_ONLY_TARGETS: readonly PurgeTarget[] = [ const LABELS: Readonly> = { database: - 'Database (browserhive.db + WAL): events, sessions, vault bindings and policies, tokens', + 'Database (browserhive.db + WAL): events, sessions, vault bindings and policies, tokens, notification channels', sessions: 'Session directories (traces, screenshots, downloads, browser profiles)', 'auth-states': 'Saved auth states (logged-in profiles + storage snapshots)', uploads: 'Upload sandbox', diff --git a/packages/browserhive/src/composition/context.ts b/packages/browserhive/src/composition/context.ts index 342c693..f0f06ea 100644 --- a/packages/browserhive/src/composition/context.ts +++ b/packages/browserhive/src/composition/context.ts @@ -31,7 +31,9 @@ import type { AttentionService, AuthStateStore, BlocklistService, + ChannelRegistry, LeaseSweeper, + NotificationOutbox, NotificationService, OperatorRequestBroker, PageActions, @@ -107,6 +109,10 @@ export interface DomainPart { readonly adminAuthenticator: Authenticator; readonly mcpAuthenticator: Authenticator; readonly notifications: NotificationService; + /** Configured external notification channels (D-39). */ + readonly channels: ChannelRegistry; + /** The notification delivery outbox worker (D-34). */ + readonly notificationOutbox: NotificationOutbox; readonly preferences: PreferenceService; readonly recorder: Recorder; readonly retention: RetentionScheduler; diff --git a/packages/browserhive/src/composition/phases/build-domain.ts b/packages/browserhive/src/composition/phases/build-domain.ts index a8a5bd5..a3d31ed 100644 --- a/packages/browserhive/src/composition/phases/build-domain.ts +++ b/packages/browserhive/src/composition/phases/build-domain.ts @@ -38,7 +38,7 @@ async function buildDomain( undo: (() => void | Promise)[], ): Promise { const { config, clock, transport, relay } = ctx; - const { logger, secrets, redactor } = part(ctx.observability, 'observability'); + const { logger, secrets, redactor, telemetry } = part(ctx.observability, 'observability'); const storage = part(ctx.storage, 'storage'); const repos = storage.uow.repos; const ids = createNanoidIdGenerator({ clock }); @@ -124,8 +124,20 @@ async function buildDomain( logger, redactor, degradations, + uow: storage.uow, + env: ctx.input.env, + registerSecret: (literal) => secrets.add(literal), + dashboardUrl: () => ctx.listeners?.url ?? `http://${config.host}:${config.port}`, + deliveryCounter: telemetry.instruments.notificationDeliveries, }); + // Startup channels (--notificationChannel, D-39) arrive with the first platform adapters. + await ops.channels.load(); await reconcile({ repos, clock, logger, degradations, broker: operators.broker }); + // Requests settled while nothing listened (the last shutdown, the orphan recovery above) revise + // their notifications now; the producers subscribe later, in wire-observers. + await ops.notifications + .reconcileRequests(repos.operatorRequests) + .catch((err: unknown) => logger.warn('catch-up failed', { err: serializeError(err) })); const tools = buildTools({ config, @@ -179,6 +191,8 @@ async function buildDomain( adminAuthenticator: auth.admin, mcpAuthenticator: auth.mcp, notifications: ops.notifications, + channels: ops.channels, + notificationOutbox: ops.notificationOutbox, preferences: ops.preferences, recorder: ops.recorder, retention: ops.retention, diff --git a/packages/browserhive/src/composition/phases/domain-ops.ts b/packages/browserhive/src/composition/phases/domain-ops.ts index 0556786..f588600 100644 --- a/packages/browserhive/src/composition/phases/domain-ops.ts +++ b/packages/browserhive/src/composition/phases/domain-ops.ts @@ -1,4 +1,4 @@ -/** @module composition/phases/domain-ops — recorder, notifications, preferences, retention/outbox/backup schedulers and the startup reconcile pass (spec 03 §7–9, spec 10 §3). */ +/** @module composition/phases/domain-ops — recorder, notifications (with the channel registry and the delivery outbox), preferences, retention/outbox/backup schedulers and the startup reconcile pass (spec 03 §7–9, spec 10 §3). */ import type { ServerConfig } from '@browserhive/contracts/config'; import type { DatabaseHandle, SqliteMaintenanceService } from '@browserhive/core/persistence'; @@ -12,6 +12,7 @@ import type { Logger, Redactor, Repositories, + UnitOfWork, WriteQueue, } from '@browserhive/core/runtime'; import { @@ -23,7 +24,16 @@ import { retentionPolicyFromConfig, } from '@browserhive/core/runtime'; import type { OperatorRequestBroker } from '@browserhive/core/server'; -import { NotificationService, PreferenceService, Recorder } from '@browserhive/core/server'; +import { + type ChannelAdapterFactory, + ChannelRegistry, + createLocalLinkBuilder, + type DeliveryCounter, + NotificationOutbox, + NotificationService, + PreferenceService, + Recorder, +} from '@browserhive/core/server'; /** Inputs of {@link buildOps}. */ export interface OpsInput { @@ -39,12 +49,28 @@ export interface OpsInput { readonly logger: Logger; readonly redactor: Redactor; readonly degradations: DegradationService; + /** Transaction boundary for a notification and its outbox rows (D-34). */ + readonly uow: UnitOfWork; + /** The process environment: channel secrets are read by variable name (D-33). */ + readonly env: Readonly>; + /** Registers a resolved channel secret with the redactor. */ + readonly registerSecret: (value: string) => void; + /** Base URL of the local dashboard for notification links until `publicUrl` (D-37). */ + readonly dashboardUrl: () => string; + /** `browserhive.notifications.deliveries` (a no-op without telemetry). */ + readonly deliveryCounter?: DeliveryCounter; + /** Platform adapter factories by channel kind; none ship yet. */ + readonly channelFactories?: ReadonlyMap; } /** Built operations services (not started; `wire-observers` starts them). */ export interface OpsParts { readonly recorder: Recorder; readonly notifications: NotificationService; + /** Configured external channels (loaded by `build-domain`). */ + readonly channels: ChannelRegistry; + /** The delivery outbox worker (started by `wire-observers`). */ + readonly notificationOutbox: NotificationOutbox; readonly preferences: PreferenceService; readonly retention: RetentionScheduler; readonly outbox: ArtifactOutboxSweeper; @@ -55,6 +81,27 @@ export interface OpsParts { export function buildOps(input: OpsInput): OpsParts { const { config, repos, bus, clock, ids, logger, degradations } = input; const fs = createNodeFileSystem(); + const channels = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + env: (name) => input.env[name], + registerSecret: input.registerSecret, + ...(input.channelFactories !== undefined && { factories: input.channelFactories }), + }); + const notificationOutbox = new NotificationOutbox({ + uow: input.uow, + repos, + registry: channels, + links: createLocalLinkBuilder(input.dashboardUrl), + clock, + logger, + bus, + redactor: input.redactor, + jitter: Math.random, + ...(input.deliveryCounter !== undefined && { counter: input.deliveryCounter }), + }); return { recorder: new Recorder({ bus, @@ -66,7 +113,18 @@ export function buildOps(input: OpsInput): OpsParts { urlQueryAllowlist: config.urlQueryAllowlist, }, }), - notifications: new NotificationService({ repo: repos.notifications, bus, clock, ids, logger }), + notifications: new NotificationService({ + repo: repos.notifications, + bus, + clock, + ids, + logger, + uow: input.uow, + outbox: notificationOutbox, + redactor: input.redactor, + }), + channels, + notificationOutbox, preferences: new PreferenceService({ repo: repos.preferences, clock, logger }), retention: new RetentionScheduler({ maintenance: input.maintenance, diff --git a/packages/browserhive/src/composition/phases/wire-observers.ts b/packages/browserhive/src/composition/phases/wire-observers.ts index b1b51a9..8422a09 100644 --- a/packages/browserhive/src/composition/phases/wire-observers.ts +++ b/packages/browserhive/src/composition/phases/wire-observers.ts @@ -101,6 +101,7 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.recorder.start(); domain.notifications.start(); + domain.notificationOutbox.start(); status.start(); domain.retention.start(); domain.outbox.start(); @@ -125,6 +126,7 @@ export async function wireObserversPhase(ctx: BootContext): Promise domain.outbox.stop(); domain.retention.stop(); status.stop(); + domain.notificationOutbox.stop(); domain.notifications.stop(); }, }; diff --git a/packages/contracts/generated/openapi.json b/packages/contracts/generated/openapi.json index 376d5bb..e660020 100644 --- a/packages/contracts/generated/openapi.json +++ b/packages/contracts/generated/openapi.json @@ -8751,6 +8751,59 @@ "null" ], "minimum": 0 + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "thread": { + "type": "string" } }, "required": [ @@ -8767,7 +8820,13 @@ "updated_at", "count", "read_at", - "dismissed_at" + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ] } }, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e786aa0..a81c7d1 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -14,7 +14,8 @@ "./ids": "./src/ids/index.ts", "./tools": "./src/tools/index.ts", "./http": "./src/http/index.ts", - "./ws": "./src/ws/index.ts" + "./ws": "./src/ws/index.ts", + "./notifications": "./src/notifications/index.ts" }, "scripts": { "build": "tsdown", diff --git a/packages/contracts/src/enums/index.ts b/packages/contracts/src/enums/index.ts index b785158..80b4995 100644 --- a/packages/contracts/src/enums/index.ts +++ b/packages/contracts/src/enums/index.ts @@ -17,7 +17,17 @@ export { LogColor } from './log-color.ts'; export { LogFormat } from './log-format.ts'; export { LogLevel } from './log-level.ts'; export { LogPersist } from './log-persist.ts'; +export { NotificationCategory } from './notification-category.ts'; +export { NotificationChannelKind } from './notification-channel-kind.ts'; +export { NotificationChannelSource } from './notification-channel-source.ts'; +export { NotificationChannelStatus } from './notification-channel-status.ts'; +export { NotificationCommandOp } from './notification-command-op.ts'; +export { NotificationContentLevel } from './notification-content-level.ts'; +export { NotificationDeliveryOp } from './notification-delivery-op.ts'; +export { NotificationDeliveryStatus } from './notification-delivery-status.ts'; +export { NotificationKind } from './notification-kind.ts'; export { NotificationSeverity } from './notification-severity.ts'; +export { NotificationState } from './notification-state.ts'; export { NotificationType } from './notification-type.ts'; export { OperatorRequestKind } from './operator-request-kind.ts'; export { OperatorRequestStatus } from './operator-request-status.ts'; diff --git a/packages/contracts/src/enums/notification-category.ts b/packages/contracts/src/enums/notification-category.ts new file mode 100644 index 0000000..d9e0929 --- /dev/null +++ b/packages/contracts/src/enums/notification-category.ts @@ -0,0 +1,16 @@ +/** @module contracts/enums/notification-category — NotificationCategory enum: Coarse group of a notification kind (D-32); drives channel presets, TTL and image rules. */ + +import { z } from 'zod'; + +/** + * Coarse group of a notification kind (D-32); drives channel presets, TTL and image rules. + */ +export const NotificationCategory = z.enum([ + 'needs-you', + 'problems', + 'wrap-ups', + 'reports', + 'system', +]); +/** Union of {@link NotificationCategory} members. */ +export type NotificationCategory = z.infer; diff --git a/packages/contracts/src/enums/notification-channel-kind.ts b/packages/contracts/src/enums/notification-channel-kind.ts new file mode 100644 index 0000000..04f7bd2 --- /dev/null +++ b/packages/contracts/src/enums/notification-channel-kind.ts @@ -0,0 +1,21 @@ +/** @module contracts/enums/notification-channel-kind — NotificationChannelKind enum: Platform of a notification channel (03 §9.3). `in-app` is the built-in inbox; the rest are external. Open set: a platform is added without a schema rebuild. */ + +import { z } from 'zod'; + +/** + * Platform of a notification channel (03 §9.3). `in-app` is the built-in inbox; the rest are external. Open set: a platform is added without a schema rebuild. + */ +export const NotificationChannelKind = z.enum([ + 'in-app', + 'telegram', + 'discord', + 'ntfy', + 'webhook', + 'slack', + 'pushover', + 'teams', + 'apprise', + 'email', +]); +/** Union of {@link NotificationChannelKind} members. */ +export type NotificationChannelKind = z.infer; diff --git a/packages/contracts/src/enums/notification-channel-source.ts b/packages/contracts/src/enums/notification-channel-source.ts new file mode 100644 index 0000000..979b116 --- /dev/null +++ b/packages/contracts/src/enums/notification-channel-source.ts @@ -0,0 +1,10 @@ +/** @module contracts/enums/notification-channel-source — NotificationChannelSource enum: Where a notification channel is defined (D-39): the dashboard (`db`) or a `--notificationChannel` flag (`startup`, read-only). */ + +import { z } from 'zod'; + +/** + * Where a notification channel is defined (D-39): the dashboard (`db`) or a `--notificationChannel` flag (`startup`, read-only). + */ +export const NotificationChannelSource = z.enum(['db', 'startup']); +/** Union of {@link NotificationChannelSource} members. */ +export type NotificationChannelSource = z.infer; diff --git a/packages/contracts/src/enums/notification-channel-status.ts b/packages/contracts/src/enums/notification-channel-status.ts new file mode 100644 index 0000000..75a7d73 --- /dev/null +++ b/packages/contracts/src/enums/notification-channel-status.ts @@ -0,0 +1,10 @@ +/** @module contracts/enums/notification-channel-status — NotificationChannelStatus enum: Operational status of a notification channel (`notification_channels.status`, D-34): `paused` by the operator, `broken` by the circuit breaker. */ + +import { z } from 'zod'; + +/** + * Operational status of a notification channel (`notification_channels.status`, D-34): `paused` by the operator, `broken` by the circuit breaker. + */ +export const NotificationChannelStatus = z.enum(['active', 'paused', 'broken']); +/** Union of {@link NotificationChannelStatus} members. */ +export type NotificationChannelStatus = z.infer; diff --git a/packages/contracts/src/enums/notification-command-op.ts b/packages/contracts/src/enums/notification-command-op.ts new file mode 100644 index 0000000..0cce698 --- /dev/null +++ b/packages/contracts/src/enums/notification-command-op.ts @@ -0,0 +1,15 @@ +/** @module contracts/enums/notification-command-op — NotificationCommandOp enum: Operation an `act` action asks BrowserHive to run when pressed in a chat (D-32); executed through the same services and scopes as the dashboard. */ + +import { z } from 'zod'; + +/** + * Operation an `act` action asks BrowserHive to run when pressed in a chat (D-32); executed through the same services and scopes as the dashboard. + */ +export const NotificationCommandOp = z.enum([ + 'attention.resolve', + 'vault.confirm.resolve', + 'session.extend_lease', + 'session.close', +]); +/** Union of {@link NotificationCommandOp} members. */ +export type NotificationCommandOp = z.infer; diff --git a/packages/contracts/src/enums/notification-content-level.ts b/packages/contracts/src/enums/notification-content-level.ts new file mode 100644 index 0000000..96f138c --- /dev/null +++ b/packages/contracts/src/enums/notification-content-level.ts @@ -0,0 +1,10 @@ +/** @module contracts/enums/notification-content-level — NotificationContentLevel enum: How much of a notification a channel may carry (D-32): `counts` (kind, counts, session slug, links), `titles` (plus title, summary and structured fields) or `full`. */ + +import { z } from 'zod'; + +/** + * How much of a notification a channel may carry (D-32): `counts` (kind, counts, session slug, links), `titles` (plus title, summary and structured fields) or `full`. + */ +export const NotificationContentLevel = z.enum(['counts', 'titles', 'full']); +/** Union of {@link NotificationContentLevel} members. */ +export type NotificationContentLevel = z.infer; diff --git a/packages/contracts/src/enums/notification-delivery-op.ts b/packages/contracts/src/enums/notification-delivery-op.ts new file mode 100644 index 0000000..3c1638e --- /dev/null +++ b/packages/contracts/src/enums/notification-delivery-op.ts @@ -0,0 +1,10 @@ +/** @module contracts/enums/notification-delivery-op — NotificationDeliveryOp enum: Operation of one outbox job (`notification_deliveries.op`, D-34). */ + +import { z } from 'zod'; + +/** + * Operation of one outbox job (`notification_deliveries.op`, D-34). + */ +export const NotificationDeliveryOp = z.enum(['send', 'edit', 'delete']); +/** Union of {@link NotificationDeliveryOp} members. */ +export type NotificationDeliveryOp = z.infer; diff --git a/packages/contracts/src/enums/notification-delivery-status.ts b/packages/contracts/src/enums/notification-delivery-status.ts new file mode 100644 index 0000000..b4d69e1 --- /dev/null +++ b/packages/contracts/src/enums/notification-delivery-status.ts @@ -0,0 +1,18 @@ +/** @module contracts/enums/notification-delivery-status — NotificationDeliveryStatus enum: Status of one outbox job (`notification_deliveries.status`, D-34): `pending` → `sending` → `sent`, or `retrying` → `dead`; `suppressed` (with a reason) and `superseded` are terminal without a platform call. */ + +import { z } from 'zod'; + +/** + * Status of one outbox job (`notification_deliveries.status`, D-34): `pending` → `sending` → `sent`, or `retrying` → `dead`; `suppressed` (with a reason) and `superseded` are terminal without a platform call. + */ +export const NotificationDeliveryStatus = z.enum([ + 'pending', + 'sending', + 'sent', + 'retrying', + 'dead', + 'suppressed', + 'superseded', +]); +/** Union of {@link NotificationDeliveryStatus} members. */ +export type NotificationDeliveryStatus = z.infer; diff --git a/packages/contracts/src/enums/notification-kind.ts b/packages/contracts/src/enums/notification-kind.ts new file mode 100644 index 0000000..5567896 --- /dev/null +++ b/packages/contracts/src/enums/notification-kind.ts @@ -0,0 +1,23 @@ +/** @module contracts/enums/notification-kind — NotificationKind enum: What a notification is about (D-32): the stable identity renderers, rules and presets branch on. Open set: new kinds are additive and consumers ignore kinds they do not know. */ + +import { z } from 'zod'; + +/** + * What a notification is about (D-32): the stable identity renderers, rules and presets branch on. Open set: new kinds are additive and consumers ignore kinds they do not know. + */ +export const NotificationKind = z.enum([ + 'attention.requested', + 'vault.confirm', + 'vault.filled', + 'session.finished', + 'session.crashed', + 'session.reaped', + 'tool.errors', + 'system.degraded', + 'channel.broken', + 'digest.daily', + 'report.anomaly', + 'test', +]); +/** Union of {@link NotificationKind} members. */ +export type NotificationKind = z.infer; diff --git a/packages/contracts/src/enums/notification-severity.ts b/packages/contracts/src/enums/notification-severity.ts index 8eedbc9..83adc56 100644 --- a/packages/contracts/src/enums/notification-severity.ts +++ b/packages/contracts/src/enums/notification-severity.ts @@ -1,10 +1,10 @@ -/** @module contracts/enums/notification-severity — NotificationSeverity enum: Severity of an in-app notification (D-16); derived from the producing bus event so the dashboard can style and sort the inbox. */ +/** @module contracts/enums/notification-severity — NotificationSeverity enum: severity of a notification (D-16, D-32), set by its producer; drives inbox styling, channel minimum-severity rules and platform priority. `critical` bypasses quiet hours. */ import { z } from 'zod'; /** - * Severity of an in-app notification (D-16); derived from the producing bus event so the dashboard can style and sort the inbox. + * Severity of a notification (D-16, D-32), set by its producer; drives inbox styling, channel minimum-severity rules and platform priority. `critical` bypasses quiet hours. */ -export const NotificationSeverity = z.enum(['info', 'warn', 'error']); +export const NotificationSeverity = z.enum(['info', 'warn', 'error', 'critical']); /** Union of {@link NotificationSeverity} members. */ export type NotificationSeverity = z.infer; diff --git a/packages/contracts/src/enums/notification-state.ts b/packages/contracts/src/enums/notification-state.ts new file mode 100644 index 0000000..2a07c1d --- /dev/null +++ b/packages/contracts/src/enums/notification-state.ts @@ -0,0 +1,10 @@ +/** @module contracts/enums/notification-state — NotificationState enum: Lifecycle state of a notification (D-32): `open` while it may still need someone, then `acted`, `resolved` or `expired`; `final` for one-shot facts. Actions exist only while `open`. */ + +import { z } from 'zod'; + +/** + * Lifecycle state of a notification (D-32): `open` while it may still need someone, then `acted`, `resolved` or `expired`; `final` for one-shot facts. Actions exist only while `open`. + */ +export const NotificationState = z.enum(['open', 'acted', 'resolved', 'expired', 'final']); +/** Union of {@link NotificationState} members. */ +export type NotificationState = z.infer; diff --git a/packages/contracts/src/http/notifications.ts b/packages/contracts/src/http/notifications.ts index 102184d..d8ef8a1 100644 --- a/packages/contracts/src/http/notifications.ts +++ b/packages/contracts/src/http/notifications.ts @@ -1,6 +1,12 @@ /** @module contracts/http/notifications — in-app notifications and operator preferences (spec 03 §4.8, D-16) */ import { z } from 'zod'; -import { NotificationType } from '../enums/index.ts'; +import { + NotificationCategory, + NotificationKind, + NotificationSeverity, + NotificationState, + NotificationType, +} from '../enums/index.ts'; import { NotificationId, SessionId } from '../ids/index.ts'; import { Count, @@ -17,7 +23,8 @@ import { * One notification row. Read/dismiss state is server-side and survives reloads. Repeated * occurrences of the same thing (tool errors of one session) fold into one row: `count` grows, * `updated_at`, `title`, `body` and `source_event_id` follow the latest occurrence, and the change - * is broadcast as `notification.updated`. + * is broadcast as `notification.updated`. A lifecycle revision (a request resolved elsewhere) changes + * `state` and `revision` only, never `updated_at`. */ export const Notification = z.object({ notification_id: NotificationId, @@ -37,6 +44,17 @@ export const Notification = z.object({ count: z.number().int().min(1), read_at: EpochMs.nullable(), dismissed_at: EpochMs.nullable(), + /** What the notification is about (D-32); rows from before schema v5 read a value derived from `type`. */ + kind: NotificationKind, + /** Coarse group of `kind` (needs-you, problems, wrap-ups, reports, system). */ + category: NotificationCategory, + severity: NotificationSeverity, + /** Lifecycle: `open` while it may still need someone; `resolved`/`expired`/`acted` after; `final` for one-shot facts. */ + state: NotificationState, + /** 1 at creation, +1 per change of the notification's message (group growth, resolution). */ + revision: z.number().int().min(1), + /** Conversation key shared with external channels (`attention:`, `tool-errors:`). */ + thread: z.string(), }); /** One notification row. */ export type Notification = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c330dcf..ed79fb0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -128,7 +128,17 @@ export { LogFormat, LogLevel, LogPersist, + NotificationCategory, + NotificationChannelKind, + NotificationChannelSource, + NotificationChannelStatus, + NotificationCommandOp, + NotificationContentLevel, + NotificationDeliveryOp, + NotificationDeliveryStatus, + NotificationKind, NotificationSeverity, + NotificationState, NotificationType, OperatorRequestKind, OperatorRequestStatus, @@ -518,6 +528,72 @@ export { TabId, ULID_RE, } from './ids/index.ts'; +export { + DEFAULT_CONTENT_LEVEL, + NotificationChannelName, + NotificationChannelRules, + NotificationChannelSecretRefs, + NotificationChannelTarget, + QuietHours, + RESERVED_ENV_PREFIX, + SecretEnvName, + StartupNotificationChannel, + SUPPRESSION_REASONS, + type SuppressionReason, +} from './notifications/channel.ts'; +export { + NOTIFICATION_MESSAGE_SCHEMA_ID, + notificationMessageJsonSchema, +} from './notifications/json-schema.ts'; +export { + ActAction, + ActionStyle, + Block, + type BlockType, + CodeBlock, + DashboardPath, + DividerBlock, + FieldsBlock, + FooterBlock, + HeadingBlock, + ImageBlock, + Inline, + InlineBold, + InlineCode, + InlineItalic, + InlineLink, + InlineRun, + InlineText, + InlineTime, + ListBlock, + NOTIFICATION_ACTIONS_MAX, + NOTIFICATION_BLOCKS_MAX, + NOTIFICATION_LABEL_MAX, + NOTIFICATION_SCHEMA_VERSION, + NOTIFICATION_SUMMARY_MAX, + NOTIFICATION_TEXT_MAX, + NOTIFICATION_TITLE_MAX, + NotificationAction, + NotificationCommand, + NotificationEntities, + NotificationMessage, + NotificationPrivacy, + OpenAction, + QuoteBlock, + TableBlock, + TextBlock, +} from './notifications/message.ts'; +export { + classifyLegacy, + IN_APP_ONLY_KINDS, + KIND_CATEGORY, + KIND_LABEL, + KIND_SEVERITY, + KIND_TYPE, + type LegacyClassification, + type LegacyNotificationFacts, + SESSION_CRASHED_TITLE, +} from './notifications/taxonomy.ts'; export { ALL_TOOL_NAMES, AppliedIdentity, diff --git a/packages/contracts/src/notifications/channel.ts b/packages/contracts/src/notifications/channel.ts new file mode 100644 index 0000000..b759a83 --- /dev/null +++ b/packages/contracts/src/notifications/channel.ts @@ -0,0 +1,121 @@ +/** @module contracts/notifications/channel — the stored shape of a notification channel's rules, target and secret references (spec 03 §9.3, D-33, D-35, D-39). */ + +import { z } from 'zod'; +import { NotificationCategory } from '../enums/notification-category.ts'; +import { NotificationChannelKind } from '../enums/notification-channel-kind.ts'; +import { NotificationContentLevel } from '../enums/notification-content-level.ts'; +import { NotificationSeverity } from '../enums/notification-severity.ts'; + +/** Prefix the config loader reserves; a channel secret may not be read from such a variable (D-33). */ +export const RESERVED_ENV_PREFIX = 'BROWSERHIVE_'; + +/** Name of a notification channel: lowercase slug, unique across dashboard and startup channels. */ +export const NotificationChannelName = z + .string() + .regex(/^[a-z0-9][a-z0-9-]{0,31}$/, 'a lowercase name of up to 32 letters, digits and dashes'); +/** Channel name. */ +export type NotificationChannelName = z.infer; + +/** + * The name of an environment variable that holds a channel secret. Values are never stored (D-33): + * the database, the API and startup flags carry only the name. + */ +export const SecretEnvName = z + .string() + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'an environment variable name') + .max(128) + .refine((name) => !name.startsWith(RESERVED_ENV_PREFIX), { + message: `names starting with ${RESERVED_ENV_PREFIX} are reserved for configuration`, + }); +/** An environment variable name. */ +export type SecretEnvName = z.infer; + +/** `HH:MM` on a 24-hour clock. */ +export const ClockTime = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'a time like 22:00'); + +/** Hours during which only `critical` notifications are delivered; `start > end` spans midnight. */ +export const QuietHours = z.object({ + start: ClockTime, + end: ClockTime, + /** IANA time zone (`Europe/Berlin`); absent = the host's zone. */ + time_zone: z.string().min(1).max(64).optional(), +}); +/** Quiet hours. */ +export type QuietHours = z.infer; + +const PerCategory = (value: T) => z.partialRecord(NotificationCategory, value); + +/** + * 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 + * newer release's rules never break an older reader. + */ +export const NotificationChannelRules = z.object({ + /** Categories delivered; absent = all. */ + categories: z.array(NotificationCategory).optional(), + /** Minimum severity delivered; absent = `info`. */ + min_severity: NotificationSeverity.optional(), + /** Session slug globs (`checkout-*`); absent = every session and session-less notifications. */ + sessions: z.array(z.string().min(1).max(64)).max(32).optional(), + /** 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(), + /** Content level; absent = `titles`. */ + content: NotificationContentLevel.optional(), + /** Screenshots per category (D-36); absent = off. */ + images: PerCategory(z.boolean()).optional(), + /** Message TTL per category in milliseconds (D-35); absent = never. */ + ttl_ms: PerCategory(z.number().int().positive()).optional(), + /** Delete the message once its notification is resolved, per category; absent = off. */ + delete_when_resolved: PerCategory(z.boolean()).optional(), + /** Whether act buttons are offered (only where the platform supports them); absent = off. */ + act_buttons: z.boolean().optional(), + /** Platform user ids allowed to press act buttons (N2). */ + allow_list: z.array(z.string().min(1).max(64)).max(32).optional(), +}); +/** Channel rules. */ +export type NotificationChannelRules = z.infer; + +/** Content level used when a channel's rules do not set one. */ +export const DEFAULT_CONTENT_LEVEL = 'titles' as const satisfies NotificationContentLevel; + +/** Non-secret coordinates of a channel (chat id, topic, server URL), per kind. */ +export const NotificationChannelTarget = z.record(z.string().max(64), z.string().max(2048)); +/** Channel target. */ +export type NotificationChannelTarget = z.infer; + +/** Secret parameter → environment variable name (`{ token: 'BH_TG_TOKEN' }`). */ +export const NotificationChannelSecretRefs = z.record(z.string().max(64), SecretEnvName); +/** Secret references. */ +export type NotificationChannelSecretRefs = z.infer; + +/** + * A channel declared by `--notificationChannel` (spec 08 §5.7), after parsing. Projected into + * `notification_channels` with `source = 'startup'` at each start (D-39). + */ +export const StartupNotificationChannel = z.object({ + name: NotificationChannelName, + kind: NotificationChannelKind.exclude(['in-app']), + /** Discord: `webhook` or `bot` (D-38); `null` elsewhere. */ + mode: z.string().max(32).nullable(), + target: NotificationChannelTarget, + secret_refs: NotificationChannelSecretRefs, + rules: NotificationChannelRules, +}); +/** A parsed startup channel. */ +export type StartupNotificationChannel = z.infer; + +/** Reasons recorded on a `suppressed` delivery (D-34); the delivery log explains each. */ +export const SUPPRESSION_REASONS = [ + 'filtered', + 'quiet_hours', + 'throttled', + 'channel_paused', + 'content_blocked', + 'image_blocked', + 'edit_unsupported', + 'delete_unsupported', + 'no_adapter', +] 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 new file mode 100644 index 0000000..175e487 --- /dev/null +++ b/packages/contracts/src/notifications/index.ts @@ -0,0 +1,65 @@ +/** @module contracts/notifications — the notification contract (D-32): `NotificationMessage`, its taxonomy, channel rules and the published JSON Schema */ + +export { + DEFAULT_CONTENT_LEVEL, + NotificationChannelName, + NotificationChannelRules, + NotificationChannelSecretRefs, + NotificationChannelTarget, + QuietHours, + RESERVED_ENV_PREFIX, + SecretEnvName, + StartupNotificationChannel, + SUPPRESSION_REASONS, + type SuppressionReason, +} from './channel.ts'; +export { NOTIFICATION_MESSAGE_SCHEMA_ID, notificationMessageJsonSchema } from './json-schema.ts'; +export { + ActAction, + ActionStyle, + Block, + type BlockType, + CodeBlock, + DashboardPath, + DividerBlock, + FieldsBlock, + FooterBlock, + HeadingBlock, + ImageBlock, + Inline, + InlineBold, + InlineCode, + InlineItalic, + InlineLink, + InlineRun, + InlineText, + InlineTime, + ListBlock, + NOTIFICATION_ACTIONS_MAX, + NOTIFICATION_BLOCKS_MAX, + NOTIFICATION_LABEL_MAX, + NOTIFICATION_SCHEMA_VERSION, + NOTIFICATION_SUMMARY_MAX, + NOTIFICATION_TEXT_MAX, + NOTIFICATION_TITLE_MAX, + NotificationAction, + NotificationCommand, + NotificationEntities, + NotificationMessage, + NotificationPrivacy, + OpenAction, + QuoteBlock, + TableBlock, + TextBlock, +} from './message.ts'; +export { + classifyLegacy, + IN_APP_ONLY_KINDS, + KIND_CATEGORY, + KIND_LABEL, + KIND_SEVERITY, + KIND_TYPE, + type LegacyClassification, + type LegacyNotificationFacts, + SESSION_CRASHED_TITLE, +} from './taxonomy.ts'; diff --git a/packages/contracts/src/notifications/json-schema.ts b/packages/contracts/src/notifications/json-schema.ts new file mode 100644 index 0000000..73a1f00 --- /dev/null +++ b/packages/contracts/src/notifications/json-schema.ts @@ -0,0 +1,29 @@ +/** @module contracts/notifications/json-schema — the published JSON Schema (draft 2020-12) of `NotificationMessage`, generated from the zod schema into `docs/reference/notification-message.schema.json` (D-32). */ + +import { z } from 'zod'; +import { NOTIFICATION_SCHEMA_VERSION, NotificationMessage } from './message.ts'; + +/** `$id` of the published schema; the version in the name is the contract's `schema`. */ +export const NOTIFICATION_MESSAGE_SCHEMA_ID = `https://browserhive.ai/schemas/notification-message.v${NOTIFICATION_SCHEMA_VERSION}.json`; + +/** + * JSON Schema of the notification contract, for consumers of the generic webhook and anyone + * building a renderer outside BrowserHive. + * + * @returns A draft 2020-12 schema object. + */ +export function notificationMessageJsonSchema(): Record { + const schema = z.toJSONSchema(NotificationMessage, { + io: 'output', + target: 'draft-2020-12', + unrepresentable: 'any', + }); + const { $schema, ...rest } = schema; + return { + $schema, + $id: NOTIFICATION_MESSAGE_SCHEMA_ID, + title: 'BrowserHive notification message', + description: `NotificationMessage, schema ${NOTIFICATION_SCHEMA_VERSION}. Every revision is the complete state; consumers render the whole message and ignore kinds, blocks, inlines and actions they do not know.`, + ...rest, + }; +} diff --git a/packages/contracts/src/notifications/message.ts b/packages/contracts/src/notifications/message.ts new file mode 100644 index 0000000..2a74976 --- /dev/null +++ b/packages/contracts/src/notifications/message.ts @@ -0,0 +1,249 @@ +/** @module contracts/notifications/message — `NotificationMessage`, the versioned, producer-owned notification contract every channel renders (D-32, spec 03 §9.2). Platform-neutral and JSON-serialisable; snake_case on the wire (D-05). */ + +import { z } from 'zod'; +import { NotificationCategory } from '../enums/notification-category.ts'; +import { NotificationCommandOp } from '../enums/notification-command-op.ts'; +import { NotificationContentLevel } from '../enums/notification-content-level.ts'; +import { NotificationKind } from '../enums/notification-kind.ts'; +import { NotificationSeverity } from '../enums/notification-severity.ts'; +import { NotificationState } from '../enums/notification-state.ts'; +import { EpochMs } from '../http/common.ts'; +import { NOTIFICATION_ID_RE } from '../ids/ids.ts'; + +/** Contract version carried in every message as `schema`. Additive changes keep it; a breaking change bumps it (D-32). */ +export const NOTIFICATION_SCHEMA_VERSION = 1; +/** Maximum length of `title` (plain text). */ +export const NOTIFICATION_TITLE_MAX = 120; +/** Maximum length of `summary` (plain text; push preview, ntfy body, fallback). */ +export const NOTIFICATION_SUMMARY_MAX = 240; +/** Maximum number of `actions`, ordered by importance. */ +export const NOTIFICATION_ACTIONS_MAX = 5; +/** Maximum number of `blocks`. */ +export const NOTIFICATION_BLOCKS_MAX = 50; +/** Maximum length of a button or field label. */ +export const NOTIFICATION_LABEL_MAX = 40; +/** Maximum length of one text leaf (an inline's text, a code block). */ +export const NOTIFICATION_TEXT_MAX = 4000; + +const Text = z.string().max(NOTIFICATION_TEXT_MAX); +const Label = z.string().min(1).max(NOTIFICATION_LABEL_MAX); + +/** + * A dashboard path such as `/sessions/shop-a1b2c3d4?live=1`: absolute within the dashboard, no + * scheme or host. A `LinkBuilder` turns it into a URL (`publicUrl + path`, D-37); links never carry + * tokens. + */ +export const DashboardPath = z + .string() + .max(2048) + .regex(/^\/(?!\/)\S*$/, 'a dashboard path starting with a single /'); +/** A dashboard path. */ +export type DashboardPath = z.infer; + +/** Plain text. */ +export const InlineText = z.object({ type: z.literal('text'), text: Text }); +/** Strong emphasis. */ +export const InlineBold = z.object({ type: z.literal('bold'), text: Text }); +/** Emphasis. */ +export const InlineItalic = z.object({ type: z.literal('italic'), text: Text }); +/** Monospace (ids, codes, sanitized URLs). */ +export const InlineCode = z.object({ type: z.literal('code'), text: Text }); +/** A link to a dashboard page (never an external URL). */ +export const InlineLink = z.object({ type: z.literal('link'), text: Text, path: DashboardPath }); +/** A point in time a renderer may localise (`relative`: "2 min ago"; `absolute`: "14:02"). */ +export const InlineTime = z.object({ + type: z.literal('time'), + at: EpochMs, + style: z.enum(['relative', 'absolute']), +}); + +/** One inline node: a tiny AST, never a markdown string (D-32). */ +export const Inline = z.discriminatedUnion('type', [ + InlineText, + InlineBold, + InlineItalic, + InlineCode, + InlineLink, + InlineTime, +]); +/** One inline node. */ +export type Inline = z.infer; + +/** A run of inline nodes. */ +export const InlineRun = z.array(Inline).max(64); +/** A run of inline nodes. */ +export type InlineRun = z.infer; + +/** A paragraph. */ +export const TextBlock = z.object({ type: z.literal('text'), content: InlineRun }); +/** A section heading (plain text). */ +export const HeadingBlock = z.object({ type: z.literal('heading'), text: Text }); +/** Label/value facts (session, tool, error code…); kept at the `titles` content level. */ +export const FieldsBlock = z.object({ + type: z.literal('fields'), + items: z + .array(z.object({ label: Label, value: InlineRun })) + .min(1) + .max(12), +}); +/** Quoted text from an observed fact (an agent's attention message); may be shown collapsed. */ +export const QuoteBlock = z.object({ + type: z.literal('quote'), + content: InlineRun, + collapsible: z.boolean(), +}); +/** A bulleted or numbered list. */ +export const ListBlock = z.object({ + type: z.literal('list'), + ordered: z.boolean(), + items: z.array(InlineRun).min(1).max(20), +}); +/** A small table; `degrade` turns it into a list where tables are unsupported. */ +export const TableBlock = z.object({ + type: z.literal('table'), + columns: z.array(Label).min(1).max(8), + rows: z.array(z.array(InlineRun)).max(20), +}); +/** + * A screenshot (D-36). `ref` is an opaque artifact reference resolved by the delivering core, never + * by a consumer; `path` is the dashboard page that shows it (used when a channel cannot carry images). + */ +export const ImageBlock = z.object({ + type: z.literal('image'), + ref: z.string().min(1).max(512), + alt: Text, + captured_at: EpochMs, + masked: z.boolean(), + path: DashboardPath.nullable(), +}); +/** Preformatted text. */ +export const CodeBlock = z.object({ + type: z.literal('code'), + text: Text, + language: z.string().max(32).nullable(), +}); +/** A separator. */ +export const DividerBlock = z.object({ type: z.literal('divider') }); +/** 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 }); + +/** One semantic, platform-neutral block (D-32). */ +export const Block = z.discriminatedUnion('type', [ + TextBlock, + HeadingBlock, + FieldsBlock, + QuoteBlock, + ListBlock, + TableBlock, + ImageBlock, + CodeBlock, + DividerBlock, + FooterBlock, +]); +/** One block. */ +export type Block = z.infer; +/** The `type` of a block. */ +export type BlockType = Block['type']; + +/** Visual weight of a button. */ +export const ActionStyle = z.enum(['primary', 'danger', 'default']); +/** Visual weight of a button. */ +export type ActionStyle = z.infer; + +const ActionId = z + .string() + .regex(/^[a-z][a-z0-9-]{0,31}$/, 'a lowercase action id') + .describe('Stable within the message (`resolve`, `open-session`).'); + +/** A link button to a dashboard page (Open session, Take over, Open live view). */ +export const OpenAction = z.object({ + kind: z.literal('open'), + id: ActionId, + label: Label, + style: ActionStyle, + path: DashboardPath, +}); +/** A link button. */ +export type OpenAction = z.infer; + +/** A command an `act` button asks BrowserHive to run (D-32). */ +export const NotificationCommand = z.object({ + op: NotificationCommandOp, + args: z.record(z.string().max(64), z.union([z.string().max(256), z.number(), z.boolean()])), +}); +/** A command. */ +export type NotificationCommand = z.infer; + +/** + * A button that acts through the chat platform (Approve / Deny / Extend lease). Where a channel + * cannot carry it, `degrade` replaces it with its `fallback` link. + */ +export const ActAction = z.object({ + kind: z.literal('act'), + id: ActionId, + label: Label, + style: ActionStyle, + command: NotificationCommand, + /** Question to confirm before running, or `null`. */ + confirm: z.string().max(NOTIFICATION_SUMMARY_MAX).nullable(), + fallback: z.object({ label: Label, path: DashboardPath }), +}); +/** An act button. */ +export type ActAction = z.infer; + +/** One action: `act` (through the platform) or `open` (a dashboard link). */ +export const NotificationAction = z.discriminatedUnion('kind', [ActAction, OpenAction]); +/** One action. */ +export type NotificationAction = z.infer; + +/** Routing input: what the notification is about. Every key is optional. */ +export const NotificationEntities = z.object({ + session_id: z.string().max(128).optional(), + session_slug: z.string().max(64).optional(), + harness: z.string().max(64).optional(), + owner: z.string().max(128).optional(), + tool: z.string().max(64).optional(), + error_code: z.string().max(64).optional(), + domain: z.string().max(253).optional(), + request_id: z.string().max(64).optional(), +}); +/** Routing input. */ +export type NotificationEntities = z.infer; + +/** The content level already applied to this message, and whether it carries an image. */ +export const NotificationPrivacy = z.object({ + level: NotificationContentLevel, + has_image: z.boolean(), +}); +/** Applied privacy. */ +export type NotificationPrivacy = 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 + * actions they do not know. + */ +export const NotificationMessage = z.object({ + schema: z.literal(NOTIFICATION_SCHEMA_VERSION), + /** The notification id (`n-…`), stable for its life. */ + id: z.string().regex(NOTIFICATION_ID_RE), + /** 1 for the first state; +1 per change. */ + revision: z.number().int().min(1), + /** Conversation the message belongs to (`attention:`, `session:`, `tool-errors:`). */ + thread: z.string().min(1).max(160), + kind: NotificationKind, + category: NotificationCategory, + severity: NotificationSeverity, + state: NotificationState, + /** Whether this revision should make noise. Edits are always silent (D-34). */ + alert: z.boolean(), + at: z.object({ created: EpochMs, updated: EpochMs }), + title: z.string().min(1).max(NOTIFICATION_TITLE_MAX), + summary: z.string().max(NOTIFICATION_SUMMARY_MAX), + blocks: z.array(Block).max(NOTIFICATION_BLOCKS_MAX), + actions: z.array(NotificationAction).max(NOTIFICATION_ACTIONS_MAX), + entities: NotificationEntities, + privacy: NotificationPrivacy, +}); +/** The notification contract. */ +export type NotificationMessage = z.infer; diff --git a/packages/contracts/src/notifications/taxonomy.ts b/packages/contracts/src/notifications/taxonomy.ts new file mode 100644 index 0000000..99afb6f --- /dev/null +++ b/packages/contracts/src/notifications/taxonomy.ts @@ -0,0 +1,151 @@ +/** @module contracts/notifications/taxonomy — the fixed kind → category map, per-kind labels, and the classification of rows written before the contract existed (spec 03 §9.1–9.2). */ + +import type { NotificationCategory } from '../enums/notification-category.ts'; +import type { NotificationKind } from '../enums/notification-kind.ts'; +import type { NotificationSeverity } from '../enums/notification-severity.ts'; +import type { NotificationState } from '../enums/notification-state.ts'; +import type { NotificationType } from '../enums/notification-type.ts'; + +/** The category of every kind. Fixed: a preset selects categories, never kinds. */ +export const KIND_CATEGORY: { readonly [K in NotificationKind]: NotificationCategory } = { + 'attention.requested': 'needs-you', + 'vault.confirm': 'needs-you', + 'vault.filled': 'wrap-ups', + 'session.finished': 'wrap-ups', + 'session.crashed': 'problems', + 'session.reaped': 'problems', + 'tool.errors': 'problems', + 'system.degraded': 'system', + 'channel.broken': 'system', + 'digest.daily': 'reports', + 'report.anomaly': 'reports', + test: 'system', +}; + +/** Severity each kind is produced with (a producer may raise it, never lower it below `info`). */ +export const KIND_SEVERITY: { readonly [K in NotificationKind]: NotificationSeverity } = { + 'attention.requested': 'warn', + 'vault.confirm': 'warn', + 'vault.filled': 'info', + 'session.finished': 'info', + 'session.crashed': 'error', + 'session.reaped': 'warn', + 'tool.errors': 'warn', + 'system.degraded': 'error', + 'channel.broken': 'error', + 'digest.daily': 'info', + 'report.anomaly': 'warn', + test: 'info', +}; + +/** Generic title per kind: what a `counts`-level channel shows instead of the producer's title. */ +export const KIND_LABEL: { readonly [K in NotificationKind]: string } = { + 'attention.requested': 'Attention requested', + 'vault.confirm': 'Vault fill awaiting confirm', + 'vault.filled': 'Vault fill', + 'session.finished': 'Session finished', + 'session.crashed': 'Session crashed', + 'session.reaped': 'Session reaped', + 'tool.errors': 'Tool errors', + 'system.degraded': 'BrowserHive degraded', + 'channel.broken': 'Notification channel failing', + 'digest.daily': 'Daily digest', + 'report.anomaly': 'Something looks off', + test: 'Test notification', +}; + +/** Kinds that are delivered in-app only and never enqueued for an external channel (D-34 loop cut). */ +export const IN_APP_ONLY_KINDS: ReadonlySet = new Set([ + 'channel.broken', +]); + +/** Title every crashed-session row has carried since v1; used to tell crashes from tool errors. */ +export const SESSION_CRASHED_TITLE = 'Session crashed'; + +/** In-app type that represents each kind in the inbox (icon, colour, the `type` filter). */ +export const KIND_TYPE: { readonly [K in NotificationKind]: NotificationType } = { + 'attention.requested': 'attention', + 'vault.confirm': 'vault', + 'vault.filled': 'vault', + 'session.finished': 'lifecycle', + 'session.crashed': 'error', + 'session.reaped': 'lifecycle', + 'tool.errors': 'error', + 'system.degraded': 'system', + 'channel.broken': 'system', + 'digest.daily': 'lifecycle', + 'report.anomaly': 'system', + test: 'system', +}; + +/** What a row from before the contract (or from an older reader) carries. */ +export interface LegacyNotificationFacts { + readonly notificationId: string; + readonly type: NotificationType; + readonly title: string; + readonly groupKey: string | null; + readonly sessionId: string | null; + readonly sourceEventId: string | null; +} + +/** Classification derived for a legacy row. */ +export interface LegacyClassification { + readonly kind: NotificationKind; + readonly category: NotificationCategory; + readonly severity: NotificationSeverity; + readonly state: NotificationState; + readonly thread: string; +} + +/** + * Classifies a row whose contract columns are NULL, exactly as migration v5 backfills rows (spec 03 + * §7): kind from `type` (an `error` titled "Session crashed" is a crash, any other `error` a tool + * error group), category and severity from the kind, `open` for tool-error groups and `final` + * otherwise (the request or degradation a row points at is not consulted here), thread from the ids. + * + * @returns The derived classification. + */ +export function classifyLegacy(row: LegacyNotificationFacts): LegacyClassification { + const kind = legacyKind(row.type, row.title); + return { + kind, + category: KIND_CATEGORY[kind], + severity: KIND_SEVERITY[kind], + state: kind === 'tool.errors' ? 'open' : 'final', + thread: legacyThread(kind, row), + }; +} + +function legacyKind(type: NotificationType, title: string): NotificationKind { + switch (type) { + case 'attention': + return 'attention.requested'; + case 'vault': + return 'vault.confirm'; + case 'lifecycle': + return 'session.reaped'; + case 'system': + return 'system.degraded'; + case 'error': + return title === SESSION_CRASHED_TITLE ? 'session.crashed' : 'tool.errors'; + } +} + +function legacyThread(kind: NotificationKind, row: LegacyNotificationFacts): string { + const own = `notification:${row.notificationId}`; + switch (kind) { + case 'attention.requested': + return row.sourceEventId === null ? own : `attention:${row.sourceEventId}`; + case 'vault.confirm': + return row.sourceEventId === null ? own : `vault:${row.sourceEventId}`; + case 'tool.errors': + return row.groupKey ?? `tool-errors:${row.sessionId ?? 'none'}`; + case 'session.crashed': + case 'session.reaped': + return row.sessionId === null ? own : `session:${row.sessionId}`; + case 'system.degraded': + return row.sourceEventId === null ? own : `system:${row.sourceEventId}`; + default: + return own; + } +} diff --git a/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap b/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap index 3ba614d..982cb91 100644 --- a/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap +++ b/packages/contracts/test/__snapshots__/exports.snapshot.test.ts.snap @@ -11,6 +11,8 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "AUDIT_ERRORS", "AUTH_ERRORS", "AUTH_NAME_RE", + "ActAction", + "ActionStyle", "ActivityBucket", "ActivityGroupBy", "ActivityQuery", @@ -45,6 +47,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "BOOT_ERRORS", "BULK_REQUESTS_MAX", "BULK_SESSIONS_MAX", + "Block", "BlockedAttemptsPage", "BlockedAttemptsQuery", "BlockedRequestRow", @@ -84,6 +87,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "Channel", "ClientErrorReport", "ClosedReason", + "CodeBlock", "ConnectionId", "ContextTransport", "Cookie", @@ -97,10 +101,13 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "CredentialKind", "Cursor", "DECLARED_SOURCES", + "DEFAULT_CONTENT_LEVEL", "DERIVED_SOURCES", + "DashboardPath", "DegradationSeverity", "DeleteSessionResponse", "DeleteVaultBindingResponse", + "DividerBlock", "DomainCount", "DurationMs", "EMPTY_INPUT_JSON_SCHEMA", @@ -116,8 +123,10 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "ErrorCodeSchema", "EventId", "Facet", + "FieldsBlock", "FleetPageRow", "FleetToolCallRow", + "FooterBlock", "GENERIC_CLIENT_NAMES", "GRAMMAR_META_KEY", "GrantQuery", @@ -136,6 +145,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "HarnessMetricsQuery", "HarnessMetricsResponse", "HarnessSlug", + "HeadingBlock", "HealthCheckState", "HealthResponse", "HealthStatus", @@ -145,14 +155,28 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "IDENTITY_META_PREFIXES", "ID_ALPHABET", "INJECTED_ENV_HARNESSES", + "IN_APP_ONLY_KINDS", "IdempotencyKey", "IfMatchVersion", + "ImageBlock", "ImportVaultQuery", "ImportVaultResponse", + "Inline", + "InlineBold", + "InlineCode", + "InlineItalic", + "InlineLink", + "InlineRun", + "InlineText", + "InlineTime", "InputCommand", "InputModifiers", "InputRejectionCode", "KEY_ALIASES", + "KIND_CATEGORY", + "KIND_LABEL", + "KIND_SEVERITY", + "KIND_TYPE", "KeyInput", "LIMIT_DEFAULT", "LIMIT_MAX", @@ -161,6 +185,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "LOGS_LIMIT_MAX", "LOG_LEVEL_SPEC_RE", "LaunchOptions", + "ListBlock", "LiveInput", "LockVaultResponse", "LogColor", @@ -194,14 +219,41 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "MigrationRow", "MouseInput", "NANOID_ALPHABET", + "NOTIFICATION_ACTIONS_MAX", + "NOTIFICATION_BLOCKS_MAX", "NOTIFICATION_ID_RE", + "NOTIFICATION_LABEL_MAX", + "NOTIFICATION_MESSAGE_SCHEMA_ID", + "NOTIFICATION_SCHEMA_VERSION", + "NOTIFICATION_SUMMARY_MAX", + "NOTIFICATION_TEXT_MAX", + "NOTIFICATION_TITLE_MAX", "NO_EXPLICIT_KEYS", "Notification", "NotificationAckResponse", + "NotificationAction", + "NotificationCategory", + "NotificationChannelKind", + "NotificationChannelName", + "NotificationChannelRules", + "NotificationChannelSecretRefs", + "NotificationChannelSource", + "NotificationChannelStatus", + "NotificationChannelTarget", + "NotificationCommand", + "NotificationCommandOp", + "NotificationContentLevel", "NotificationCreatedEvent", + "NotificationDeliveryOp", + "NotificationDeliveryStatus", + "NotificationEntities", "NotificationId", "NotificationIdParams", + "NotificationKind", + "NotificationMessage", + "NotificationPrivacy", "NotificationSeverity", + "NotificationState", "NotificationType", "NotificationUpdatedEvent", "NotificationsPage", @@ -212,6 +264,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "OTHER_HARNESS", "OkReply", "OkResponse", + "OpenAction", "OperatorRequestId", "OperatorRequestKind", "OperatorRequestRow", @@ -253,12 +306,15 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "QueryEpochMs", "QueryInt", "QueryText", + "QuietHours", + "QuoteBlock", "RECORDING_KEYS", "REDACTED", "REF_FORMS", "REF_SCHEMES", "RESERVED_CONFIG_KEYS", "RESERVED_ENUM_MEMBERS", + "RESERVED_ENV_PREFIX", "RESERVED_META_NAMES", "RealtimeConnection", "RecentPagesQuery", @@ -289,6 +345,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "SENSITIVE_META_KEY", "SERVER_KEYS", "SERVICE_ERRORS", + "SESSION_CRASHED_TITLE", "SESSION_ERRORS", "SESSION_EXPORT_MAX_ROWS", "SESSION_ID_RE", @@ -297,6 +354,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "SESSION_TOPIC_RE", "SLUG_RE", "STEALTH_KEYS", + "SUPPRESSION_REASONS", "SavedAuthEntry", "SavedAuthKind", "SavedAuthResult", @@ -319,6 +377,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "ScreenshotsPage", "SearchQuery", "SearchResponse", + "SecretEnvName", "Selector", "SerializedError", "SessionAttentionPage", @@ -360,6 +419,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "SetViewportRequest", "SetViewportResponse", "SortDir", + "StartupNotificationChannel", "StealthDriver", "StealthDriverName", "StealthLevel", @@ -388,7 +448,9 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "TRANSPORT_ERRORS", "TabId", "TabSummary", + "TableBlock", "TerminateSessionResponse", + "TextBlock", "TimeWindow", "TimelineItem", "TimelineKind", @@ -494,6 +556,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "WsStreamMessage", "WsTopic", "capMetaBag", + "classifyLegacy", "codesInCategory", "configFileJsonSchema", "crossFieldIssues", @@ -542,6 +605,7 @@ exports[`public surface sorted export names of src/index.ts match the snapshot 1 "metricHarness", "namesFor", "normalizeHarness", + "notificationMessageJsonSchema", "page", "parseKeyMeta", "parseSessionId", diff --git a/packages/contracts/test/goldens/ws/transcript.json b/packages/contracts/test/goldens/ws/transcript.json index 6900e89..f339c7f 100644 --- a/packages/contracts/test/goldens/ws/transcript.json +++ b/packages/contracts/test/goldens/ws/transcript.json @@ -246,7 +246,13 @@ "updated_at": 1735689597000, "count": 1, "read_at": null, - "dismissed_at": null + "dismissed_at": null, + "kind": "attention.requested", + "category": "needs-you", + "severity": "warn", + "state": "open", + "revision": 1, + "thread": "attention:a-k3j4h5g6f7d8" } } } diff --git a/packages/contracts/test/goldens/ws/ws-protocol.json b/packages/contracts/test/goldens/ws/ws-protocol.json index 1e9fd49..555b54f 100644 --- a/packages/contracts/test/goldens/ws/ws-protocol.json +++ b/packages/contracts/test/goldens/ws/ws-protocol.json @@ -9,7 +9,12 @@ }, "kind": { "type": "string", - "enum": ["event", "reply", "error", "stream"] + "enum": [ + "event", + "reply", + "error", + "stream" + ] }, "seq": { "type": "integer", @@ -32,7 +37,13 @@ }, "payload": {} }, - "required": ["v", "kind", "seq", "ts", "payload"], + "required": [ + "v", + "kind", + "seq", + "ts", + "payload" + ], "additionalProperties": false }, "client_commands": { @@ -51,7 +62,9 @@ "maxLength": 64 } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false }, { @@ -76,7 +89,10 @@ "maxLength": 64 } }, - "required": ["type", "topic"], + "required": [ + "type", + "topic" + ], "additionalProperties": false }, { @@ -96,7 +112,10 @@ "maxLength": 64 } }, - "required": ["type", "topic"], + "required": [ + "type", + "topic" + ], "additionalProperties": false }, { @@ -131,7 +150,10 @@ "maxLength": 64 } }, - "required": ["type", "session_id"], + "required": [ + "type", + "session_id" + ], "additionalProperties": false }, { @@ -151,7 +173,10 @@ "maxLength": 64 } }, - "required": ["type", "session_id"], + "required": [ + "type", + "session_id" + ], "additionalProperties": false }, { @@ -181,7 +206,12 @@ "maxLength": 64 } }, - "required": ["type", "session_id", "max_width", "max_height"], + "required": [ + "type", + "session_id", + "max_width", + "max_height" + ], "additionalProperties": false }, { @@ -206,7 +236,12 @@ }, "action": { "type": "string", - "enum": ["mouseMoved", "mousePressed", "mouseReleased", "mouseWheel"] + "enum": [ + "mouseMoved", + "mousePressed", + "mouseReleased", + "mouseWheel" + ] }, "x": { "type": "number", @@ -220,7 +255,12 @@ }, "button": { "type": "string", - "enum": ["none", "left", "middle", "right"] + "enum": [ + "none", + "left", + "middle", + "right" + ] }, "clickCount": { "type": "integer", @@ -243,7 +283,12 @@ "maximum": 15 } }, - "required": ["type", "action", "x", "y"], + "required": [ + "type", + "action", + "x", + "y" + ], "additionalProperties": false }, { @@ -255,7 +300,12 @@ }, "action": { "type": "string", - "enum": ["keyDown", "keyUp", "char", "rawKeyDown"] + "enum": [ + "keyDown", + "keyUp", + "char", + "rawKeyDown" + ] }, "key": { "type": "string", @@ -280,7 +330,10 @@ "maximum": 15 } }, - "required": ["type", "action"], + "required": [ + "type", + "action" + ], "additionalProperties": false }, { @@ -292,7 +345,12 @@ }, "action": { "type": "string", - "enum": ["touchStart", "touchEnd", "touchMove", "touchCancel"] + "enum": [ + "touchStart", + "touchEnd", + "touchMove", + "touchCancel" + ] }, "points": { "maxItems": 10, @@ -331,7 +389,10 @@ "maximum": 32 } }, - "required": ["x", "y"], + "required": [ + "x", + "y" + ], "additionalProperties": false } }, @@ -341,7 +402,11 @@ "maximum": 15 } }, - "required": ["type", "action", "points"], + "required": [ + "type", + "action", + "points" + ], "additionalProperties": false } ] @@ -352,7 +417,11 @@ "maxLength": 64 } }, - "required": ["type", "session_id", "input"], + "required": [ + "type", + "session_id", + "input" + ], "additionalProperties": false }, { @@ -382,7 +451,12 @@ "maxLength": 64 } }, - "required": ["type", "session_id", "width", "height"], + "required": [ + "type", + "session_id", + "width", + "height" + ], "additionalProperties": false }, { @@ -394,7 +468,13 @@ }, "level": { "type": "string", - "enum": ["error", "warn", "info", "debug", "trace"] + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ] }, "module": { "type": "string", @@ -407,7 +487,9 @@ "maxLength": 64 } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false } ] @@ -468,15 +550,24 @@ "type": "string" }, "tenant_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "channel": { "type": "string", - "enum": ["chromium", "chrome", "edge"] + "enum": [ + "chromium", + "chrome", + "edge" + ] }, "engine": { "type": "string", - "enum": ["chromium"] + "enum": [ + "chromium" + ] }, "headless": { "type": "boolean" @@ -486,10 +577,17 @@ }, "persistence_mode": { "type": "string", - "enum": ["memory", "persistent", "storage-state"] + "enum": [ + "memory", + "persistent", + "storage-state" + ] }, "current_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -617,17 +715,26 @@ "type": "object", "properties": { "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sandboxed": { "type": "boolean" } }, - "required": ["version", "sandboxed"], + "required": [ + "version", + "sandboxed" + ], "additionalProperties": false }, "proxy_label": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "counts": { "type": "object", @@ -687,10 +794,16 @@ "type": "object", "properties": { "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "agent_name": { "type": "string" @@ -731,7 +844,10 @@ } } }, - "required": ["name", "version"], + "required": [ + "name", + "version" + ], "additionalProperties": false }, { @@ -777,7 +893,10 @@ "additionalProperties": false } }, - "required": ["type", "session"], + "required": [ + "type", + "session" + ], "additionalProperties": false }, { @@ -801,15 +920,24 @@ "type": "string" }, "tenant_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "channel": { "type": "string", - "enum": ["chromium", "chrome", "edge"] + "enum": [ + "chromium", + "chrome", + "edge" + ] }, "engine": { "type": "string", - "enum": ["chromium"] + "enum": [ + "chromium" + ] }, "headless": { "type": "boolean" @@ -819,10 +947,17 @@ }, "persistence_mode": { "type": "string", - "enum": ["memory", "persistent", "storage-state"] + "enum": [ + "memory", + "persistent", + "storage-state" + ] }, "current_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -950,17 +1085,26 @@ "type": "object", "properties": { "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sandboxed": { "type": "boolean" } }, - "required": ["version", "sandboxed"], + "required": [ + "version", + "sandboxed" + ], "additionalProperties": false }, "proxy_label": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "counts": { "type": "object", @@ -1020,10 +1164,16 @@ "type": "object", "properties": { "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "agent_name": { "type": "string" @@ -1064,7 +1214,10 @@ } } }, - "required": ["name", "version"], + "required": [ + "name", + "version" + ], "additionalProperties": false }, { @@ -1110,7 +1263,10 @@ "additionalProperties": false } }, - "required": ["type", "session"], + "required": [ + "type", + "session" + ], "additionalProperties": false }, { @@ -1142,7 +1298,12 @@ ] } }, - "required": ["type", "session_id", "closed_at", "reason"], + "required": [ + "type", + "session_id", + "closed_at", + "reason" + ], "additionalProperties": false }, { @@ -1158,7 +1319,11 @@ }, "action": { "type": "string", - "enum": ["archived", "unarchived", "deleted"] + "enum": [ + "archived", + "unarchived", + "deleted" + ] }, "at": { "type": "integer", @@ -1166,7 +1331,12 @@ "maximum": 9007199254740991 } }, - "required": ["type", "session_id", "action", "at"], + "required": [ + "type", + "session_id", + "action", + "at" + ], "additionalProperties": false }, { @@ -1188,7 +1358,12 @@ }, "details": {} }, - "required": ["type", "session_id", "code", "message"], + "required": [ + "type", + "session_id", + "code", + "message" + ], "additionalProperties": false }, { @@ -1236,10 +1411,16 @@ "type": "boolean" }, "error_code": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "error_message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "duration_ms": { "type": "integer", @@ -1257,7 +1438,10 @@ "maximum": 9007199254740991 }, "trace_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "has_screenshot": { "type": "boolean" @@ -1269,7 +1453,10 @@ }, "args_json": {}, "result_text": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1292,7 +1479,11 @@ "type": "boolean" } }, - "required": ["type", "row", "has_detail"], + "required": [ + "type", + "row", + "has_detail" + ], "additionalProperties": false }, { @@ -1321,14 +1512,23 @@ "type": "string" }, "title": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "domain": { "type": "string" }, "category": { "type": "string", - "enum": ["public", "ip", "local", "ftp", "other"] + "enum": [ + "public", + "ip", + "local", + "ftp", + "other" + ] }, "ts": { "type": "integer", @@ -1349,7 +1549,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -1375,7 +1578,10 @@ }, "kind": { "type": "string", - "enum": ["tool", "trace"] + "enum": [ + "tool", + "trace" + ] }, "content_type": { "type": "string" @@ -1419,7 +1625,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -1441,7 +1650,10 @@ "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool_event_id": { "anyOf": [ @@ -1470,10 +1682,19 @@ }, "result": { "type": "string", - "enum": ["success", "origin_mismatch", "auth_failed", "blocked", "denied"] + "enum": [ + "success", + "origin_mismatch", + "auth_failed", + "blocked", + "denied" + ] }, "reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "evaluate_enabled": { "type": "boolean" @@ -1483,10 +1704,17 @@ }, "origin_check": { "type": "string", - "enum": ["pass", "fail", "skipped"] + "enum": [ + "pass", + "fail", + "skipped" + ] }, "principal_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "details": { "anyOf": [ @@ -1521,7 +1749,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -1550,7 +1781,10 @@ ] }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool_event_id": { "anyOf": [ @@ -1567,17 +1801,26 @@ "type": "string" }, "domain": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "pattern": { "type": "string" }, "source": { "type": "string", - "enum": ["tool", "request"] + "enum": [ + "tool", + "request" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "ts": { "type": "integer", @@ -1600,7 +1843,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -1619,7 +1865,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -1638,7 +1887,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -1655,16 +1907,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -1708,10 +1975,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -1725,7 +1998,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1753,7 +2029,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -1772,7 +2051,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -1791,7 +2073,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -1808,16 +2093,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -1861,10 +2161,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -1878,7 +2184,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1906,7 +2215,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -1925,7 +2237,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -1944,7 +2259,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -1961,16 +2279,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -2014,10 +2347,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -2031,7 +2370,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -2059,7 +2401,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -2078,7 +2423,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -2097,7 +2445,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -2114,16 +2465,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -2167,10 +2533,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -2184,7 +2556,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -2212,7 +2587,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -2228,10 +2606,18 @@ }, "action": { "type": "string", - "enum": ["created", "updated", "removed"] + "enum": [ + "created", + "updated", + "removed" + ] } }, - "required": ["type", "handle", "action"], + "required": [ + "type", + "handle", + "action" + ], "additionalProperties": false }, { @@ -2242,10 +2628,16 @@ "const": "vault.policy.changed" }, "group_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, - "required": ["type", "group_id"], + "required": [ + "type", + "group_id" + ], "additionalProperties": false }, { @@ -2259,7 +2651,10 @@ "type": "boolean" } }, - "required": ["type", "unlocked"], + "required": [ + "type", + "unlocked" + ], "additionalProperties": false }, { @@ -2285,7 +2680,12 @@ "maximum": 9007199254740991 } }, - "required": ["type", "patterns", "skipped", "loaded_at"], + "required": [ + "type", + "patterns", + "skipped", + "loaded_at" + ], "additionalProperties": false }, { @@ -2306,7 +2706,11 @@ }, "severity": { "type": "string", - "enum": ["info", "warn", "error"] + "enum": [ + "info", + "warn", + "error" + ] }, "message": { "type": "string" @@ -2361,7 +2765,10 @@ "additionalProperties": false } }, - "required": ["type", "event"], + "required": [ + "type", + "event" + ], "additionalProperties": false }, { @@ -2382,7 +2789,11 @@ }, "severity": { "type": "string", - "enum": ["info", "warn", "error"] + "enum": [ + "info", + "warn", + "error" + ] }, "message": { "type": "string" @@ -2437,7 +2848,10 @@ "additionalProperties": false } }, - "required": ["type", "event"], + "required": [ + "type", + "event" + ], "additionalProperties": false }, { @@ -2453,7 +2867,10 @@ "maximum": 9007199254740991 } }, - "required": ["type", "now"], + "required": [ + "type", + "now" + ], "additionalProperties": false }, { @@ -2481,7 +2898,11 @@ ] } }, - "required": ["type", "live", "max"], + "required": [ + "type", + "live", + "max" + ], "additionalProperties": false }, { @@ -2503,14 +2924,27 @@ }, "result": { "type": "string", - "enum": ["ok", "partial", "failed"] + "enum": [ + "ok", + "partial", + "failed" + ] }, "severity": { "type": "string", - "enum": ["info", "warn", "error"] + "enum": [ + "info", + "warn", + "error" + ] } }, - "required": ["type", "at", "pruned_rows", "result"], + "required": [ + "type", + "at", + "pruned_rows", + "result" + ], "additionalProperties": false }, { @@ -2528,17 +2962,29 @@ "pattern": "^n-[A-Za-z0-9_-]{12}$" }, "principal_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "type": { "type": "string", - "enum": ["attention", "error", "vault", "lifecycle", "system"] + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] }, "title": { "type": "string" }, "body": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "session_id": { "anyOf": [ @@ -2552,13 +2998,22 @@ ] }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "target": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "source_event_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -2598,6 +3053,60 @@ "type": "null" } ] + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "thread": { + "type": "string" } }, "required": [ @@ -2614,12 +3123,21 @@ "updated_at", "count", "read_at", - "dismissed_at" + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ], "additionalProperties": false } }, - "required": ["type", "notification"], + "required": [ + "type", + "notification" + ], "additionalProperties": false }, { @@ -2637,17 +3155,29 @@ "pattern": "^n-[A-Za-z0-9_-]{12}$" }, "principal_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "type": { "type": "string", - "enum": ["attention", "error", "vault", "lifecycle", "system"] + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] }, "title": { "type": "string" }, "body": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "session_id": { "anyOf": [ @@ -2661,13 +3191,22 @@ ] }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "target": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "source_event_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -2707,6 +3246,60 @@ "type": "null" } ] + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "thread": { + "type": "string" } }, "required": [ @@ -2723,12 +3316,21 @@ "updated_at", "count", "read_at", - "dismissed_at" + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ], "additionalProperties": false } }, - "required": ["type", "notification"], + "required": [ + "type", + "notification" + ], "additionalProperties": false }, { @@ -2753,7 +3355,13 @@ }, "level": { "type": "string", - "enum": ["error", "warn", "info", "debug", "trace"] + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ] }, "msg": { "type": "string" @@ -2778,7 +3386,12 @@ }, "transport": { "type": "string", - "enum": ["http", "stdio", "ws", "cli"] + "enum": [ + "http", + "stdio", + "ws", + "cli" + ] }, "err": { "type": "object", @@ -2797,21 +3410,40 @@ }, "cause": {} }, - "required": ["name", "message"], + "required": [ + "name", + "message" + ], "additionalProperties": false } }, - "required": ["seq", "ts", "level", "msg", "module"], + "required": [ + "seq", + "ts", + "level", + "msg", + "module" + ], "additionalProperties": {} } }, - "required": ["type", "record"], + "required": [ + "type", + "record" + ], "additionalProperties": false } ] } }, - "required": ["v", "kind", "seq", "ts", "topic", "payload"], + "required": [ + "v", + "kind", + "seq", + "ts", + "topic", + "payload" + ], "additionalProperties": false }, { @@ -2915,7 +3547,13 @@ "type": "boolean" } }, - "required": ["type", "topic", "from", "to", "complete"], + "required": [ + "type", + "topic", + "from", + "to", + "complete" + ], "additionalProperties": false }, { @@ -2933,7 +3571,11 @@ "type": "boolean" } }, - "required": ["type", "topic", "ok"], + "required": [ + "type", + "topic", + "ok" + ], "additionalProperties": false }, { @@ -2949,7 +3591,10 @@ "maximum": 9007199254740991 } }, - "required": ["type", "ts"], + "required": [ + "type", + "ts" + ], "additionalProperties": false }, { @@ -2969,7 +3614,11 @@ "maximum": 9007199254740991 } }, - "required": ["type", "topic", "ordinal"], + "required": [ + "type", + "topic", + "ordinal" + ], "additionalProperties": false }, { @@ -2981,7 +3630,9 @@ }, "result": {} }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false }, { @@ -2997,16 +3648,29 @@ }, "reason": { "type": "string", - "enum": ["cursor_expired", "epoch_changed", "buffer_overflow"] + "enum": [ + "cursor_expired", + "epoch_changed", + "buffer_overflow" + ] } }, - "required": ["type", "reason"], + "required": [ + "type", + "reason" + ], "additionalProperties": false } ] } }, - "required": ["v", "kind", "seq", "ts", "payload"], + "required": [ + "v", + "kind", + "seq", + "ts", + "payload" + ], "additionalProperties": false }, { @@ -3050,11 +3714,20 @@ }, "details": {} }, - "required": ["code", "title"], + "required": [ + "code", + "title" + ], "additionalProperties": false } }, - "required": ["v", "kind", "seq", "ts", "payload"], + "required": [ + "v", + "kind", + "seq", + "ts", + "payload" + ], "additionalProperties": false }, { @@ -3151,7 +3824,11 @@ "maximum": 4294967295 } }, - "required": ["type", "session_id", "ordinal"], + "required": [ + "type", + "session_id", + "ordinal" + ], "additionalProperties": false }, { @@ -3167,10 +3844,19 @@ }, "reason": { "type": "string", - "enum": ["stopped", "session_closed", "session_crashed", "connection_closed"] + "enum": [ + "stopped", + "session_closed", + "session_crashed", + "connection_closed" + ] } }, - "required": ["type", "session_id", "reason"], + "required": [ + "type", + "session_id", + "reason" + ], "additionalProperties": false }, { @@ -3191,13 +3877,24 @@ "type": "string" } }, - "required": ["type", "session_id", "code"], + "required": [ + "type", + "session_id", + "code" + ], "additionalProperties": false } ] } }, - "required": ["v", "kind", "seq", "ts", "topic", "payload"], + "required": [ + "v", + "kind", + "seq", + "ts", + "topic", + "payload" + ], "additionalProperties": false } ] @@ -3226,15 +3923,24 @@ "type": "string" }, "tenant_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "channel": { "type": "string", - "enum": ["chromium", "chrome", "edge"] + "enum": [ + "chromium", + "chrome", + "edge" + ] }, "engine": { "type": "string", - "enum": ["chromium"] + "enum": [ + "chromium" + ] }, "headless": { "type": "boolean" @@ -3244,10 +3950,17 @@ }, "persistence_mode": { "type": "string", - "enum": ["memory", "persistent", "storage-state"] + "enum": [ + "memory", + "persistent", + "storage-state" + ] }, "current_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -3326,7 +4039,15 @@ }, "state": { "type": "string", - "enum": ["reserved", "launching", "live", "paused", "draining", "closed", "crashed"] + "enum": [ + "reserved", + "launching", + "live", + "paused", + "draining", + "closed", + "crashed" + ] }, "live": { "type": "boolean" @@ -3367,17 +4088,26 @@ "type": "object", "properties": { "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sandboxed": { "type": "boolean" } }, - "required": ["version", "sandboxed"], + "required": [ + "version", + "sandboxed" + ], "additionalProperties": false }, "proxy_label": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "counts": { "type": "object", @@ -3437,10 +4167,16 @@ "type": "object", "properties": { "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "agent_name": { "type": "string" @@ -3481,7 +4217,10 @@ } } }, - "required": ["name", "version"], + "required": [ + "name", + "version" + ], "additionalProperties": false }, { @@ -3527,7 +4266,10 @@ "additionalProperties": false } }, - "required": ["type", "session"], + "required": [ + "type", + "session" + ], "additionalProperties": false }, { @@ -3551,15 +4293,24 @@ "type": "string" }, "tenant_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "channel": { "type": "string", - "enum": ["chromium", "chrome", "edge"] + "enum": [ + "chromium", + "chrome", + "edge" + ] }, "engine": { "type": "string", - "enum": ["chromium"] + "enum": [ + "chromium" + ] }, "headless": { "type": "boolean" @@ -3569,10 +4320,17 @@ }, "persistence_mode": { "type": "string", - "enum": ["memory", "persistent", "storage-state"] + "enum": [ + "memory", + "persistent", + "storage-state" + ] }, "current_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -3651,7 +4409,15 @@ }, "state": { "type": "string", - "enum": ["reserved", "launching", "live", "paused", "draining", "closed", "crashed"] + "enum": [ + "reserved", + "launching", + "live", + "paused", + "draining", + "closed", + "crashed" + ] }, "live": { "type": "boolean" @@ -3692,17 +4458,26 @@ "type": "object", "properties": { "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sandboxed": { "type": "boolean" } }, - "required": ["version", "sandboxed"], + "required": [ + "version", + "sandboxed" + ], "additionalProperties": false }, "proxy_label": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "counts": { "type": "object", @@ -3762,10 +4537,16 @@ "type": "object", "properties": { "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "version": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "agent_name": { "type": "string" @@ -3806,7 +4587,10 @@ } } }, - "required": ["name", "version"], + "required": [ + "name", + "version" + ], "additionalProperties": false }, { @@ -3852,7 +4636,10 @@ "additionalProperties": false } }, - "required": ["type", "session"], + "required": [ + "type", + "session" + ], "additionalProperties": false }, { @@ -3884,7 +4671,12 @@ ] } }, - "required": ["type", "session_id", "closed_at", "reason"], + "required": [ + "type", + "session_id", + "closed_at", + "reason" + ], "additionalProperties": false }, { @@ -3900,7 +4692,11 @@ }, "action": { "type": "string", - "enum": ["archived", "unarchived", "deleted"] + "enum": [ + "archived", + "unarchived", + "deleted" + ] }, "at": { "type": "integer", @@ -3908,7 +4704,12 @@ "maximum": 9007199254740991 } }, - "required": ["type", "session_id", "action", "at"], + "required": [ + "type", + "session_id", + "action", + "at" + ], "additionalProperties": false }, { @@ -3930,7 +4731,12 @@ }, "details": {} }, - "required": ["type", "session_id", "code", "message"], + "required": [ + "type", + "session_id", + "code", + "message" + ], "additionalProperties": false }, { @@ -3978,10 +4784,16 @@ "type": "boolean" }, "error_code": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "error_message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "duration_ms": { "type": "integer", @@ -3999,7 +4811,10 @@ "maximum": 9007199254740991 }, "trace_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "has_screenshot": { "type": "boolean" @@ -4011,7 +4826,10 @@ }, "args_json": {}, "result_text": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -4034,7 +4852,11 @@ "type": "boolean" } }, - "required": ["type", "row", "has_detail"], + "required": [ + "type", + "row", + "has_detail" + ], "additionalProperties": false }, { @@ -4063,14 +4885,23 @@ "type": "string" }, "title": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "domain": { "type": "string" }, "category": { "type": "string", - "enum": ["public", "ip", "local", "ftp", "other"] + "enum": [ + "public", + "ip", + "local", + "ftp", + "other" + ] }, "ts": { "type": "integer", @@ -4091,7 +4922,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -4117,7 +4951,10 @@ }, "kind": { "type": "string", - "enum": ["tool", "trace"] + "enum": [ + "tool", + "trace" + ] }, "content_type": { "type": "string" @@ -4161,7 +4998,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -4183,7 +5023,10 @@ "pattern": "^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$" }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool_event_id": { "anyOf": [ @@ -4212,10 +5055,19 @@ }, "result": { "type": "string", - "enum": ["success", "origin_mismatch", "auth_failed", "blocked", "denied"] + "enum": [ + "success", + "origin_mismatch", + "auth_failed", + "blocked", + "denied" + ] }, "reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "evaluate_enabled": { "type": "boolean" @@ -4225,10 +5077,17 @@ }, "origin_check": { "type": "string", - "enum": ["pass", "fail", "skipped"] + "enum": [ + "pass", + "fail", + "skipped" + ] }, "principal_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "details": { "anyOf": [ @@ -4263,7 +5122,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -4292,7 +5154,10 @@ ] }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool_event_id": { "anyOf": [ @@ -4309,17 +5174,26 @@ "type": "string" }, "domain": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "pattern": { "type": "string" }, "source": { "type": "string", - "enum": ["tool", "request"] + "enum": [ + "tool", + "request" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "ts": { "type": "integer", @@ -4342,7 +5216,10 @@ "additionalProperties": false } }, - "required": ["type", "row"], + "required": [ + "type", + "row" + ], "additionalProperties": false }, { @@ -4361,7 +5238,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -4380,7 +5260,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -4397,16 +5280,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -4450,10 +5348,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -4467,7 +5371,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -4495,7 +5402,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -4514,7 +5424,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -4533,7 +5446,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -4550,16 +5466,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -4603,10 +5534,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -4620,7 +5557,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -4648,7 +5588,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -4667,7 +5610,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -4686,7 +5632,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -4703,16 +5652,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -4756,10 +5720,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -4773,7 +5743,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -4801,7 +5774,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -4820,7 +5796,10 @@ }, "kind": { "type": "string", - "enum": ["attention", "vault_confirm"] + "enum": [ + "attention", + "vault_confirm" + ] }, "session_id": { "type": "string", @@ -4839,7 +5818,10 @@ "anyOf": [ { "type": "string", - "enum": ["takeover", "notify"] + "enum": [ + "takeover", + "notify" + ] }, { "type": "null" @@ -4856,16 +5838,31 @@ }, "status": { "type": "string", - "enum": ["pending", "resolved", "rejected", "timeout", "cancelled"] + "enum": [ + "pending", + "resolved", + "rejected", + "timeout", + "cancelled" + ] }, "message": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolved_by": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "resolution_reason": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -4909,10 +5906,16 @@ ] }, "page_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "tool": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "event_id": { "anyOf": [ @@ -4926,7 +5929,10 @@ ] }, "entry_name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "required": [ @@ -4954,7 +5960,10 @@ "additionalProperties": false } }, - "required": ["type", "request"], + "required": [ + "type", + "request" + ], "additionalProperties": false }, { @@ -4970,10 +5979,18 @@ }, "action": { "type": "string", - "enum": ["created", "updated", "removed"] + "enum": [ + "created", + "updated", + "removed" + ] } }, - "required": ["type", "handle", "action"], + "required": [ + "type", + "handle", + "action" + ], "additionalProperties": false }, { @@ -4984,10 +6001,16 @@ "const": "vault.policy.changed" }, "group_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, - "required": ["type", "group_id"], + "required": [ + "type", + "group_id" + ], "additionalProperties": false }, { @@ -5001,7 +6024,10 @@ "type": "boolean" } }, - "required": ["type", "unlocked"], + "required": [ + "type", + "unlocked" + ], "additionalProperties": false }, { @@ -5027,7 +6053,12 @@ "maximum": 9007199254740991 } }, - "required": ["type", "patterns", "skipped", "loaded_at"], + "required": [ + "type", + "patterns", + "skipped", + "loaded_at" + ], "additionalProperties": false }, { @@ -5048,7 +6079,11 @@ }, "severity": { "type": "string", - "enum": ["info", "warn", "error"] + "enum": [ + "info", + "warn", + "error" + ] }, "message": { "type": "string" @@ -5103,7 +6138,10 @@ "additionalProperties": false } }, - "required": ["type", "event"], + "required": [ + "type", + "event" + ], "additionalProperties": false }, { @@ -5124,7 +6162,11 @@ }, "severity": { "type": "string", - "enum": ["info", "warn", "error"] + "enum": [ + "info", + "warn", + "error" + ] }, "message": { "type": "string" @@ -5179,7 +6221,10 @@ "additionalProperties": false } }, - "required": ["type", "event"], + "required": [ + "type", + "event" + ], "additionalProperties": false }, { @@ -5195,7 +6240,10 @@ "maximum": 9007199254740991 } }, - "required": ["type", "now"], + "required": [ + "type", + "now" + ], "additionalProperties": false }, { @@ -5223,7 +6271,11 @@ ] } }, - "required": ["type", "live", "max"], + "required": [ + "type", + "live", + "max" + ], "additionalProperties": false }, { @@ -5245,14 +6297,27 @@ }, "result": { "type": "string", - "enum": ["ok", "partial", "failed"] + "enum": [ + "ok", + "partial", + "failed" + ] }, "severity": { "type": "string", - "enum": ["info", "warn", "error"] + "enum": [ + "info", + "warn", + "error" + ] } }, - "required": ["type", "at", "pruned_rows", "result"], + "required": [ + "type", + "at", + "pruned_rows", + "result" + ], "additionalProperties": false }, { @@ -5270,17 +6335,29 @@ "pattern": "^n-[A-Za-z0-9_-]{12}$" }, "principal_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "type": { "type": "string", - "enum": ["attention", "error", "vault", "lifecycle", "system"] + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] }, "title": { "type": "string" }, "body": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "session_id": { "anyOf": [ @@ -5294,13 +6371,22 @@ ] }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "target": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "source_event_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -5340,6 +6426,60 @@ "type": "null" } ] + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "thread": { + "type": "string" } }, "required": [ @@ -5356,12 +6496,21 @@ "updated_at", "count", "read_at", - "dismissed_at" + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ], "additionalProperties": false } }, - "required": ["type", "notification"], + "required": [ + "type", + "notification" + ], "additionalProperties": false }, { @@ -5379,17 +6528,29 @@ "pattern": "^n-[A-Za-z0-9_-]{12}$" }, "principal_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "type": { "type": "string", - "enum": ["attention", "error", "vault", "lifecycle", "system"] + "enum": [ + "attention", + "error", + "vault", + "lifecycle", + "system" + ] }, "title": { "type": "string" }, "body": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "session_id": { "anyOf": [ @@ -5403,13 +6564,22 @@ ] }, "session_slug": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "target": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "source_event_id": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "created_at": { "type": "integer", @@ -5449,6 +6619,60 @@ "type": "null" } ] + }, + "kind": { + "type": "string", + "enum": [ + "attention.requested", + "vault.confirm", + "vault.filled", + "session.finished", + "session.crashed", + "session.reaped", + "tool.errors", + "system.degraded", + "channel.broken", + "digest.daily", + "report.anomaly", + "test" + ] + }, + "category": { + "type": "string", + "enum": [ + "needs-you", + "problems", + "wrap-ups", + "reports", + "system" + ] + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warn", + "error", + "critical" + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "acted", + "resolved", + "expired", + "final" + ] + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "thread": { + "type": "string" } }, "required": [ @@ -5465,12 +6689,21 @@ "updated_at", "count", "read_at", - "dismissed_at" + "dismissed_at", + "kind", + "category", + "severity", + "state", + "revision", + "thread" ], "additionalProperties": false } }, - "required": ["type", "notification"], + "required": [ + "type", + "notification" + ], "additionalProperties": false }, { @@ -5495,7 +6728,13 @@ }, "level": { "type": "string", - "enum": ["error", "warn", "info", "debug", "trace"] + "enum": [ + "error", + "warn", + "info", + "debug", + "trace" + ] }, "msg": { "type": "string" @@ -5520,7 +6759,12 @@ }, "transport": { "type": "string", - "enum": ["http", "stdio", "ws", "cli"] + "enum": [ + "http", + "stdio", + "ws", + "cli" + ] }, "err": { "type": "object", @@ -5539,15 +6783,27 @@ }, "cause": {} }, - "required": ["name", "message"], + "required": [ + "name", + "message" + ], "additionalProperties": false } }, - "required": ["seq", "ts", "level", "msg", "module"], + "required": [ + "seq", + "ts", + "level", + "msg", + "module" + ], "additionalProperties": {} } }, - "required": ["type", "record"], + "required": [ + "type", + "record" + ], "additionalProperties": false } ] diff --git a/packages/contracts/test/http/fixtures.ts b/packages/contracts/test/http/fixtures.ts index 1b1b763..e4abb31 100644 --- a/packages/contracts/test/http/fixtures.ts +++ b/packages/contracts/test/http/fixtures.ts @@ -192,6 +192,12 @@ export const notification = (): Input => ({ count: 1, read_at: null, dismissed_at: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: `attention:${REQUEST_ID}`, }); export const logRecord = (): Input => ({ diff --git a/packages/contracts/test/notifications.test.ts b/packages/contracts/test/notifications.test.ts new file mode 100644 index 0000000..c6eb64e --- /dev/null +++ b/packages/contracts/test/notifications.test.ts @@ -0,0 +1,227 @@ +/** @module contracts/test/notifications.test — the notification contract (D-32): message schema, dashboard paths, the kind taxonomy, the legacy classification mirrored by migration v5, channel rules and secret references (D-33), the published JSON Schema */ +import { describe, expect, it } from 'bun:test'; +import { NotificationKind } from '../src/enums/index.ts'; +import { + classifyLegacy, + DashboardPath, + IN_APP_ONLY_KINDS, + KIND_CATEGORY, + KIND_LABEL, + KIND_SEVERITY, + NOTIFICATION_MESSAGE_SCHEMA_ID, + NotificationChannelRules, + NotificationMessage, + notificationMessageJsonSchema, + SecretEnvName, + StartupNotificationChannel, +} from '../src/notifications/index.ts'; + +const MESSAGE = { + schema: 1, + id: 'n-k3j4h5g6f7d8', + revision: 1, + thread: 'attention:a-k3j4h5g6f7d8', + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + alert: true, + at: { created: 1, updated: 1 }, + title: 'Attention requested', + summary: 'captcha — agent blocked', + blocks: [ + { type: 'quote', content: [{ type: 'text', text: 'solve it' }], collapsible: true }, + { + type: 'fields', + items: [ + { + label: 'Session', + value: [{ type: 'link', text: 'shop', path: '/sessions/shop-a1b2c3d4' }], + }, + ], + }, + { type: 'table', columns: ['Tool'], rows: [[[{ type: 'code', text: 'click' }]]] }, + { type: 'image', ref: 'shot', alt: 'page', captured_at: 1, masked: false, path: null }, + { type: 'code', text: 'x', language: null }, + { type: 'divider' }, + { type: 'footer', content: [{ type: 'time', at: 1, style: 'relative' }] }, + ], + actions: [ + { + kind: 'open', + id: 'take-over', + label: 'Take over', + style: 'primary', + path: '/sessions/shop-a1b2c3d4?live=1', + }, + { + kind: 'act', + id: 'reject', + label: 'Reject', + style: 'danger', + command: { + op: 'attention.resolve', + args: { request_id: 'a-k3j4h5g6f7d8', decision: 'reject' }, + }, + confirm: 'Reject?', + fallback: { label: 'Open in BrowserHive', path: '/attention' }, + }, + ], + entities: { session_id: 'shop-a1b2c3d4', session_slug: 'shop' }, + privacy: { level: 'full', has_image: true }, +}; + +describe('NotificationMessage', () => { + it('accepts a full message and rejects another schema version', () => { + expect(NotificationMessage.safeParse(MESSAGE).success).toBe(true); + expect(NotificationMessage.safeParse({ ...MESSAGE, schema: 2 }).success).toBe(false); + expect(NotificationMessage.safeParse({ ...MESSAGE, title: 'x'.repeat(121) }).success).toBe( + false, + ); + expect( + NotificationMessage.safeParse({ ...MESSAGE, actions: Array(6).fill(MESSAGE.actions[0]) }) + .success, + ).toBe(false); + }); + + it('links only to dashboard paths', () => { + expect(DashboardPath.safeParse('/sessions/x?live=1').success).toBe(true); + for (const bad of ['https://evil.example/', '//evil.example/x', 'sessions/x', '/a b']) { + expect(DashboardPath.safeParse(bad).success).toBe(false); + } + }); + + it('publishes a draft 2020-12 JSON Schema with every top-level field required', () => { + const schema = notificationMessageJsonSchema(); + expect(schema['$id']).toBe(NOTIFICATION_MESSAGE_SCHEMA_ID); + expect(schema['$schema']).toBe('https://json-schema.org/draft/2020-12/schema'); + expect([...((schema['required'] as string[] | undefined) ?? [])].sort()).toEqual( + Object.keys(MESSAGE).sort(), + ); + }); +}); + +describe('taxonomy', () => { + it('maps every kind to a category, a severity and a label', () => { + for (const kind of NotificationKind.options) { + expect(KIND_CATEGORY[kind]).toBeDefined(); + expect(KIND_SEVERITY[kind]).toBeDefined(); + expect(KIND_LABEL[kind].length).toBeGreaterThan(0); + } + expect([...IN_APP_ONLY_KINDS]).toEqual(['channel.broken']); + }); + + it('classifies legacy rows as migration v5 does (state aside)', () => { + const base = { + notificationId: 'n-1', + groupKey: null, + sessionId: 'shop-a1b2c3d4', + sourceEventId: 'a-1', + }; + const rows = [ + [ + { type: 'attention', title: 'Attention requested' }, + 'attention.requested', + 'needs-you', + 'warn', + 'final', + 'attention:a-1', + ], + [ + { type: 'vault', title: 'Vault fill awaiting confirm' }, + 'vault.confirm', + 'needs-you', + 'warn', + 'final', + 'vault:a-1', + ], + [ + { type: 'lifecycle', title: 'Session reaped (lease expired)' }, + 'session.reaped', + 'problems', + 'warn', + 'final', + 'session:shop-a1b2c3d4', + ], + [{ type: 'system', title: 'm' }, 'system.degraded', 'system', 'error', 'final', 'system:a-1'], + [ + { type: 'error', title: 'Session crashed' }, + 'session.crashed', + 'problems', + 'error', + 'final', + 'session:shop-a1b2c3d4', + ], + [ + { type: 'error', title: 'shop · 2 tool errors' }, + 'tool.errors', + 'problems', + 'warn', + 'open', + 'tool-errors:shop-a1b2c3d4', + ], + ] as const; + for (const [row, kind, category, severity, state, thread] of rows) { + expect(classifyLegacy({ ...base, ...row })).toEqual({ + kind, + category, + severity, + state, + thread, + }); + } + expect( + classifyLegacy({ ...base, type: 'attention', title: 't', sourceEventId: null }).thread, + ).toBe('notification:n-1'); + }); +}); + +describe('channel configuration', () => { + it('secrets are environment variable names, never BROWSERHIVE_*', () => { + expect(SecretEnvName.safeParse('BH_TG_TOKEN').success).toBe(true); + for (const bad of ['BROWSERHIVE_TOKEN', 'bad-name', '1ABC', '']) { + expect(SecretEnvName.safeParse(bad).success).toBe(false); + } + }); + + it('rules are optional per key and unknown keys are dropped', () => { + expect(NotificationChannelRules.parse({})).toEqual({}); + expect( + NotificationChannelRules.parse({ + categories: ['needs-you'], + ttl_ms: { 'needs-you': 7_200_000 }, + quiet_hours: { start: '22:00', end: '07:00' }, + future_rule: true, + }), + ).toEqual({ + categories: ['needs-you'], + ttl_ms: { 'needs-you': 7_200_000 }, + quiet_hours: { start: '22:00', end: '07:00' }, + }); + expect( + NotificationChannelRules.safeParse({ quiet_hours: { start: '25:00', end: '07:00' } }).success, + ).toBe(false); + }); + + it('a startup channel is external and names its secrets', () => { + const channel = { + name: 'phone', + kind: 'telegram', + mode: null, + target: { chat: '123456' }, + secret_refs: { token: 'BH_TG_TOKEN' }, + rules: {}, + }; + expect(StartupNotificationChannel.safeParse(channel).success).toBe(true); + expect(StartupNotificationChannel.safeParse({ ...channel, kind: 'in-app' }).success).toBe( + false, + ); + expect(StartupNotificationChannel.safeParse({ ...channel, name: 'Phone!' }).success).toBe( + false, + ); + expect( + StartupNotificationChannel.safeParse({ ...channel, secret_refs: { token: '123:abc' } }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/contracts/tsdown.config.ts b/packages/contracts/tsdown.config.ts index 6b14978..18d2b1f 100644 --- a/packages/contracts/tsdown.config.ts +++ b/packages/contracts/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ tools: 'src/tools/index.ts', http: 'src/http/index.ts', ws: 'src/ws/index.ts', + notifications: 'src/notifications/index.ts', }, format: 'esm', platform: 'neutral', diff --git a/packages/core/src/app/events/bus.test.ts b/packages/core/src/app/events/bus.test.ts index 2614fb0..402f2e8 100644 --- a/packages/core/src/app/events/bus.test.ts +++ b/packages/core/src/app/events/bus.test.ts @@ -156,6 +156,7 @@ describe('DomainEvents catalog', () => { 'notification.updated', 'notification.read', 'notification.dismissed', + 'notification.channel.changed', 'auth.login_success', 'auth.logout', 'auth.login_failure', diff --git a/packages/core/src/app/events/catalog.ts b/packages/core/src/app/events/catalog.ts index 0d3aed3..b9a0ebd 100644 --- a/packages/core/src/app/events/catalog.ts +++ b/packages/core/src/app/events/catalog.ts @@ -71,6 +71,21 @@ export interface ToolObservation { readonly seq: number; } +/** Payload of `notification.channel.changed` (internal; the channel page arrives later). */ +export interface NotificationChannelChangedEvent { + readonly type: 'notification.channel.changed'; + readonly channel_id: string; + readonly name: string; + readonly kind: string; + readonly status: 'active' | 'paused' | 'broken'; + readonly previous_status: 'active' | 'paused' | 'broken'; + /** Consecutive failures when the status changed. */ + readonly failure_count: number; + /** Last platform error, already scrubbed. */ + readonly last_error: string | null; + readonly at: number; +} + /** Payload of the `auth.*` events: the audit row as written (see `app/auth/events.ts`). */ export type { AuthEventPayload } from '../auth/events.ts'; @@ -141,6 +156,8 @@ export type DomainEvents = { readonly type: 'notification.dismissed'; readonly notification: NotificationStatePayload; }; + /** A notification channel's status changed (the breaker opened, D-34). Internal; not on the feed. */ + readonly 'notification.channel.changed': NotificationChannelChangedEvent; // logs readonly 'log.record': z.infer; } & AuthEvents; // auth (audit; never on the public feed): `auth.` diff --git a/packages/core/src/app/maintenance/retention-scheduler.test.ts b/packages/core/src/app/maintenance/retention-scheduler.test.ts index edfd987..5e88925 100644 --- a/packages/core/src/app/maintenance/retention-scheduler.test.ts +++ b/packages/core/src/app/maintenance/retention-scheduler.test.ts @@ -54,6 +54,7 @@ describe('retentionPolicyFromConfig', () => { auditRetentionDays: 90, notificationSeenDays: 30, notificationDays: 90, + notificationDeliveryDays: 30, }); }); }); diff --git a/packages/core/src/app/maintenance/retention-scheduler.ts b/packages/core/src/app/maintenance/retention-scheduler.ts index 0b3305c..5ccff95 100644 --- a/packages/core/src/app/maintenance/retention-scheduler.ts +++ b/packages/core/src/app/maintenance/retention-scheduler.ts @@ -43,6 +43,7 @@ export function retentionPolicyFromConfig(config: RetentionConfig): RetentionPol auditRetentionDays: config.auditRetentionDays ?? DEFAULT_AUDIT_RETENTION_DAYS, notificationSeenDays: 30, notificationDays: 90, + notificationDeliveryDays: 30, }; } diff --git a/packages/core/src/app/notifications/channel-registry.test.ts b/packages/core/src/app/notifications/channel-registry.test.ts new file mode 100644 index 0000000..bc99c97 --- /dev/null +++ b/packages/core/src/app/notifications/channel-registry.test.ts @@ -0,0 +1,156 @@ +/** @module app/notifications/channel-registry.test — startup channels projected read-only into the table (D-39), removal of undeclared ones, the name clash, adapter factories and secret resolution (D-33). */ + +import { describe, expect, it } from 'bun:test'; +import type { StartupNotificationChannel } from '@browserhive/contracts/notifications'; +import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; +import { 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 } from '../../../test/helpers/in-memory-repos.ts'; +import { AppError } from '../../kernel/errors/app-error.ts'; +import { + type ChannelAdapterFactory, + ChannelRegistry, + type ChannelRegistryDeps, +} from './channel-registry.ts'; + +const STARTUP: StartupNotificationChannel = { + name: 'pager', + kind: 'telegram', + mode: null, + target: { chat: '42' }, + secret_refs: { token: 'BH_TG_TOKEN' }, + rules: { categories: ['needs-you'] }, +}; + +function setup(extra: Partial = {}) { + const repos = new InMemoryRepositories(); + const clock = new FakeClock(); + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids: new FakeIdGenerator(), + logger: new CollectingLogger(), + ...extra, + }); + return { repos, clock, registry }; +} + +describe('ChannelRegistry', () => { + it('is empty and costs nothing without channels', async () => { + const { registry } = setup(); + await registry.load(); + expect(registry.hasChannels()).toBe(false); + expect(registry.channels()).toEqual([]); + }); + + it('projects startup channels as read-only rows and keeps their breaker state across starts', async () => { + const { repos, registry, clock } = setup(); + await registry.load([STARTUP]); + const [row] = await repos.notificationChannels.list(); + expect(row).toMatchObject({ + name: 'pager', + kind: 'telegram', + source: 'startup', + status: 'active', + secretRefs: { token: 'BH_TG_TOKEN' }, + rules: { categories: ['needs-you'] }, + }); + if (row === undefined) throw new Error('no row'); + await repos.notificationChannels.recordFailure(row.channelId, 5, 'down'); + await repos.notificationChannels.setStatus(row.channelId, 'paused', 6); + await clock.advance(1_000); + await registry.load([{ ...STARTUP, rules: { categories: ['problems'] } }]); + const [again] = await repos.notificationChannels.list(); + expect(again).toMatchObject({ + channelId: row.channelId, + status: 'paused', + failureCount: 1, + rules: { categories: ['problems'] }, + createdAt: row.createdAt, + updatedAt: clock.now(), + }); + }); + + it('removes startup channels that are no longer declared, with their delivery log', async () => { + const { repos, registry } = setup(); + await registry.load([STARTUP]); + const [row] = await repos.notificationChannels.list(); + await repos.notificationDeliveries.enqueue([ + { + channelId: row?.channelId ?? '', + notificationId: 'n-000000000001', + revision: 1, + op: 'send', + status: 'pending', + reason: null, + nextAttemptAt: 1, + createdAt: 1, + }, + ]); + await repos.notificationChannels.upsert(channelRecord({ channelId: 'nc-db', name: 'team' })); + await registry.load([]); + expect((await repos.notificationChannels.list()).map((c) => c.name)).toEqual(['team']); + expect(repos.notificationDeliveries.rows).toEqual([]); + }); + + it('refuses a startup channel whose name a dashboard channel uses', async () => { + const { repos, registry } = setup(); + await repos.notificationChannels.upsert(channelRecord({ name: 'pager' })); + const err = await registry.load([STARTUP]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).code).toBe('CONFIG_INVALID'); + expect((err as AppError).message).toBe( + "notification channel 'pager' is defined by --notificationChannel and in the dashboard. Rename one of them.", + ); + }); + + it('builds adapters through the factory of their kind, resolving and registering secrets by name', async () => { + const registered: string[] = []; + const seen: (string | null)[] = []; + const fake = new FakeChannel(); + const { repos, registry } = setup({ + env: (name) => ({ BH_FAKE_TOKEN: 'a'.repeat(32), EMPTY: '' })[name], + registerSecret: (v) => void registered.push(v), + factories: new Map([ + [ + 'fake', + (_row, ctx) => { + seen.push(ctx.secret('BH_FAKE_TOKEN'), ctx.secret('EMPTY'), ctx.secret('UNSET')); + return fake; + }, + ], + [ + 'broken', + () => { + throw new Error('bad target'); + }, + ], + ]), + }); + await repos.notificationChannels.upsert(channelRecord()); + await repos.notificationChannels.upsert( + channelRecord({ channelId: 'nc-2', name: 'b', kind: 'broken' }), + ); + await repos.notificationChannels.upsert( + channelRecord({ channelId: 'nc-3', name: 'c', kind: 'nothing' }), + ); + let changes = 0; + registry.onChange(() => { + changes++; + }); + await registry.load(); + expect(changes).toBe(1); + expect(seen).toEqual(['a'.repeat(32), null, null]); + expect(registered).toEqual(['a'.repeat(32)]); + expect(registry.get('nc-000000000001')?.adapter).toBe(fake); + expect(registry.get('nc-000000000001')?.capabilities).toBe(fake.capabilities); + expect(registry.get('nc-2')?.adapter).toBeNull(); + expect(registry.get('nc-3')?.capabilities).toBeNull(); + registry.setCachedStatus('nc-000000000001', 'broken', 5); + expect(registry.get('nc-000000000001')?.record).toMatchObject({ + status: 'broken', + failureCount: 5, + }); + }); +}); diff --git a/packages/core/src/app/notifications/channel-registry.ts b/packages/core/src/app/notifications/channel-registry.ts new file mode 100644 index 0000000..1c670c2 --- /dev/null +++ b/packages/core/src/app/notifications/channel-registry.ts @@ -0,0 +1,178 @@ +/** @module app/notifications/channel-registry — the configured notification channels and their adapters (spec 03 §9.3, D-33, D-39): loads `notification_channels`, projects the startup channels into it (read-only, name clash = `CONFIG_INVALID`), builds one adapter per channel through the factories composition registers per kind. */ + +import type { StartupNotificationChannel } from '@browserhive/contracts/notifications'; +import { AppError } from '../../kernel/errors/app-error.ts'; +import { serializeError } from '../../kernel/errors/serialize-error.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 { NotificationChannel } from '../../ports/notification-channel.ts'; +import type { NotificationChannelStatus } from '../../ports/persistence/enums.ts'; +import type { NotificationChannelRepository } from '../../ports/persistence/notification-outbox.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import type { RoutableChannel } from './routing.ts'; + +/** What an adapter factory receives besides the channel row. */ +export interface ChannelFactoryContext { + /** + * Value of a secret environment variable named in the channel's `secretRefs` (D-33), or `null` + * when it is unset or empty. Every value returned is registered with the redactor first, so it + * can never reach a log line, a stored message or the delivery log. + */ + secret(envName: string): string | null; +} + +/** Builds the adapter of one channel row. Throwing marks the channel as having no adapter. */ +export type ChannelAdapterFactory = ( + channel: NotificationChannelRecord, + context: ChannelFactoryContext, +) => NotificationChannel; + +/** A channel with its adapter (`null` when no factory exists for its kind or the factory failed). */ +export interface RegisteredChannel extends RoutableChannel { + readonly adapter: NotificationChannel | null; +} + +/** Dependencies of {@link ChannelRegistry}. */ +export interface ChannelRegistryDeps { + readonly repo: NotificationChannelRepository; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly logger: Logger; + /** One factory per `NotificationChannelKind`; none are registered until platform adapters ship. */ + readonly factories?: ReadonlyMap; + /** Reads an environment variable (composition passes the host environment). */ + readonly env?: (name: string) => string | undefined; + /** Registers a resolved secret with the redactor (`SecretRegistry.add`). */ + readonly registerSecret?: (value: string) => void; +} + +/** + * The in-memory view of `notification_channels` the planner and the outbox read on every change, + * so a notification with no external channel costs no query. Reload after any change to the table. + */ +export class ChannelRegistry { + private entries: RegisteredChannel[] = []; + private readonly listeners = new Set<() => void>(); + private readonly log: Logger; + + constructor(private readonly deps: ChannelRegistryDeps) { + this.log = deps.logger.child({ module: 'notifications' }); + } + + /** + * Projects the startup channels (D-39) and loads every channel. A startup channel whose name a + * dashboard channel already uses throws `CONFIG_INVALID` (exit 64); startup rows no longer + * declared are removed with their delivery log; the configuration of the others is rewritten + * while their status and breaker counters are kept. + */ + async load(startup: readonly StartupNotificationChannel[] = []): Promise { + const now = this.deps.clock.now(); + const existing = await this.deps.repo.list(); + const declared = new Set(startup.map((s) => s.name)); + for (const spec of startup) { + const row = existing.find((r) => r.name === spec.name); + if (row !== undefined && row.source !== 'startup') { + throw new AppError( + 'CONFIG_INVALID', + { + key: 'notificationChannel', + source: 'cli', + reason: `notification channel '${spec.name}' is defined by --notificationChannel and in the dashboard. Rename one of them.`, + }, + { + message: `notification channel '${spec.name}' is defined by --notificationChannel and in the dashboard. Rename one of them.`, + }, + ); + } + await this.deps.repo.upsert({ + channelId: row?.channelId ?? `nc-${this.deps.ids.opaque(12)}`, + name: spec.name, + kind: spec.kind, + mode: spec.mode, + source: 'startup', + status: row?.status ?? 'active', + target: spec.target, + secretRefs: spec.secret_refs, + rules: spec.rules, + failureCount: row?.failureCount ?? 0, + lastError: row?.lastError ?? null, + lastOkAt: row?.lastOkAt ?? null, + lastFailureAt: row?.lastFailureAt ?? null, + createdAt: row?.createdAt ?? now, + updatedAt: now, + }); + } + for (const row of existing) { + if (row.source === 'startup' && !declared.has(row.name)) { + await this.deps.repo.remove(row.channelId); + this.log.info('startup channel removed', { channel: row.name }); + } + } + await this.reload(); + } + + /** Re-reads the table and rebuilds adapters; listeners run after. */ + async reload(): Promise { + const rows = await this.deps.repo.list(); + this.entries = rows.map((record) => ({ + record, + ...this.build(record), + })); + for (const fn of this.listeners) fn(); + } + + /** Every configured external channel (the in-app inbox is not one of them). */ + channels(): readonly RegisteredChannel[] { + return this.entries; + } + + /** One channel by id. */ + get(channelId: string): RegisteredChannel | undefined { + return this.entries.find((e) => e.record.channelId === channelId); + } + + /** True when at least one external channel is configured (the outbox runs only then). */ + hasChannels(): boolean { + return this.entries.length > 0; + } + + /** Updates the cached status of one channel after the outbox changed it in the table. */ + setCachedStatus( + channelId: string, + status: NotificationChannelStatus, + failureCount: number, + ): void { + this.entries = this.entries.map((e) => + e.record.channelId === channelId + ? { ...e, record: { ...e.record, status, failureCount } } + : e, + ); + } + + /** Runs `fn` after every reload; returns the unsubscribe function. */ + onChange(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + private build(record: NotificationChannelRecord): Omit { + const factory = this.deps.factories?.get(record.kind); + if (factory === undefined) return { adapter: null, capabilities: null }; + const context: ChannelFactoryContext = { + secret: (envName) => { + const value = this.deps.env?.(envName); + if (value === undefined || value === '') return null; + this.deps.registerSecret?.(value); + return value; + }, + }; + try { + const adapter = factory(record, context); + return { adapter, capabilities: adapter.capabilities }; + } catch (err) { + this.log.warn('channel adapter failed', { channel: record.name, err: serializeError(err) }); + return { adapter: null, capabilities: null }; + } + } +} diff --git a/packages/core/src/app/notifications/content-level.ts b/packages/core/src/app/notifications/content-level.ts new file mode 100644 index 0000000..ed65202 --- /dev/null +++ b/packages/core/src/app/notifications/content-level.ts @@ -0,0 +1,48 @@ +/** @module app/notifications/content-level — `restrictContent(message, level)`: the per-channel content levels of D-32 (`counts` < `titles` < `full`), applied by the core before any adapter sees a message. Pure. */ + +import type { NotificationContentLevel } from '@browserhive/contracts/enums'; +import { KIND_LABEL, type NotificationMessage } from '@browserhive/contracts/notifications'; + +/** Order of the levels, least to most revealing. */ +const RANK: { readonly [L in NotificationContentLevel]: number } = { + counts: 0, + titles: 1, + full: 2, +}; + +/** + * Restricts a message to what `level` may carry (spec 03 §9.2): + * - `full`: unchanged; + * - `titles`: title, summary, `fields` and `footer` blocks, actions and entities (no quotes, code, + * 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. + * + * @returns The restricted message. + */ +export function restrictContent( + message: NotificationMessage, + level: NotificationContentLevel, +): NotificationMessage { + const target = RANK[level] < RANK[message.privacy.level] ? level : message.privacy.level; + if (target === 'full') 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 } }; + } + const count = /(\d+) tool errors?$/.exec(message.title)?.[1]; + const label = KIND_LABEL[message.kind]; + const slug = message.entities.session_slug; + return { + ...message, + title: count === undefined || count === '1' ? label : `${label} (${count})`, + summary: slug === undefined ? '' : `Session ${slug}`, + blocks: [], + entities: { + ...(message.entities.session_id !== undefined && { session_id: message.entities.session_id }), + ...(slug !== undefined && { session_slug: slug }), + }, + privacy: { level: 'counts', has_image: false }, + }; +} diff --git a/packages/core/src/app/notifications/degrade.test.ts b/packages/core/src/app/notifications/degrade.test.ts new file mode 100644 index 0000000..d449e8e --- /dev/null +++ b/packages/core/src/app/notifications/degrade.test.ts @@ -0,0 +1,215 @@ +/** @module app/notifications/degrade.test — `degrade()` and `restrictContent()` table-driven (D-32): tables → lists, images dropped or linked, act → open, duplicate links, button cap, plain text, truncation with the "Open in BrowserHive" footer, content levels. */ + +import { describe, expect, it } from 'bun:test'; +import { + type Block, + type NotificationMessage as Message, + NotificationMessage, +} 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 { buildMessage, code, link, text } from './message.ts'; + +const TABLE: Block = { + type: 'table', + columns: ['Tool', 'Errors'], + rows: [ + [[code('click')], [text('3')]], + [[code('navigate')], [text('1')]], + ], +}; +const IMAGE: Block = { + type: 'image', + ref: 'shot-1', + alt: 'Login page', + captured_at: 5, + masked: true, + path: '/sessions/shop-a1b2c3d4?tab=screenshots', +}; +const IMAGE_NO_PAGE: Block = { ...IMAGE, path: null }; + +function message(overrides: Partial[0]> = {}): Message { + return buildMessage({ + id: 'n-000000000001', + revision: 1, + thread: 'attention:a-000000000001', + kind: 'attention.requested', + severity: 'warn', + state: 'open', + alert: true, + createdAt: 1, + updatedAt: 1, + title: 'Attention requested', + summary: 'captcha — agent blocked', + blocks: [ + { type: 'quote', content: [text('please solve the captcha')], collapsible: true }, + { + type: 'fields', + items: [{ label: 'Session', value: [link('shop', '/sessions/shop-a1b2c3d4')] }], + }, + ], + actions: [ + { + kind: 'open', + id: 'take-over', + label: 'Take over', + style: 'primary', + path: '/sessions/shop-a1b2c3d4?live=1&takeover=1', + }, + { + kind: 'act', + id: 'resolve', + label: 'Mark resolved', + style: 'default', + command: { + op: 'attention.resolve', + args: { request_id: 'a-000000000001', decision: 'resolve' }, + }, + confirm: null, + fallback: { label: 'Open in BrowserHive', path: '/sessions/shop-a1b2c3d4?live=1' }, + }, + { + kind: 'act', + id: 'reject', + label: 'Reject', + style: 'danger', + command: { + op: 'attention.resolve', + args: { request_id: 'a-000000000001', decision: 'reject' }, + }, + confirm: 'Reject?', + fallback: { label: 'Open in BrowserHive', path: '/sessions/shop-a1b2c3d4?live=1' }, + }, + ], + entities: { session_id: 'shop-a1b2c3d4', session_slug: 'shop', request_id: 'a-000000000001' }, + ...overrides, + }); +} + +describe('degrade', () => { + it('keeps everything a capable channel supports', () => { + const full = capabilities({ tables: true, actButtons: true, maxButtons: 5 }); + const m = message({ blocks: [TABLE, IMAGE] }); + const out = degrade(m, full); + expect(out.blocks).toEqual([TABLE, IMAGE]); + expect(out.actions).toEqual(m.actions); + expect(out.privacy.has_image).toBe(true); + expect(NotificationMessage.safeParse(out).success).toBe(true); + }); + + 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(out.blocks[0]?.type === 'list' && out.blocks[0].items).toHaveLength(2); + }); + + it('drops an image, or links to its page, where images are unsupported', () => { + const out = degrade( + message({ blocks: [IMAGE, IMAGE_NO_PAGE] }), + capabilities({ images: false }), + ); + expect(out.blocks).toEqual([ + { + type: 'text', + content: [link('View screenshot', '/sessions/shop-a1b2c3d4?tab=screenshots')], + }, + ]); + expect(out.privacy.has_image).toBe(false); + }); + + it('replaces act buttons by their open fallback once, and caps the buttons', () => { + const out = degrade(message(), capabilities({ actButtons: false, maxButtons: 5 })); + expect(out.actions.map((a) => [a.kind, a.id])).toEqual([ + ['open', 'take-over'], + ['open', 'resolve'], + ]); + const capped = degrade(message(), capabilities({ actButtons: true, maxButtons: 2 })); + expect(capped.actions.map((a) => a.id)).toEqual(['take-over', 'resolve']); + }); + + it('never shows an act button outside the open state', () => { + const resolved = { ...message(), state: 'resolved' as const }; + const out = degrade(resolved, capabilities({ actButtons: true, maxButtons: 5 })); + expect(out.actions.every((a) => a.kind === 'open')).toBe(true); + }); + + it('flattens rich blocks into paragraphs for plain-text channels', () => { + const out = degrade( + message({ + blocks: [{ type: 'heading', text: 'Details' }, ...message().blocks, { type: 'divider' }], + }), + capabilities({ richBlocks: false }), + ); + expect(out.blocks.every((b) => b.type === 'text')).toBe(true); + expect(JSON.stringify(out.blocks)).toContain('Session: '); + }); + + it('moves the first link into a footer where link buttons are unsupported', () => { + const out = degrade(message(), capabilities({ openLinks: false, actButtons: false })); + expect(out.actions).toEqual([]); + expect(out.blocks.at(-1)).toEqual({ + type: 'footer', + content: [link(OPEN_IN_BROWSERHIVE, '/sessions/shop-a1b2c3d4?live=1&takeover=1')], + }); + }); + + it('clips the title and cuts blocks past the text budget with an Open in BrowserHive footer', () => { + const long = message({ + title: 'T'.repeat(100), + blocks: [ + { type: 'text', content: [text('a'.repeat(50))] }, + { type: 'text', content: [text('b'.repeat(500))] }, + ], + }); + const out = degrade(long, capabilities({ maxTitleChars: 20, maxTextChars: 120 })); + expect(out.title).toHaveLength(20); + expect(out.title.endsWith('…')).toBe(true); + expect(out.blocks.at(-1)).toEqual({ + type: 'footer', + content: [text('… '), link(OPEN_IN_BROWSERHIVE, '/sessions/shop-a1b2c3d4?live=1&takeover=1')], + }); + expect(JSON.stringify(out.blocks)).not.toContain('bbbb'); + expect(NotificationMessage.safeParse(out).success).toBe(true); + }); + + it('does not modify its input', () => { + const m = message({ blocks: [TABLE, IMAGE] }); + const copy = structuredClone(m); + degrade(m, capabilities({ tables: false, images: false, richBlocks: false, maxTextChars: 10 })); + expect(m).toEqual(copy); + }); +}); + +describe('restrictContent', () => { + it('full is unchanged', () => { + const m = message(); + expect(restrictContent(m, 'full')).toBe(m); + }); + + it('titles keeps title, summary, fields and footers only', () => { + const out = restrictContent(message({ blocks: [...message().blocks, IMAGE, TABLE] }), 'titles'); + expect(out.blocks.map((b) => b.type)).toEqual(['fields']); + expect(out.title).toBe('Attention requested'); + expect(out.summary).toBe('captcha — agent blocked'); + expect(out.privacy).toEqual({ level: 'titles', has_image: false }); + }); + + it('counts keeps the kind label, the group count and the session slug', () => { + const out = restrictContent( + message({ kind: 'tool.errors', title: 'shop · 7 tool errors', summary: 'click · X' }), + 'counts', + ); + expect(out.title).toBe('Tool errors (7)'); + expect(out.summary).toBe('Session shop'); + expect(out.blocks).toEqual([]); + expect(out.entities).toEqual({ session_id: 'shop-a1b2c3d4', session_slug: 'shop' }); + expect(out.privacy.level).toBe('counts'); + }); + + it('never raises a message above the level it already has', () => { + const counts = restrictContent(message(), 'counts'); + expect(restrictContent(counts, 'full')).toEqual(counts); + }); +}); diff --git a/packages/core/src/app/notifications/degrade.ts b/packages/core/src/app/notifications/degrade.ts new file mode 100644 index 0000000..48c5667 --- /dev/null +++ b/packages/core/src/app/notifications/degrade.ts @@ -0,0 +1,214 @@ +/** @module app/notifications/degrade — `degrade(message, capabilities)`: the one shared, pure step that adapts a `NotificationMessage` to what a renderer supports (D-32, spec 03 §9.2). Renderers never implement fallbacks themselves. */ + +import type { + Block, + Inline, + NotificationAction, + NotificationMessage, + OpenAction, +} from '@browserhive/contracts/notifications'; +import type { ChannelCapabilities } from '../../ports/notification-channel.ts'; +import { bold, clip, 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'; +/** Path used when a message has no link of its own. */ +const FALLBACK_PATH = '/notifications'; + +/** Characters of visible text in an inline run. */ +function inlineLength(run: readonly Inline[]): number { + let n = 0; + for (const node of run) n += node.type === 'time' ? 16 : node.text.length; + return n; +} + +/** Characters of visible text in one block. */ +function blockLength(block: Block): number { + switch (block.type) { + case 'text': + case 'footer': + case 'quote': + return inlineLength(block.content); + case 'heading': + case 'code': + return block.text.length; + case 'fields': + return block.items.reduce((n, i) => n + i.label.length + 2 + inlineLength(i.value), 0); + case 'list': + return block.items.reduce((n, i) => n + 2 + inlineLength(i), 0); + case 'table': + return ( + block.columns.reduce((n, c) => n + c.length, 0) + + block.rows.reduce((n, r) => n + r.reduce((m, c) => m + inlineLength(c), 0), 0) + ); + case 'image': + return block.alt.length; + case 'divider': + return 0; + } +} + +/** 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 []; + const items = block.rows.map((row) => { + const out: Inline[] = []; + row.forEach((cell, i) => { + if (i > 0) out.push(text(' · ')); + const column = block.columns[i]; + if (column !== undefined) out.push(bold(`${column}: `)); + out.push(...cell); + }); + return out; + }); + return [{ type: 'list', ordered: false, items }]; +} + +/** Rich blocks as plain paragraphs, for renderers that only show text. */ +function toPlain(block: Block): Block[] { + switch (block.type) { + case 'heading': + return [{ type: 'text', content: [bold(block.text)] }]; + case 'fields': + return block.items.map((i) => ({ + type: 'text', + content: [bold(`${i.label}: `), ...i.value], + })); + case 'quote': + return [{ type: 'text', content: [text('“'), ...block.content, text('”')] }]; + case 'list': + return block.items.map((item, n) => ({ + type: 'text', + content: [text(block.ordered ? `${n + 1}. ` : '• '), ...item], + })); + case 'footer': + return [{ type: 'text', content: block.content }]; + case 'code': + return [{ type: 'text', content: [{ type: 'code', text: block.text }] }]; + case 'divider': + return []; + default: + return [block]; + } +} + +function adaptBlocks(blocks: readonly Block[], caps: ChannelCapabilities): Block[] { + let out: Block[] = []; + for (const block of blocks) { + if (block.type === 'image' && !caps.images) { + if (block.path !== null) + out.push({ type: 'text', content: [link('View screenshot', block.path)] }); + continue; + } + if (block.type === 'table' && !caps.tables) { + out.push(...tableToList(block)); + continue; + } + out.push(block); + } + if (!caps.richBlocks) out = out.flatMap(toPlain); + return out; +} + +/** Act buttons become their open fallback where a channel cannot act; duplicates by path go. */ +function adaptActions( + message: NotificationMessage, + caps: ChannelCapabilities, +): NotificationAction[] { + const out: NotificationAction[] = []; + const paths = new Set(); + for (const action of message.actions) { + if (action.kind === 'act') { + if (message.state !== 'open') continue; + if (caps.actButtons) { + out.push(action); + continue; + } + if (paths.has(action.fallback.path)) continue; + paths.add(action.fallback.path); + out.push({ + kind: 'open', + id: action.id, + label: action.fallback.label, + style: action.style === 'danger' ? 'default' : action.style, + path: action.fallback.path, + }); + continue; + } + if (paths.has(action.path)) continue; + paths.add(action.path); + out.push(action); + } + return out.slice(0, Math.max(0, caps.maxButtons)); +} + +/** The path "Open in BrowserHive" should go to: the first open link, else the inbox. */ +function primaryPath(message: NotificationMessage): string { + const first = message.actions[0]; + if (first === undefined) return FALLBACK_PATH; + return first.kind === 'open' ? first.path : first.fallback.path; +} + +function openFooter(path: string, prefix = ''): Block { + return { + type: 'footer', + content: [...(prefix === '' ? [] : [text(prefix)]), link(OPEN_IN_BROWSERHIVE, path)], + }; +} + +/** + * 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; + * - 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; + * - the title is clipped to `maxTitleChars`, and when summary and blocks exceed `maxTextChars` the + * blocks are cut and a "… Open in BrowserHive" footer is added. + * + * @returns The degraded message; the input is not modified. + */ +export function degrade( + message: NotificationMessage, + caps: ChannelCapabilities, +): NotificationMessage { + const path = primaryPath(message); + let actions: NotificationAction[] = adaptActions(message, caps); + let blocks = adaptBlocks(message.blocks, caps); + if (!caps.openLinks && actions.length > 0) { + const first = actions.find((a): a is OpenAction => a.kind === 'open'); + actions = actions.filter((a) => a.kind === 'act'); + if (first !== undefined) blocks = [...blocks, openFooter(first.path)]; + } + const budget = Math.max(0, caps.maxTextChars); + const summary = clip(message.summary, budget); + let used = summary.length; + const kept: Block[] = []; + let cut = false; + for (const block of blocks) { + const n = blockLength(block); + if (used + n <= budget) { + kept.push(block); + used += n; + continue; + } + cut = true; + break; + } + if (cut) { + const footer = openFooter(path, '… '); + if (used + blockLength(footer) > budget && kept.length > 0) kept.pop(); + kept.push(footer); + } + return { + ...message, + title: clip(message.title, Math.max(1, caps.maxTitleChars)), + summary, + blocks: kept, + actions, + privacy: { + level: message.privacy.level, + has_image: kept.some((b) => b.type === 'image'), + }, + }; +} diff --git a/packages/core/src/app/notifications/in-app-channel.ts b/packages/core/src/app/notifications/in-app-channel.ts index 2478bf6..228335b 100644 --- a/packages/core/src/app/notifications/in-app-channel.ts +++ b/packages/core/src/app/notifications/in-app-channel.ts @@ -1,23 +1,59 @@ -/** @module app/notifications/in-app-channel — the built-in `NotificationChannel`: publishes `notification.created` on the bus for the WS `notifications` topic. */ +/** @module app/notifications/in-app-channel — the built-in `NotificationChannel` (spec 03 §9.3): the dashboard inbox. `send` publishes `notification.created`, `edit` publishes `notification.updated`, for the WS `notifications` topic. Delivered inline after the commit, never through the outbox: the row is the delivery. */ import type { EventPublisher } from '../../ports/event-bus.ts'; -import type { NotificationChannel } from '../../ports/notification-channel.ts'; +import type { + ChannelCapabilities, + ChannelDelivery, + ChannelSendResult, + NotificationChannel, +} from '../../ports/notification-channel.ts'; import type { DomainEvents } from '../events/catalog.ts'; -/** Name of the built-in channel. */ +/** Id, name and kind of the built-in channel. */ export const IN_APP_CHANNEL = 'in-app'; +/** The inbox renders everything the contract can express, without limits. */ +export const IN_APP_CAPABILITIES: ChannelCapabilities = { + richBlocks: true, + tables: true, + images: true, + actButtons: true, + openLinks: true, + edit: true, + delete: true, + replies: false, + deleteWindowMs: null, + maxTitleChars: Number.MAX_SAFE_INTEGER, + maxTextChars: Number.MAX_SAFE_INTEGER, + maxButtons: Number.MAX_SAFE_INTEGER, +}; + +function inboxOf(delivery: ChannelDelivery) { + if (delivery.inbox === undefined) throw new Error('in-app delivery without its inbox row'); + return delivery.inbox; +} + /** - * Builds the in-app channel. External adapters (webhook, ntfy, Telegram, Slack, email) implement - * the same port and are passed to the service alongside this one; none are built (D-16). + * Builds the in-app channel. External adapters implement the same port and are fed by the outbox + * (D-34). * - * @returns A channel that publishes `notification.created`. + * @returns A channel whose message ref is the notification id. */ export function createInAppChannel(bus: EventPublisher): NotificationChannel { return { + id: IN_APP_CHANNEL, name: IN_APP_CHANNEL, - send(payload) { - bus.publish('notification.created', { type: 'notification.created', notification: payload }); + kind: IN_APP_CHANNEL, + capabilities: IN_APP_CAPABILITIES, + async send(delivery): Promise { + const notification = inboxOf(delivery); + bus.publish('notification.created', { type: 'notification.created', notification }); + return { ref: { notification_id: notification.notification_id } }; + }, + async edit(_ref, delivery): Promise { + const notification = inboxOf(delivery); + bus.publish('notification.updated', { type: 'notification.updated', notification }); + return { ref: { notification_id: notification.notification_id } }; }, }; } diff --git a/packages/core/src/app/notifications/index.ts b/packages/core/src/app/notifications/index.ts index 4ba1c27..3efa94c 100644 --- a/packages/core/src/app/notifications/index.ts +++ b/packages/core/src/app/notifications/index.ts @@ -1,6 +1,33 @@ -/** @module app/notifications — public surface of the notification subsystem (D-16). */ +/** @module app/notifications — public surface of the notification subsystem (D-16, D-32, D-34): producers, the message contract builders, content levels, `degrade`, routing, the channel registry, the delivery outbox and the inbox service. */ -export { createInAppChannel, IN_APP_CHANNEL } from './in-app-channel.ts'; +export { + type ChannelAdapterFactory, + type ChannelFactoryContext, + ChannelRegistry, + type ChannelRegistryDeps, + type RegisteredChannel, +} from './channel-registry.ts'; +export { restrictContent } from './content-level.ts'; +export { degrade, OPEN_IN_BROWSERHIVE } from './degrade.ts'; +export { createInAppChannel, IN_APP_CAPABILITIES, IN_APP_CHANNEL } from './in-app-channel.ts'; +export { createLocalLinkBuilder } from './links.ts'; +export { + type BuildMessageInput, + bold, + buildMessage, + clip, + code, + decodeMessage, + encodeMessage, + formatDuration, + type LifecycleChange, + link, + type MessageContent, + reviseMessage, + scrubMessage, + text, + time, +} from './message.ts'; export { DEDUP_WINDOW, NOTIFICATION_DAYS, @@ -9,6 +36,14 @@ export { type NotificationServiceDeps, toNotification, } from './notification-service.ts'; +export { + DEFAULT_OUTBOX_OPTIONS, + type DeliveryCounter, + NotificationOutbox, + type NotificationOutboxDeps, + type OutboxOptions, + type OutboxPass, +} from './outbox.ts'; export { crashed, draftFor, @@ -20,5 +55,20 @@ export { PRODUCED_EVENTS, type ProducedEvent, type ProducedEventName, + requestSettled, + revisionFor, + type SettledRequestFacts, + type ThreadRevision, toolErrorsTitle, } from './producers.ts'; +export { + contentLevelOf, + deleteWhenResolved, + expiryFor, + inQuietHours, + localMinutes, + planDeliveries, + type RoutableChannel, + type RouteDecision, + route, +} from './routing.ts'; diff --git a/packages/core/src/app/notifications/links.ts b/packages/core/src/app/notifications/links.ts new file mode 100644 index 0000000..4e0e56c --- /dev/null +++ b/packages/core/src/app/notifications/links.ts @@ -0,0 +1,20 @@ +/** @module app/notifications/links — the default `LinkBuilder`: links to this computer's dashboard until `publicUrl` exists (D-37). */ + +import type { LinkBuilder } from '../../ports/notification-channel.ts'; + +/** + * Links to the local dashboard (`http://127.0.0.1:9876/sessions/…`). `local` is true, so + * renderers label them "Open on this computer" (D-37). `baseUrl` is read per call because the + * listening port is known only after the listeners open. + * + * @returns A link builder over `baseUrl()`. + */ +export function createLocalLinkBuilder(baseUrl: () => string): LinkBuilder { + return { + local: true, + url(path) { + const base = baseUrl().replace(/\/+$/, ''); + return `${base}${path.startsWith('/') ? path : `/${path}`}`; + }, + }; +} diff --git a/packages/core/src/app/notifications/message.test.ts b/packages/core/src/app/notifications/message.test.ts new file mode 100644 index 0000000..e82c380 --- /dev/null +++ b/packages/core/src/app/notifications/message.test.ts @@ -0,0 +1,341 @@ +/** @module app/notifications/message.test — the contract builders (D-32): every producer's first revision validates against `NotificationMessage` with the documented kind, category, severity, state, thread, actions and entities; lifecycle revisions; redaction and the stored-JSON codec. */ + +import { describe, expect, it } from 'bun:test'; +import { NotificationMessage } from '@browserhive/contracts/notifications'; +import { createRedactor, REDACTED, SecretRegistry } from '../../kernel/redact.ts'; +import { + buildMessage, + clip, + decodeMessage, + encodeMessage, + formatDuration, + reviseMessage, + scrubMessage, +} from './message.ts'; +import { draftFor, type ProducedEvent, revisionFor } from './producers.ts'; +import { + attentionCreated, + attentionResolved, + SESSION, + sessionClosed, + systemDegraded, + systemRecovered, + toolCalled, + vaultConfirmCreated, + vaultConfirmResolved, +} from './test-fixtures.ts'; + +/** First revision of a producer's draft, as the service builds it. */ +function first(event: ProducedEvent, count = 1): NotificationMessage { + const draft = draftFor(event); + if (draft === null) throw new Error('silent event'); + return buildMessage({ + id: 'n-000000000001', + revision: count, + thread: draft.thread, + kind: draft.kind, + severity: draft.severity, + state: draft.state, + alert: count === 1, + createdAt: 10, + updatedAt: 10, + title: draft.group?.title(count) ?? draft.title, + summary: draft.body ?? '', + ...draft.content(count), + }); +} + +describe('producer messages', () => { + const cases: ReadonlyArray< + readonly [ + string, + ProducedEvent, + { + kind: string; + category: string; + severity: string; + state: string; + thread: string; + actions: string[]; + }, + ] + > = [ + [ + 'attention takeover', + attentionCreated('a-000000000001', 'takeover', { + page_url: 'https://shop.example/checkout?token=abc#x', + tool: 'click', + }), + { + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + thread: 'attention:a-000000000001', + actions: ['take-over', 'resolve', 'reject'], + }, + ], + [ + 'attention notify', + attentionCreated('a-000000000002', 'notify'), + { + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + thread: 'attention:a-000000000002', + actions: ['open-live', 'resolve', 'reject'], + }, + ], + [ + 'vault confirm', + vaultConfirmCreated('a-000000000003', 'github'), + { + kind: 'vault.confirm', + category: 'needs-you', + severity: 'warn', + state: 'open', + thread: 'vault:a-000000000003', + actions: ['approve', 'deny', 'review'], + }, + ], + [ + 'session crash', + sessionClosed('crash'), + { + kind: 'session.crashed', + category: 'problems', + severity: 'error', + state: 'final', + thread: `session:${SESSION}`, + actions: ['open-session'], + }, + ], + [ + 'lease reap', + sessionClosed('lease_expired'), + { + kind: 'session.reaped', + category: 'problems', + severity: 'warn', + state: 'final', + thread: `session:${SESSION}`, + actions: ['open-session'], + }, + ], + [ + 'tool errors', + toolCalled(1, { ok: false }), + { + kind: 'tool.errors', + category: 'problems', + severity: 'warn', + state: 'open', + thread: `tool-errors:${SESSION}`, + actions: ['open-errors'], + }, + ], + [ + 'session-less tool errors', + toolCalled(2, { + ok: false, + tool: 'launch_session', + code: 'BROWSER_NOT_INSTALLED', + sessionId: null, + }), + { + kind: 'tool.errors', + category: 'problems', + severity: 'warn', + state: 'open', + thread: 'tool-errors:none', + actions: [], + }, + ], + [ + 'degradation', + systemDegraded('error'), + { + kind: 'system.degraded', + category: 'system', + severity: 'error', + state: 'open', + thread: `system:e-${'1'.padStart(26, '0')}`, + actions: ['open-system'], + }, + ], + ]; + for (const [name, event, expected] of cases) { + it(name, () => { + const message = NotificationMessage.parse(first(event)); + expect(message).toMatchObject({ + schema: 1, + kind: expected.kind, + category: expected.category, + severity: expected.severity, + state: expected.state, + thread: expected.thread, + alert: true, + privacy: { level: 'full', has_image: false }, + }); + expect(message.actions.map((a) => a.id)).toEqual(expected.actions); + }); + } + + it('sanitizes the page URL and names the domain and request', () => { + const message = first( + attentionCreated('a-000000000001', 'takeover', { + page_url: 'https://shop.example/checkout?token=abc#x', + }), + ); + const json = JSON.stringify(message); + expect(json).toContain('https://shop.example/checkout'); + expect(json).not.toContain('token=abc'); + expect(message.entities).toMatchObject({ + session_id: SESSION, + session_slug: 'shop', + domain: 'shop.example', + request_id: 'a-000000000001', + owner: 'local', + }); + }); + + it('a one-shot fact carries its actions even though it is final', () => { + expect(first(sessionClosed('crash')).actions).toHaveLength(1); + }); + + it('tool error groups carry the count and the harness', () => { + const message = first(toolCalled(3, { ok: false }), 7); + expect(message.title).toBe('shop · 7 tool errors'); + expect(message.alert).toBe(false); + expect(JSON.stringify(message.blocks)).toContain('"7"'); + expect(message.entities).toMatchObject({ + tool: 'navigate', + error_code: 'NAVIGATION_TIMEOUT', + harness: 'unknown', + }); + }); + + it('silent events and revision-only events produce no draft', () => { + expect(draftFor(sessionClosed('user'))).toBeNull(); + expect(draftFor(attentionResolved('a-000000000001', 'resolved'))).toBeNull(); + expect(draftFor(systemRecovered())).toBeNull(); + }); +}); + +describe('revisions', () => { + const table = [ + [ + 'attention resolved', + attentionResolved('a-000000000001', 'resolved'), + 'resolved', + 'Resolved by local after 2m 10s', + ], + [ + 'attention rejected', + attentionResolved('a-000000000001', 'rejected'), + 'resolved', + 'Rejected by local after 2m 10s', + ], + [ + 'attention timeout', + attentionResolved('a-000000000001', 'timeout'), + 'expired', + 'Timed out after 2m 10s', + ], + [ + 'attention cancelled', + attentionResolved('a-000000000001', 'cancelled'), + 'final', + 'Cancelled after 2m 10s: the agent stopped waiting', + ], + [ + 'vault approved', + vaultConfirmResolved('a-000000000002', 'resolved'), + 'resolved', + 'Approved by local after 5 s', + ], + [ + 'vault denied', + vaultConfirmResolved('a-000000000002', 'rejected'), + 'resolved', + 'Denied by local after 5 s', + ], + ] as const; + for (const [name, event, state, summary] of table) { + it(name, () => { + const revision = revisionFor(event); + expect(revision?.change).toMatchObject({ state, summary }); + }); + } + + it('a recovered degradation resolves its thread', () => { + expect(revisionFor(systemRecovered())).toMatchObject({ + thread: `system:e-${'1'.padStart(26, '0')}`, + change: { state: 'resolved' }, + }); + }); + + it('reviseMessage is the next silent full state without buttons', () => { + const prev = first(attentionCreated('a-000000000001', 'takeover')); + const change = revisionFor(attentionResolved('a-000000000001', 'resolved'))?.change; + if (change === undefined) throw new Error('no change'); + const next = NotificationMessage.parse(reviseMessage(prev, change, 500)); + expect(next).toMatchObject({ + revision: 2, + state: 'resolved', + alert: false, + actions: [], + summary: 'Resolved by local after 2m 10s', + at: { created: 10, updated: 500 }, + title: prev.title, + }); + const fields = next.blocks.find((b) => b.type === 'fields'); + expect(fields?.type === 'fields' && fields.items.map((i) => i.label)).toContain('Outcome'); + // Applying the same change again replaces the field instead of duplicating it. + const again = reviseMessage(next, change, 600); + const again_fields = again.blocks.find((b) => b.type === 'fields'); + expect( + again_fields?.type === 'fields' && + again_fields.items.filter((i) => i.label === 'Outcome').length, + ).toBe(1); + }); +}); + +describe('redaction and codec', () => { + it('scrubs registered secrets from every string leaf and keeps the limits', () => { + const secret = 's3cr3t-value'; + const registry = new SecretRegistry({ now: () => 0 }); + registry.add(secret); + const redactor = createRedactor(registry); + const message = first( + attentionCreated('a-000000000001', 'takeover', { + reason: `${'x'.repeat(230)} ${secret}`, + page_url: `https://example.com/${secret}`, + }), + ); + const scrubbed = scrubMessage(message, redactor); + const json = JSON.stringify(scrubbed); + expect(json).not.toContain(secret); + expect(json).toContain(REDACTED); + expect(scrubbed.summary.length).toBeLessThanOrEqual(240); + expect(NotificationMessage.safeParse(scrubbed).success).toBe(true); + }); + + it('round-trips through JSON and refuses garbage', () => { + const message = first(sessionClosed('crash')); + expect(decodeMessage(encodeMessage(message))).toEqual(message); + expect(decodeMessage(null)).toBeNull(); + expect(decodeMessage('{')).toBeNull(); + expect(decodeMessage('{"schema":2}')).toBeNull(); + }); + + it('clip and formatDuration', () => { + expect(clip('abcdef', 4)).toBe('abc…'); + expect(clip('abc', 4)).toBe('abc'); + expect(formatDuration(850)).toBe('850 ms'); + expect(formatDuration(42_000)).toBe('42 s'); + expect(formatDuration(130_000)).toBe('2m 10s'); + expect(formatDuration(3 * 3_600_000 + 5 * 60_000)).toBe('3h 05m'); + }); +}); diff --git a/packages/core/src/app/notifications/message.ts b/packages/core/src/app/notifications/message.ts new file mode 100644 index 0000000..a9c236e --- /dev/null +++ b/packages/core/src/app/notifications/message.ts @@ -0,0 +1,257 @@ +/** @module app/notifications/message — pure builders of the `NotificationMessage` contract (D-32, spec 03 §9.2): inline helpers, the first revision from a draft, lifecycle revisions, redaction of every string leaf and the stored-JSON codec. */ + +import type { + NotificationKind, + NotificationSeverity, + NotificationState, +} from '@browserhive/contracts/enums'; +import { + type Block, + type Inline, + KIND_CATEGORY, + NOTIFICATION_LABEL_MAX, + NOTIFICATION_SCHEMA_VERSION, + NOTIFICATION_SUMMARY_MAX, + NOTIFICATION_TEXT_MAX, + NOTIFICATION_TITLE_MAX, + type NotificationAction, + type NotificationEntities, + NotificationMessage, +} from '@browserhive/contracts/notifications'; +import type { Redactor } from '../../kernel/redact.ts'; + +/** Plain text inline. */ +export function text(value: string): Inline { + return { type: 'text', text: clip(value, NOTIFICATION_TEXT_MAX) }; +} + +/** Bold inline. */ +export function bold(value: string): Inline { + return { type: 'bold', text: clip(value, NOTIFICATION_TEXT_MAX) }; +} + +/** Monospace inline. */ +export function code(value: string): Inline { + return { type: 'code', text: clip(value, NOTIFICATION_TEXT_MAX) }; +} + +/** Dashboard link inline. */ +export function link(label: string, path: string): Inline { + return { type: 'link', text: clip(label, NOTIFICATION_TEXT_MAX), path }; +} + +/** Localisable time inline. */ +export function time(at: number, style: 'relative' | 'absolute' = 'absolute'): Inline { + return { type: 'time', at, style }; +} + +/** + * Shortens `value` to at most `max` characters, ending in `…` when cut. + * + * @returns The clipped string. + */ +export function clip(value: string, max: number): string { + if (value.length <= max) return value; + return max <= 1 ? value.slice(0, max) : `${value.slice(0, max - 1)}…`; +} + +/** + * Human duration for summaries: `850 ms`, `42 s`, `2m 10s`, `3h 05m`. + * + * @returns The formatted duration. + */ +export function formatDuration(ms: number): string { + const v = Math.max(0, Math.round(ms)); + if (v < 1000) return `${v} ms`; + const s = Math.round(v / 1000); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`; + const h = Math.floor(m / 60); + return `${h}h ${String(m % 60).padStart(2, '0')}m`; +} + +/** Everything the first revision of a message is built from (the producer's facts). */ +export interface MessageContent { + readonly blocks: readonly Block[]; + readonly actions: readonly NotificationAction[]; + readonly entities: NotificationEntities; +} + +/** Inputs of {@link buildMessage}. */ +export interface BuildMessageInput extends MessageContent { + readonly id: string; + readonly revision: number; + readonly thread: string; + readonly kind: NotificationKind; + readonly severity: NotificationSeverity; + readonly state: NotificationState; + readonly alert: boolean; + readonly createdAt: number; + readonly updatedAt: number; + readonly title: string; + readonly summary: string; +} + +/** + * Builds one full-state revision. Title and summary are clipped to the contract's limits; act + * buttons exist only while the state is `open` (a one-shot `final` fact keeps its links, spec 03 §9.2). + * + * @returns The message (not yet redacted; see {@link scrubMessage}). + */ +export function buildMessage(input: BuildMessageInput): NotificationMessage { + return { + schema: NOTIFICATION_SCHEMA_VERSION, + id: input.id, + revision: input.revision, + thread: input.thread, + kind: input.kind, + category: KIND_CATEGORY[input.kind], + severity: input.severity, + state: input.state, + alert: input.alert, + at: { created: input.createdAt, updated: Math.max(input.updatedAt, input.createdAt) }, + title: clip(input.title, NOTIFICATION_TITLE_MAX), + summary: clip(input.summary, NOTIFICATION_SUMMARY_MAX), + blocks: [...input.blocks], + actions: + input.state === 'open' ? [...input.actions] : input.actions.filter((a) => a.kind === 'open'), + entities: input.entities, + privacy: { level: 'full', has_image: input.blocks.some((b) => b.type === 'image') }, + }; +} + +/** A lifecycle change applied to the previous revision. */ +export interface LifecycleChange { + readonly state: NotificationState; + /** New summary (the outcome sentence); `undefined` keeps the previous one. */ + readonly summary?: string; + /** Severity after the change; `undefined` keeps it. */ + readonly severity?: NotificationSeverity; + /** Fields appended to the first `fields` block (or added as a new one), e.g. the outcome. */ + readonly fields?: readonly { readonly label: string; readonly value: readonly Inline[] }[]; +} + +/** + * Derives the next revision from the previous full state: `revision + 1`, silent (`alert: false`), + * the new state and outcome, and no actions once the state has left `open` (the buttons disappear + * with the silent edit). + * + * @returns The next revision. + */ +export function reviseMessage( + prev: NotificationMessage, + change: LifecycleChange, + at: number, +): NotificationMessage { + let blocks: Block[] = [...prev.blocks]; + if (change.fields !== undefined && change.fields.length > 0) { + const index = blocks.findIndex((b) => b.type === 'fields'); + const extra = change.fields.map((f) => ({ label: f.label, value: [...f.value] })); + const existing = blocks[index]; + if (existing !== undefined && existing.type === 'fields') { + const items = [ + ...existing.items.filter((i) => !extra.some((e) => e.label === i.label)), + ...extra, + ].slice(0, 12); + blocks = blocks.map((b, i) => (i === index ? { type: 'fields', items } : b)); + } else { + blocks = [{ type: 'fields', items: extra.slice(0, 12) }, ...blocks]; + } + } + return { + ...prev, + revision: prev.revision + 1, + state: change.state, + severity: change.severity ?? prev.severity, + alert: false, + at: { created: prev.at.created, updated: Math.max(at, prev.at.updated) }, + summary: + change.summary === undefined ? prev.summary : clip(change.summary, NOTIFICATION_SUMMARY_MAX), + blocks, + actions: change.state === 'open' ? prev.actions : [], + }; +} + +/** + * Redacts every string leaf of a message with the `Redactor` (registered secrets and credential + * patterns, spec 10 §9), then re-clips title and summary so a replacement cannot break the limits. + * Structural fields (schema, ids, enums) contain no free text and pass unchanged. + * + * @returns The redacted message. + */ +export function scrubMessage( + message: NotificationMessage, + redactor: Redactor, +): NotificationMessage { + const walk = (value: unknown, key: string): unknown => { + if (typeof value === 'string') { + const max = LIMIT_BY_KEY[key] ?? NOTIFICATION_TEXT_MAX; + return clip(redactor.scrubText(value), max); + } + if (Array.isArray(value)) return value.map((v) => walk(v, key)); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = PASS_THROUGH.has(k) ? v : walk(v, k); + return out; + } + return value; + }; + return NotificationMessage.parse(walk(message, '')); +} + +/** Length limits by key, so a replacement longer than the secret never breaks the contract. */ +const LIMIT_BY_KEY: Readonly> = { + title: NOTIFICATION_TITLE_MAX, + summary: NOTIFICATION_SUMMARY_MAX, + confirm: NOTIFICATION_SUMMARY_MAX, + label: NOTIFICATION_LABEL_MAX, + columns: NOTIFICATION_LABEL_MAX, + thread: 160, + path: 2048, + ref: 512, + language: 32, + session_id: 128, + session_slug: 64, + harness: 64, + owner: 128, + tool: 64, + error_code: 64, + domain: 253, + request_id: 64, + decision: 256, +}; + +/** Keys whose values are identifiers or enums, never free text. */ +const PASS_THROUGH: ReadonlySet = new Set([ + 'schema', + 'id', + 'kind', + 'category', + 'severity', + 'state', + 'type', + 'style', + 'op', +]); + +/** Serialises a message for `notifications.message_json`. */ +export function encodeMessage(message: NotificationMessage): string { + return JSON.stringify(message); +} + +/** + * Parses a stored message. A row from before schema v5 (`null`) or an unreadable value yields + * `null`: nothing is ever fabricated for it. + * + * @returns The message, or `null`. + */ +export function decodeMessage(json: string | null): NotificationMessage | null { + if (json === null) return null; + try { + const parsed = NotificationMessage.safeParse(JSON.parse(json)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} diff --git a/packages/core/src/app/notifications/notification-service.test.ts b/packages/core/src/app/notifications/notification-service.test.ts index 3ee7957..0d91d3f 100644 --- a/packages/core/src/app/notifications/notification-service.test.ts +++ b/packages/core/src/app/notifications/notification-service.test.ts @@ -2,25 +2,28 @@ import { describe, expect, it } from 'bun:test'; import { Notification } from '@browserhive/contracts/http'; +import { NotificationMessage } from '@browserhive/contracts/notifications'; import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; import { FakeClock } from '../../../test/helpers/fake-clock.ts'; import { FakeIdGenerator } from '../../../test/helpers/fake-id-generator.ts'; import { InMemoryNotificationRepository } from '../../../test/helpers/in-memory-repos.ts'; import { RecordingEventBus } from '../../../test/helpers/recording-event-bus.ts'; -import type { NotificationChannel } from '../../ports/notification-channel.ts'; import type { DomainEvents } from '../events/catalog.ts'; import { NotificationService } from './notification-service.ts'; import { draftFor, type ProducedEvent } from './producers.ts'; import { attentionCreated, + attentionResolved, SESSION, sessionClosed, systemDegraded, + systemRecovered, toolCalled, vaultConfirmCreated, + vaultConfirmResolved, } from './test-fixtures.ts'; -function setup(channels: NotificationChannel[] = []) { +function setup() { const clock = new FakeClock(); const repo = new InMemoryNotificationRepository(); const bus = new RecordingEventBus(); @@ -30,7 +33,6 @@ function setup(channels: NotificationChannel[] = []) { clock, ids: new FakeIdGenerator(), logger: new CollectingLogger(), - channels, }); return { clock, repo, bus, service }; } @@ -235,17 +237,6 @@ describe('NotificationService producers', () => { expect(rows.map((r) => r.count)).toEqual([1, 1, 1, 15, 2]); }); - it('isolates a failing external channel', async () => { - const sent: string[] = []; - const { repo, service } = setup([ - { name: 'broken', send: () => Promise.reject(new Error('down')) }, - { name: 'ok', send: (n) => void sent.push(n.title) }, - ]); - await service.produce(sessionClosed('crash')); - expect(repo.rows.size).toBe(1); - expect(sent).toEqual(['Session crashed']); - }); - it('fans out to every recipient inbox', async () => { const { repo, bus, clock } = setup(); const service = new NotificationService({ @@ -291,3 +282,170 @@ describe('NotificationService inbox', () => { expect(page.items.every((n) => Notification.safeParse(n).success)).toBe(true); }); }); + +describe('NotificationService contract and revisions', () => { + it('stores the first revision of the message with the row and serves the classification', async () => { + const { repo, service } = setup(); + const [dto] = await service.produce(attentionCreated('a-000000000001', 'takeover')); + expect(dto).toMatchObject({ + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'attention:a-000000000001', + }); + const row = repo.rows.get(dto?.notification_id ?? ''); + const message = NotificationMessage.parse(JSON.parse(row?.messageJson ?? 'null')); + expect(message).toMatchObject({ + id: dto?.notification_id, + revision: 1, + alert: true, + title: dto?.title, + }); + expect(message.summary).toBe(dto?.body ?? ''); + }); + + it('revises the request notification when it resolves; title, body and updated_at stay', async () => { + const { clock, repo, bus, service } = setup(); + const [created] = await service.produce(attentionCreated('a-000000000001', 'takeover')); + await clock.advance(130_000); + const [revised] = await service.produce(attentionResolved('a-000000000001', 'resolved')); + expect(revised).toMatchObject({ + notification_id: created?.notification_id, + state: 'resolved', + revision: 2, + title: created?.title, + body: created?.body, + updated_at: created?.updated_at, + }); + const message = NotificationMessage.parse( + JSON.parse(repo.rows.get(created?.notification_id ?? '')?.messageJson ?? 'null'), + ); + expect(message).toMatchObject({ revision: 2, state: 'resolved', alert: false, actions: [] }); + const updates = bus.published.filter((p) => p.name === 'notification.updated'); + expect(updates).toHaveLength(1); + // A replayed resolution and a later terminal status change nothing. + expect(await service.produce(attentionResolved('a-000000000001', 'resolved'))).toEqual([]); + expect(await service.produce(attentionResolved('a-000000000001', 'timeout'))).toEqual([]); + }); + + it('revises vault confirmations and recovered degradations', async () => { + const { service } = setup(); + await service.produce(vaultConfirmCreated('a-000000000003', 'github')); + const [vault] = await service.produce(vaultConfirmResolved('a-000000000003', 'rejected')); + expect(vault).toMatchObject({ kind: 'vault.confirm', state: 'resolved', revision: 2 }); + await service.produce(systemDegraded('error')); + const [system] = await service.produce(systemRecovered()); + expect(system).toMatchObject({ kind: 'system.degraded', state: 'resolved', revision: 2 }); + }); + + it('a resolution without a notification (or of a row from before v5) fabricates nothing', async () => { + const { repo, service } = setup(); + expect(await service.produce(attentionResolved('a-000000000009', 'resolved'))).toEqual([]); + await repo.insert({ + notificationId: 'n-legacy000001', + principalId: null, + type: 'attention', + title: 'Attention requested', + body: null, + sessionId: SESSION, + target: null, + sourceEventId: 'a-000000000008', + createdAt: 1, + updatedAt: 1, + count: 1, + groupKey: null, + readAt: null, + dismissedAt: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'attention:a-000000000008', + messageJson: null, + }); + const [legacy] = await service.produce(attentionResolved('a-000000000008', 'timeout')); + expect(legacy).toMatchObject({ state: 'expired', revision: 2 }); + expect(repo.rows.get('n-legacy000001')?.messageJson).toBeNull(); + }); + + it('grows a tool-error group as silent revisions of one message', async () => { + const { clock, repo, service } = setup(); + const [a] = await service.produce(toolCalled(1, { ok: false })); + await clock.advance(1_000); + const [b] = await service.produce(toolCalled(2, { ok: false })); + expect(b).toMatchObject({ notification_id: a?.notification_id, revision: 2, count: 2 }); + const message = NotificationMessage.parse( + JSON.parse(repo.rows.get(a?.notification_id ?? '')?.messageJson ?? 'null'), + ); + expect(message).toMatchObject({ revision: 2, alert: false, title: 'shop · 2 tool errors' }); + }); + + it('the breaker notice is an in-app system notification', async () => { + const { service } = setup(); + const [notice] = await service.produce({ + name: 'notification.channel.changed', + at: 1, + payload: { + type: 'notification.channel.changed', + channel_id: 'nc-1', + name: 'phone', + kind: 'telegram', + status: 'broken', + previous_status: 'active', + failure_count: 5, + last_error: 'unavailable: down', + at: 1, + }, + }); + expect(notice).toMatchObject({ type: 'system', kind: 'channel.broken', severity: 'error' }); + }); +}); + +describe('NotificationService startup catch-up', () => { + it('revises notifications of requests settled while nothing listened', async () => { + const { repo, service } = setup(); + const [attention] = await service.produce(attentionCreated('a-000000000001', 'takeover')); + const [vault] = await service.produce(vaultConfirmCreated('a-000000000002', 'github')); + await service.produce(attentionCreated('a-000000000003', 'notify')); + const settled = new Map([ + [ + 'a-000000000001', + { + status: 'rejected' as const, + resolvedBy: 'system', + createdAt: 1, + resolvedAt: 61_001, + waitedMs: 61_000, + }, + ], + [ + 'a-000000000002', + { status: 'timeout' as const, resolvedBy: null, createdAt: 1, resolvedAt: 5, waitedMs: 4 }, + ], + [ + 'a-000000000003', + { + status: 'pending' as const, + resolvedBy: null, + createdAt: 1, + resolvedAt: null, + waitedMs: null, + }, + ], + ]); + expect(await service.reconcileRequests({ get: async (id) => settled.get(id) ?? null })).toBe(2); + expect(repo.rows.get(attention?.notification_id ?? '')).toMatchObject({ + state: 'resolved', + revision: 2, + }); + expect(repo.rows.get(vault?.notification_id ?? '')).toMatchObject({ + state: 'expired', + revision: 2, + }); + // Already settled rows are left alone on the next start. + expect(await service.reconcileRequests({ get: async (id) => settled.get(id) ?? null })).toBe(0); + }); +}); diff --git a/packages/core/src/app/notifications/notification-service.ts b/packages/core/src/app/notifications/notification-service.ts index 3fae132..2d323d7 100644 --- a/packages/core/src/app/notifications/notification-service.ts +++ b/packages/core/src/app/notifications/notification-service.ts @@ -1,19 +1,33 @@ -/** @module app/notifications/notification-service — server-side notification producer + inbox API (D-16, spec 03 §4.8/§9): bus rules → rows → channels; read/dismiss state with `notification.*` events. */ +/** @module app/notifications/notification-service — server-side notification producer + inbox API (D-16, D-32, D-34, spec 03 §4.8/§9): bus rules → rows with their contract message → in-app channel inline and external channels through the outbox; lifecycle revisions; read/dismiss state with `notification.*` events. */ import type { Notification } from '@browserhive/contracts/http'; import { Notification as NotificationSchema } from '@browserhive/contracts/http'; import { parseSessionId } from '@browserhive/contracts/ids'; +import 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 { EventBus } from '../../ports/event-bus.ts'; import type { IdGenerator } from '../../ports/id-generator.ts'; import type { Logger } from '../../ports/logger.ts'; -import type { NotificationChannel } from '../../ports/notification-channel.ts'; +import type { LinkBuilder, NotificationChannel } from '../../ports/notification-channel.ts'; import type { NotificationRepository } from '../../ports/persistence/notifications.ts'; import type { NotificationListQuery, Page } from '../../ports/persistence/queries.ts'; -import type { NotificationRecord } from '../../ports/persistence/records.ts'; +import type { + NewNotificationDelivery, + NotificationRecord, +} from '../../ports/persistence/records.ts'; +import type { Repositories, UnitOfWork } from '../../ports/persistence/unit-of-work.ts'; import type { DomainEvents } from '../events/catalog.ts'; import { createInAppChannel } from './in-app-channel.ts'; +import { + buildMessage, + decodeMessage, + encodeMessage, + reviseMessage, + scrubMessage, +} from './message.ts'; +import type { NotificationOutbox } from './outbox.ts'; import { draftFor, NOTIFICATION_GROUP_IDLE_MS, @@ -21,6 +35,10 @@ import { type NotificationDraft, type NotificationGroup, type ProducedEvent, + requestSettled, + revisionFor, + type SettledRequestFacts, + type ThreadRevision, } from './producers.ts'; /** Days a read or dismissed notification is kept (spec 03 §7.1). */ @@ -31,6 +49,10 @@ export const NOTIFICATION_DAYS = 90; export const DEDUP_WINDOW = 2000; /** Rows touched by one bulk read/dismiss (the repository does the update; events go per row). */ const BULK_EVENT_LIMIT = 500; +/** Kinds whose notification follows an operator request (the startup catch-up). */ +const RECONCILED_KINDS = ['attention.requested', 'vault.confirm'] as const; +/** The in-app channel needs no absolute links. */ +const INBOX_LINKS: LinkBuilder = { local: true, url: (path) => path }; /** Dependencies of {@link NotificationService}. */ export interface NotificationServiceDeps { @@ -39,8 +61,18 @@ export interface NotificationServiceDeps { readonly clock: Clock; readonly ids: IdGenerator; readonly logger: Logger; - /** External channels on top of the built-in in-app channel (none are shipped). */ - readonly channels?: readonly NotificationChannel[]; + /** + * Transaction boundary: a notification change and its outbox rows commit together (D-34). Needed + * only when `outbox` is set. + */ + readonly uow?: UnitOfWork; + /** The delivery outbox for external channels; absent = in-app only. */ + readonly outbox?: Pick; + /** + * Redacts every string a producer copied from an event (spec 10 §9). Defaults to the key and + * pattern redactor; composition passes the one bound to the `SecretRegistry`. + */ + readonly redactor?: Redactor; /** Inboxes that receive produced rows; default the anonymous inbox (`[null]`). */ readonly recipients?: () => readonly (string | null)[]; /** Idle time after which a group row stops growing; default {@link NOTIFICATION_GROUP_IDLE_MS}. */ @@ -71,23 +103,56 @@ export function toNotification(record: NotificationRecord): Notification { count: record.count, read_at: record.readAt, dismissed_at: record.dismissedAt, + kind: record.kind, + category: record.category, + severity: record.severity, + state: record.state, + revision: record.revision, + thread: record.thread, }); } /** - * Subscribes the producer rules to the bus, persists rows for every recipient inbox, delivers - * them through the channels (in-app first) and serves the inbox API used by HTTP. Producer - * work is serialised on one chain (`idle()`), and no handler failure escapes to the bus. + * A message for the in-app delivery of a row that has none stored (a row from before schema v5). + * Transient: it is never persisted or sent to an external channel, so nothing is fabricated. + */ +function transientMessage(record: NotificationRecord): NotificationMessage { + return buildMessage({ + id: record.notificationId, + revision: record.revision, + thread: record.thread, + kind: record.kind, + severity: record.severity, + state: record.state, + alert: false, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + title: record.title, + summary: record.body ?? '', + blocks: [], + actions: [], + entities: {}, + }); +} + +/** + * Subscribes the producer rules to the bus, persists rows (with their contract message) for every + * recipient inbox, delivers them through the in-app channel and enqueues them for external + * channels in the same transaction, applies lifecycle revisions, and serves the inbox API used by + * HTTP. Producer work is serialised on one chain (`idle()`), and no handler failure escapes to the + * bus. */ export class NotificationService { - private readonly channels: readonly NotificationChannel[]; + private readonly inApp: NotificationChannel; + private readonly redactor: Redactor; private readonly log: Logger; private readonly seen = new Set(); private unsubscribe: (() => void)[] = []; private tail: Promise = Promise.resolve(); constructor(private readonly deps: NotificationServiceDeps) { - this.channels = [createInAppChannel(deps.bus), ...(deps.channels ?? [])]; + this.inApp = createInAppChannel(deps.bus); + this.redactor = deps.redactor ?? createRedactor(); this.log = deps.logger.child({ module: 'notifications' }); } @@ -98,10 +163,14 @@ export class NotificationService { const on = (event: ProducedEvent) => this.enqueue(event); this.unsubscribe.push( bus.subscribe('attention.created', on), + bus.subscribe('attention.resolved', on), bus.subscribe('session.closed', on), bus.subscribe('tool.called', on), bus.subscribe('vault.confirm.created', on), + bus.subscribe('vault.confirm.resolved', on), bus.subscribe('system.degraded', on), + bus.subscribe('system.recovered', on), + bus.subscribe('notification.channel.changed', on), ); } return () => this.stop(); @@ -119,38 +188,66 @@ export class NotificationService { } /** - * Applies the producer table to one event (de-dup + grouping), creating or growing one row per - * recipient. + * Applies the producer and revision tables to one event (de-dup + grouping): creates or grows one + * row per recipient, or revises the rows of a thread. * - * @returns The created or grown notifications (empty when the event is silent or replayed). + * @returns The created, grown or revised notifications (empty when silent or replayed). */ async produce(event: ProducedEvent): Promise { + const revision = revisionFor(event); + if (revision !== null) return this.revise(revision); const draft = draftFor(event); if (draft === null) return []; if (this.seen.has(draft.dedupKey)) return []; this.remember(draft.dedupKey); const out: Notification[] = []; + let primary = true; for (const principalId of (this.deps.recipients ?? (() => [null]))()) { const grown = - draft.group === undefined ? null : await this.grow(draft, draft.group, principalId); - out.push(grown ?? (await this.create(draft, principalId))); + draft.group === undefined + ? null + : await this.grow(draft, draft.group, principalId, primary); + out.push(grown ?? (await this.create(draft, principalId, primary))); + primary = false; } return out; } /** - * Persists one notification and delivers it through every channel. + * Persists one notification with its first message, enqueues it for external channels in the + * same transaction (first recipient only: channels are instance-wide), and delivers it in-app. * * @returns The stored DTO. */ - async create(draft: NotificationDraft, principalId: string | null): Promise { + async create( + draft: NotificationDraft, + principalId: string | null, + primary = true, + ): Promise { const now = this.deps.clock.now(); + const notificationId = `n-${this.deps.ids.opaque(12)}`; + const message = this.seal( + buildMessage({ + id: notificationId, + revision: 1, + thread: draft.thread, + kind: draft.kind, + severity: draft.severity, + state: draft.state, + alert: true, + createdAt: now, + updatedAt: now, + title: draft.title, + summary: draft.body ?? '', + ...draft.content(1), + }), + ); const record: NotificationRecord = { - notificationId: `n-${this.deps.ids.opaque(12)}`, + notificationId, principalId, type: draft.type, - title: draft.title, - body: draft.body, + title: this.redactor.scrubText(draft.title), + body: draft.body === null ? null : this.redactor.scrubText(draft.body), sessionId: draft.sessionId, target: draft.target, sourceEventId: draft.sourceEventId, @@ -160,20 +257,63 @@ export class NotificationService { groupKey: draft.group?.key ?? null, readAt: null, dismissedAt: null, + kind: draft.kind, + category: message.category, + severity: draft.severity, + state: draft.state, + revision: 1, + thread: draft.thread, + messageJson: encodeMessage(message), }; - await this.deps.repo.insert(record); + const jobs = primary ? this.plan(message, now) : []; + await this.write(jobs, async (repos) => { + await repos.notifications.insert(record); + return true; + }); const dto = toNotification(record); this.log.info('notification created', { type: dto.type, notification_id: dto.notification_id }); - for (const channel of this.channels) { - try { - await channel.send(dto); - } catch (err) { - this.log.warn('channel send failed', { channel: channel.name, err: serializeError(err) }); - } - } + await this.inbox('send', dto, message); + this.kick(jobs); return dto; } + /** + * Startup catch-up (spec 03 §9.1): an attention request or vault confirmation settled while no + * subscriber listened (rejected by the previous shutdown, or by the orphan recovery of this + * start) revises its notification now, exactly as the live `*.resolved` event would have. + * Composition runs it after the startup reconcile. + * + * @returns The number of notifications revised. + */ + async reconcileRequests(requests: { + get(requestId: string): Promise<{ + readonly status: SettledRequestFacts['status']; + readonly resolvedBy: string | null; + readonly createdAt: number; + readonly resolvedAt: number | null; + readonly waitedMs: number | null; + } | null>; + }): Promise { + let revised = 0; + const rows = await this.deps.repo.listUnsettled(RECONCILED_KINDS, BULK_EVENT_LIMIT); + for (const row of rows) { + if (row.sourceEventId === null) continue; + const request = await requests.get(row.sourceEventId); + if (request === null) continue; + const revision = requestSettled(row.kind === 'vault.confirm' ? 'vault' : 'attention', { + requestId: row.sourceEventId, + status: request.status, + resolvedBy: request.resolvedBy, + createdAt: request.createdAt, + resolvedAt: request.resolvedAt, + waitedMs: request.waitedMs, + }); + if (revision !== null) revised += (await this.revise(revision)).length; + } + if (revised > 0) this.log.info('notifications caught up', { revised }); + return revised; + } + /** Inbox page, newest first. */ async list(query: NotificationListQuery): Promise> { const page = await this.deps.repo.list(query); @@ -283,8 +423,9 @@ export class NotificationService { } /** - * Folds a draft into the inbox's open row of its group when that row is still fresh; the change - * goes out as `notification.updated` (external channels only see created rows). + * Folds a draft into the inbox's open row of its group when that row is still fresh: the row + * gains a revision whose message is rebuilt from the latest occurrence (silent, `alert: false`), + * enqueued as an edit in the same transaction; in-app it goes out as `notification.updated`. * * @returns The grown DTO, or `null` when a new row must be created. */ @@ -292,6 +433,7 @@ export class NotificationService { draft: NotificationDraft, group: NotificationGroup, principalId: string | null, + primary: boolean, ): Promise { const open = await this.deps.repo.findOpenGroup(principalId, group.key); if (open === null) return null; @@ -300,21 +442,152 @@ export class NotificationService { const maxAgeMs = this.deps.groupMaxAgeMs ?? NOTIFICATION_GROUP_MAX_AGE_MS; if (now - open.updatedAt >= idleMs || now - open.createdAt >= maxAgeMs) return null; const count = open.count + 1; - const updated = await this.deps.repo.updateGroup(open.notificationId, { - title: group.title(count), - body: draft.body, - target: draft.target, - sourceEventId: draft.sourceEventId, - count, - updatedAt: Math.max(now, open.updatedAt), + const updatedAt = Math.max(now, open.updatedAt); + const revision = open.revision + 1; + const message = this.seal( + buildMessage({ + id: open.notificationId, + revision, + thread: open.thread, + kind: draft.kind, + severity: draft.severity, + state: open.state, + alert: false, + createdAt: open.createdAt, + updatedAt, + title: group.title(count), + summary: draft.body ?? '', + ...draft.content(count), + }), + ); + const jobs = primary ? this.plan(message, now) : []; + const result: { row: NotificationRecord | null } = { row: null }; + await this.write(jobs, async (repos) => { + result.row = await repos.notifications.updateGroup(open.notificationId, { + title: this.redactor.scrubText(group.title(count)), + body: draft.body === null ? null : this.redactor.scrubText(draft.body), + target: draft.target, + sourceEventId: draft.sourceEventId, + count, + updatedAt, + revision, + messageJson: encodeMessage(message), + }); + return result.row !== null; }); - if (updated === null) return null; - const dto = toNotification(updated); + if (result.row === null) return null; + const dto = toNotification(result.row); this.log.debug('notification grown', { notification_id: dto.notification_id, count }); - this.deps.bus.publish('notification.updated', { - type: 'notification.updated', - notification: dto, - }); + await this.inbox('edit', dto, message); + this.kick(jobs); return dto; } + + /** + * Applies a lifecycle revision to the newest notification of a thread in every inbox (a request + * resolved, a degradation recovered). The in-app title, body and `updated_at` stay; state, + * revision and the message change, and the edit is enqueued for external channels. Rows that + * are no longer open, and rows from before schema v5 (no stored message), are revised in their + * classification fields only. + * + * @returns The revised DTOs. + */ + private async revise(revision: ThreadRevision): Promise { + if (this.seen.has(revision.dedupKey)) return []; + this.remember(revision.dedupKey); + const out: Notification[] = []; + let primary = true; + for (const principalId of (this.deps.recipients ?? (() => [null]))()) { + const isPrimary = primary; + primary = false; + const row = await this.deps.repo.findLatestByThread(principalId, revision.thread); + if (row === null || (row.state !== 'open' && row.state !== 'acted')) continue; + if (row.state === revision.change.state) continue; + const now = this.deps.clock.now(); + const previous = decodeMessage(row.messageJson); + const message = + previous === null ? null : this.seal(reviseMessage(previous, revision.change, now)); + const next = row.revision + 1; + const jobs = isPrimary && message !== null ? this.plan(message, now) : []; + const result: { row: NotificationRecord | null } = { row: null }; + await this.write(jobs, async (repos) => { + result.row = await repos.notifications.revise(row.notificationId, { + state: revision.change.state, + severity: revision.change.severity ?? row.severity, + revision: next, + messageJson: message === null ? null : encodeMessage(message), + }); + return result.row !== null; + }); + const updated = result.row; + if (updated === null) continue; + const dto = toNotification(updated); + this.log.debug('notification revised', { + notification_id: dto.notification_id, + state: dto.state, + }); + await this.inbox('edit', dto, message ?? transientMessage(updated)); + this.kick(jobs); + out.push(dto); + } + return out; + } + + /** + * Redacts a message and validates it against the contract. A message that still fails (a + * producer bug) loses its blocks, actions and entities rather than the notification itself. + */ + private seal(message: NotificationMessage): NotificationMessage { + try { + return scrubMessage(message, this.redactor); + } catch (err) { + this.log.error('message build failed', { kind: message.kind, err: serializeError(err) }); + return scrubMessage({ ...message, blocks: [], actions: [], entities: {} }, this.redactor); + } + } + + /** Outbox rows for a change; none without an outbox, or when there is no unit of work. */ + private plan(message: NotificationMessage, now: number): NewNotificationDelivery[] { + if (this.deps.outbox === undefined || this.deps.uow === undefined) return []; + return this.deps.outbox.plan(message, now); + } + + /** + * Runs `change` and enqueues `jobs` in one transaction (D-34). Without jobs the change runs on + * the auto-commit repository, exactly as before the outbox existed. `change` returns false when + * nothing was updated, which rolls the jobs back. + */ + private async write( + jobs: readonly NewNotificationDelivery[], + change: (repos: Pick) => Promise, + ): Promise { + const uow = this.deps.uow; + if (jobs.length === 0 || uow === undefined) { + await change({ notifications: this.deps.repo }); + return; + } + await uow.transaction(async (repos) => { + if (await change(repos)) await repos.notificationDeliveries.enqueue(jobs); + }); + } + + /** Delivers the row to the in-app channel (inline, after the commit). Never throws. */ + private async inbox( + op: 'send' | 'edit', + dto: Notification, + message: NotificationMessage, + ): Promise { + const delivery = { message, links: INBOX_LINKS, replyTo: null, inbox: dto }; + try { + if (op === 'send' || this.inApp.edit === undefined) await this.inApp.send(delivery); + else await this.inApp.edit({ notification_id: dto.notification_id }, delivery); + } catch (err) { + this.log.warn('in-app delivery failed', { err: serializeError(err) }); + } + } + + /** Wakes the outbox when work was enqueued. */ + private kick(jobs: readonly NewNotificationDelivery[]): void { + if (jobs.some((j) => j.status === 'pending')) this.deps.outbox?.kick(); + } } diff --git a/packages/core/src/app/notifications/outbox.test.ts b/packages/core/src/app/notifications/outbox.test.ts new file mode 100644 index 0000000..4706c9d --- /dev/null +++ b/packages/core/src/app/notifications/outbox.test.ts @@ -0,0 +1,489 @@ +/** @module app/notifications/outbox.test — the delivery outbox state machine on a fake clock with a scripted channel (D-34, D-35, spec 03 §9.4): enqueue in the notification's transaction, zero-channel cost, send/edit/delete, coalescing, edit spacing, retries, dead jobs, the breaker and the degradation-loop cut, message_gone, backlog collapse, crash recovery, the TTL sweep. */ + +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 { RecordingEventBus } from '../../../test/helpers/recording-event-bus.ts'; +import { type ChannelCapabilities, ChannelSendError } from '../../ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../ports/persistence/records.ts'; +import type { DomainEvents } from '../events/catalog.ts'; +import { ManualIntervals } from '../maintenance/test-support.ts'; +import { ChannelRegistry } from './channel-registry.ts'; +import { createLocalLinkBuilder } from './links.ts'; +import { buildMessage, encodeMessage } from './message.ts'; +import { NotificationService } from './notification-service.ts'; +import { NotificationOutbox, type OutboxOptions } from './outbox.ts'; +import { attentionCreated, attentionResolved, toolCalled } from './test-fixtures.ts'; + +interface SetupOptions { + readonly channels?: readonly NotificationChannelRecord[]; + readonly caps?: ChannelCapabilities; + readonly rules?: NotificationChannelRules; + readonly options?: Partial; + readonly noFactory?: boolean; + /** Let the service kick the worker after each commit (default: tests drive every pass). */ + readonly kick?: boolean; +} + +async function setup(opts: SetupOptions = {}) { + const clock = new FakeClock(); + const repos = new InMemoryRepositories(); + const uow = new InMemoryUnitOfWork(repos); + const bus = new RecordingEventBus(); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const fake = new FakeChannel('nc-000000000001', opts.caps ?? capabilities()); + const channels = opts.channels ?? [channelRecord({ rules: opts.rules ?? {} })]; + for (const c of channels) await repos.notificationChannels.upsert(c); + const registry = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + ...(opts.noFactory !== true && { factories: new Map([['fake', () => fake]]) }), + }); + await registry.load(); + const counted: { channel_kind: string; status: string }[] = []; + const intervals = new ManualIntervals(); + const outbox = new NotificationOutbox({ + uow, + repos, + registry, + links: createLocalLinkBuilder(() => 'http://127.0.0.1:9876'), + clock, + logger, + bus, + scheduler: intervals, + jitter: () => 0.5, + counter: { add: (_n, attributes) => void counted.push({ ...attributes }) }, + ...(opts.options !== undefined && { options: opts.options }), + }); + const service = new NotificationService({ + repo: repos.notifications, + bus, + clock, + ids, + logger, + uow, + outbox: + opts.kick === true + ? outbox + : { plan: (message, now) => outbox.plan(message, now), kick: () => undefined }, + }); + service.start(); + const deliveries = () => repos.notificationDeliveries.rows; + const statuses = () => deliveries().map((d) => [d.op, d.revision, d.status, d.reason]); + return { + clock, + repos, + bus, + logger, + fake, + registry, + outbox, + service, + intervals, + counted, + deliveries, + statuses, + }; +} + +type Ctx = Awaited>; + +/** Produces an attention request through the bus and waits for the service. */ +async function attention(t: Ctx, id = 'a-000000000001') { + t.bus.publish( + 'attention.created', + attentionCreated(id, 'takeover').payload as DomainEvents['attention.created'], + ); + await t.service.idle(); +} + +async function resolve(t: Ctx, id = 'a-000000000001') { + t.bus.publish( + 'attention.resolved', + attentionResolved(id, 'resolved').payload as DomainEvents['attention.resolved'], + ); + await t.service.idle(); +} + +describe('enqueue', () => { + it('writes the send job with the notification; zero channels write nothing and arm no timer', async () => { + const t = await setup(); + await attention(t); + expect(t.statuses()).toEqual([['send', 1, 'pending', null]]); + const none = await setup({ channels: [] }); + none.outbox.start(); + await attention(none); + expect(none.deliveries()).toEqual([]); + expect(none.intervals.fns).toHaveLength(0); + expect(await none.outbox.tick()).toEqual({ processed: 0, deletesEnqueued: 0, collapsed: 0 }); + expect(none.repos.notifications.rows.size).toBe(1); + }); + + it('a commit kicks the worker, and a tick joins the running pass', async () => { + const t = await setup({ kick: true }); + await attention(t); + await t.outbox.tick(); + expect(t.statuses()).toEqual([['send', 1, 'sent', null]]); + expect(t.fake.calls).toHaveLength(1); + }); + + it('arms the timer only while a channel exists and stops it', async () => { + const t = await setup(); + t.outbox.start(); + expect(t.intervals.fns).toHaveLength(1); + t.outbox.stop(); + expect(t.intervals.fns).toHaveLength(0); + }); + + it('suppresses with a reason where a channel is paused or has no adapter', async () => { + const paused = await setup({ channels: [channelRecord({ status: 'paused' })] }); + await attention(paused); + expect(paused.statuses()).toEqual([['send', 1, 'suppressed', 'channel_paused']]); + const bare = await setup({ noFactory: true }); + await attention(bare); + expect(bare.statuses()).toEqual([['send', 1, 'suppressed', 'no_adapter']]); + }); +}); + +describe('send and edit', () => { + it('sends the degraded message and records the platform message', async () => { + const t = await setup(); + await attention(t); + await t.outbox.tick(); + expect(t.fake.ops('send')).toHaveLength(1); + const delivered = t.fake.ops('send')[0]?.delivery; + // actButtons is false on the fake: act buttons became their open fallback, links are absolute. + expect(delivered?.message.actions.every((a) => a.kind === 'open')).toBe(true); + expect(delivered?.links.url('/x')).toBe('http://127.0.0.1:9876/x'); + expect(delivered?.message.privacy.level).toBe('titles'); + expect(t.statuses()).toEqual([['send', 1, 'sent', null]]); + const cm = await t.repos.notificationChannelMessages.get( + 'nc-000000000001', + delivered?.message.id ?? '', + ); + expect(cm).toMatchObject({ lastRevision: 1, messageRef: { message_id: 1 }, expiresAt: null }); + expect(t.counted).toEqual([{ channel_kind: 'fake', status: 'sent' }]); + expect((await t.repos.notificationChannels.get('nc-000000000001'))?.lastOkAt).toBe( + t.clock.now(), + ); + }); + + it('edits in place on a revision, at most once per 3 s, silently and without buttons', async () => { + const t = await setup(); + await attention(t); + await t.outbox.tick(); + await resolve(t); + expect(t.statuses().at(-1)).toEqual(['edit', 2, 'pending', null]); + await t.outbox.tick(); + expect(t.fake.ops('edit')).toHaveLength(0); // deferred: the message was updated < 3 s ago + await t.clock.advance(3_000); + await t.outbox.tick(); + const edit = t.fake.ops('edit')[0]; + expect(edit?.ref).toEqual({ message_id: 1 }); + expect(edit?.delivery?.message).toMatchObject({ + revision: 2, + state: 'resolved', + alert: false, + actions: [], + }); + expect(t.statuses()).toEqual([ + ['send', 1, 'sent', null], + ['edit', 2, 'sent', null], + ]); + }); + + it('coalesces: a send that runs after a revision sends the latest state and supersedes the edit', async () => { + const t = await setup(); + await attention(t); + await resolve(t); + await t.outbox.tick(); + expect(t.fake.calls.map((c) => c.op)).toEqual(['send']); + expect(t.fake.ops('send')[0]?.delivery?.message).toMatchObject({ + revision: 2, + state: 'resolved', + }); + expect(t.statuses()).toEqual([ + ['send', 1, 'sent', null], + ['edit', 2, 'superseded', 'covered'], + ]); + }); + + it('grows a tool-error group as edits of one message', async () => { + const t = await setup(); + await t.service.produce(toolCalled(1, { ok: false })); + await t.outbox.tick(); + await t.clock.advance(4_000); + await t.service.produce(toolCalled(2, { ok: false })); + await t.service.produce(toolCalled(3, { ok: false })); + await t.outbox.tick(); + expect(t.fake.calls.map((c) => c.op)).toEqual(['send', 'edit']); + expect(t.fake.ops('edit')[0]?.delivery?.message.title).toBe('shop · 3 tool errors'); + // The older edit job rendered the current state (3 errors), which covers the newer one. + expect(t.statuses()).toEqual([ + ['send', 1, 'sent', null], + ['edit', 2, 'sent', null], + ['edit', 3, 'superseded', 'covered'], + ]); + }); + + it('a message deleted in the chat supersedes the edit and later ones, without counting a failure', async () => { + const t = await setup(); + await attention(t); + await t.outbox.tick(); + await t.clock.advance(3_000); + t.fake.script(new ChannelSendError('message_gone', 'message to edit not found')); + await resolve(t); + await t.outbox.tick(); + expect(t.statuses().at(-1)).toEqual(['edit', 2, 'superseded', 'message_gone']); + const cm = [...t.repos.notificationChannelMessages.rows.values()][0]; + expect(cm?.deletedAt).not.toBeNull(); + expect((await t.repos.notificationChannels.get('nc-000000000001'))?.failureCount).toBe(0); + }); + + it('replies in the thread where the platform supports it', async () => { + const t = await setup(); + await t.service.produce(toolCalled(1, { ok: false })); + await t.outbox.tick(); + const first = [...t.repos.notifications.rows.values()][0]; + if (first === undefined) throw new Error('no row'); + await t.repos.notifications.markRead(first.notificationId, t.clock.now()); + await t.service.produce(toolCalled(2, { ok: false })); // a read group starts a new row, same thread + await t.outbox.tick(); + expect(t.fake.ops('send')[1]?.delivery?.replyTo).toEqual({ message_id: 1 }); + }); +}); + +describe('failures', () => { + it('retries with exponential backoff and honours retry_after', async () => { + const t = await setup(); + t.fake.script( + new Error('socket hang up'), + new ChannelSendError('rate_limited', 'slow down', { retryAfterMs: 7_000 }), + ); + await attention(t); + const start = t.clock.now(); + await t.outbox.tick(); + expect(t.deliveries()[0]).toMatchObject({ + status: 'retrying', + attempts: 1, + nextAttemptAt: start + 1_000, + reason: 'unavailable', + }); + await t.clock.advance(1_000); + await t.outbox.tick(); + expect(t.deliveries()[0]).toMatchObject({ + status: 'retrying', + attempts: 2, + nextAttemptAt: start + 1_000 + 7_000, + }); + await t.clock.advance(6_999); + await t.outbox.tick(); + expect(t.fake.calls).toHaveLength(2); + await t.clock.advance(1); + await t.outbox.tick(); + expect(t.deliveries()[0]).toMatchObject({ status: 'sent', attempts: 3 }); + expect(t.counted.map((c) => c.status)).toEqual(['retrying', 'retrying', 'sent']); + }); + + it('is dead after 8 attempts', async () => { + const t = await setup({ options: { breakerThreshold: 100 } }); + t.fake.script(...Array.from({ length: 8 }, () => new Error('down'))); + await attention(t); + for (let i = 0; i < 8; i++) { + await t.outbox.tick(); + await t.clock.advance(20 * 60_000); + } + expect(t.deliveries()[0]).toMatchObject({ + status: 'dead', + reason: 'max_attempts', + attempts: 8, + }); + expect(t.fake.calls).toHaveLength(8); + }); + + it('is dead once 24 h old, and at once when not retryable', async () => { + const t = await setup(); + t.fake.script(new Error('down'), new Error('down')); + await attention(t); + await t.outbox.tick(); + await t.clock.advance(24 * 3_600_000); + await t.outbox.tick(); + expect(t.deliveries()[0]).toMatchObject({ status: 'dead', reason: 'expired' }); + const auth = await setup(); + auth.fake.script(new ChannelSendError('auth', 'Unauthorized')); + await attention(auth); + await auth.outbox.tick(); + expect(auth.deliveries()[0]).toMatchObject({ status: 'dead', reason: 'auth', attempts: 1 }); + expect(auth.deliveries()[0]?.lastError).toBe('auth: Unauthorized'); + }); + + it('recovers jobs a crash left sending', async () => { + const t = await setup(); + await attention(t); + const job = t.deliveries()[0]; + if (job === undefined) throw new Error('no job'); + expect(await t.repos.notificationDeliveries.claim(job.seq, t.clock.now())).toBe(true); + expect(await t.outbox.tick()).toMatchObject({ processed: 0 }); + expect(await t.outbox.recover()).toBe(1); + await t.outbox.tick(); + expect(t.deliveries()[0]).toMatchObject({ status: 'sent', attempts: 2 }); + }); + + it('opens the breaker after 5 consecutive failures: broken, in-app notice, no degradation, no loop', async () => { + const t = await setup(); + t.fake.script(...Array.from({ length: 5 }, () => new Error('down'))); + await attention(t); + for (let i = 0; i < 5; i++) { + await t.outbox.tick(); + await t.clock.advance(20 * 60_000); + } + await t.service.idle(); + expect((await t.repos.notificationChannels.get('nc-000000000001'))?.status).toBe('broken'); + expect(t.registry.get('nc-000000000001')?.record.status).toBe('broken'); + expect(t.deliveries()[0]).toMatchObject({ status: 'suppressed', reason: 'channel_paused' }); + const changed = t.bus.published.filter((p) => p.name === 'notification.channel.changed'); + expect(changed).toHaveLength(1); + // The in-app notice exists and was never enqueued for the (or any) external channel. + const notice = [...t.repos.notifications.rows.values()].find( + (r) => r.kind === 'channel.broken', + ); + expect(notice?.title).toBe('Notification channel phone is failing'); + expect(t.deliveries().some((d) => d.notificationId === notice?.notificationId)).toBe(false); + // Structural cut: nothing was reported as a degradation. + expect(t.bus.names().filter((n) => n === 'system.degraded')).toEqual([]); + expect(t.repos.systemEvents.rows).toEqual([]); + // Later notifications are logged as suppressed while the channel is broken. + await attention(t, 'a-000000000002'); + expect(t.statuses().at(-1)).toEqual(['send', 1, 'suppressed', 'channel_paused']); + await t.outbox.tick(); + expect(t.fake.calls).toHaveLength(5); + }); +}); + +describe('backlog', () => { + it('collapses more than 20 pending info sends into the newest with a note', async () => { + const t = await setup(); + for (let i = 1; i <= 22; i++) { + const id = `n-${String(i).padStart(12, '0')}`; + const message = buildMessage({ + id, + revision: 1, + thread: `session:s${i}`, + kind: 'session.finished', + severity: 'info', + state: 'final', + alert: true, + createdAt: i, + updatedAt: i, + title: `Session ${i} finished`, + summary: '', + blocks: [], + actions: [], + entities: {}, + }); + await t.repos.notifications.insert({ + notificationId: id, + principalId: null, + type: 'lifecycle', + title: message.title, + body: null, + sessionId: null, + target: null, + sourceEventId: null, + createdAt: i, + updatedAt: i, + count: 1, + groupKey: null, + readAt: null, + dismissedAt: null, + kind: 'session.finished', + category: 'wrap-ups', + severity: 'info', + state: 'final', + revision: 1, + thread: message.thread, + messageJson: encodeMessage(message), + }); + await t.repos.notificationDeliveries.enqueue(t.outbox.plan(message, t.clock.now())); + } + const pass = await t.outbox.tick(); + expect(pass?.collapsed).toBe(21); + expect(t.fake.ops('send')).toHaveLength(1); + const sent = t.fake.ops('send')[0]?.delivery?.message; + expect(sent?.title).toBe('Session 22 finished'); + expect(JSON.stringify(sent?.blocks)).toContain('You missed 21 earlier notifications'); + expect(t.deliveries().filter((d) => d.reason === 'collapsed')).toHaveLength(21); + }); +}); + +describe('TTL', () => { + it('deletes a message when its TTL is due and logs a late delete', async () => { + const t = await setup({ rules: { ttl_ms: { 'needs-you': 60_000 } } }); + await attention(t); + await t.outbox.tick(); + const cm = [...t.repos.notificationChannelMessages.rows.values()][0]; + expect(cm?.expiresAt).toBe(t.clock.now() + 60_000); + await t.clock.advance(59_999); + await t.outbox.tick(); + expect(t.fake.ops('delete')).toHaveLength(0); + await t.clock.advance(3 * 60_000); // BrowserHive was busy (or off): the delete is late + const pass = await t.outbox.tick(); + expect(pass?.deletesEnqueued).toBe(1); + expect(t.fake.ops('delete')[0]?.ref).toEqual({ message_id: 1 }); + expect([...t.repos.notificationChannelMessages.rows.values()][0]?.deletedAt).not.toBeNull(); + expect(t.statuses().at(-1)).toEqual(['delete', 1, 'sent', null]); + expect(t.logger.records.some((r) => r.msg === 'late message delete')).toBe(true); + await t.outbox.tick(); + expect(t.fake.ops('delete')).toHaveLength(1); // never enqueued twice + }); + + it('deletes when resolved where the category opts in', async () => { + const t = await setup({ rules: { delete_when_resolved: { 'needs-you': true } } }); + await attention(t); + await t.outbox.tick(); + await t.clock.advance(3_000); + await resolve(t); + await t.outbox.tick(); // the edit sets expires_at = now + await t.outbox.tick(); // the sweep deletes it + expect(t.fake.calls.map((c) => c.op)).toEqual(['send', 'edit', 'delete']); + }); + + it('cannot delete past the platform window (Telegram 48 h) or without delete support', async () => { + const old = await setup({ + rules: { ttl_ms: { 'needs-you': 72 * 3_600_000 } }, + caps: capabilities({ deleteWindowMs: 48 * 3_600_000 }), + }); + await attention(old); + await old.outbox.tick(); + await old.clock.advance(72 * 3_600_000); + await old.outbox.tick(); + expect(old.fake.ops('delete')).toHaveLength(0); + expect(old.statuses().at(-1)).toEqual(['delete', 1, 'dead', 'could_not_delete: too_old']); + const platform = await setup({ rules: { ttl_ms: { 'needs-you': 1_000 } } }); + platform.fake.script('ok', new ChannelSendError('too_old', "message can't be deleted")); + await attention(platform); + await platform.outbox.tick(); + await platform.clock.advance(1_000); + await platform.outbox.tick(); + expect(platform.statuses().at(-1)).toEqual(['delete', 1, 'dead', 'could_not_delete: too_old']); + expect((await platform.repos.notificationChannels.get('nc-000000000001'))?.failureCount).toBe( + 0, + ); + const none = await setup({ + rules: { ttl_ms: { 'needs-you': 1_000 } }, + caps: capabilities({ delete: false }), + }); + await attention(none); + await none.outbox.tick(); + await none.clock.advance(1_000); + await none.outbox.tick(); + expect(none.statuses().at(-1)).toEqual(['delete', 1, 'suppressed', 'delete_unsupported']); + }); +}); diff --git a/packages/core/src/app/notifications/outbox.ts b/packages/core/src/app/notifications/outbox.ts new file mode 100644 index 0000000..e51069c --- /dev/null +++ b/packages/core/src/app/notifications/outbox.ts @@ -0,0 +1,677 @@ +/** @module app/notifications/outbox — the delivery outbox worker (D-34, spec 03 §9.4): drains `notification_deliveries` through the channel adapters with coalescing, edit spacing, retries with backoff and jitter, crash recovery, the circuit breaker (never a degradation), backlog collapse and the TTL sweep. Runs only while an external channel exists. */ + +import type { NotificationMessage } from '@browserhive/contracts/notifications'; +import { SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'; +import { serializeError } from '../../kernel/errors/serialize-error.ts'; +import type { Redactor } from '../../kernel/redact.ts'; +import type { Clock } from '../../ports/clock.ts'; +import type { EventPublisher } from '../../ports/event-bus.ts'; +import type { Logger } from '../../ports/logger.ts'; +import { + type ChannelDelivery, + type ChannelErrorCode, + ChannelSendError, + type ChannelSendResult, + type LinkBuilder, + type NotificationChannel, + type PlatformMessageRef, +} from '../../ports/notification-channel.ts'; +import type { + DeliveryFinishPatch, + NewNotificationDelivery, + NotificationChannelMessageRecord, + NotificationDeliveryRecord, +} from '../../ports/persistence/records.ts'; +import type { Repositories, UnitOfWork } from '../../ports/persistence/unit-of-work.ts'; +import type { DomainEvents } from '../events/catalog.ts'; +import { type IntervalScheduler, realIntervalScheduler } from '../maintenance/timer.ts'; +import type { ChannelRegistry, RegisteredChannel } from './channel-registry.ts'; +import { restrictContent } from './content-level.ts'; +import { degrade } from './degrade.ts'; +import { clip, decodeMessage, text } from './message.ts'; +import { contentLevelOf, deleteWhenResolved, expiryFor, planDeliveries } from './routing.ts'; + +/** Tunables of the worker; the defaults are the documented behaviour (spec 03 §9.4). */ +export interface OutboxOptions { + /** Poll interval while channels exist. */ + readonly tickMs: number; + /** Jobs processed per pass. */ + readonly batchSize: number; + /** A job is `dead` after this many attempts. */ + readonly maxAttempts: number; + /** A job is `dead` once this old. */ + readonly maxAgeMs: number; + /** First retry delay; doubles per attempt. */ + readonly baseBackoffMs: number; + readonly maxBackoffMs: number; + /** ± share of the delay drawn at random. */ + readonly jitterRatio: number; + /** At most one edit per message this often. */ + readonly editSpacingMs: number; + /** Consecutive failures that open the breaker. */ + readonly breakerThreshold: number; + /** Pending `info` sends on one channel above which they collapse into the newest. */ + readonly backlogThreshold: number; + /** A TTL delete this late is logged as late. */ + readonly lateDeleteMs: number; +} + +/** The documented defaults. */ +export const DEFAULT_OUTBOX_OPTIONS: OutboxOptions = { + tickMs: 1_000, + batchSize: 25, + maxAttempts: 8, + maxAgeMs: 24 * 60 * 60_000, + baseBackoffMs: 1_000, + maxBackoffMs: 15 * 60_000, + jitterRatio: 0.2, + editSpacingMs: 3_000, + breakerThreshold: 5, + backlogThreshold: 20, + lateDeleteMs: 60_000, +}; + +/** Longest `last_error` stored on a delivery or a channel. */ +const ERROR_MAX = 500; +/** Prefix of the reason that carries a backlog note on the newest pending send. */ +const BACKLOG_PREFIX = 'backlog:'; + +/** Counter the worker increments once per finished or rescheduled job (spec 10 §7). */ +export interface DeliveryCounter { + add(value: number, attributes: { readonly channel_kind: string; readonly status: string }): void; +} + +/** Dependencies of {@link NotificationOutbox}. */ +export interface NotificationOutboxDeps { + readonly uow: UnitOfWork; + /** Auto-commit repositories for reads. */ + readonly repos: Pick< + Repositories, + 'notifications' | 'notificationDeliveries' | 'notificationChannelMessages' + >; + readonly registry: ChannelRegistry; + readonly links: LinkBuilder; + readonly clock: Clock; + readonly logger: Logger; + /** Receives `notification.channel.changed` when the breaker opens. */ + readonly bus?: EventPublisher; + /** Scrubs platform error text before it is stored. */ + readonly redactor?: Redactor; + readonly scheduler?: IntervalScheduler; + /** Uniform [0, 1) for backoff jitter; default a fixed 0.5 (no jitter). */ + readonly jitter?: () => number; + /** Defaults to `trace.getTracer('browserhive')`. */ + readonly tracer?: Tracer; + readonly counter?: DeliveryCounter; + readonly options?: Partial; +} + +/** Summary of one pass (tests, logs). */ +export interface OutboxPass { + readonly processed: number; + readonly deletesEnqueued: number; + readonly collapsed: number; +} + +interface Classified { + readonly code: ChannelErrorCode; + readonly retryable: boolean; + readonly retryAfterMs: number | null; + readonly detail: string; +} + +/** + * The outbox worker. `plan()` is called by the notification service inside its transaction; + * `kick()` after the commit wakes the worker. With no external channel the worker never arms a + * timer and `plan()` returns nothing, so the in-app path is unchanged (D-34). + */ +export class NotificationOutbox { + private readonly opts: OutboxOptions; + private readonly log: Logger; + private readonly tracer: Tracer; + private cancel: (() => void) | undefined; + private offRegistry: (() => void) | undefined; + private current: Promise | undefined; + private again = false; + private started = false; + + constructor(private readonly deps: NotificationOutboxDeps) { + this.opts = { ...DEFAULT_OUTBOX_OPTIONS, ...deps.options }; + this.log = deps.logger.child({ module: 'notifications' }); + this.tracer = deps.tracer ?? trace.getTracer('browserhive'); + } + + /** + * The outbox rows for one notification change (spec 03 §9.4); written by the caller in the + * notification's own transaction. + * + * @returns The rows (empty without external channels). + */ + plan(message: NotificationMessage, now: number): NewNotificationDelivery[] { + return planDeliveries(message, this.deps.registry.channels(), now); + } + + /** Wakes the worker after a commit that enqueued work. No-op without channels. */ + kick(): void { + if (!this.deps.registry.hasChannels()) return; + void this.tick().catch((err: unknown) => this.report(err)); + } + + /** + * Starts the worker: recovers jobs a crash left `sending`, then arms the timer while channels + * exist (and follows registry reloads). Idempotent. + */ + start(): void { + if (this.started) return; + this.started = true; + this.offRegistry = this.deps.registry.onChange(() => this.arm()); + this.arm(); + if (this.deps.registry.hasChannels()) { + void this.recover() + .then(() => 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; + } + + /** + * Crash recovery: jobs left `sending` become `retrying`, due now. A `send` the platform had + * already accepted is therefore repeated (at least once, D-34). + * + * @returns The number of jobs recovered. + */ + async recover(): Promise { + const n = await this.deps.repos.notificationDeliveries.recoverSending(this.deps.clock.now()); + if (n > 0) this.log.warn('deliveries recovered', { count: n }); + return n; + } + + /** + * One pass: TTL sweep, backlog collapse, then up to `batchSize` due jobs, oldest first. A call + * while a pass runs joins it and makes it loop once more, so the returned promise always covers + * work enqueued before the call. + * + * @returns The pass summary. + */ + tick(): Promise { + if (this.current !== undefined) { + this.again = true; + return this.current; + } + const run = this.pass().finally(() => { + this.current = undefined; + }); + this.current = run; + return run; + } + + private async pass(): Promise { + let processed = 0; + let deletesEnqueued = 0; + let collapsed = 0; + do { + this.again = false; + if (!this.deps.registry.hasChannels()) break; + const now = this.deps.clock.now(); + deletesEnqueued += await this.sweepTtl(now); + collapsed += await this.collapseBacklog(now); + const jobs = await this.deps.repos.notificationDeliveries.due(now, this.opts.batchSize); + for (const job of jobs) { + try { + await this.process(job); + } catch (err) { + this.log.error('delivery job failed', { seq: job.seq, err: serializeError(err) }); + } + processed++; + } + } while (this.again); + return { processed, deletesEnqueued, collapsed }; + } + + private arm(): void { + const want = this.started && this.deps.registry.hasChannels(); + if (want && this.cancel === undefined) { + this.cancel = (this.deps.scheduler ?? realIntervalScheduler).setInterval(() => { + void this.tick().catch((err: unknown) => this.report(err)); + }, this.opts.tickMs); + } else if (!want && this.cancel !== undefined) { + this.cancel(); + this.cancel = undefined; + } + } + + private report(err: unknown): void { + this.log.error('outbox pass failed', { err: serializeError(err) }); + } + + /** Enqueues a `delete` for every message whose TTL is due (D-35). */ + private async sweepTtl(now: number): Promise { + const due = await this.deps.repos.notificationChannelMessages.dueForDelete( + now, + this.opts.batchSize, + ); + const rows: NewNotificationDelivery[] = due + .filter((m) => this.deps.registry.get(m.channelId) !== undefined) + .map((m) => ({ + channelId: m.channelId, + notificationId: m.notificationId, + revision: m.lastRevision, + op: 'delete', + status: 'pending', + reason: null, + nextAttemptAt: now, + createdAt: now, + })); + if (rows.length === 0) return 0; + return this.deps.uow.transaction((r) => r.notificationDeliveries.enqueue(rows)); + } + + /** More than `backlogThreshold` pending `info` sends on a channel collapse into the newest. */ + private async collapseBacklog(now: number): Promise { + let collapsed = 0; + for (const { record } of this.deps.registry.channels()) { + if (record.status !== 'active') continue; + const pending = await this.deps.repos.notificationDeliveries.pendingInfoSends( + record.channelId, + ); + if (pending.length <= this.opts.backlogThreshold) continue; + const newest = pending[pending.length - 1]; + if (newest === undefined) continue; + const older = pending.slice(0, -1); + await this.deps.uow.transaction(async (r) => { + for (const job of older) { + await r.notificationDeliveries.finish(job.seq, { + status: 'superseded', + reason: 'collapsed', + updatedAt: now, + }); + } + await r.notificationDeliveries.annotate( + newest.seq, + `${BACKLOG_PREFIX}${older.length}`, + now, + ); + }); + for (const _ of older) this.count(record.kind, 'superseded'); + collapsed += older.length; + this.log.info('backlog collapsed', { channel: record.name, count: older.length }); + } + return collapsed; + } + + private count(kind: string, status: string): void { + this.deps.counter?.add(1, { channel_kind: kind, status }); + } + + /** Writes a decision made without a platform call. */ + private async settle( + job: NotificationDeliveryRecord, + entry: RegisteredChannel | undefined, + status: 'superseded' | 'suppressed' | 'dead', + reason: string, + ): Promise { + await this.deps.repos.notificationDeliveries.finish(job.seq, { + status, + reason, + updatedAt: this.deps.clock.now(), + }); + this.count(entry?.record.kind ?? 'unknown', status); + } + + private async process(job: NotificationDeliveryRecord): Promise { + const entry = this.deps.registry.get(job.channelId); + if (entry === undefined) return this.settle(job, entry, 'superseded', 'channel_gone'); + if (entry.record.status !== 'active') { + return this.settle(job, entry, 'suppressed', 'channel_paused'); + } + const adapter = entry.adapter; + if (adapter === null) return this.settle(job, entry, 'suppressed', 'no_adapter'); + const cm = await this.deps.repos.notificationChannelMessages.get( + job.channelId, + job.notificationId, + ); + if (job.op === 'delete') return this.processDelete(job, entry, adapter, cm); + const row = await this.deps.repos.notifications.get(job.notificationId); + const message = row === null ? null : decodeMessage(row.messageJson); + if (message === null) return this.settle(job, entry, 'superseded', 'no_message'); + const now = this.deps.clock.now(); + if (job.op === 'edit') { + if (cm === null) return this.settle(job, entry, 'superseded', 'not_sent'); + if (cm.deletedAt !== null) return this.settle(job, entry, 'superseded', 'message_deleted'); + if (cm.lastRevision >= message.revision) + return this.settle(job, entry, 'superseded', 'covered'); + if (!entry.capabilities?.edit || adapter.edit === undefined) { + return this.settle(job, entry, 'suppressed', 'edit_unsupported'); + } + const earliest = cm.updatedAt + this.opts.editSpacingMs; + if (now < earliest) { + await this.deps.repos.notificationDeliveries.reschedule(job.seq, earliest, now); + return; + } + } else if (cm !== null && cm.deletedAt === null && cm.lastRevision >= message.revision) { + return this.settle(job, entry, 'superseded', 'covered'); + } + if (!(await this.deps.repos.notificationDeliveries.claim(job.seq, now))) return; + await this.deps.repos.notificationDeliveries.supersede( + job.channelId, + job.notificationId, + message.revision, + now, + 'covered', + job.seq, + ); + const delivery = await this.delivery(job, entry, message); + const started = this.deps.clock.now(); + try { + const result = await this.call(entry, job, message.revision, () => + job.op === 'edit' && cm !== null && adapter.edit !== undefined + ? adapter.edit(cm.messageRef, delivery) + : adapter.send(delivery), + ); + await this.delivered(job, entry, message, cm, result, started); + } catch (err) { + await this.failed(job, entry, err, started, cm); + } + } + + /** The message as this channel may show it: content level, backlog note, degrade. */ + private async delivery( + job: NotificationDeliveryRecord, + entry: RegisteredChannel, + message: NotificationMessage, + ): Promise { + let shown = restrictContent(message, contentLevelOf(entry.record.rules)); + const missed = job.reason?.startsWith(BACKLOG_PREFIX) + ? Number(job.reason.slice(BACKLOG_PREFIX.length)) + : 0; + if (missed > 0) { + shown = { + ...shown, + blocks: [ + ...shown.blocks, + { + type: 'footer', + content: [ + text( + `You missed ${missed} earlier notification${missed === 1 ? '' : 's'} while delivery was behind.`, + ), + ], + }, + ], + }; + } + const capabilities = entry.capabilities; + const degraded = capabilities === null ? shown : degrade(shown, capabilities); + let replyTo: PlatformMessageRef | null = null; + if (job.op === 'send' && capabilities?.replies === true) { + const first = await this.deps.repos.notificationChannelMessages.firstInThread( + job.channelId, + message.thread, + ); + if (first !== null && first.notificationId !== job.notificationId && first.deletedAt === null) + replyTo = first.messageRef; + } + return { message: degraded, links: this.deps.links, replyTo }; + } + + /** One platform call inside a `notification.deliver` span. */ + private call( + entry: RegisteredChannel, + job: NotificationDeliveryRecord, + revision: number, + fn: () => Promise, + ): Promise { + return this.tracer.startActiveSpan( + 'notification.deliver', + { + attributes: { + 'browserhive.channel_kind': entry.record.kind, + 'browserhive.channel_id': entry.record.channelId, + 'browserhive.notification_id': job.notificationId, + 'browserhive.revision': revision, + 'browserhive.op': job.op, + 'browserhive.attempt': job.attempts + 1, + }, + }, + async (span) => { + try { + const out = await fn(); + span.setAttribute('browserhive.status', 'sent'); + return out; + } catch (err) { + const code = err instanceof ChannelSendError ? err.code : 'unavailable'; + span.setAttribute('browserhive.status', 'failed'); + span.setAttribute('browserhive.error_code', code); + span.setStatus({ code: SpanStatusCode.ERROR, message: code }); + throw err; + } finally { + span.end(); + } + }, + ); + } + + private async delivered( + job: NotificationDeliveryRecord, + entry: RegisteredChannel, + message: NotificationMessage, + previous: NotificationChannelMessageRecord | null, + result: ChannelSendResult, + started: number, + ): Promise { + const done = this.deps.clock.now(); + const rules = entry.record.rules; + const edit = job.op === 'edit' && previous !== null; + let expiresAt = edit ? previous.expiresAt : expiryFor(rules, message, done); + if (deleteWhenResolved(rules, message)) expiresAt = done; + await this.deps.uow.transaction(async (r) => { + await r.notificationDeliveries.finish(job.seq, { + status: 'sent', + reason: null, + lastError: null, + durationMs: Math.max(0, done - started), + messageRef: result.ref, + updatedAt: done, + }); + await r.notificationChannelMessages.upsert({ + channelId: job.channelId, + notificationId: job.notificationId, + thread: message.thread, + messageRef: result.ref, + lastRevision: message.revision, + sentAt: edit ? previous.sentAt : done, + updatedAt: done, + expiresAt, + deletedAt: null, + }); + await r.notificationChannels.recordSuccess(job.channelId, done); + }); + this.deps.registry.setCachedStatus(job.channelId, entry.record.status, 0); + this.count(entry.record.kind, 'sent'); + } + + private classify(err: unknown): Classified { + if (err instanceof ChannelSendError) { + return { + code: err.code, + retryable: err.retryable, + retryAfterMs: err.retryAfterMs, + detail: `${err.code}: ${err.message}`, + }; + } + return { + code: 'unavailable', + retryable: true, + retryAfterMs: null, + detail: `unavailable: ${serializeError(err).message}`, + }; + } + + /** Backoff before the next attempt: the platform's wait, else exponential with jitter. */ + private backoff(attempts: number, retryAfterMs: number | null): number { + if (retryAfterMs !== null) return Math.max(0, retryAfterMs); + const raw = Math.min( + this.opts.maxBackoffMs, + this.opts.baseBackoffMs * 2 ** Math.max(0, attempts - 1), + ); + const draw = this.deps.jitter?.() ?? 0.5; + return Math.max(0, Math.round(raw * (1 + this.opts.jitterRatio * (2 * draw - 1)))); + } + + private scrub(detail: string): string { + const scrubbed = this.deps.redactor?.scrubText(detail) ?? detail; + return clip(scrubbed, ERROR_MAX); + } + + private async failed( + job: NotificationDeliveryRecord, + entry: RegisteredChannel, + err: unknown, + started: number, + cm: NotificationChannelMessageRecord | null, + ): Promise { + const now = this.deps.clock.now(); + const e = this.classify(err); + const detail = this.scrub(e.detail); + const durationMs = Math.max(0, now - started); + if (e.code === 'message_gone') { + // Deleted in the chat: not a health failure. Later revisions have nothing to edit. + await this.deps.uow.transaction(async (r) => { + await r.notificationDeliveries.finish(job.seq, { + status: 'superseded', + reason: 'message_gone', + lastError: detail, + durationMs, + updatedAt: now, + }); + if (cm !== null) + await r.notificationChannelMessages.markDeleted(job.channelId, job.notificationId, now); + }); + this.count(entry.record.kind, 'superseded'); + return; + } + const attempts = job.attempts + 1; + const patch: DeliveryFinishPatch = ((): DeliveryFinishPatch => { + const base = { lastError: detail, durationMs, updatedAt: now }; + if (e.code === 'too_old') + return { ...base, status: 'dead', reason: 'could_not_delete: too_old' }; + if (!e.retryable) return { ...base, status: 'dead', reason: e.code }; + if (attempts >= this.opts.maxAttempts) + return { ...base, status: 'dead', reason: 'max_attempts' }; + if (now - job.createdAt >= this.opts.maxAgeMs) + return { ...base, status: 'dead', reason: 'expired' }; + return { + ...base, + status: 'retrying', + reason: e.code, + nextAttemptAt: now + this.backoff(attempts, e.retryAfterMs), + }; + })(); + const health = e.code !== 'too_old'; + const failures = await this.deps.uow.transaction(async (r) => { + await r.notificationDeliveries.finish(job.seq, patch); + return health ? r.notificationChannels.recordFailure(job.channelId, now, detail) : 0; + }); + this.count(entry.record.kind, patch.status); + this.log.warn('delivery failed', { + channel: entry.record.name, + seq: job.seq, + op: job.op, + status: patch.status, + code: e.code, + }); + if (health && failures >= this.opts.breakerThreshold && entry.record.status === 'active') { + await this.breakChannel(entry, failures, detail, now); + } else if (health) { + this.deps.registry.setCachedStatus(job.channelId, entry.record.status, failures); + } + } + + /** + * Opens the breaker: the channel becomes `broken`, its pending jobs are suppressed, and + * `notification.channel.changed` produces the in-app-only `channel.broken` notification. Never a + * system degradation: that would be delivered through this channel again (D-34). + */ + private async breakChannel( + entry: RegisteredChannel, + failures: number, + detail: string, + now: number, + ): Promise { + const channelId = entry.record.channelId; + const suppressed = await this.deps.uow.transaction(async (r) => { + await r.notificationChannels.setStatus(channelId, 'broken', now); + return r.notificationDeliveries.suppressChannel(channelId, 'channel_paused', now); + }); + this.deps.registry.setCachedStatus(channelId, 'broken', failures); + for (let i = 0; i < suppressed; i++) this.count(entry.record.kind, 'suppressed'); + this.log.warn('channel breaker opened', { channel: entry.record.name, failures }); + this.deps.bus?.publish('notification.channel.changed', { + type: 'notification.channel.changed', + channel_id: channelId, + name: entry.record.name, + kind: entry.record.kind, + status: 'broken', + previous_status: 'active', + failure_count: failures, + last_error: detail, + at: now, + }); + } + + private async processDelete( + job: NotificationDeliveryRecord, + entry: RegisteredChannel, + adapter: NotificationChannel, + cm: NotificationChannelMessageRecord | null, + ): Promise { + if (cm === null || cm.deletedAt !== null) { + return this.settle(job, entry, 'superseded', 'message_deleted'); + } + const caps = entry.capabilities; + if (caps === null || !caps.delete || adapter.delete === undefined) { + return this.settle(job, entry, 'suppressed', 'delete_unsupported'); + } + const now = this.deps.clock.now(); + if (caps.deleteWindowMs !== null && now - cm.sentAt > caps.deleteWindowMs) { + this.log.warn('message too old to delete', { channel: entry.record.name, seq: job.seq }); + return this.settle(job, entry, 'dead', 'could_not_delete: too_old'); + } + if (!(await this.deps.repos.notificationDeliveries.claim(job.seq, now))) return; + const remove = adapter.delete.bind(adapter); + const started = this.deps.clock.now(); + try { + await this.call(entry, job, cm.lastRevision, () => remove(cm.messageRef)); + const done = this.deps.clock.now(); + await this.deps.uow.transaction(async (r) => { + await r.notificationDeliveries.finish(job.seq, { + status: 'sent', + reason: null, + lastError: null, + durationMs: Math.max(0, done - started), + messageRef: cm.messageRef, + updatedAt: done, + }); + await r.notificationChannelMessages.markDeleted(job.channelId, job.notificationId, done); + await r.notificationChannels.recordSuccess(job.channelId, done); + }); + if (cm.expiresAt !== null && done - cm.expiresAt > this.opts.lateDeleteMs) { + this.log.info('late message delete', { + channel: entry.record.name, + late_ms: done - cm.expiresAt, + }); + } + this.count(entry.record.kind, 'sent'); + } catch (err) { + await this.failed(job, entry, err, started, cm); + } + } +} diff --git a/packages/core/src/app/notifications/producers.ts b/packages/core/src/app/notifications/producers.ts index 8733453..12a7024 100644 --- a/packages/core/src/app/notifications/producers.ts +++ b/packages/core/src/app/notifications/producers.ts @@ -1,15 +1,44 @@ -/** @module app/notifications/producers — the pure producer rules (spec 03 §9, D-16): bus event → notification draft or null. */ +/** @module app/notifications/producers — the pure producer rules (spec 03 §9.1, D-16, D-32): bus event → notification draft (with its contract content) or a lifecycle revision of an existing thread, or null. */ -import type { NotificationType } from '@browserhive/contracts/enums'; +import type { + NotificationKind, + NotificationSeverity, + NotificationState, + NotificationType, +} from '@browserhive/contracts/enums'; import { ERROR_REGISTRY, isErrorCode } from '@browserhive/contracts/errors'; +import type { OperatorRequestRow } from '@browserhive/contracts/http'; import { parseSessionId } from '@browserhive/contracts/ids'; +import { + type Block, + KIND_SEVERITY, + type NotificationAction, + type NotificationEntities, +} from '@browserhive/contracts/notifications'; import { assertNever } from '../../kernel/errors/app-error.ts'; +import { sanitizeUrl } from '../../kernel/url.ts'; import type { DomainEvent } from '../../ports/event-bus.ts'; import type { DomainEventName, DomainEvents } from '../events/catalog.ts'; +import { + code, + formatDuration, + type LifecycleChange, + link, + type MessageContent, + text, + time, +} from './message.ts'; /** What a producer rule yields before persistence assigns id, principal and timestamps. */ export interface NotificationDraft { readonly type: NotificationType; + /** Contract kind (D-32); category follows from it. */ + readonly kind: NotificationKind; + readonly severity: NotificationSeverity; + /** State of the first revision (`open` for requests and groups, `final` for one-shot facts). */ + readonly state: NotificationState; + /** Conversation key (`attention:`, `session:`, `tool-errors:`). */ + readonly thread: string; readonly title: string; readonly body: string | null; readonly sessionId: string | null; @@ -21,6 +50,8 @@ export interface NotificationDraft { readonly dedupKey: string; /** When set, occurrences fold into one open row of the group instead of new rows (spec 03 §9). */ readonly group?: NotificationGroup; + /** Blocks, actions and entities of the message for a row holding `count` occurrences (1 unless grouped). */ + readonly content: (count: number) => MessageContent; } /** How a draft folds into an existing row. */ @@ -31,6 +62,14 @@ export interface NotificationGroup { readonly title: (count: number) => string; } +/** A lifecycle revision of the notification that owns `thread` (spec 03 §9.1). */ +export interface ThreadRevision { + readonly thread: string; + readonly change: LifecycleChange; + /** Idempotency key of the revision (`att-res:`). */ + readonly dedupKey: string; +} + /** A group row stops growing once it has been idle this long (a new failure starts a new row). */ export const NOTIFICATION_GROUP_IDLE_MS = 5 * 60_000; /** A group row stops growing once it is this old, so a long failing run surfaces again hourly. */ @@ -56,10 +95,14 @@ function isCallerMistake(code: string | null): boolean { /** Bus events the producer subscribes to. */ export const PRODUCED_EVENTS = [ 'attention.created', + 'attention.resolved', 'session.closed', 'tool.called', 'vault.confirm.created', + 'vault.confirm.resolved', 'system.degraded', + 'system.recovered', + 'notification.channel.changed', ] as const satisfies readonly DomainEventName[]; /** Element of {@link PRODUCED_EVENTS}. */ @@ -83,7 +126,8 @@ export type ProducedEvent = { /** * Applies the producer table to one event. * - * @returns The draft, or `null` for deliberately silent events (opened, page, removed, resolved…). + * @returns The draft, or `null` for deliberately silent events and for events that only revise an + * existing notification (see {@link revisionFor}). */ export function draftFor(event: ProducedEvent): NotificationDraft | null { switch (event.name) { @@ -97,34 +141,316 @@ export function draftFor(event: ProducedEvent): NotificationDraft | null { return vaultConfirmCreated(event.payload); case 'system.degraded': return systemDegraded(event.payload); + case 'notification.channel.changed': + return channelChanged(event.payload); + case 'attention.resolved': + case 'vault.confirm.resolved': + case 'system.recovered': + return null; default: return assertNever(event); } } +/** + * Applies the revision table to one event: a resolved request or a recovered degradation revises + * the notification of its thread (spec 03 §9.1). + * + * @returns The revision, or `null` for events that create or say nothing. + */ +export function revisionFor(event: ProducedEvent): ThreadRevision | null { + switch (event.name) { + case 'attention.resolved': + return requestSettled('attention', factsOf(event.payload.request)); + case 'vault.confirm.resolved': + return requestSettled('vault', factsOf(event.payload.request)); + case 'system.recovered': { + const e = event.payload.event; + return { + thread: `system:${e.event_id}`, + dedupKey: `sys-rec:${e.event_id}:${e.resolved_at ?? 0}`, + change: { + state: 'resolved', + summary: `Recovered: ${e.message}`, + fields: + e.resolved_at === null ? [] : [{ label: 'Recovered', value: [time(e.resolved_at)] }], + }, + }; + } + default: + return null; + } +} + +function sessionLabel(sessionId: string): string { + return parseSessionId(sessionId)?.slug ?? sessionId; +} + +function sessionEntities(sessionId: string | null): NotificationEntities { + if (sessionId === null) return {}; + return { session_id: sessionId, session_slug: sessionLabel(sessionId) }; +} + +function sessionField(sessionId: string) { + return { label: 'Session', value: [link(sessionLabel(sessionId), `/sessions/${sessionId}`)] }; +} + +/** Hostname of a sanitized page URL, for routing (`entities.domain`). */ +function domainOf(pageUrl: string | null): string | undefined { + if (pageUrl === null) return undefined; + try { + const host = new URL(pageUrl).hostname; + return host.length > 0 ? host : undefined; + } catch { + return undefined; + } +} + +/** Page, tool and wait fields shared by attention and vault confirm requests. */ +function requestFields(d: OperatorRequestRow) { + return [ + sessionField(d.session_id), + ...(d.page_url === null ? [] : [{ label: 'Page', value: [code(sanitizeUrl(d.page_url))] }]), + ...(d.tool === null ? [] : [{ label: 'Tool', value: [code(d.tool)] }]), + { label: 'Waiting since', value: [time(d.created_at)] }, + ]; +} + +function requestEntities(d: OperatorRequestRow): NotificationEntities { + const domain = domainOf(d.page_url); + return { + ...sessionEntities(d.session_id), + owner: d.owner, + request_id: d.request_id, + ...(d.tool !== null && { tool: d.tool }), + ...(domain !== undefined && { domain }), + }; +} + function attentionCreated(payload: DomainEvents['attention.created']): NotificationDraft { const d = payload.request; + const live = `/sessions/${d.session_id}?live=1`; + const takeover = d.mode === 'takeover'; + const actions: NotificationAction[] = [ + takeover + ? { + kind: 'open', + id: 'take-over', + label: 'Take over', + style: 'primary', + path: `${live}&takeover=1`, + } + : { kind: 'open', id: 'open-live', label: 'Open live view', style: 'primary', path: live }, + { + kind: 'act', + id: 'resolve', + label: 'Mark resolved', + style: 'default', + command: { op: 'attention.resolve', args: { request_id: d.request_id, decision: 'resolve' } }, + confirm: null, + fallback: { label: 'Open in BrowserHive', path: live }, + }, + { + kind: 'act', + id: 'reject', + label: 'Reject', + style: 'danger', + command: { op: 'attention.resolve', args: { request_id: d.request_id, decision: 'reject' } }, + confirm: 'Reject this request? The agent is told it was rejected.', + fallback: { label: 'Open in BrowserHive', path: live }, + }, + ]; + const blocks: Block[] = [ + { type: 'quote', content: [text(d.reason)], collapsible: true }, + { + type: 'fields', + items: [{ label: 'Mode', value: [text(d.mode ?? 'notify')] }, ...requestFields(d)], + }, + ]; return { type: 'attention', + kind: 'attention.requested', + severity: KIND_SEVERITY['attention.requested'], + state: 'open', + thread: `attention:${d.request_id}`, title: 'Attention requested', body: `${d.reason}${d.mode ? ` · ${d.mode}` : ''} — agent blocked, lease frozen`, sessionId: d.session_id, - target: `/sessions/${d.session_id}?live=1${d.mode === 'takeover' ? '&takeover=1' : ''}`, + target: `/sessions/${d.session_id}?live=1${takeover ? '&takeover=1' : ''}`, sourceEventId: d.request_id, dedupKey: `att:${d.request_id}`, + content: () => ({ blocks, actions, entities: requestEntities(d) }), + }; +} + +function vaultConfirmCreated(payload: DomainEvents['vault.confirm.created']): NotificationDraft { + const d = payload.request; + const queue = '/vault?tab=confirm'; + const entry = d.entry_name ?? ''; + const actions: NotificationAction[] = [ + { + kind: 'act', + id: 'approve', + label: 'Approve', + style: 'primary', + command: { + op: 'vault.confirm.resolve', + args: { request_id: d.request_id, decision: 'approve' }, + }, + confirm: null, + fallback: { label: 'Review in BrowserHive', path: queue }, + }, + { + kind: 'act', + id: 'deny', + label: 'Deny', + style: 'danger', + command: { + op: 'vault.confirm.resolve', + args: { request_id: d.request_id, decision: 'deny' }, + }, + confirm: 'Deny this vault fill?', + fallback: { label: 'Review in BrowserHive', path: queue }, + }, + { kind: 'open', id: 'review', label: 'Review', style: 'default', path: queue }, + ]; + const blocks: Block[] = [ + { + type: 'fields', + items: [{ label: 'Entry', value: [code(entry)] }, ...requestFields(d)], + }, + ]; + return { + type: 'vault', + kind: 'vault.confirm', + severity: KIND_SEVERITY['vault.confirm'], + state: 'open', + thread: `vault:${d.request_id}`, + title: 'Vault fill awaiting confirm', + body: `entry ${entry} — approve or deny the release`, + sessionId: d.session_id, + // Send the operator to the vault page's confirm-release queue, where they approve/deny. + target: queue, + sourceEventId: d.request_id, + dedupKey: `vault:${d.request_id}`, + content: () => ({ blocks, actions, entities: requestEntities(d) }), + }; +} + +/** What the revision of a settled operator request is built from (a wire row or a stored record). */ +export interface SettledRequestFacts { + readonly requestId: string; + readonly status: OperatorRequestRow['status']; + readonly resolvedBy: string | null; + readonly createdAt: number; + readonly resolvedAt: number | null; + readonly waitedMs: number | null; +} + +function factsOf(d: OperatorRequestRow): SettledRequestFacts { + return { + requestId: d.request_id, + status: d.status, + resolvedBy: d.resolved_by, + createdAt: d.created_at, + resolvedAt: d.resolved_at, + waitedMs: d.waited_ms, + }; +} + +/** + * Outcome of a settled operator request as a lifecycle change: resolved/approved and + * rejected/denied → `resolved`, a timeout → `expired`, cancelled → `final`. Serves the live + * `*.resolved` events and the startup catch-up of requests settled while nothing listened. + * + * @returns The revision of the request's thread, or `null` while it is pending. + */ +export function requestSettled( + prefix: 'attention' | 'vault', + f: SettledRequestFacts, +): ThreadRevision | null { + if (f.status === 'pending') return null; + const waited = f.waitedMs ?? (f.resolvedAt === null ? null : f.resolvedAt - f.createdAt); + const after = waited === null ? '' : ` after ${formatDuration(waited)}`; + // Orphan recovery and shutdown settle a request with no actor: the summary names none. + const by = f.resolvedBy === null ? '' : ` by ${f.resolvedBy}`; + const vault = prefix === 'vault'; + const outcome: { state: NotificationState; label: string; summary: string } = (() => { + switch (f.status) { + case 'resolved': + return { + state: 'resolved', + label: vault ? 'approved' : 'resolved', + summary: `${vault ? 'Approved' : 'Resolved'}${by}${after}`, + }; + case 'rejected': + return { + state: 'resolved', + label: vault ? 'denied' : 'rejected', + summary: `${vault ? 'Denied' : 'Rejected'}${by}${after}`, + }; + case 'timeout': + return { state: 'expired', label: 'timed out', summary: `Timed out${after}` }; + case 'cancelled': + return { + state: 'final', + label: 'cancelled', + summary: `Cancelled${after}: the agent stopped waiting`, + }; + } + })(); + return { + thread: `${prefix}:${f.requestId}`, + dedupKey: `${prefix}-res:${f.requestId}`, + change: { + state: outcome.state, + summary: outcome.summary, + fields: [ + { label: 'Outcome', value: [text(outcome.label)] }, + ...(f.resolvedAt === null ? [] : [{ label: 'Settled', value: [time(f.resolvedAt)] }]), + ], + }, }; } function sessionClosed(d: DomainEvents['session.closed']): NotificationDraft | null { + const open: NotificationAction[] = [ + { + kind: 'open', + id: 'open-session', + label: 'Open session', + style: 'primary', + path: `/sessions/${d.session_id}`, + }, + ]; + const content = (): MessageContent => ({ + blocks: [ + { + type: 'fields', + items: [ + sessionField(d.session_id), + { label: 'Reason', value: [code(d.reason)] }, + { label: 'Closed', value: [time(d.closed_at)] }, + ], + }, + ], + actions: open, + entities: sessionEntities(d.session_id), + }); if (d.reason === 'lease_expired') { return { type: 'lifecycle', + kind: 'session.reaped', + severity: KIND_SEVERITY['session.reaped'], + state: 'final', + thread: `session:${d.session_id}`, title: 'Session reaped (lease expired)', body: `reason: ${d.reason}`, sessionId: d.session_id, target: `/sessions/${d.session_id}`, sourceEventId: null, dedupKey: `closed:${d.session_id}:${d.closed_at}`, + content, }; } // A clean close (an operator or the agent ended it) is routine; only an unexpected death is @@ -132,12 +458,17 @@ function sessionClosed(d: DomainEvents['session.closed']): NotificationDraft | n if (!crashed(d.reason)) return null; return { type: 'error', + kind: 'session.crashed', + severity: KIND_SEVERITY['session.crashed'], + state: 'final', + thread: `session:${d.session_id}`, title: 'Session crashed', body: `reason: ${d.reason}`, sessionId: d.session_id, target: `/sessions/${d.session_id}`, sourceEventId: null, dedupKey: `closed:${d.session_id}:${d.closed_at}`, + content, }; } @@ -149,34 +480,59 @@ function toolCalled(payload: DomainEvents['tool.called']): NotificationDraft | n // operator to open or fix; the tool-call log keeps it. Other session-less failures (a launch that // could not start a browser) still reach the inbox, grouped under "No session". if (sessionId === null && isCallerMistake(d.error_code)) return null; - const label = - sessionId === null ? NO_SESSION_LABEL : (parseSessionId(sessionId)?.slug ?? sessionId); + const label = sessionId === null ? NO_SESSION_LABEL : sessionLabel(sessionId); + const target = sessionId === null ? null : `/sessions/${sessionId}?kinds=tool&errors_only=1`; + const harness = payload.observation.harness; + const entities: NotificationEntities = { + ...sessionEntities(sessionId), + tool: d.tool, + ...(d.error_code !== null && { error_code: d.error_code }), + harness, + }; return { type: 'error', + kind: 'tool.errors', + severity: KIND_SEVERITY['tool.errors'], + state: 'open', + thread: `tool-errors:${sessionId ?? 'none'}`, title: toolErrorsTitle(label, 1), body: `${d.tool} · ${d.error_code ?? 'failed'} (${d.duration_ms} ms)`, sessionId, - target: sessionId === null ? null : `/sessions/${sessionId}?kinds=tool&errors_only=1`, + target, sourceEventId: d.event_id, dedupKey: `err:${d.event_id}`, group: { key: `tool-errors:${sessionId ?? 'none'}`, title: (count) => toolErrorsTitle(label, count), }, - }; -} - -function vaultConfirmCreated(payload: DomainEvents['vault.confirm.created']): NotificationDraft { - const d = payload.request; - return { - type: 'vault', - title: 'Vault fill awaiting confirm', - body: `entry ${d.entry_name ?? ''} — approve or deny the release`, - sessionId: d.session_id, - // Send the operator to the vault page's confirm-release queue, where they approve/deny. - target: '/vault?tab=confirm', - sourceEventId: d.request_id, - dedupKey: `vault:${d.request_id}`, + content: (count) => ({ + blocks: [ + { + type: 'fields', + items: [ + sessionId === null + ? { label: 'Session', value: [text(NO_SESSION_LABEL)] } + : sessionField(sessionId), + { label: 'Errors', value: [text(String(count))] }, + { label: 'Latest', value: [code(`${d.tool} · ${d.error_code ?? 'failed'}`)] }, + { label: 'Duration', value: [text(formatDuration(d.duration_ms))] }, + ], + }, + ], + actions: + target === null + ? [] + : [ + { + kind: 'open', + id: 'open-errors', + label: 'Open errors', + style: 'primary', + path: target, + }, + ], + entities, + }), }; } @@ -185,11 +541,73 @@ function systemDegraded(payload: DomainEvents['system.degraded']): NotificationD if (e.severity !== 'error') return null; return { type: 'system', + kind: 'system.degraded', + severity: KIND_SEVERITY['system.degraded'], + state: 'open', + thread: `system:${e.event_id}`, title: e.message, body: e.code, sessionId: null, target: '/system', sourceEventId: e.event_id, dedupKey: `sys:${e.event_id}`, + content: () => ({ + blocks: [ + { + type: 'fields', + items: [ + { label: 'Code', value: [code(e.code)] }, + { label: 'Since', value: [time(e.first_seen_at)] }, + ], + }, + ], + actions: [ + { + kind: 'open', + id: 'open-system', + label: 'Open System', + style: 'primary', + path: '/system', + }, + ], + entities: { error_code: e.code }, + }), + }; +} + +/** + * The circuit breaker opened on a channel (D-34). Delivered in-app only: `channel.broken` is never + * enqueued for an external channel, which cuts the degradation loop by kind. + */ +function channelChanged(d: DomainEvents['notification.channel.changed']): NotificationDraft | null { + if (d.status !== 'broken' || d.previous_status === 'broken') return null; + const reason = d.last_error ?? 'unknown error'; + return { + type: 'system', + kind: 'channel.broken', + severity: KIND_SEVERITY['channel.broken'], + state: 'final', + thread: `channel:${d.channel_id}`, + title: `Notification channel ${d.name} is failing`, + body: `${d.failure_count} deliveries failed in a row; paused until resumed. Last error: ${reason}`, + sessionId: null, + target: '/notifications', + sourceEventId: null, + dedupKey: `channel:${d.channel_id}:${d.at}`, + content: () => ({ + blocks: [ + { + type: 'fields', + items: [ + { label: 'Channel', value: [text(d.name)] }, + { label: 'Platform', value: [text(d.kind)] }, + { label: 'Failures', value: [text(String(d.failure_count))] }, + ], + }, + { type: 'code', text: reason, language: null }, + ], + actions: [], + entities: {}, + }), }; } diff --git a/packages/core/src/app/notifications/redaction.property.test.ts b/packages/core/src/app/notifications/redaction.property.test.ts new file mode 100644 index 0000000..83e908c --- /dev/null +++ b/packages/core/src/app/notifications/redaction.property.test.ts @@ -0,0 +1,179 @@ +/** @module app/notifications/redaction.property.test — the redaction invariant for notifications (spec 10 §9, D-32): a sentinel secret registered in the `SecretRegistry` and routed through every producer input never appears in the stored message, the in-app row or payload, the channel delivery, or the delivery log. Seeded generator, 1 000 cases. */ + +import { describe, expect, it } from 'bun:test'; +import { CollectingLogger } from '../../../test/helpers/collecting-logger.ts'; +import { 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 { RecordingEventBus } from '../../../test/helpers/recording-event-bus.ts'; +import { createRedactor, SecretRegistry } from '../../kernel/redact.ts'; +import { ChannelSendError } from '../../ports/notification-channel.ts'; +import type { DomainEvents } from '../events/catalog.ts'; +import { ChannelRegistry } from './channel-registry.ts'; +import { createLocalLinkBuilder } from './links.ts'; +import { NotificationService } from './notification-service.ts'; +import { NotificationOutbox } from './outbox.ts'; +import type { ProducedEvent } from './producers.ts'; +import { + attentionCreated, + attentionResolved, + systemDegraded, + toolCalled, + vaultConfirmCreated, +} from './test-fixtures.ts'; + +/** Deterministic PRNG (mulberry32). */ +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 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +function sentinelOf(next: () => number): string { + const length = 12 + Math.floor(next() * 20); + let out = 'zq'; + for (let i = 0; i < length; i++) out += ALPHABET[Math.floor(next() * ALPHABET.length)]; + return out; +} + +/** Embeds `secret` in random filler text. */ +function around(next: () => number, secret: string): string { + const pad = (n: number) => 'lorem ipsum '.repeat(n).slice(0, n); + return `${pad(Math.floor(next() * 40))}${secret}${pad(Math.floor(next() * 40))}`; +} + +/** Every documented producer input that carries free text, with the sentinel routed into it. */ +function events(next: () => number, secret: string): ProducedEvent[] { + const path = next() < 0.5; + const text = around(next, secret); + const picks: ProducedEvent[][] = [ + [ + attentionCreated('a-000000000001', next() < 0.5 ? 'takeover' : 'notify', { + reason: text, + page_url: path ? `https://example.com/${secret}/login?q=${secret}` : 'https://example.com/', + tool: path ? `tool-${secret}`.slice(0, 60) : null, + }), + attentionResolved('a-000000000001', 'resolved'), + ], + [vaultConfirmCreated('a-000000000002', text)], + [toolCalled(1, { ok: false, code: text })], + [ + (() => { + const e = systemDegraded('error'); + if (e.name !== 'system.degraded') return e; + return { + ...e, + payload: { + ...e.payload, + event: { ...e.payload.event, message: text, code: `C_${secret}` }, + }, + }; + })(), + ], + [ + { + name: 'notification.channel.changed', + at: 1, + payload: { + type: 'notification.channel.changed', + channel_id: 'nc-1', + name: 'phone', + kind: 'telegram', + status: 'broken', + previous_status: 'active', + failure_count: 5, + last_error: text, + at: 1, + }, + }, + ], + ]; + return picks[Math.floor(next() * picks.length)] ?? []; +} + +async function runCase(seed: number): Promise<{ leaks: string[]; rows: number; calls: number }> { + const next = rng(seed); + const secret = sentinelOf(next); + const clock = new FakeClock(); + const registry = new SecretRegistry({ now: () => clock.now() }); + registry.add(secret); + const redactor = createRedactor(registry); + const repos = new InMemoryRepositories(); + const uow = new InMemoryUnitOfWork(repos); + const bus = new RecordingEventBus(); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const fake = new FakeChannel(); + // Platform errors echo what they were sent; the delivery log must not keep the secret. + if (next() < 0.5) fake.script(new ChannelSendError('rejected', `bad request near ${secret}`)); + await repos.notificationChannels.upsert(channelRecord({ rules: { content: 'full' } })); + const channels = new ChannelRegistry({ + repo: repos.notificationChannels, + clock, + ids, + logger, + factories: new Map([['fake', () => fake]]), + }); + await channels.load(); + const outbox = new NotificationOutbox({ + uow, + repos, + registry: channels, + links: createLocalLinkBuilder(() => 'http://127.0.0.1:9876'), + clock, + logger, + bus, + redactor, + }); + const service = new NotificationService({ + repo: repos.notifications, + bus, + clock, + ids, + logger, + uow, + outbox: { plan: (m, now) => outbox.plan(m, now), kick: () => undefined }, + redactor, + }); + for (const event of events(next, secret)) await service.produce(event); + await outbox.tick(); + const leaks: string[] = []; + const check = (where: string, value: unknown) => { + if (JSON.stringify(value).includes(secret)) leaks.push(`${where} (seed ${seed})`); + }; + check('notification row', [...repos.notifications.rows.values()]); + check( + 'in-app payload', + bus.published.filter((p) => p.name.startsWith('notification.')), + ); + check('channel delivery', fake.calls); + check('delivery log', repos.notificationDeliveries.rows); + check('channel row', [...repos.notificationChannels.rows.values()]); + return { leaks, rows: repos.notifications.rows.size, calls: fake.calls.length }; +} + +describe('notification redaction invariant', () => { + it('a registered sentinel never leaves through a notification (1 000 cases)', async () => { + const leaks: string[] = []; + let rows = 0; + let calls = 0; + for (let seed = 1; seed <= 1_000; seed++) { + const result = await runCase(seed); + leaks.push(...result.leaks); + rows += result.rows; + calls += result.calls; + } + expect(leaks).toEqual([]); + // Not vacuous: notifications were stored and delivered in most cases. + expect(rows).toBeGreaterThan(900); + expect(calls).toBeGreaterThan(500); + }); +}); diff --git a/packages/core/src/app/notifications/routing.test.ts b/packages/core/src/app/notifications/routing.test.ts new file mode 100644 index 0000000..86834eb --- /dev/null +++ b/packages/core/src/app/notifications/routing.test.ts @@ -0,0 +1,224 @@ +/** @module app/notifications/routing.test — channel rules, quiet hours across time zones and DST, TTL deadlines and the outbox rows planned per change (D-34, D-35). */ + +import { describe, expect, it } from 'bun:test'; +import type { NotificationMessage } from '@browserhive/contracts/notifications'; +import { capabilities, channelRecord } from '../../../test/helpers/fake-channel.ts'; +import { buildMessage } from './message.ts'; +import { + deleteWhenResolved, + expiryFor, + inQuietHours, + localMinutes, + planDeliveries, + type RoutableChannel, + route, +} from './routing.ts'; + +const NOW = Date.UTC(2026, 8, 27, 12, 0); // 12:00 UTC + +function message(overrides: Partial[0]> = {}): NotificationMessage { + return buildMessage({ + id: 'n-000000000001', + revision: 1, + thread: 'tool-errors:shop-a1b2c3d4', + kind: 'tool.errors', + severity: 'warn', + state: 'open', + alert: true, + createdAt: NOW, + updatedAt: NOW, + title: 'shop · 1 tool error', + summary: 'navigate · X', + blocks: [], + actions: [], + entities: { session_id: 'shop-a1b2c3d4', session_slug: 'shop', harness: 'claude-code' }, + ...overrides, + }); +} + +describe('route', () => { + const cases: ReadonlyArray< + readonly [string, Parameters[0], NotificationMessage, ReturnType] + > = [ + ['no rules delivers everything', {}, message(), { deliver: true }], + ['category allowed', { categories: ['problems'] }, message(), { deliver: true }], + [ + 'category filtered', + { categories: ['needs-you'] }, + message(), + { deliver: false, reason: 'filtered' }, + ], + [ + 'below minimum severity', + { min_severity: 'error' }, + message(), + { deliver: false, reason: 'filtered' }, + ], + ['at minimum severity', { min_severity: 'warn' }, message(), { deliver: true }], + ['session glob matches', { sessions: ['sh*'] }, message(), { deliver: true }], + [ + 'session glob misses', + { sessions: ['checkout-*'] }, + message(), + { deliver: false, reason: 'filtered' }, + ], + [ + 'session filter drops session-less notifications', + { sessions: ['*'] }, + message({ entities: {} }), + { deliver: false, reason: 'filtered' }, + ], + ['harness allowed', { harness: ['claude-code'] }, message(), { deliver: true }], + ['harness filtered', { harness: ['codex'] }, message(), { deliver: false, reason: 'filtered' }], + [ + 'quiet hours hold an alert', + { quiet_hours: { start: '11:00', end: '13:00', time_zone: 'UTC' } }, + message(), + { deliver: false, reason: 'quiet_hours' }, + ], + [ + 'quiet hours never hold a silent edit', + { quiet_hours: { start: '11:00', end: '13:00', time_zone: 'UTC' } }, + message({ alert: false, revision: 2 }), + { deliver: true }, + ], + [ + 'critical bypasses quiet hours', + { quiet_hours: { start: '11:00', end: '13:00', time_zone: 'UTC' } }, + message({ severity: 'critical' }), + { deliver: true }, + ], + [ + 'outside quiet hours', + { quiet_hours: { start: '22:00', end: '07:00', time_zone: 'UTC' } }, + message(), + { deliver: true }, + ], + ]; + for (const [name, rules, m, expected] of cases) { + it(name, () => expect(route(rules, m, NOW)).toEqual(expected)); + } +}); + +describe('quiet hours', () => { + it('reads the wall clock of the channel time zone', () => { + expect(localMinutes(NOW, 'UTC')).toBe(12 * 60); + expect(localMinutes(NOW, 'Asia/Tokyo')).toBe(21 * 60); + expect(localMinutes(NOW, 'America/New_York')).toBe(8 * 60); // EDT in September + }); + + it('spans midnight when start is after end, and an empty window is never quiet', () => { + const night = { start: '22:00', end: '07:00', time_zone: 'UTC' }; + expect(inQuietHours(Date.UTC(2026, 0, 1, 23, 30), night)).toBe(true); + expect(inQuietHours(Date.UTC(2026, 0, 1, 6, 59), night)).toBe(true); + expect(inQuietHours(Date.UTC(2026, 0, 1, 7, 0), night)).toBe(false); + expect(inQuietHours(NOW, { start: '12:00', end: '12:00', time_zone: 'UTC' })).toBe(false); + }); + + it('follows daylight saving time', () => { + const early = { start: '07:00', end: '08:00', time_zone: 'America/New_York' }; + // 12:30 UTC is 07:30 EST in January and 08:30 EDT in July. + expect(inQuietHours(Date.UTC(2026, 0, 15, 12, 30), early)).toBe(true); + expect(inQuietHours(Date.UTC(2026, 6, 15, 12, 30), early)).toBe(false); + // The DST switch day: 2026-03-08 06:30 UTC is 01:30 EST; 07:30 UTC is 03:30 EDT. + const night = { start: '01:00', end: '03:00', time_zone: 'America/New_York' }; + expect(inQuietHours(Date.UTC(2026, 2, 8, 6, 30), night)).toBe(true); + expect(inQuietHours(Date.UTC(2026, 2, 8, 7, 30), night)).toBe(false); + }); + + it('an unknown time zone falls back to the host zone instead of throwing', () => { + expect(() => + inQuietHours(NOW, { start: '00:00', end: '23:59', time_zone: 'Mars/Olympus' }), + ).not.toThrow(); + }); +}); + +describe('TTL', () => { + it('is never by default and per category when set', () => { + expect(expiryFor({}, message(), 100)).toBeNull(); + expect(expiryFor({ ttl_ms: { problems: 60_000 } }, message(), 100)).toBe(60_100); + expect(expiryFor({ ttl_ms: { 'needs-you': 60_000 } }, message(), 100)).toBeNull(); + }); + + it('deletes resolved messages only when the category opts in', () => { + const resolved = message({ state: 'resolved' }); + expect(deleteWhenResolved({}, resolved)).toBe(false); + expect(deleteWhenResolved({ delete_when_resolved: { problems: true } }, resolved)).toBe(true); + expect(deleteWhenResolved({ delete_when_resolved: { problems: true } }, message())).toBe(false); + }); +}); + +describe('planDeliveries', () => { + const editable: RoutableChannel = { record: channelRecord(), capabilities: capabilities() }; + + it('plans nothing without channels and nothing for in-app-only kinds', () => { + expect(planDeliveries(message(), [], NOW)).toEqual([]); + expect(planDeliveries(message({ kind: 'channel.broken' }), [editable], NOW)).toEqual([]); + }); + + it('sends the first revision and edits later ones', () => { + expect(planDeliveries(message(), [editable], NOW)).toEqual([ + { + channelId: 'nc-000000000001', + notificationId: 'n-000000000001', + revision: 1, + op: 'send', + status: 'pending', + reason: null, + nextAttemptAt: NOW, + createdAt: NOW, + }, + ]); + expect( + planDeliveries(message({ revision: 3, alert: false }), [editable], NOW)[0], + ).toMatchObject({ + op: 'edit', + status: 'pending', + revision: 3, + }); + }); + + it('logs suppressed rows with their reason', () => { + const rows = planDeliveries( + message(), + [ + { + record: channelRecord({ channelId: 'nc-paused', status: 'paused' }), + capabilities: capabilities(), + }, + { + record: channelRecord({ channelId: 'nc-broken', status: 'broken' }), + capabilities: capabilities(), + }, + { record: channelRecord({ channelId: 'nc-noadapter' }), capabilities: null }, + { + record: channelRecord({ channelId: 'nc-filtered', rules: { categories: ['reports'] } }), + capabilities: capabilities(), + }, + ], + NOW, + ); + expect(rows.map((r) => [r.channelId, r.status, r.reason])).toEqual([ + ['nc-paused', 'suppressed', 'channel_paused'], + ['nc-broken', 'suppressed', 'channel_paused'], + ['nc-noadapter', 'suppressed', 'no_adapter'], + ['nc-filtered', 'suppressed', 'filtered'], + ]); + }); + + it('on a platform that cannot edit, alerts become new sends and silent revisions are suppressed', () => { + const plain: RoutableChannel = { + record: channelRecord(), + capabilities: capabilities({ edit: false }), + }; + expect(planDeliveries(message({ revision: 2, alert: false }), [plain], NOW)[0]).toMatchObject({ + op: 'edit', + status: 'suppressed', + reason: 'edit_unsupported', + }); + expect(planDeliveries(message({ revision: 2, alert: true }), [plain], NOW)[0]).toMatchObject({ + op: 'send', + status: 'pending', + }); + }); +}); diff --git a/packages/core/src/app/notifications/routing.ts b/packages/core/src/app/notifications/routing.ts new file mode 100644 index 0000000..d2cc1aa --- /dev/null +++ b/packages/core/src/app/notifications/routing.ts @@ -0,0 +1,200 @@ +/** @module app/notifications/routing — pure channel routing (spec 03 §9.4, D-34, D-35): whether a message goes to a channel under its rules, quiet hours in the channel's time zone, TTL deadlines, and the outbox rows planned for one notification change. */ + +import { + DEFAULT_CONTENT_LEVEL, + IN_APP_ONLY_KINDS, + type NotificationChannelRules, + type NotificationMessage, + type QuietHours, + type SuppressionReason, +} from '@browserhive/contracts/notifications'; +import { matchesGlob } from '../../kernel/glob.ts'; +import type { ChannelCapabilities } from '../../ports/notification-channel.ts'; +import type { + NewNotificationDelivery, + NotificationChannelRecord, +} from '../../ports/persistence/records.ts'; + +/** Severities in increasing order. */ +const SEVERITY_RANK = { info: 0, warn: 1, error: 2, critical: 3 } as const; + +/** A channel as the planner sees it: its row and its adapter's capabilities (`null` without an adapter). */ +export interface RoutableChannel { + readonly record: NotificationChannelRecord; + readonly capabilities: ChannelCapabilities | null; +} + +/** Outcome of the rules for one channel. */ +export type RouteDecision = + | { readonly deliver: true } + | { readonly deliver: false; readonly reason: SuppressionReason }; + +/** + * Minutes after local midnight of `now` in `timeZone` (IANA; `undefined` = the host's zone). DST + * is handled by `Intl`: the wall clock is what counts. + * + * @returns 0..1439. + */ +export function localMinutes(now: number, timeZone?: string): number { + const parts = new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + ...(timeZone !== undefined && { timeZone }), + }).formatToParts(now); + const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0'); + const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0'); + return (hour % 24) * 60 + minute; +} + +function minutesOf(clock: string): number { + const [h = '0', m = '0'] = clock.split(':'); + return Number(h) * 60 + Number(m); +} + +/** + * Whether `now` falls inside quiet hours: `[start, end)` on the channel's wall clock; a window + * whose start is after its end spans midnight; an empty window (`start == end`) is never quiet. + * An unknown time zone falls back to the host's zone rather than silencing a channel. + * + * @returns True inside the window. + */ +export function inQuietHours(now: number, hours: QuietHours): boolean { + let at: number; + try { + at = localMinutes(now, hours.time_zone); + } catch { + at = localMinutes(now); + } + const start = minutesOf(hours.start); + const end = minutesOf(hours.end); + if (start === end) return false; + return start < end ? at >= start && at < end : at >= start || at < end; +} + +/** + * 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 + * edit is never held). + * + * @returns Deliver, or the suppression reason for the delivery log. + */ +export function route( + rules: NotificationChannelRules, + message: NotificationMessage, + now: number, +): RouteDecision { + if (rules.categories !== undefined && !rules.categories.includes(message.category)) { + return { deliver: false, reason: 'filtered' }; + } + if ( + rules.min_severity !== undefined && + SEVERITY_RANK[message.severity] < SEVERITY_RANK[rules.min_severity] + ) { + return { deliver: false, reason: 'filtered' }; + } + if (rules.sessions !== undefined && rules.sessions.length > 0) { + const slug = message.entities.session_slug; + if (slug === undefined || !rules.sessions.some((g) => matchesGlob(slug, g))) { + return { deliver: false, reason: 'filtered' }; + } + } + if (rules.harness !== undefined && rules.harness.length > 0) { + const harness = message.entities.harness; + if (harness === undefined || !rules.harness.includes(harness)) { + return { deliver: false, reason: 'filtered' }; + } + } + if ( + rules.quiet_hours !== undefined && + message.alert && + message.severity !== 'critical' && + inQuietHours(now, rules.quiet_hours) + ) { + return { deliver: false, reason: 'quiet_hours' }; + } + return { deliver: true }; +} + +/** + * TTL deadline of a message sent at `sentAt` (D-35): the channel's TTL for the category, or + * `null` (never, the default). + * + * @returns Epoch ms, or `null`. + */ +export function expiryFor( + rules: NotificationChannelRules, + message: Pick, + sentAt: number, +): number | null { + const ttl = rules.ttl_ms?.[message.category]; + return ttl === undefined ? null : sentAt + ttl; +} + +/** + * Whether a message should be deleted now because its notification left `open` and the channel + * deletes resolved messages of that category (off by default, D-35). + * + * @returns True to set `expires_at = now`. + */ +export function deleteWhenResolved( + rules: NotificationChannelRules, + message: Pick, +): boolean { + return message.state !== 'open' && rules.delete_when_resolved?.[message.category] === true; +} + +/** Content level a channel delivers at. */ +export function contentLevelOf(rules: NotificationChannelRules) { + return rules.content ?? DEFAULT_CONTENT_LEVEL; +} + +/** + * 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 + * transaction. + * + * @returns The rows to enqueue (empty with no external channel). + */ +export function planDeliveries( + message: NotificationMessage, + channels: readonly RoutableChannel[], + now: number, +): NewNotificationDelivery[] { + if (channels.length === 0 || IN_APP_ONLY_KINDS.has(message.kind)) return []; + const rows: NewNotificationDelivery[] = []; + for (const { record, capabilities } of channels) { + const first = message.revision === 1; + const base = { + channelId: record.channelId, + notificationId: message.id, + revision: message.revision, + createdAt: now, + }; + const suppressed = (op: 'send' | 'edit', reason: SuppressionReason) => + rows.push({ ...base, op, status: 'suppressed', reason, nextAttemptAt: null }); + const op: 'send' | 'edit' = + first || (capabilities !== null && !capabilities.edit && message.alert) ? 'send' : 'edit'; + if (record.status !== 'active') { + suppressed(op, 'channel_paused'); + continue; + } + if (capabilities === null) { + suppressed(op, 'no_adapter'); + continue; + } + const decision = route(record.rules, message, now); + if (!decision.deliver) { + suppressed(op, decision.reason); + continue; + } + if (!first && !capabilities.edit && !message.alert) { + suppressed('edit', 'edit_unsupported'); + continue; + } + rows.push({ ...base, op, status: 'pending', reason: null, nextAttemptAt: now }); + } + return rows; +} diff --git a/packages/core/src/app/notifications/test-fixtures.ts b/packages/core/src/app/notifications/test-fixtures.ts index 5c8080c..440cec5 100644 --- a/packages/core/src/app/notifications/test-fixtures.ts +++ b/packages/core/src/app/notifications/test-fixtures.ts @@ -4,10 +4,13 @@ import type { ClosedReason } from '@browserhive/contracts/enums'; import type { ToolName } from '@browserhive/contracts/tools'; import { AttentionCreatedEvent, + AttentionResolvedEvent, SessionClosedEvent, SystemDegradedEvent, + SystemRecoveredEvent, ToolCalledEvent, VaultConfirmCreatedEvent, + VaultConfirmResolvedEvent, } from '@browserhive/contracts/ws'; import type { DomainEvents } from '../events/catalog.ts'; import type { ProducedEvent } from './producers.ts'; @@ -46,15 +49,52 @@ function request(kind: 'attention' | 'vault_confirm', id: string, extra: object) }; } -/** `attention.created`. */ -export function attentionCreated(id: string, mode: 'takeover' | 'notify' | null): ProducedEvent { +/** `attention.created`; `extra` overrides request fields (reason, page_url, tool…). */ +export function attentionCreated( + id: string, + mode: 'takeover' | 'notify' | null, + extra: object = {}, +): ProducedEvent { const payload: DomainEvents['attention.created'] = AttentionCreatedEvent.parse({ type: 'attention.created', - request: request('attention', id, { mode }), + request: request('attention', id, { mode, ...extra }), }); return { name: 'attention.created', at: 1, payload }; } +/** Operator request statuses a resolution can carry. */ +export type Settled = 'resolved' | 'rejected' | 'timeout' | 'cancelled'; + +/** `attention.resolved` with `status`, settled 130 s after creation by `local`. */ +export function attentionResolved(id: string, status: Settled): ProducedEvent { + const payload: DomainEvents['attention.resolved'] = AttentionResolvedEvent.parse({ + type: 'attention.resolved', + request: request('attention', id, { + mode: 'takeover', + status, + resolved_by: status === 'timeout' || status === 'cancelled' ? null : 'local', + resolved_at: 130_001, + waited_ms: 130_000, + }), + }); + return { name: 'attention.resolved', at: 2, payload }; +} + +/** `vault.confirm.resolved` with `status`. */ +export function vaultConfirmResolved(id: string, status: Settled): ProducedEvent { + const payload: DomainEvents['vault.confirm.resolved'] = VaultConfirmResolvedEvent.parse({ + type: 'vault.confirm.resolved', + request: request('vault_confirm', id, { + entry_name: 'github', + status, + resolved_by: 'local', + resolved_at: 5_001, + waited_ms: 5_000, + }), + }); + return { name: 'vault.confirm.resolved', at: 2, payload }; +} + /** `vault.confirm.created`. */ export function vaultConfirmCreated(id: string, entry: string): ProducedEvent { const payload: DomainEvents['vault.confirm.created'] = VaultConfirmCreatedEvent.parse({ @@ -125,6 +165,25 @@ export function toolCalled( return { name: 'tool.called', at: n, payload }; } +/** `system.recovered` of the degradation {@link systemDegraded} raised. */ +export function systemRecovered(n = 1): ProducedEvent { + const payload: DomainEvents['system.recovered'] = SystemRecoveredEvent.parse({ + type: 'system.recovered', + event: { + event_id: eventId(n), + code: 'RETENTION_FAILED', + severity: 'error', + message: 'retention sweep failed', + details: null, + first_seen_at: 1, + last_seen_at: 1, + count: 1, + resolved_at: 9, + }, + }); + return { name: 'system.recovered', at: 9, payload }; +} + /** `system.degraded`. */ export function systemDegraded(severity: 'info' | 'warn' | 'error', n = 1): ProducedEvent { const payload: DomainEvents['system.degraded'] = SystemDegradedEvent.parse({ diff --git a/packages/core/src/infra/persistence/generated/db.d.ts b/packages/core/src/infra/persistence/generated/db.d.ts index dc871aa..d897639 100644 --- a/packages/core/src/infra/persistence/generated/db.d.ts +++ b/packages/core/src/infra/persistence/generated/db.d.ts @@ -144,18 +144,72 @@ export interface Meta { value: string; } +export interface NotificationChannelMessages { + channel_id: string; + deleted_at: number | null; + expires_at: number | null; + last_revision: number; + message_ref_json: string; + notification_id: string; + sent_at: number; + thread: string; + updated_at: number; +} + +export interface NotificationChannels { + channel_id: string; + created_at: number; + failure_count: Generated; + kind: string; + last_error: string | null; + last_failure_at: number | null; + last_ok_at: number | null; + mode: string | null; + name: string; + rules_json: Generated; + secret_refs_json: Generated; + source: Generated; + status: Generated; + target_json: Generated; + updated_at: number; +} + +export interface NotificationDeliveries { + attempts: Generated; + channel_id: string; + created_at: number; + duration_ms: number | null; + last_error: string | null; + message_ref_json: string | null; + next_attempt_at: number | null; + notification_id: string; + op: string; + reason: string | null; + revision: number; + seq: Generated; + status: string; + updated_at: number; +} + export interface Notifications { body: string | null; + category: string | null; count: Generated; created_at: number; dismissed_at: number | null; group_key: string | null; + kind: string | null; + message_json: string | null; notification_id: string; principal_id: string | null; read_at: number | null; + revision: Generated; session_id: string | null; + severity: string | null; source_event_id: string | null; + state: string | null; target: string | null; + thread: string | null; title: string; type: string; updated_at: Generated; @@ -381,6 +435,9 @@ export interface DB { logs: Logs; mcp_connections: McpConnections; meta: Meta; + notification_channel_messages: NotificationChannelMessages; + notification_channels: NotificationChannels; + notification_deliveries: NotificationDeliveries; notifications: Notifications; operator_actions: OperatorActions; operator_requests: OperatorRequests; diff --git a/packages/core/src/infra/persistence/maintenance.ts b/packages/core/src/infra/persistence/maintenance.ts index 7465ac9..eed535f 100644 --- a/packages/core/src/infra/persistence/maintenance.ts +++ b/packages/core/src/infra/persistence/maintenance.ts @@ -101,7 +101,17 @@ export class SqliteMaintenanceService implements MaintenanceService { await this.#drain(); const { handle } = this.#o; const tables: { table: string; rows: number }[] = []; + // `purge` opens a database without migrating it: a file from an older schema lacks the newer + // tables, which are simply not listed. + const present = new Set( + ( + await sql<{ name: string }>`SELECT name FROM sqlite_master WHERE type = 'table'`.execute( + handle.db, + ) + ).rows.map((r) => r.name), + ); for (const table of TABLES) { + if (!present.has(table)) continue; const result = await sql<{ n: number; }>`SELECT COUNT(*) AS n FROM ${sql.table(table)}`.execute(handle.db); diff --git a/packages/core/src/infra/persistence/mappers/mappers.test.ts b/packages/core/src/infra/persistence/mappers/mappers.test.ts index 652ff4c..cd1bfb2 100644 --- a/packages/core/src/infra/persistence/mappers/mappers.test.ts +++ b/packages/core/src/infra/persistence/mappers/mappers.test.ts @@ -71,7 +71,7 @@ describe('round-trips', () => { identity: { ua: 'x' }, closedAt: 5, closedReason: 'crash', - state: 'crashed', + state: 'crashed' as const, tenantId: 't', }); expect(sessionFromRow(sessionToRow(record))).toEqual(record); @@ -195,6 +195,13 @@ describe('round-trips', () => { groupKey: 'tool-errors:s', readAt: null, dismissedAt: 2, + kind: 'tool.errors' as const, + category: 'problems' as const, + severity: 'warn' as const, + state: 'open' as const, + revision: 1, + thread: 'tool-errors:s', + messageJson: null, }; expect(notificationFromRow(notificationToRow(notification))).toEqual(notification); const connection = { diff --git a/packages/core/src/infra/persistence/mappers/notifications.ts b/packages/core/src/infra/persistence/mappers/notifications.ts new file mode 100644 index 0000000..92e6ef2 --- /dev/null +++ b/packages/core/src/infra/persistence/mappers/notifications.ts @@ -0,0 +1,166 @@ +/** @module infra/persistence/mappers/notifications — `notification_channels`, `notification_deliveries` and `notification_channel_messages` rows ↔ records (spec 03 §7). */ + +import { NotificationChannelRules } from '@browserhive/contracts/notifications'; +import type { Insertable, Selectable } from 'kysely'; +import { + NOTIFICATION_CHANNEL_SOURCES, + NOTIFICATION_CHANNEL_STATUSES, + NOTIFICATION_DELIVERY_OPS, + NOTIFICATION_DELIVERY_STATUSES, +} from '../../../ports/persistence/enums.ts'; +import type { + NewNotificationDelivery, + NotificationChannelMessageRecord, + NotificationChannelRecord, + NotificationDeliveryRecord, + PlatformMessageRef, +} from '../../../ports/persistence/records.ts'; +import type { + NotificationChannelMessages, + NotificationChannels, + NotificationDeliveries, +} from '../generated/db.d.ts'; +import { parseEnum, parseJsonObject } from './codec.ts'; + +/** A JSON object column whose values are all strings (target, secret refs). */ +function stringMap(text: string, where: string): Readonly> { + const out: Record = {}; + for (const [k, v] of Object.entries(parseJsonObject(text, where))) { + if (typeof v === 'string') out[k] = v; + } + return out; +} + +/** A platform ref column: an object of string/number leaves. */ +function messageRef(text: string, where: string): PlatformMessageRef { + const out: Record = {}; + for (const [k, v] of Object.entries(parseJsonObject(text, where))) { + if (typeof v === 'string' || typeof v === 'number') out[k] = v; + } + return out; +} + +/** `notification_channels` row → record. Unknown rule keys (a newer release) are dropped. */ +export function channelFromRow(row: Selectable): NotificationChannelRecord { + const where = `notification_channels.${row.channel_id}`; + const rules = NotificationChannelRules.safeParse(parseJsonObject(row.rules_json, where)); + return { + channelId: row.channel_id, + name: row.name, + kind: row.kind, + mode: row.mode, + source: parseEnum(NOTIFICATION_CHANNEL_SOURCES, row.source, `${where}.source`), + status: parseEnum(NOTIFICATION_CHANNEL_STATUSES, row.status, `${where}.status`), + target: stringMap(row.target_json, `${where}.target`), + secretRefs: stringMap(row.secret_refs_json, `${where}.secret_refs`), + rules: rules.success ? rules.data : {}, + failureCount: row.failure_count, + lastError: row.last_error, + lastOkAt: row.last_ok_at, + lastFailureAt: row.last_failure_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** Record → `notification_channels` insert row. */ +export function channelToRow(record: NotificationChannelRecord): Insertable { + return { + channel_id: record.channelId, + name: record.name, + kind: record.kind, + mode: record.mode, + source: record.source, + status: record.status, + target_json: JSON.stringify(record.target), + secret_refs_json: JSON.stringify(record.secretRefs), + rules_json: JSON.stringify(record.rules), + failure_count: record.failureCount, + last_error: record.lastError, + last_ok_at: record.lastOkAt, + last_failure_at: record.lastFailureAt, + created_at: record.createdAt, + updated_at: record.updatedAt, + }; +} + +/** `notification_deliveries` row → record. */ +export function deliveryFromRow( + row: Selectable, +): NotificationDeliveryRecord { + const seq = row.seq ?? 0; + const where = `notification_deliveries.${seq}`; + return { + seq, + channelId: row.channel_id, + notificationId: row.notification_id, + revision: row.revision, + op: parseEnum(NOTIFICATION_DELIVERY_OPS, row.op, `${where}.op`), + status: parseEnum(NOTIFICATION_DELIVERY_STATUSES, row.status, `${where}.status`), + reason: row.reason, + attempts: row.attempts, + nextAttemptAt: row.next_attempt_at, + lastError: row.last_error, + durationMs: row.duration_ms, + messageRef: + row.message_ref_json === null ? null : messageRef(row.message_ref_json, `${where}.ref`), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** New job → `notification_deliveries` insert row. */ +export function deliveryToRow(job: NewNotificationDelivery): Insertable { + return { + channel_id: job.channelId, + notification_id: job.notificationId, + revision: job.revision, + op: job.op, + status: job.status, + reason: job.reason, + attempts: 0, + next_attempt_at: job.nextAttemptAt, + last_error: null, + duration_ms: null, + message_ref_json: null, + created_at: job.createdAt, + updated_at: job.createdAt, + }; +} + +/** `notification_channel_messages` row → record. */ +export function channelMessageFromRow( + row: Selectable, +): NotificationChannelMessageRecord { + return { + channelId: row.channel_id, + notificationId: row.notification_id, + thread: row.thread, + messageRef: messageRef( + row.message_ref_json, + `notification_channel_messages.${row.channel_id}.${row.notification_id}`, + ), + lastRevision: row.last_revision, + sentAt: row.sent_at, + updatedAt: row.updated_at, + expiresAt: row.expires_at, + deletedAt: row.deleted_at, + }; +} + +/** Record → `notification_channel_messages` row. */ +export function channelMessageToRow( + record: NotificationChannelMessageRecord, +): Selectable { + return { + channel_id: record.channelId, + notification_id: record.notificationId, + thread: record.thread, + message_ref_json: JSON.stringify(record.messageRef), + last_revision: record.lastRevision, + sent_at: record.sentAt, + updated_at: record.updatedAt, + expires_at: record.expiresAt, + deleted_at: record.deletedAt, + }; +} diff --git a/packages/core/src/infra/persistence/mappers/operations.ts b/packages/core/src/infra/persistence/mappers/operations.ts index 353fbe9..4acc04a 100644 --- a/packages/core/src/infra/persistence/mappers/operations.ts +++ b/packages/core/src/infra/persistence/mappers/operations.ts @@ -1,9 +1,13 @@ /** @module infra/persistence/mappers/operations — notifications, preferences, system_events, artifact_outbox, idempotency_keys, mcp_connections, schema_migrations rows ↔ records. */ +import { NotificationCategory, NotificationKind } from '@browserhive/contracts/enums'; +import { classifyLegacy } from '@browserhive/contracts/notifications'; import type { Insertable, Selectable, Updateable } from 'kysely'; import { ARTIFACT_KINDS, MCP_TRANSPORTS, + NOTIFICATION_SEVERITIES, + NOTIFICATION_STATES, NOTIFICATION_TYPES, SYSTEM_EVENT_SEVERITIES, } from '../../../ports/persistence/enums.ts'; @@ -28,14 +32,38 @@ import type { SchemaMigrations, SystemEvents, } from '../generated/db.d.ts'; -import { parseEnum, parseJsonObjectOrNull, parseJsonValue, toJson, toJsonOrNull } from './codec.ts'; +import { + parseEnum, + parseEnumOrNull, + parseJsonObjectOrNull, + parseJsonValue, + toJson, + toJsonOrNull, +} from './codec.ts'; -/** `notifications` row → record. */ +/** + * `notifications` row → record. Contract columns that are NULL (a row an older reader inserted in + * the compatibility window) or that name a kind this binary does not know read the values derived + * from `type`, exactly as migration v5 classifies rows (spec 03 §9.2). + */ export function notificationFromRow(row: Selectable): NotificationRecord { + const where = `notifications.${row.notification_id}`; + const type = parseEnum(NOTIFICATION_TYPES, row.type, where); + const legacy = classifyLegacy({ + notificationId: row.notification_id, + type, + title: row.title, + groupKey: row.group_key, + sessionId: row.session_id, + sourceEventId: row.source_event_id, + }); + const kind = NotificationKind.safeParse(row.kind); + const category = NotificationCategory.safeParse(row.category); + const known = kind.success && category.success; return { notificationId: row.notification_id, principalId: row.principal_id, - type: parseEnum(NOTIFICATION_TYPES, row.type, `notifications.${row.notification_id}`), + type, title: row.title, body: row.body, sessionId: row.session_id, @@ -47,6 +75,15 @@ export function notificationFromRow(row: Selectable): Notificatio groupKey: row.group_key, readAt: row.read_at, dismissedAt: row.dismissed_at, + kind: known ? kind.data : legacy.kind, + category: known ? category.data : legacy.category, + severity: + parseEnumOrNull(NOTIFICATION_SEVERITIES, row.severity, `${where}.severity`) ?? + legacy.severity, + state: parseEnumOrNull(NOTIFICATION_STATES, row.state, `${where}.state`) ?? legacy.state, + revision: row.revision, + thread: row.thread ?? legacy.thread, + messageJson: row.message_json, }; } @@ -67,6 +104,13 @@ export function notificationToRow(record: NotificationRecord): Selectable= 1); +ALTER TABLE notifications ADD COLUMN thread TEXT; +ALTER TABLE notifications ADD COLUMN message_json TEXT; + +UPDATE notifications SET kind = CASE + WHEN type = 'attention' THEN 'attention.requested' + WHEN type = 'vault' THEN 'vault.confirm' + WHEN type = 'lifecycle' THEN 'session.reaped' + WHEN type = 'system' THEN 'system.degraded' + WHEN title = 'Session crashed' THEN 'session.crashed' + ELSE 'tool.errors' END; + +UPDATE notifications SET + category = CASE kind + WHEN 'attention.requested' THEN 'needs-you' + WHEN 'vault.confirm' THEN 'needs-you' + WHEN 'system.degraded' THEN 'system' + ELSE 'problems' END, + severity = CASE kind + WHEN 'session.crashed' THEN 'error' + WHEN 'system.degraded' THEN 'error' + ELSE 'warn' END, + state = CASE + WHEN kind IN ('attention.requested', 'vault.confirm') THEN COALESCE(( + SELECT CASE r.status + WHEN 'pending' THEN 'open' + WHEN 'resolved' THEN 'resolved' + WHEN 'rejected' THEN 'resolved' + WHEN 'timeout' THEN 'expired' + ELSE 'final' END + FROM operator_requests r WHERE r.request_id = notifications.source_event_id), 'final') + WHEN kind = 'system.degraded' THEN COALESCE(( + SELECT CASE WHEN e.resolved_at IS NULL THEN 'open' ELSE 'resolved' END + FROM system_events e WHERE e.event_id = notifications.source_event_id), 'final') + WHEN kind = 'tool.errors' THEN 'open' + ELSE 'final' END, + thread = CASE + WHEN kind = 'attention.requested' AND source_event_id IS NOT NULL THEN 'attention:' || source_event_id + WHEN kind = 'vault.confirm' AND source_event_id IS NOT NULL THEN 'vault:' || source_event_id + WHEN kind = 'tool.errors' THEN COALESCE(group_key, 'tool-errors:' || COALESCE(session_id, 'none')) + WHEN kind IN ('session.crashed', 'session.reaped') AND session_id IS NOT NULL THEN 'session:' || session_id + WHEN kind = 'system.degraded' AND source_event_id IS NOT NULL THEN 'system:' || source_event_id + ELSE 'notification:' || notification_id END; + +CREATE INDEX idx_notifications_thread ON notifications(thread, created_at) WHERE thread IS NOT NULL; + +CREATE TABLE notification_channels ( + channel_id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + mode TEXT, + source TEXT NOT NULL DEFAULT 'db' CHECK (source IN (${sqlIn(NOTIFICATION_CHANNEL_SOURCES)})), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN (${sqlIn(NOTIFICATION_CHANNEL_STATUSES)})), + target_json TEXT NOT NULL DEFAULT '{}', + secret_refs_json TEXT NOT NULL DEFAULT '{}', + rules_json TEXT NOT NULL DEFAULT '{}', + failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + last_error TEXT, + last_ok_at INTEGER, + last_failure_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE TABLE notification_deliveries ( + seq INTEGER PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES notification_channels(channel_id) ON DELETE CASCADE, + notification_id TEXT NOT NULL REFERENCES notifications(notification_id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK (revision >= 1), + op TEXT NOT NULL CHECK (op IN (${sqlIn(NOTIFICATION_DELIVERY_OPS)})), + status TEXT NOT NULL CHECK (status IN (${sqlIn(NOTIFICATION_DELIVERY_STATUSES)})), + reason TEXT, + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at INTEGER, + last_error TEXT, + duration_ms INTEGER, + message_ref_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE UNIQUE INDEX idx_notification_deliveries_idem ON notification_deliveries(channel_id, notification_id, revision, op); +CREATE INDEX idx_notification_deliveries_due ON notification_deliveries(next_attempt_at, seq) WHERE status IN ('pending', 'retrying'); +CREATE INDEX idx_notification_deliveries_sending ON notification_deliveries(updated_at) WHERE status = 'sending'; +CREATE INDEX idx_notification_deliveries_channel ON notification_deliveries(channel_id, seq); +CREATE INDEX idx_notification_deliveries_notification ON notification_deliveries(notification_id, seq); +CREATE INDEX idx_notification_deliveries_updated ON notification_deliveries(updated_at); + +CREATE TABLE notification_channel_messages ( + channel_id TEXT NOT NULL REFERENCES notification_channels(channel_id) ON DELETE CASCADE, + notification_id TEXT NOT NULL REFERENCES notifications(notification_id) ON DELETE CASCADE, + thread TEXT NOT NULL, + message_ref_json TEXT NOT NULL, + last_revision INTEGER NOT NULL CHECK (last_revision >= 1), + sent_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER, + deleted_at INTEGER, + PRIMARY KEY (channel_id, notification_id) +) WITHOUT ROWID; +CREATE INDEX idx_notification_channel_messages_expiry ON notification_channel_messages(expires_at) WHERE expires_at IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX idx_notification_channel_messages_thread ON notification_channel_messages(channel_id, thread, sent_at); +`; + +/** + * Schema v5. `compatible`: purely additive (nullable columns, new tables), so a v4 reader still + * understands every table it knows; rows it inserts read their classification from `type`. + */ +export const notificationOutbox: Migration = { + version: 5, + name: 'notification-outbox', + compatible: true, + sql, +}; diff --git a/packages/core/src/infra/persistence/migrations/index.ts b/packages/core/src/infra/persistence/migrations/index.ts index 0c5ec47..aabbedb 100644 --- a/packages/core/src/infra/persistence/migrations/index.ts +++ b/packages/core/src/infra/persistence/migrations/index.ts @@ -4,6 +4,7 @@ import { initial } from './0001-initial.ts'; import { notificationGroups } from './0002-notification-groups.ts'; import { harnessIdentity } from './0003-harness-identity.ts'; import { sessionBrowser } from './0004-session-browser.ts'; +import { notificationOutbox } from './0005-notification-outbox.ts'; import type { Migration } from './migration.ts'; export type { Migration } from './migration.ts'; @@ -14,6 +15,7 @@ export const MIGRATIONS: readonly Migration[] = [ notificationGroups, harnessIdentity, sessionBrowser, + notificationOutbox, ]; /** The schema version this binary writes. */ @@ -22,7 +24,7 @@ export const SCHEMA_VERSION: number = MIGRATIONS[MIGRATIONS.length - 1]?.version /** `application_id` stamped on every BrowserHive database ("BHIV"). */ export const APPLICATION_ID = 0x42484956; -/** Table names of schema v1, in FK-safe delete order (children first). */ +/** Table names of the current schema, in FK-safe delete order (children first). */ export const TABLES: readonly string[] = [ 'screenshots', 'tool_calls', @@ -32,7 +34,10 @@ export const TABLES: readonly string[] = [ 'operator_requests', 'resource_samples', 'events', + 'notification_deliveries', + 'notification_channel_messages', 'notifications', + 'notification_channels', 'sessions', 'grants', 'auth_sessions', diff --git a/packages/core/src/infra/persistence/repositories/index.ts b/packages/core/src/infra/persistence/repositories/index.ts index 9df7e7b..3eba008 100644 --- a/packages/core/src/infra/persistence/repositories/index.ts +++ b/packages/core/src/infra/persistence/repositories/index.ts @@ -12,6 +12,11 @@ import { import { SqliteBlocklistAuditRepository } from './blocklist-audit.ts'; import { SqliteEventLogRepository } from './event-log.ts'; import { SqliteCredentialRepository, SqlitePrincipalRepository } from './identity.ts'; +import { + SqliteNotificationChannelMessageRepository, + SqliteNotificationChannelRepository, + SqliteNotificationDeliveryRepository, +} from './notification-outbox.ts'; import { SqliteNotificationRepository, SqlitePreferenceRepository } from './notifications.ts'; import { SqliteArtifactOutboxRepository, @@ -52,6 +57,9 @@ export function createRepositories(db: Kysely): Repositories { vaultBindings: new SqliteVaultBindingRepository(db), vaultGroupPolicies: new SqliteVaultGroupPolicyRepository(db), notifications: new SqliteNotificationRepository(db), + notificationChannels: new SqliteNotificationChannelRepository(db), + notificationDeliveries: new SqliteNotificationDeliveryRepository(db), + notificationChannelMessages: new SqliteNotificationChannelMessageRepository(db), preferences: new SqlitePreferenceRepository(db), systemEvents: new SqliteSystemEventRepository(db), idempotency: new SqliteIdempotencyRepository(db), diff --git a/packages/core/src/infra/persistence/repositories/notification-outbox.ts b/packages/core/src/infra/persistence/repositories/notification-outbox.ts new file mode 100644 index 0000000..d07f281 --- /dev/null +++ b/packages/core/src/infra/persistence/repositories/notification-outbox.ts @@ -0,0 +1,403 @@ +/** @module infra/persistence/repositories/notification-outbox — SQLite `NotificationChannelRepository`, `NotificationDeliveryRepository` and `NotificationChannelMessageRepository` (spec 03 §7, §9.3–9.4). */ + +import { type Kysely, sql } from 'kysely'; +import type { + NotificationChannelStatus, + NotificationDeliveryStatus, +} from '../../../ports/persistence/enums.ts'; +import type { + NotificationChannelMessageRepository, + NotificationChannelRepository, + NotificationDeliveryRepository, +} from '../../../ports/persistence/notification-outbox.ts'; +import type { + DeliveryFinishPatch, + NewNotificationDelivery, + NotificationChannelMessageRecord, + NotificationChannelRecord, + NotificationDeliveryListQuery, + NotificationDeliveryRecord, +} from '../../../ports/persistence/records.ts'; + +import type { DB } from '../generated/db.d.ts'; +import { + channelFromRow, + channelMessageFromRow, + channelMessageToRow, + channelToRow, + deliveryFromRow, + deliveryToRow, +} from '../mappers/notifications.ts'; +import { asNumber } from './common.ts'; + +/** Jobs that are still work to do. */ +const OPEN_STATUSES = ['pending', 'retrying'] as const; + +/** SQLite implementation of {@link NotificationChannelRepository}. */ +export class SqliteNotificationChannelRepository implements NotificationChannelRepository { + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async list(): Promise { + const rows = await this.#db + .selectFrom('notification_channels') + .selectAll() + .orderBy('name') + .execute(); + return rows.map(channelFromRow); + } + + async get(channelId: string): Promise { + const row = await this.#db + .selectFrom('notification_channels') + .selectAll() + .where('channel_id', '=', channelId) + .executeTakeFirst(); + return row === undefined ? null : channelFromRow(row); + } + + async getByName(name: string): Promise { + const row = await this.#db + .selectFrom('notification_channels') + .selectAll() + .where('name', '=', name) + .executeTakeFirst(); + return row === undefined ? null : channelFromRow(row); + } + + async upsert(record: NotificationChannelRecord): Promise { + const row = channelToRow(record); + await this.#db + .insertInto('notification_channels') + .values(row) + .onConflict((oc) => + oc.column('channel_id').doUpdateSet({ + name: row.name, + kind: row.kind, + mode: row.mode, + source: row.source, + target_json: row.target_json, + secret_refs_json: row.secret_refs_json, + rules_json: row.rules_json, + updated_at: row.updated_at, + }), + ) + .execute(); + } + + async remove(channelId: string): Promise { + const result = await this.#db + .deleteFrom('notification_channels') + .where('channel_id', '=', channelId) + .executeTakeFirst(); + return result.numDeletedRows > 0n; + } + + async setStatus( + channelId: string, + status: NotificationChannelStatus, + at: number, + ): Promise { + const result = await this.#db + .updateTable('notification_channels') + .set({ status, updated_at: at, ...(status === 'active' && { failure_count: 0 }) }) + .where('channel_id', '=', channelId) + .executeTakeFirst(); + return result.numUpdatedRows > 0n; + } + + async recordSuccess(channelId: string, at: number): Promise { + await this.#db + .updateTable('notification_channels') + .set({ failure_count: 0, last_ok_at: at }) + .where('channel_id', '=', channelId) + .execute(); + } + + async recordFailure(channelId: string, at: number, error: string): Promise { + await this.#db + .updateTable('notification_channels') + .set((eb) => ({ + failure_count: eb('failure_count', '+', 1), + last_error: error, + last_failure_at: at, + })) + .where('channel_id', '=', channelId) + .execute(); + const row = await this.#db + .selectFrom('notification_channels') + .select('failure_count') + .where('channel_id', '=', channelId) + .executeTakeFirst(); + return row === undefined ? 0 : row.failure_count; + } +} + +/** SQLite implementation of {@link NotificationDeliveryRepository}. */ +export class SqliteNotificationDeliveryRepository implements NotificationDeliveryRepository { + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async enqueue(rows: readonly NewNotificationDelivery[]): Promise { + if (rows.length === 0) return 0; + const result = await this.#db + .insertInto('notification_deliveries') + .values(rows.map(deliveryToRow)) + .onConflict((oc) => + oc.columns(['channel_id', 'notification_id', 'revision', 'op']).doNothing(), + ) + .executeTakeFirst(); + return Number(result.numInsertedOrUpdatedRows ?? 0n); + } + + async get(seq: number): Promise { + const row = await this.#db + .selectFrom('notification_deliveries') + .selectAll() + .where('seq', '=', seq) + .executeTakeFirst(); + return row === undefined ? null : deliveryFromRow(row); + } + + async due(now: number, limit: number): Promise { + const rows = await this.#db + .selectFrom('notification_deliveries') + .selectAll() + .where('status', 'in', [...OPEN_STATUSES]) + .where('next_attempt_at', '<=', now) + .orderBy('next_attempt_at') + .orderBy('seq') + .limit(Math.max(1, limit)) + .execute(); + return rows.map(deliveryFromRow); + } + + async claim(seq: number, at: number): Promise { + const result = await this.#db + .updateTable('notification_deliveries') + .set((eb) => ({ status: 'sending', attempts: eb('attempts', '+', 1), updated_at: at })) + .where('seq', '=', seq) + .where('status', 'in', [...OPEN_STATUSES]) + .executeTakeFirst(); + return result.numUpdatedRows > 0n; + } + + async finish(seq: number, patch: DeliveryFinishPatch): Promise { + await this.#db + .updateTable('notification_deliveries') + .set({ + status: patch.status, + updated_at: patch.updatedAt, + next_attempt_at: patch.nextAttemptAt ?? null, + ...(patch.reason !== undefined && { reason: patch.reason }), + ...(patch.lastError !== undefined && { last_error: patch.lastError }), + ...(patch.durationMs !== undefined && { duration_ms: patch.durationMs }), + ...(patch.messageRef !== undefined && { + message_ref_json: patch.messageRef === null ? null : JSON.stringify(patch.messageRef), + }), + }) + .where('seq', '=', seq) + .execute(); + } + + async annotate(seq: number, reason: string, at: number): Promise { + await this.#db + .updateTable('notification_deliveries') + .set({ reason, updated_at: at }) + .where('seq', '=', seq) + .where('status', 'in', [...OPEN_STATUSES]) + .execute(); + } + + async reschedule(seq: number, nextAttemptAt: number, at: number): Promise { + await this.#db + .updateTable('notification_deliveries') + .set({ next_attempt_at: nextAttemptAt, updated_at: at }) + .where('seq', '=', seq) + .where('status', 'in', [...OPEN_STATUSES]) + .execute(); + } + + async supersede( + channelId: string, + notificationId: string, + revision: number, + at: number, + reason: string, + exceptSeq?: number, + ): Promise { + let qb = this.#db + .updateTable('notification_deliveries') + .set({ status: 'superseded', reason, next_attempt_at: null, updated_at: at }) + .where('channel_id', '=', channelId) + .where('notification_id', '=', notificationId) + .where('revision', '<=', revision) + .where('op', 'in', ['send', 'edit']) + .where('status', 'in', [...OPEN_STATUSES]); + if (exceptSeq !== undefined) qb = qb.where('seq', '!=', exceptSeq); + return Number((await qb.executeTakeFirst()).numUpdatedRows); + } + + async suppressChannel(channelId: string, reason: string, at: number): Promise { + const result = await this.#db + .updateTable('notification_deliveries') + .set({ status: 'suppressed', reason, next_attempt_at: null, updated_at: at }) + .where('channel_id', '=', channelId) + .where('status', 'in', [...OPEN_STATUSES]) + .executeTakeFirst(); + return Number(result.numUpdatedRows); + } + + async recoverSending(at: number): Promise { + const result = await this.#db + .updateTable('notification_deliveries') + .set({ status: 'retrying', next_attempt_at: at, updated_at: at }) + .where('status', '=', 'sending') + .executeTakeFirst(); + return Number(result.numUpdatedRows); + } + + async pendingInfoSends(channelId: string): Promise { + const rows = await this.#db + .selectFrom('notification_deliveries as d') + .innerJoin('notifications as n', 'n.notification_id', 'd.notification_id') + .selectAll('d') + .where('d.channel_id', '=', channelId) + .where('d.op', '=', 'send') + .where('d.status', 'in', [...OPEN_STATUSES]) + .where('n.severity', '=', 'info') + .orderBy('d.seq') + .execute(); + return rows.map(deliveryFromRow); + } + + async count(statuses: readonly NotificationDeliveryStatus[]): Promise { + if (statuses.length === 0) return 0; + const row = await this.#db + .selectFrom('notification_deliveries') + .select(sql`COUNT(*)`.as('n')) + .where('status', 'in', [...statuses]) + .executeTakeFirst(); + return asNumber(row?.n); + } + + async list(query: NotificationDeliveryListQuery): Promise { + let qb = this.#db.selectFrom('notification_deliveries').selectAll(); + if (query.channelId !== undefined) qb = qb.where('channel_id', '=', query.channelId); + if (query.notificationId !== undefined) + qb = qb.where('notification_id', '=', query.notificationId); + if (query.statuses !== undefined && query.statuses.length > 0) + qb = qb.where('status', 'in', [...query.statuses]); + if (query.beforeSeq !== undefined) qb = qb.where('seq', '<', query.beforeSeq); + const rows = await qb + .orderBy('seq', 'desc') + .limit(Math.min(Math.max(1, query.limit ?? 100), 1000)) + .execute(); + return rows.map(deliveryFromRow); + } +} + +/** SQLite implementation of {@link NotificationChannelMessageRepository}. */ +export class SqliteNotificationChannelMessageRepository + implements NotificationChannelMessageRepository +{ + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async get( + channelId: string, + notificationId: string, + ): Promise { + const row = await this.#db + .selectFrom('notification_channel_messages') + .selectAll() + .where('channel_id', '=', channelId) + .where('notification_id', '=', notificationId) + .executeTakeFirst(); + return row === undefined ? null : channelMessageFromRow(row); + } + + async upsert(record: NotificationChannelMessageRecord): Promise { + const row = channelMessageToRow(record); + await this.#db + .insertInto('notification_channel_messages') + .values(row) + .onConflict((oc) => + oc.columns(['channel_id', 'notification_id']).doUpdateSet({ + thread: row.thread, + message_ref_json: row.message_ref_json, + last_revision: row.last_revision, + sent_at: row.sent_at, + updated_at: row.updated_at, + expires_at: row.expires_at, + deleted_at: row.deleted_at, + }), + ) + .execute(); + } + + async firstInThread( + channelId: string, + thread: string, + ): Promise { + const row = await this.#db + .selectFrom('notification_channel_messages') + .selectAll() + .where('channel_id', '=', channelId) + .where('thread', '=', thread) + .orderBy('sent_at') + .limit(1) + .executeTakeFirst(); + return row === undefined ? null : channelMessageFromRow(row); + } + + async dueForDelete( + now: number, + limit: number, + ): Promise { + const rows = await this.#db + .selectFrom('notification_channel_messages as m') + .selectAll('m') + .where('m.expires_at', 'is not', null) + .where('m.expires_at', '<=', now) + .where('m.deleted_at', 'is', null) + .where( + sql`NOT EXISTS (SELECT 1 FROM notification_deliveries d WHERE d.channel_id = m.channel_id AND d.notification_id = m.notification_id AND d.revision = m.last_revision AND d.op = 'delete')`, + ) + .orderBy('m.expires_at') + .limit(Math.max(1, limit)) + .execute(); + return rows.map(channelMessageFromRow); + } + + async setExpiry( + channelId: string, + notificationId: string, + expiresAt: number | null, + ): Promise { + await this.#db + .updateTable('notification_channel_messages') + .set({ expires_at: expiresAt }) + .where('channel_id', '=', channelId) + .where('notification_id', '=', notificationId) + .execute(); + } + + async markDeleted(channelId: string, notificationId: string, at: number): Promise { + await this.#db + .updateTable('notification_channel_messages') + .set({ deleted_at: at, updated_at: at }) + .where('channel_id', '=', channelId) + .where('notification_id', '=', notificationId) + .execute(); + } +} diff --git a/packages/core/src/infra/persistence/repositories/notifications.ts b/packages/core/src/infra/persistence/repositories/notifications.ts index 41eb32c..de221a2 100644 --- a/packages/core/src/infra/persistence/repositories/notifications.ts +++ b/packages/core/src/infra/persistence/repositories/notifications.ts @@ -10,6 +10,7 @@ import type { JsonValue, NotificationGroupPatch, NotificationRecord, + NotificationRevisionPatch, PreferenceRecord, } from '../../../ports/persistence/records.ts'; import type { DB } from '../generated/db.d.ts'; @@ -91,6 +92,8 @@ export class SqliteNotificationRepository implements NotificationRepository { source_event_id: patch.sourceEventId, count: patch.count, updated_at: patch.updatedAt, + revision: patch.revision, + message_json: patch.messageJson, }) .where('notification_id', '=', notificationId) .where('read_at', 'is', null) @@ -100,6 +103,57 @@ export class SqliteNotificationRepository implements NotificationRepository { return this.get(notificationId); } + async findLatestByThread( + principalId: string | null, + thread: string, + ): Promise { + let qb = this.#db.selectFrom('notifications').selectAll().where('thread', '=', thread); + qb = + principalId === null + ? qb.where('principal_id', 'is', null) + : qb.where('principal_id', '=', principalId); + const row = await qb + .orderBy('created_at', 'desc') + .orderBy('notification_id', 'desc') + .limit(1) + .executeTakeFirst(); + return row === undefined ? null : notificationFromRow(row); + } + + async revise( + notificationId: string, + patch: NotificationRevisionPatch, + ): Promise { + const result = await this.#db + .updateTable('notifications') + .set({ + state: patch.state, + severity: patch.severity, + revision: patch.revision, + ...(patch.messageJson !== null && { message_json: patch.messageJson }), + }) + .where('notification_id', '=', notificationId) + .executeTakeFirst(); + if (result.numUpdatedRows === 0n) return null; + return this.get(notificationId); + } + + async listUnsettled( + kinds: readonly string[], + limit: number, + ): Promise { + if (kinds.length === 0) return []; + const rows = await this.#db + .selectFrom('notifications') + .selectAll() + .where('state', 'in', ['open', 'acted']) + .where('kind', 'in', [...kinds]) + .orderBy('created_at') + .limit(Math.max(1, limit)) + .execute(); + return rows.map(notificationFromRow); + } + async list(query: NotificationListQuery): Promise> { const limit = clampLimit(query.limit); const dir = query.dir ?? 'desc'; diff --git a/packages/core/src/infra/persistence/retention.ts b/packages/core/src/infra/persistence/retention.ts index 10db026..73e7373 100644 --- a/packages/core/src/infra/persistence/retention.ts +++ b/packages/core/src/infra/persistence/retention.ts @@ -1,4 +1,4 @@ -/** @module infra/persistence/retention — one retention pass over the retention classes of spec 03 §7.1. */ +/** @module infra/persistence/retention — one retention pass over the retention classes of spec 03 §7.1 (including the notification outbox history, D-34). */ import { type Kysely, sql } from 'kysely'; import type { Clock } from '../../ports/clock.ts'; @@ -210,6 +210,29 @@ class Sweep { }); } + /** + * Outbox history (D-34): terminal deliveries and settled channel messages older than the cutoff. + * Work still to do (`pending`, `sending`, `retrying`) and messages with a pending TTL are kept. + */ + async pruneNotificationDeliveries(cutoff: number): Promise { + await this.step('notification_deliveries', async () => { + const result = await this.ctx.db + .deleteFrom('notification_deliveries') + .where('status', 'in', ['sent', 'dead', 'suppressed', 'superseded']) + .where('updated_at', '<', cutoff) + .executeTakeFirst(); + return Number(result.numDeletedRows); + }); + await this.step('notification_channel_messages', async () => { + const result = await this.ctx.db + .deleteFrom('notification_channel_messages') + .where('updated_at', '<', cutoff) + .where((eb) => eb.or([eb('deleted_at', 'is not', null), eb('expires_at', 'is', null)])) + .executeTakeFirst(); + return Number(result.numDeletedRows); + }); + } + async oldestTelemetryTs(): Promise { const result = await sql<{ m: number | null }>` SELECT MIN(ts) AS m FROM ( @@ -246,6 +269,7 @@ export async function sweepRetention( now - (policy.notificationSeenDays ?? 30) * DAY_MS, now - (policy.notificationDays ?? 90) * DAY_MS, ); + await sweep.pruneNotificationDeliveries(now - (policy.notificationDeliveryDays ?? 30) * DAY_MS); await sweep.step('idempotency_keys', async () => { const result = await ctx.db .deleteFrom('idempotency_keys') diff --git a/packages/core/src/infra/telemetry/metrics.ts b/packages/core/src/infra/telemetry/metrics.ts index 8ced463..1409b35 100644 --- a/packages/core/src/infra/telemetry/metrics.ts +++ b/packages/core/src/infra/telemetry/metrics.ts @@ -21,6 +21,7 @@ export const METRIC = { VAULT_FILLS: 'browserhive.vault.fills', BLOCKLIST_HITS: 'browserhive.blocklist.hits', RETENTION_PRUNED_ROWS: 'browserhive.retention.pruned_rows', + NOTIFICATION_DELIVERIES: 'browserhive.notifications.deliveries', PROCESS_RSS_BYTES: 'browserhive.process.rss_bytes', PROCESS_HEAP_BYTES: 'browserhive.process.heap_bytes', PROCESS_EVENT_LOOP_LAG: 'browserhive.process.event_loop_lag', @@ -48,6 +49,8 @@ export interface Instruments { readonly vaultFills: Counter; readonly blocklistHits: Counter; readonly retentionPrunedRows: Counter; + /** Outbox jobs by `channel_kind` and `status` (D-34). */ + readonly notificationDeliveries: Counter; readonly processRssBytes: ObservableGauge; readonly processHeapBytes: ObservableGauge; readonly processEventLoopLag: ObservableGauge; @@ -181,6 +184,13 @@ export function createInstruments(meter: Meter): Instruments { meter.createCounter(METRIC.RETENTION_PRUNED_ROWS, { description: 'Rows pruned by table' }), ); }, + get notificationDeliveries() { + return lazy(METRIC.NOTIFICATION_DELIVERIES, () => + meter.createCounter(METRIC.NOTIFICATION_DELIVERIES, { + description: 'Notification deliveries by channel_kind and status', + }), + ); + }, get processRssBytes() { return lazy(METRIC.PROCESS_RSS_BYTES, () => meter.createObservableGauge(METRIC.PROCESS_RSS_BYTES, { diff --git a/packages/core/src/interface/http/serializers/serializers.test.ts b/packages/core/src/interface/http/serializers/serializers.test.ts index 9c3b5e9..6cfdd40 100644 --- a/packages/core/src/interface/http/serializers/serializers.test.ts +++ b/packages/core/src/interface/http/serializers/serializers.test.ts @@ -292,6 +292,13 @@ describe('serializers', () => { groupKey: null, readAt: null, dismissedAt: null, + kind: 'session.crashed' as const, + category: 'problems' as const, + severity: 'error' as const, + state: 'final' as const, + revision: 1, + thread: 'notification:n-1', + messageJson: null, }; expect(Notification.parse(notificationToWire(notification)).title).toBe('t'); const bundle = resolveOk({ env: { BROWSERHIVE_AUTH_TOKENS: `bot:${'a'.repeat(32)}` } }); diff --git a/packages/core/src/interface/http/serializers/system.ts b/packages/core/src/interface/http/serializers/system.ts index a646b24..0202670 100644 --- a/packages/core/src/interface/http/serializers/system.ts +++ b/packages/core/src/interface/http/serializers/system.ts @@ -56,6 +56,12 @@ export function notificationToWire(record: NotificationRecord): z.input | void; + /** `NotificationChannelKind`. */ + readonly kind: string; + readonly capabilities: ChannelCapabilities; + /** Sends a new message. */ + send(delivery: ChannelDelivery): Promise; + /** Replaces a sent message with the delivery's full state (silent). Required when `capabilities.edit`. */ + edit?(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise; + /** Deletes a sent message. Required when `capabilities.delete`. */ + delete?(ref: PlatformMessageRef): Promise; } diff --git a/packages/core/src/ports/persistence/enums.test.ts b/packages/core/src/ports/persistence/enums.test.ts index cde9c40..20c3ad4 100644 --- a/packages/core/src/ports/persistence/enums.test.ts +++ b/packages/core/src/ports/persistence/enums.test.ts @@ -9,6 +9,12 @@ import { ClosedReason, CredentialKind, DegradationSeverity, + NotificationChannelSource, + NotificationChannelStatus, + NotificationDeliveryOp, + NotificationDeliveryStatus, + NotificationSeverity, + NotificationState, NotificationType, OperatorRequestKind, OperatorRequestStatus, @@ -25,6 +31,12 @@ import { CLOSED_REASONS, CREDENTIAL_KINDS, MCP_TRANSPORTS, + NOTIFICATION_CHANNEL_SOURCES, + NOTIFICATION_CHANNEL_STATUSES, + NOTIFICATION_DELIVERY_OPS, + NOTIFICATION_DELIVERY_STATUSES, + NOTIFICATION_SEVERITIES, + NOTIFICATION_STATES, NOTIFICATION_TYPES, OPERATOR_REQUEST_KINDS, OPERATOR_REQUEST_STATUSES, @@ -47,6 +59,12 @@ describe('persistence enums', () => { [CREDENTIAL_KINDS, CredentialKind.options], [SYSTEM_EVENT_SEVERITIES, DegradationSeverity.options], [NOTIFICATION_TYPES, NotificationType.options], + [NOTIFICATION_SEVERITIES, NotificationSeverity.options], + [NOTIFICATION_STATES, NotificationState.options], + [NOTIFICATION_CHANNEL_STATUSES, NotificationChannelStatus.options], + [NOTIFICATION_CHANNEL_SOURCES, NotificationChannelSource.options], + [NOTIFICATION_DELIVERY_OPS, NotificationDeliveryOp.options], + [NOTIFICATION_DELIVERY_STATUSES, NotificationDeliveryStatus.options], [OPERATOR_REQUEST_KINDS, OperatorRequestKind.options], [OPERATOR_REQUEST_STATUSES, OperatorRequestStatus.options], [PERSISTENCE_MODES, PersistenceMode.options], diff --git a/packages/core/src/ports/persistence/enums.ts b/packages/core/src/ports/persistence/enums.ts index 25fbe7a..087d0c8 100644 --- a/packages/core/src/ports/persistence/enums.ts +++ b/packages/core/src/ports/persistence/enums.ts @@ -145,6 +145,44 @@ export const NOTIFICATION_TYPES = ['attention', 'error', 'vault', 'lifecycle', ' /** Element of {@link NOTIFICATION_TYPES}. */ export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; +/** Severity of a notification (`notifications.severity`, D-32). */ +export const NOTIFICATION_SEVERITIES = ['info', 'warn', 'error', 'critical'] as const; +/** Element of {@link NOTIFICATION_SEVERITIES}. */ +export type NotificationSeverity = (typeof NOTIFICATION_SEVERITIES)[number]; + +/** Lifecycle state of a notification (`notifications.state`, D-32). */ +export const NOTIFICATION_STATES = ['open', 'acted', 'resolved', 'expired', 'final'] as const; +/** Element of {@link NOTIFICATION_STATES}. */ +export type NotificationState = (typeof NOTIFICATION_STATES)[number]; + +/** Status of a notification channel (`notification_channels.status`, D-34). */ +export const NOTIFICATION_CHANNEL_STATUSES = ['active', 'paused', 'broken'] as const; +/** Element of {@link NOTIFICATION_CHANNEL_STATUSES}. */ +export type NotificationChannelStatus = (typeof NOTIFICATION_CHANNEL_STATUSES)[number]; + +/** Where a notification channel is defined (`notification_channels.source`, D-39). */ +export const NOTIFICATION_CHANNEL_SOURCES = ['db', 'startup'] as const; +/** Element of {@link NOTIFICATION_CHANNEL_SOURCES}. */ +export type NotificationChannelSource = (typeof NOTIFICATION_CHANNEL_SOURCES)[number]; + +/** Operation of an outbox job (`notification_deliveries.op`). */ +export const NOTIFICATION_DELIVERY_OPS = ['send', 'edit', 'delete'] as const; +/** Element of {@link NOTIFICATION_DELIVERY_OPS}. */ +export type NotificationDeliveryOp = (typeof NOTIFICATION_DELIVERY_OPS)[number]; + +/** Status of an outbox job (`notification_deliveries.status`). */ +export const NOTIFICATION_DELIVERY_STATUSES = [ + 'pending', + 'sending', + 'sent', + 'retrying', + 'dead', + 'suppressed', + 'superseded', +] as const; +/** Element of {@link NOTIFICATION_DELIVERY_STATUSES}. */ +export type NotificationDeliveryStatus = (typeof NOTIFICATION_DELIVERY_STATUSES)[number]; + /** Severity of a `system_events` row. */ export const SYSTEM_EVENT_SEVERITIES = ['info', 'warn', 'error'] as const; /** Element of {@link SYSTEM_EVENT_SEVERITIES}. */ diff --git a/packages/core/src/ports/persistence/index.ts b/packages/core/src/ports/persistence/index.ts index 276968a..31276bd 100644 --- a/packages/core/src/ports/persistence/index.ts +++ b/packages/core/src/ports/persistence/index.ts @@ -22,6 +22,12 @@ export type { ClosedReason, CredentialKind, McpTransport, + NotificationChannelSource, + NotificationChannelStatus, + NotificationDeliveryOp, + NotificationDeliveryStatus, + NotificationSeverity, + NotificationState, NotificationType, OperatorRequestKind, OperatorRequestStatus, @@ -46,6 +52,12 @@ export { CLOSED_REASONS, CREDENTIAL_KINDS, MCP_TRANSPORTS, + NOTIFICATION_CHANNEL_SOURCES, + NOTIFICATION_CHANNEL_STATUSES, + NOTIFICATION_DELIVERY_OPS, + NOTIFICATION_DELIVERY_STATUSES, + NOTIFICATION_SEVERITIES, + NOTIFICATION_STATES, NOTIFICATION_TYPES, OPERATOR_REQUEST_KINDS, OPERATOR_REQUEST_STATUSES, @@ -79,6 +91,11 @@ export type { RetentionPolicy, RetentionResult, } from './maintenance.ts'; +export type { + NotificationChannelMessageRepository, + NotificationChannelRepository, + NotificationDeliveryRepository, +} from './notification-outbox.ts'; export type { NotificationRepository, PreferenceRepository } from './notifications.ts'; export type { ArtifactOutboxRepository, @@ -124,6 +141,7 @@ export type { AuthSessionRecord, BlockedRequestRecord, CredentialRecord, + DeliveryFinishPatch, EventRecord, GrantRecord, HarnessConflictRecord, @@ -135,14 +153,21 @@ export type { NewArtifact, NewAuthEvent, NewEvent, + NewNotificationDelivery, NewOperatorAction, NewOperatorRequest, NewSystemEvent, + NotificationChannelMessageRecord, + NotificationChannelRecord, + NotificationDeliveryListQuery, + NotificationDeliveryRecord, NotificationGroupPatch, NotificationRecord, + NotificationRevisionPatch, OperatorActionRecord, OperatorRequestRecord, PageRecord, + PlatformMessageRef, PreferenceRecord, PrincipalRecord, SchemaMigrationRecord, diff --git a/packages/core/src/ports/persistence/maintenance.ts b/packages/core/src/ports/persistence/maintenance.ts index b20b63c..930826f 100644 --- a/packages/core/src/ports/persistence/maintenance.ts +++ b/packages/core/src/ports/persistence/maintenance.ts @@ -12,6 +12,8 @@ export interface RetentionPolicy { readonly notificationSeenDays?: number; /** Days an untouched notification is kept (default 90). */ readonly notificationDays?: number; + /** Days terminal notification deliveries and settled channel messages are kept (default 30, D-34). */ + readonly notificationDeliveryDays?: number; /** Pages reclaimed per `incremental_vacuum` chunk (default 256). */ readonly vacuumChunkPages?: number; } diff --git a/packages/core/src/ports/persistence/notification-outbox.ts b/packages/core/src/ports/persistence/notification-outbox.ts new file mode 100644 index 0000000..9a1e5c2 --- /dev/null +++ b/packages/core/src/ports/persistence/notification-outbox.ts @@ -0,0 +1,115 @@ +/** @module ports/persistence/notification-outbox — notification channels, the delivery outbox and the channel message index (spec 03 §7.2, §9.3–9.4; D-34, D-35, D-39). */ + +import type { NotificationChannelStatus, NotificationDeliveryStatus } from './enums.ts'; +import type { + DeliveryFinishPatch, + NewNotificationDelivery, + NotificationChannelMessageRecord, + NotificationChannelRecord, + NotificationDeliveryListQuery, + NotificationDeliveryRecord, +} from './records-notifications.ts'; + +/** Repository over `notification_channels`. */ +export interface NotificationChannelRepository { + /** Every channel, by name. */ + list(): Promise; + get(channelId: string): Promise; + getByName(name: string): Promise; + /** + * Inserts a channel, or rewrites the configuration columns of an existing `channelId` (name, + * kind, mode, source, target, secret refs, rules, `updatedAt`) while keeping its status and + * breaker counters. A name used by another channel throws (unique constraint). + */ + upsert(record: NotificationChannelRecord): Promise; + /** Removes a channel with its deliveries and channel messages; false when unknown. */ + remove(channelId: string): Promise; + /** Sets the status; `active` also resets the consecutive failure count. False when unknown. */ + setStatus(channelId: string, status: NotificationChannelStatus, at: number): Promise; + /** A successful platform call: failure count 0, `lastOkAt = at`. */ + recordSuccess(channelId: string, at: number): Promise; + /** + * A failed platform call: failure count +1, `lastError`, `lastFailureAt`. + * + * @returns The consecutive failure count after the increment (0 when the channel is unknown). + */ + recordFailure(channelId: string, at: number, error: string): Promise; +} + +/** Repository over `notification_deliveries` (the outbox and the delivery log). */ +export interface NotificationDeliveryRepository { + /** + * Inserts jobs; a duplicate of (channel, notification, revision, op) is ignored. + * + * @returns The number of rows inserted. + */ + enqueue(rows: readonly NewNotificationDelivery[]): Promise; + get(seq: number): Promise; + /** `pending`/`retrying` jobs with `nextAttemptAt <= now`, oldest due first. */ + due(now: number, limit: number): Promise; + /** + * Moves one due job to `sending` and counts the attempt. + * + * @returns False when the job was no longer `pending`/`retrying` (another claim won). + */ + claim(seq: number, at: number): Promise; + /** Writes the outcome of an attempt (or a decision made without one). */ + finish(seq: number, patch: DeliveryFinishPatch): Promise; + /** Sets the `reason` of a `pending`/`retrying` job without changing its status (a backlog note). */ + annotate(seq: number, reason: string, at: number): Promise; + /** Moves a `pending`/`retrying` job's due time without counting an attempt (edit spacing). */ + reschedule(seq: number, nextAttemptAt: number, at: number): Promise; + /** + * Marks `pending`/`retrying` `send`/`edit` jobs of one channel and notification with a revision + * at or below `revision` as `superseded` (except `exceptSeq`). + * + * @returns The number of jobs superseded. + */ + supersede( + channelId: string, + notificationId: string, + revision: number, + at: number, + reason: string, + exceptSeq?: number, + ): Promise; + /** + * Marks every `pending`/`retrying` job of a channel `suppressed` with `reason` (a paused or + * broken channel). + * + * @returns The number of jobs suppressed. + */ + suppressChannel(channelId: string, reason: string, at: number): Promise; + /** + * Crash recovery: every `sending` job becomes `retrying`, due at `at`. + * + * @returns The number of jobs recovered. + */ + recoverSending(at: number): Promise; + /** `pending`/`retrying` `send` jobs of a channel whose notification has `severity = 'info'`, oldest first. */ + pendingInfoSends(channelId: string): Promise; + /** Number of jobs in `statuses` (all channels). */ + count(statuses: readonly NotificationDeliveryStatus[]): Promise; + /** The delivery log, newest first. */ + list(query: NotificationDeliveryListQuery): Promise; +} + +/** Repository over `notification_channel_messages`. */ +export interface NotificationChannelMessageRepository { + get(channelId: string, notificationId: string): Promise; + /** Inserts or replaces the row of (channel, notification). */ + upsert(record: NotificationChannelMessageRecord): Promise; + /** The earliest message of a thread on a channel (reply-to target), or `null`. */ + firstInThread( + channelId: string, + thread: string, + ): Promise; + /** + * Messages whose TTL is due (`expiresAt <= now`), not deleted, and without a `delete` job for + * their last revision yet; earliest deadline first. + */ + dueForDelete(now: number, limit: number): Promise; + /** Sets (or clears) the TTL deadline of one message. */ + setExpiry(channelId: string, notificationId: string, expiresAt: number | null): Promise; + markDeleted(channelId: string, notificationId: string, at: number): Promise; +} diff --git a/packages/core/src/ports/persistence/notifications.ts b/packages/core/src/ports/persistence/notifications.ts index 8acf4b5..23e8e8b 100644 --- a/packages/core/src/ports/persistence/notifications.ts +++ b/packages/core/src/ports/persistence/notifications.ts @@ -5,6 +5,7 @@ import type { JsonValue, NotificationGroupPatch, NotificationRecord, + NotificationRevisionPatch, PreferenceRecord, } from './records.ts'; @@ -28,6 +29,26 @@ export interface NotificationRepository { notificationId: string, patch: NotificationGroupPatch, ): Promise; + /** Newest row of a thread in one inbox (`attention:`), or `null`. */ + findLatestByThread( + principalId: string | null, + thread: string, + ): Promise; + /** + * Applies a lifecycle revision (state, severity, revision, message). Unlike `updateGroup` it + * applies whatever the read state, and never changes `title`, `body` or `updated_at`. + * + * @returns The updated row, or `null` when the id is unknown. + */ + revise( + notificationId: string, + patch: NotificationRevisionPatch, + ): Promise; + /** + * Rows still `open`/`acted` whose kind is one of `kinds`, oldest first (the startup catch-up of + * requests settled while no subscriber listened). + */ + listUnsettled(kinds: readonly string[], limit: number): Promise; /** Lists notifications newest first (by `query.sort`, default `updated_at`). */ list(query: NotificationListQuery): Promise>; /** Unread, undismissed count for a principal (or the anonymous inbox when `null`). */ diff --git a/packages/core/src/ports/persistence/records-notifications.ts b/packages/core/src/ports/persistence/records-notifications.ts new file mode 100644 index 0000000..cfeb858 --- /dev/null +++ b/packages/core/src/ports/persistence/records-notifications.ts @@ -0,0 +1,112 @@ +/** @module ports/persistence/records-notifications — notification channel, outbox job and channel message records (spec 03 §7, §9.3–9.4; D-33, D-34, D-35, D-39). */ + +import type { NotificationChannelRules } from '@browserhive/contracts/notifications'; +import type { + NotificationChannelSource, + NotificationChannelStatus, + NotificationDeliveryOp, + NotificationDeliveryStatus, +} from './enums.ts'; + +/** + * Opaque platform coordinates of one sent message (message id, chat id, ntfy sequence id). Only the + * adapter that produced a ref interprets it; the core stores and hands it back. + */ +export type PlatformMessageRef = Readonly>; + +/** A configured notification channel (`notification_channels`). Secrets are env var names only. */ +export interface NotificationChannelRecord { + readonly channelId: string; + /** Unique across dashboard and startup channels. */ + readonly name: string; + /** `NotificationChannelKind` (`telegram`, `discord`, …); open set, validated by the contract. */ + readonly kind: string; + /** Discord: `webhook` or `bot` (D-38); `null` elsewhere. */ + readonly mode: string | null; + readonly source: NotificationChannelSource; + readonly status: NotificationChannelStatus; + /** Non-secret coordinates. */ + readonly target: Readonly>; + /** Secret parameter → environment variable name (D-33). */ + readonly secretRefs: Readonly>; + readonly rules: NotificationChannelRules; + /** Consecutive failures (the breaker opens at 5). */ + readonly failureCount: number; + readonly lastError: string | null; + readonly lastOkAt: number | null; + readonly lastFailureAt: number | null; + readonly createdAt: number; + readonly updatedAt: number; +} + +/** One outbox job and delivery log row (`notification_deliveries`). */ +export interface NotificationDeliveryRecord { + readonly seq: number; + readonly channelId: string; + readonly notificationId: string; + /** The revision that caused the job. */ + readonly revision: number; + readonly op: NotificationDeliveryOp; + readonly status: NotificationDeliveryStatus; + /** Why it was suppressed, superseded or dead (`quiet_hours`, `collapsed`, `too_old`, …). */ + readonly reason: string | null; + readonly attempts: number; + readonly nextAttemptAt: number | null; + readonly lastError: string | null; + readonly durationMs: number | null; + /** The platform message the job produced or addressed. */ + readonly messageRef: PlatformMessageRef | null; + readonly createdAt: number; + readonly updatedAt: number; +} + +/** A job to enqueue. Duplicates of (channel, notification, revision, op) are ignored. */ +export interface NewNotificationDelivery { + readonly channelId: string; + readonly notificationId: string; + readonly revision: number; + readonly op: NotificationDeliveryOp; + /** `pending` for work; `suppressed`/`superseded` to log a decision without a platform call. */ + readonly status: Extract; + readonly reason: string | null; + /** When the job becomes due (`null` for terminal rows). */ + readonly nextAttemptAt: number | null; + readonly createdAt: number; +} + +/** The outcome written when a job finishes or is rescheduled after an attempt. */ +export interface DeliveryFinishPatch { + readonly status: Exclude; + readonly reason?: string | null; + readonly lastError?: string | null; + readonly durationMs?: number | null; + readonly messageRef?: PlatformMessageRef | null; + /** Required for `retrying`. */ + readonly nextAttemptAt?: number | null; + readonly updatedAt: number; +} + +/** Filters of the delivery log (newest first, keyset on `seq`). */ +export interface NotificationDeliveryListQuery { + readonly channelId?: string; + readonly notificationId?: string; + readonly statuses?: readonly NotificationDeliveryStatus[]; + /** Only rows with `seq` below this (the next page). */ + readonly beforeSeq?: number; + readonly limit?: number; +} + +/** The platform message a notification became on a channel (`notification_channel_messages`). */ +export interface NotificationChannelMessageRecord { + readonly channelId: string; + readonly notificationId: string; + readonly thread: string; + readonly messageRef: PlatformMessageRef; + /** The last revision the platform message shows. */ + readonly lastRevision: number; + readonly sentAt: number; + readonly updatedAt: number; + /** TTL deadline (D-35); `null` = never. */ + readonly expiresAt: number | null; + readonly deletedAt: number | null; +} diff --git a/packages/core/src/ports/persistence/records-operations.ts b/packages/core/src/ports/persistence/records-operations.ts index cac8140..cdf1388 100644 --- a/packages/core/src/ports/persistence/records-operations.ts +++ b/packages/core/src/ports/persistence/records-operations.ts @@ -1,6 +1,13 @@ /** @module ports/persistence/records-operations — notification, preference, system event, artifact outbox, idempotency and log records. */ -import type { ArtifactKind, NotificationType, SystemEventSeverity } from './enums.ts'; +import type { NotificationCategory, NotificationKind } from '@browserhive/contracts/enums'; +import type { + ArtifactKind, + NotificationSeverity, + NotificationState, + NotificationType, + SystemEventSeverity, +} from './enums.ts'; import type { JsonObject, JsonValue } from './json.ts'; // --- notifications, preferences --------------------------------------------------------------- @@ -24,6 +31,17 @@ export interface NotificationRecord { readonly groupKey: string | null; readonly readAt: number | null; readonly dismissedAt: number | null; + /** What the notification is about (D-32). Derived from `type` for rows whose column is NULL. */ + readonly kind: NotificationKind; + readonly category: NotificationCategory; + readonly severity: NotificationSeverity; + readonly state: NotificationState; + /** 1 at creation, +1 per message change. */ + readonly revision: number; + /** Conversation key shared with external channels (`attention:`). */ + readonly thread: string; + /** The current `NotificationMessage` as stored JSON; `null` for rows from before schema v5. */ + readonly messageJson: string | null; } /** What folding one more occurrence into an open group row changes. */ @@ -34,6 +52,21 @@ export interface NotificationGroupPatch { readonly sourceEventId: string | null; readonly count: number; readonly updatedAt: number; + /** The next revision and its full message. */ + readonly revision: number; + readonly messageJson: string | null; +} + +/** + * A lifecycle revision (a request resolved, a degradation recovered). Never touches the in-app + * `title`, `body` or `updated_at`, so the inbox order does not move (spec 03 §9). + */ +export interface NotificationRevisionPatch { + readonly state: NotificationState; + readonly severity: NotificationSeverity; + readonly revision: number; + /** `null` keeps the stored message (a row from before schema v5 has none). */ + readonly messageJson: string | null; } /** One preference entry (`preferences`). */ diff --git a/packages/core/src/ports/persistence/records.ts b/packages/core/src/ports/persistence/records.ts index 1089fbc..07082fe 100644 --- a/packages/core/src/ports/persistence/records.ts +++ b/packages/core/src/ports/persistence/records.ts @@ -27,6 +27,15 @@ export type { NewAuthEvent, PrincipalRecord, } from './records-identity.ts'; +export type { + DeliveryFinishPatch, + NewNotificationDelivery, + NotificationChannelMessageRecord, + NotificationChannelRecord, + NotificationDeliveryListQuery, + NotificationDeliveryRecord, + PlatformMessageRef, +} from './records-notifications.ts'; export type { ArtifactOutboxRecord, IdempotencyRecord, @@ -35,6 +44,7 @@ export type { NewSystemEvent, NotificationGroupPatch, NotificationRecord, + NotificationRevisionPatch, PreferenceRecord, SystemEventRecord, } from './records-operations.ts'; diff --git a/packages/core/src/ports/persistence/unit-of-work.ts b/packages/core/src/ports/persistence/unit-of-work.ts index 41b40fb..fac285f 100644 --- a/packages/core/src/ports/persistence/unit-of-work.ts +++ b/packages/core/src/ports/persistence/unit-of-work.ts @@ -9,6 +9,11 @@ import type { GrantRepository, PrincipalRepository, } from './identity.ts'; +import type { + NotificationChannelMessageRepository, + NotificationChannelRepository, + NotificationDeliveryRepository, +} from './notification-outbox.ts'; import type { NotificationRepository, PreferenceRepository } from './notifications.ts'; import type { ArtifactOutboxRepository, @@ -46,6 +51,9 @@ export interface Repositories { readonly vaultBindings: VaultBindingRepository; readonly vaultGroupPolicies: VaultGroupPolicyRepository; readonly notifications: NotificationRepository; + readonly notificationChannels: NotificationChannelRepository; + readonly notificationDeliveries: NotificationDeliveryRepository; + readonly notificationChannelMessages: NotificationChannelMessageRepository; readonly preferences: PreferenceRepository; readonly systemEvents: SystemEventRepository; readonly idempotency: IdempotencyRepository; diff --git a/packages/core/src/public/runtime.ts b/packages/core/src/public/runtime.ts index dcd9eb3..6250d3b 100644 --- a/packages/core/src/public/runtime.ts +++ b/packages/core/src/public/runtime.ts @@ -49,8 +49,22 @@ export type { FileSystem } from '../ports/file-system.ts'; export type { HostEnvironment } from '../ports/host-environment.ts'; export type { IdGenerator } from '../ports/id-generator.ts'; export type { LogFields, Logger, LogLevel } from '../ports/logger.ts'; +export { + type ChannelCapabilities, + type ChannelDelivery, + ChannelSendError, + type ChannelSendResult, + type LinkBuilder, + type NotificationChannel, + type PlatformMessageRef, +} from '../ports/notification-channel.ts'; export type { AnalyticsQueries } from '../ports/persistence/analytics.ts'; export { AUTH_EVENT_TYPES } from '../ports/persistence/enums.ts'; +export type { + NotificationChannelMessageRepository, + NotificationChannelRepository, + NotificationDeliveryRepository, +} from '../ports/persistence/notification-outbox.ts'; export type { NotificationRepository } from '../ports/persistence/notifications.ts'; export type { Repositories, UnitOfWork } from '../ports/persistence/unit-of-work.ts'; export type { WriteQueue } from '../ports/persistence/write-queue.ts'; diff --git a/packages/core/src/public/server.ts b/packages/core/src/public/server.ts index 9cfc1b4..2e565d6 100644 --- a/packages/core/src/public/server.ts +++ b/packages/core/src/public/server.ts @@ -5,7 +5,14 @@ export { AuthStateStore } from '../app/auth-states/store.ts'; export { type BlocklistFileWatcher, BlocklistService } from '../app/blocklist/blocklist-service.ts'; export { configView } from '../app/config/provenance-view.ts'; export { InProcessEventBus } from '../app/events/bus.ts'; -export { NotificationService } from '../app/notifications/notification-service.ts'; +export { + type ChannelAdapterFactory, + ChannelRegistry, + createLocalLinkBuilder, + type DeliveryCounter, + NotificationOutbox, + NotificationService, +} from '../app/notifications/index.ts'; export { Recorder } from '../app/observability/recorder.ts'; export { SystemStatusService } from '../app/observability/system-status.ts'; export { PreferenceService } from '../app/preferences/preference-service.ts'; diff --git a/packages/core/test/goldens/http/listNotifications.json b/packages/core/test/goldens/http/listNotifications.json index 477a0e3..8c945d2 100644 --- a/packages/core/test/goldens/http/listNotifications.json +++ b/packages/core/test/goldens/http/listNotifications.json @@ -14,7 +14,13 @@ "updated_at": 1699999980000, "count": 1, "read_at": null, - "dismissed_at": null + "dismissed_at": null, + "kind": "tool.errors", + "category": "problems", + "severity": "warn", + "state": "open", + "revision": 1, + "thread": "tool-errors:shop-0000000a" } ], "page": { diff --git a/packages/core/test/helpers/fake-channel.ts b/packages/core/test/helpers/fake-channel.ts new file mode 100644 index 0000000..658b6ce --- /dev/null +++ b/packages/core/test/helpers/fake-channel.ts @@ -0,0 +1,117 @@ +/** @module test/helpers/fake-channel — a scripted `NotificationChannel` that records every call, plus channel-row and capability builders for the notification outbox suites (spec 09). */ + +import type { + ChannelCapabilities, + ChannelDelivery, + ChannelSendResult, + NotificationChannel, + PlatformMessageRef, +} from '../../src/ports/notification-channel.ts'; +import type { NotificationChannelRecord } from '../../src/ports/persistence/records.ts'; + +/** Capabilities of a capable chat platform (Telegram-like): everything but tables. */ +export function capabilities(overrides: Partial = {}): ChannelCapabilities { + return { + richBlocks: true, + tables: false, + images: true, + actButtons: false, + openLinks: true, + edit: true, + delete: true, + replies: true, + deleteWindowMs: null, + maxTitleChars: 120, + maxTextChars: 4000, + maxButtons: 3, + ...overrides, + }; +} + +/** The default capabilities, reachable where a parameter named `capabilities` shadows the builder. */ +const defaultCapabilities = (): ChannelCapabilities => capabilities(); + +/** A `notification_channels` row with sensible defaults. */ +export function channelRecord( + overrides: Partial = {}, +): NotificationChannelRecord { + return { + channelId: 'nc-000000000001', + name: 'phone', + kind: 'fake', + mode: null, + source: 'db', + status: 'active', + target: { chat: '1' }, + secretRefs: { token: 'BH_FAKE_TOKEN' }, + rules: {}, + failureCount: 0, + lastError: null, + lastOkAt: null, + lastFailureAt: null, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +/** One recorded platform call. */ +export interface FakeCall { + readonly op: 'send' | 'edit' | 'delete'; + readonly ref: PlatformMessageRef | null; + readonly delivery: ChannelDelivery | null; +} + +/** An outcome queued for the next call: `ok` or an error to throw. */ +export type FakeOutcome = 'ok' | Error; + +/** + * Records calls and answers them from a queue (`'ok'` when empty). Refs are `{ message_id: n }` + * with a counter per send. + */ +export class FakeChannel implements NotificationChannel { + readonly calls: FakeCall[] = []; + readonly outcomes: FakeOutcome[] = []; + private messages = 0; + + constructor( + readonly id = 'nc-000000000001', + readonly capabilities: ChannelCapabilities = defaultCapabilities(), + readonly name = 'phone', + readonly kind = 'fake', + ) {} + + /** Queues outcomes for the next calls. */ + script(...outcomes: FakeOutcome[]): this { + this.outcomes.push(...outcomes); + return this; + } + + private next(): void { + const outcome = this.outcomes.shift() ?? 'ok'; + if (outcome !== 'ok') throw outcome; + } + + async send(delivery: ChannelDelivery): Promise { + this.calls.push({ op: 'send', ref: null, delivery }); + this.next(); + this.messages += 1; + return { ref: { message_id: this.messages } }; + } + + async edit(ref: PlatformMessageRef, delivery: ChannelDelivery): Promise { + this.calls.push({ op: 'edit', ref, delivery }); + this.next(); + return { ref }; + } + + async delete(ref: PlatformMessageRef): Promise { + this.calls.push({ op: 'delete', ref, delivery: null }); + this.next(); + } + + /** Calls of one op. */ + ops(op: FakeCall['op']): FakeCall[] { + return this.calls.filter((c) => c.op === op); + } +} diff --git a/packages/core/test/helpers/http-fixtures.ts b/packages/core/test/helpers/http-fixtures.ts index 048ed29..03b9ac1 100644 --- a/packages/core/test/helpers/http-fixtures.ts +++ b/packages/core/test/helpers/http-fixtures.ts @@ -231,6 +231,13 @@ export async function seedDataset( groupKey: `tool-errors:${CLOSED_ID}`, readAt: null, dismissedAt: null, + kind: 'tool.errors', + category: 'problems', + severity: 'warn', + state: 'open', + revision: 1, + thread: `tool-errors:${CLOSED_ID}`, + messageJson: null, }); await repos.systemEvents.record({ eventId: 'e-00000000000000000000000009', diff --git a/packages/core/test/helpers/in-memory-notification-repos.ts b/packages/core/test/helpers/in-memory-notification-repos.ts new file mode 100644 index 0000000..43ecacf --- /dev/null +++ b/packages/core/test/helpers/in-memory-notification-repos.ts @@ -0,0 +1,356 @@ +/** @module test/helpers/in-memory-notification-repos — Map-backed notification channel, delivery (outbox) and channel message repositories; the conformance suite runs them beside the SQLite ones. */ + +import type { + NotificationChannelStatus, + NotificationDeliveryStatus, +} from '../../src/ports/persistence/enums.ts'; +import type { + NotificationChannelMessageRepository, + NotificationChannelRepository, + NotificationDeliveryRepository, +} from '../../src/ports/persistence/notification-outbox.ts'; +import type { + DeliveryFinishPatch, + NewNotificationDelivery, + NotificationChannelMessageRecord, + NotificationChannelRecord, + NotificationDeliveryListQuery, + NotificationDeliveryRecord, + NotificationRecord, +} from '../../src/ports/persistence/records.ts'; + +const OPEN: ReadonlySet = new Set(['pending', 'retrying']); + +/** `notification_deliveries` in memory. */ +export class InMemoryNotificationDeliveryRepository implements NotificationDeliveryRepository { + readonly rows: NotificationDeliveryRecord[] = []; + private nextSeq = 1; + + /** @param notificationOf Looks up a notification (the severity join of `pendingInfoSends`). */ + constructor(private readonly notificationOf: (id: string) => NotificationRecord | undefined) {} + + async enqueue(rows: readonly NewNotificationDelivery[]): Promise { + let n = 0; + for (const row of rows) { + const dup = this.rows.some( + (r) => + r.channelId === row.channelId && + r.notificationId === row.notificationId && + r.revision === row.revision && + r.op === row.op, + ); + if (dup) continue; + this.rows.push({ + seq: this.nextSeq++, + channelId: row.channelId, + notificationId: row.notificationId, + revision: row.revision, + op: row.op, + status: row.status, + reason: row.reason, + attempts: 0, + nextAttemptAt: row.nextAttemptAt, + lastError: null, + durationMs: null, + messageRef: null, + createdAt: row.createdAt, + updatedAt: row.createdAt, + }); + n++; + } + return n; + } + + async get(seq: number): Promise { + return this.rows.find((r) => r.seq === seq) ?? null; + } + + async due(now: number, limit: number): Promise { + return this.rows + .filter((r) => OPEN.has(r.status) && r.nextAttemptAt !== null && r.nextAttemptAt <= now) + .sort((a, b) => (a.nextAttemptAt ?? 0) - (b.nextAttemptAt ?? 0) || a.seq - b.seq) + .slice(0, Math.max(1, limit)); + } + + private update(seq: number, fn: (r: NotificationDeliveryRecord) => NotificationDeliveryRecord) { + const i = this.rows.findIndex((r) => r.seq === seq); + const row = this.rows[i]; + if (row !== undefined) this.rows[i] = fn(row); + } + + async claim(seq: number, at: number): Promise { + const row = this.rows.find((r) => r.seq === seq); + if (row === undefined || !OPEN.has(row.status)) return false; + this.update(seq, (r) => ({ ...r, status: 'sending', attempts: r.attempts + 1, updatedAt: at })); + return true; + } + + async finish(seq: number, patch: DeliveryFinishPatch): Promise { + this.update(seq, (r) => ({ + ...r, + status: patch.status, + updatedAt: patch.updatedAt, + nextAttemptAt: patch.nextAttemptAt ?? null, + ...(patch.reason !== undefined && { reason: patch.reason }), + ...(patch.lastError !== undefined && { lastError: patch.lastError }), + ...(patch.durationMs !== undefined && { durationMs: patch.durationMs }), + ...(patch.messageRef !== undefined && { messageRef: patch.messageRef }), + })); + } + + async annotate(seq: number, reason: string, at: number): Promise { + this.update(seq, (r) => (OPEN.has(r.status) ? { ...r, reason, updatedAt: at } : r)); + } + + async reschedule(seq: number, nextAttemptAt: number, at: number): Promise { + this.update(seq, (r) => (OPEN.has(r.status) ? { ...r, nextAttemptAt, updatedAt: at } : r)); + } + + async supersede( + channelId: string, + notificationId: string, + revision: number, + at: number, + reason: string, + exceptSeq?: number, + ): Promise { + let n = 0; + for (const r of [...this.rows]) { + if ( + r.channelId === channelId && + r.notificationId === notificationId && + r.revision <= revision && + (r.op === 'send' || r.op === 'edit') && + OPEN.has(r.status) && + r.seq !== exceptSeq + ) { + this.update(r.seq, (x) => ({ + ...x, + status: 'superseded', + reason, + nextAttemptAt: null, + updatedAt: at, + })); + n++; + } + } + return n; + } + + async suppressChannel(channelId: string, reason: string, at: number): Promise { + let n = 0; + for (const r of [...this.rows]) { + if (r.channelId === channelId && OPEN.has(r.status)) { + this.update(r.seq, (x) => ({ + ...x, + status: 'suppressed', + reason, + nextAttemptAt: null, + updatedAt: at, + })); + n++; + } + } + return n; + } + + async recoverSending(at: number): Promise { + let n = 0; + for (const r of [...this.rows]) { + if (r.status === 'sending') { + this.update(r.seq, (x) => ({ ...x, status: 'retrying', nextAttemptAt: at, updatedAt: at })); + n++; + } + } + return n; + } + + async pendingInfoSends(channelId: string): Promise { + return this.rows + .filter( + (r) => + r.channelId === channelId && + r.op === 'send' && + OPEN.has(r.status) && + this.notificationOf(r.notificationId)?.severity === 'info', + ) + .sort((a, b) => a.seq - b.seq); + } + + async count(statuses: readonly NotificationDeliveryStatus[]): Promise { + return this.rows.filter((r) => statuses.includes(r.status)).length; + } + + async list(query: NotificationDeliveryListQuery): Promise { + return this.rows + .filter((r) => query.channelId === undefined || r.channelId === query.channelId) + .filter( + (r) => query.notificationId === undefined || r.notificationId === query.notificationId, + ) + .filter( + (r) => + query.statuses === undefined || + query.statuses.length === 0 || + query.statuses.includes(r.status), + ) + .filter((r) => query.beforeSeq === undefined || r.seq < query.beforeSeq) + .sort((a, b) => b.seq - a.seq) + .slice(0, Math.min(Math.max(1, query.limit ?? 100), 1000)); + } + + /** Drops every job of a channel (the FK cascade). */ + removeChannel(channelId: string): void { + for (let i = this.rows.length - 1; i >= 0; i--) { + if (this.rows[i]?.channelId === channelId) this.rows.splice(i, 1); + } + } +} + +/** `notification_channel_messages` in memory. */ +export class InMemoryNotificationChannelMessageRepository + implements NotificationChannelMessageRepository +{ + readonly rows = new Map(); + + constructor(private readonly deliveries: InMemoryNotificationDeliveryRepository) {} + + private key(channelId: string, notificationId: string): string { + return `${channelId}\u0000${notificationId}`; + } + + async get( + channelId: string, + notificationId: string, + ): Promise { + return this.rows.get(this.key(channelId, notificationId)) ?? null; + } + + async upsert(record: NotificationChannelMessageRecord): Promise { + this.rows.set(this.key(record.channelId, record.notificationId), record); + } + + async firstInThread( + channelId: string, + thread: string, + ): Promise { + const rows = [...this.rows.values()] + .filter((r) => r.channelId === channelId && r.thread === thread) + .sort((a, b) => a.sentAt - b.sentAt); + return rows[0] ?? null; + } + + async dueForDelete( + now: number, + limit: number, + ): Promise { + return [...this.rows.values()] + .filter( + (m) => + m.expiresAt !== null && + m.expiresAt <= now && + m.deletedAt === null && + !this.deliveries.rows.some( + (d) => + d.channelId === m.channelId && + d.notificationId === m.notificationId && + d.revision === m.lastRevision && + d.op === 'delete', + ), + ) + .sort((a, b) => (a.expiresAt ?? 0) - (b.expiresAt ?? 0)) + .slice(0, Math.max(1, limit)); + } + + async setExpiry(channelId: string, notificationId: string, expiresAt: number | null) { + const row = this.rows.get(this.key(channelId, notificationId)); + if (row !== undefined) + this.rows.set(this.key(channelId, notificationId), { ...row, expiresAt }); + } + + async markDeleted(channelId: string, notificationId: string, at: number): Promise { + const row = this.rows.get(this.key(channelId, notificationId)); + if (row !== undefined) { + this.rows.set(this.key(channelId, notificationId), { ...row, deletedAt: at, updatedAt: at }); + } + } + + /** Drops every message of a channel (the FK cascade). */ + removeChannel(channelId: string): void { + for (const [k, v] of [...this.rows]) if (v.channelId === channelId) this.rows.delete(k); + } +} + +/** `notification_channels` in memory; `remove` cascades like the foreign keys. */ +export class InMemoryNotificationChannelRepository implements NotificationChannelRepository { + readonly rows = new Map(); + + constructor(private readonly cascade: { removeChannel(channelId: string): void }[] = []) {} + + async list(): Promise { + return [...this.rows.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + async get(channelId: string): Promise { + return this.rows.get(channelId) ?? null; + } + + async getByName(name: string): Promise { + return [...this.rows.values()].find((r) => r.name === name) ?? null; + } + + async upsert(record: NotificationChannelRecord): Promise { + const clash = [...this.rows.values()].find( + (r) => r.name === record.name && r.channelId !== record.channelId, + ); + if (clash !== undefined) + throw new Error(`UNIQUE constraint failed: notification_channels.name`); + const existing = this.rows.get(record.channelId); + this.rows.set( + record.channelId, + existing === undefined + ? record + : { + ...existing, + name: record.name, + kind: record.kind, + mode: record.mode, + source: record.source, + target: record.target, + secretRefs: record.secretRefs, + rules: record.rules, + updatedAt: record.updatedAt, + }, + ); + } + + async remove(channelId: string): Promise { + const had = this.rows.delete(channelId); + if (had) for (const c of this.cascade) c.removeChannel(channelId); + return had; + } + + async setStatus(channelId: string, status: NotificationChannelStatus, at: number) { + const row = this.rows.get(channelId); + if (row === undefined) return false; + this.rows.set(channelId, { + ...row, + status, + updatedAt: at, + ...(status === 'active' && { failureCount: 0 }), + }); + return true; + } + + async recordSuccess(channelId: string, at: number): Promise { + const row = this.rows.get(channelId); + if (row !== undefined) this.rows.set(channelId, { ...row, failureCount: 0, lastOkAt: at }); + } + + async recordFailure(channelId: string, at: number, error: string): Promise { + const row = this.rows.get(channelId); + if (row === undefined) return 0; + const failureCount = row.failureCount + 1; + this.rows.set(channelId, { ...row, failureCount, lastError: error, lastFailureAt: at }); + return failureCount; + } +} diff --git a/packages/core/test/helpers/in-memory-repos-operations.ts b/packages/core/test/helpers/in-memory-repos-operations.ts index 9cc3944..96d189c 100644 --- a/packages/core/test/helpers/in-memory-repos-operations.ts +++ b/packages/core/test/helpers/in-memory-repos-operations.ts @@ -11,6 +11,7 @@ import type { NewSystemEvent, NotificationGroupPatch, NotificationRecord, + NotificationRevisionPatch, SystemEventRecord, } from '../../src/ports/persistence/records.ts'; import { inWindow, pageOf } from './in-memory-repos-facts.ts'; @@ -112,6 +113,45 @@ export class InMemoryNotificationRepository implements NotificationRepository { return next; } + async findLatestByThread( + principalId: string | null, + thread: string, + ): Promise { + const rows = [...this.rows.values()] + .filter((r) => r.principalId === principalId && r.thread === thread) + .sort( + (a, b) => b.createdAt - a.createdAt || b.notificationId.localeCompare(a.notificationId), + ); + return rows[0] ?? null; + } + + async revise( + notificationId: string, + patch: NotificationRevisionPatch, + ): Promise { + const row = this.rows.get(notificationId); + if (row === undefined) return null; + const next: NotificationRecord = { + ...row, + state: patch.state, + severity: patch.severity, + revision: patch.revision, + messageJson: patch.messageJson ?? row.messageJson, + }; + this.rows.set(notificationId, next); + return next; + } + + async listUnsettled( + kinds: readonly string[], + limit: number, + ): Promise { + return [...this.rows.values()] + .filter((r) => (r.state === 'open' || r.state === 'acted') && kinds.includes(r.kind)) + .sort((a, b) => a.createdAt - b.createdAt) + .slice(0, Math.max(1, limit)); + } + async list(query: NotificationListQuery): Promise> { const key = query.sort ?? 'updated_at'; const at = (r: NotificationRecord) => (key === 'updated_at' ? r.updatedAt : r.createdAt); diff --git a/packages/core/test/helpers/in-memory-repos.ts b/packages/core/test/helpers/in-memory-repos.ts index 34e9a6e..292c7b6 100644 --- a/packages/core/test/helpers/in-memory-repos.ts +++ b/packages/core/test/helpers/in-memory-repos.ts @@ -22,6 +22,11 @@ import type { } from '../../src/ports/persistence/sessions.ts'; import type { Repositories, UnitOfWork } from '../../src/ports/persistence/unit-of-work.ts'; import type { WriteJob, WriteQueue } from '../../src/ports/persistence/write-queue.ts'; +import { + InMemoryNotificationChannelMessageRepository, + InMemoryNotificationChannelRepository, + InMemoryNotificationDeliveryRepository, +} from './in-memory-notification-repos.ts'; import { InMemoryNotificationRepository, InMemorySystemEventRepository, @@ -283,6 +288,16 @@ export class InMemoryRepositories implements Repositories { readonly events = new InMemoryEventLogRepository(); readonly systemEvents = new InMemorySystemEventRepository(); readonly notifications = new InMemoryNotificationRepository(); + readonly notificationDeliveries = new InMemoryNotificationDeliveryRepository((id) => + this.notifications.rows.get(id), + ); + readonly notificationChannelMessages = new InMemoryNotificationChannelMessageRepository( + this.notificationDeliveries, + ); + readonly notificationChannels = new InMemoryNotificationChannelRepository([ + this.notificationDeliveries, + this.notificationChannelMessages, + ]); readonly operatorRequests = notImplemented('operatorRequests'); readonly operatorActions = notImplemented('operatorActions'); readonly principals = notImplemented('principals'); diff --git a/packages/core/test/persistence/conformance-notifications.test.ts b/packages/core/test/persistence/conformance-notifications.test.ts new file mode 100644 index 0000000..6672ac9 --- /dev/null +++ b/packages/core/test/persistence/conformance-notifications.test.ts @@ -0,0 +1,367 @@ +/** @module test/persistence/conformance-notifications.test — one suite over the SQLite notification outbox repositories and their in-memory doubles (channels, deliveries, channel messages, thread lookup, revise), so the doubles the app tests use behave like the real thing (spec 09). */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { + NewNotificationDelivery, + NotificationRecord, +} from '../../src/ports/persistence/records.ts'; +import type { Repositories } from '../../src/ports/persistence/unit-of-work.ts'; +import { channelRecord } from '../helpers/fake-channel.ts'; +import { InMemoryRepositories } from '../helpers/in-memory-repos.ts'; +import { sessionRecord } from './helpers.ts'; +import { openMemory, type TestDb } from './setup.ts'; + +type Repos = Pick< + Repositories, + | 'notifications' + | 'notificationChannels' + | 'notificationDeliveries' + | 'notificationChannelMessages' +>; + +function notification(overrides: Partial = {}): NotificationRecord { + return { + notificationId: 'n-000000000001', + principalId: null, + type: 'attention', + title: 'Attention requested', + body: null, + sessionId: null, + target: null, + sourceEventId: 'a-000000000001', + createdAt: 10, + updatedAt: 10, + count: 1, + groupKey: null, + readAt: null, + dismissedAt: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'attention:a-000000000001', + messageJson: '{"schema":1}', + ...overrides, + }; +} + +function job(overrides: Partial = {}): NewNotificationDelivery { + return { + channelId: 'nc-000000000001', + notificationId: 'n-000000000001', + revision: 1, + op: 'send', + status: 'pending', + reason: null, + nextAttemptAt: 100, + createdAt: 100, + ...overrides, + }; +} + +const adapters: ReadonlyArray< + readonly [string, () => Promise<{ repos: Repos; close(): Promise }>] +> = [ + [ + 'sqlite', + async () => { + const t: TestDb = await openMemory(); + await t.repos.sessions.insert(sessionRecord()); + return { repos: t.repos, close: () => t.close() }; + }, + ], + ['in-memory', async () => ({ repos: new InMemoryRepositories(), close: async () => undefined })], +]; + +for (const [name, open] of adapters) { + describe(`notification outbox repositories (${name})`, () => { + let r: Repos; + let close: () => Promise; + beforeEach(async () => { + const opened = await open(); + r = opened.repos; + close = opened.close; + await r.notifications.insert(notification()); + await r.notificationChannels.upsert(channelRecord()); + }); + afterEach(async () => { + await close(); + }); + + it('finds the newest row of a thread and revises it without touching the inbox fields', async () => { + await r.notifications.insert( + notification({ notificationId: 'n-000000000002', createdAt: 20, updatedAt: 20 }), + ); + expect( + (await r.notifications.findLatestByThread(null, 'attention:a-000000000001')) + ?.notificationId, + ).toBe('n-000000000002'); + expect( + await r.notifications.findLatestByThread('someone', 'attention:a-000000000001'), + ).toBeNull(); + await r.notifications.markRead('n-000000000001', 30); + const revised = await r.notifications.revise('n-000000000001', { + state: 'resolved', + severity: 'warn', + revision: 2, + messageJson: '{"schema":1,"revision":2}', + }); + expect(revised).toMatchObject({ + state: 'resolved', + revision: 2, + title: 'Attention requested', + updatedAt: 10, + readAt: 30, + messageJson: '{"schema":1,"revision":2}', + }); + const kept = await r.notifications.revise('n-000000000001', { + state: 'final', + severity: 'info', + revision: 3, + messageJson: null, + }); + expect(kept?.messageJson).toBe('{"schema":1,"revision":2}'); + expect( + await r.notifications.revise('n-unknown00000', { + state: 'final', + severity: 'info', + revision: 2, + messageJson: null, + }), + ).toBeNull(); + }); + + it('lists unsettled request notifications, oldest first', async () => { + await r.notifications.insert( + notification({ notificationId: 'n-000000000002', createdAt: 5, state: 'acted' }), + ); + await r.notifications.insert( + notification({ notificationId: 'n-000000000003', createdAt: 1, state: 'resolved' }), + ); + await r.notifications.insert( + notification({ notificationId: 'n-000000000004', kind: 'tool.errors', createdAt: 2 }), + ); + expect( + (await r.notifications.listUnsettled(['attention.requested'], 10)).map( + (n) => n.notificationId, + ), + ).toEqual(['n-000000000002', 'n-000000000001']); + expect(await r.notifications.listUnsettled([], 10)).toEqual([]); + }); + + it('channels: upsert keeps status and counters, breaker counters, status, name clash', async () => { + expect(await r.notificationChannels.recordFailure('nc-000000000001', 5, 'down')).toBe(1); + expect(await r.notificationChannels.recordFailure('nc-000000000001', 6, 'down again')).toBe( + 2, + ); + await r.notificationChannels.upsert( + channelRecord({ rules: { min_severity: 'error' }, updatedAt: 7 }), + ); + expect(await r.notificationChannels.get('nc-000000000001')).toMatchObject({ + failureCount: 2, + lastError: 'down again', + lastFailureAt: 6, + rules: { min_severity: 'error' }, + updatedAt: 7, + }); + await r.notificationChannels.recordSuccess('nc-000000000001', 8); + expect(await r.notificationChannels.get('nc-000000000001')).toMatchObject({ + failureCount: 0, + lastOkAt: 8, + }); + await r.notificationChannels.recordFailure('nc-000000000001', 9, 'x'); + expect(await r.notificationChannels.setStatus('nc-000000000001', 'broken', 10)).toBe(true); + expect((await r.notificationChannels.get('nc-000000000001'))?.failureCount).toBe(1); + await r.notificationChannels.setStatus('nc-000000000001', 'active', 11); + expect(await r.notificationChannels.get('nc-000000000001')).toMatchObject({ + status: 'active', + failureCount: 0, + }); + expect((await r.notificationChannels.getByName('phone'))?.channelId).toBe('nc-000000000001'); + expect(await r.notificationChannels.recordFailure('nc-unknown', 1, 'x')).toBe(0); + expect(await r.notificationChannels.setStatus('nc-unknown', 'paused', 1)).toBe(false); + await expect( + r.notificationChannels.upsert(channelRecord({ channelId: 'nc-other', name: 'phone' })), + ).rejects.toThrow(); + }); + + it('deliveries: idempotent enqueue, due order, claim once, finish, reschedule, annotate', async () => { + expect( + await r.notificationDeliveries.enqueue([ + job(), + job(), + job({ revision: 2, op: 'edit', nextAttemptAt: 50 }), + ]), + ).toBe(2); + const due = await r.notificationDeliveries.due(100, 10); + expect(due.map((d) => [d.op, d.revision])).toEqual([ + ['edit', 2], + ['send', 1], + ]); + expect(await r.notificationDeliveries.due(49, 10)).toEqual([]); + const send = due[1]; + if (send === undefined) throw new Error('no job'); + expect(await r.notificationDeliveries.claim(send.seq, 101)).toBe(true); + expect(await r.notificationDeliveries.claim(send.seq, 101)).toBe(false); + expect(await r.notificationDeliveries.get(send.seq)).toMatchObject({ + status: 'sending', + attempts: 1, + updatedAt: 101, + }); + await r.notificationDeliveries.finish(send.seq, { + status: 'retrying', + reason: 'unavailable', + lastError: 'unavailable: down', + durationMs: 3, + nextAttemptAt: 200, + updatedAt: 104, + }); + expect(await r.notificationDeliveries.get(send.seq)).toMatchObject({ + status: 'retrying', + nextAttemptAt: 200, + lastError: 'unavailable: down', + durationMs: 3, + }); + await r.notificationDeliveries.reschedule(send.seq, 300, 105); + await r.notificationDeliveries.annotate(send.seq, 'backlog:3', 106); + expect(await r.notificationDeliveries.get(send.seq)).toMatchObject({ + nextAttemptAt: 300, + reason: 'backlog:3', + }); + await r.notificationDeliveries.finish(send.seq, { + status: 'sent', + messageRef: { message_id: 7 }, + updatedAt: 310, + }); + expect(await r.notificationDeliveries.get(send.seq)).toMatchObject({ + status: 'sent', + messageRef: { message_id: 7 }, + nextAttemptAt: null, + }); + // A terminal job is not rescheduled or annotated. + await r.notificationDeliveries.reschedule(send.seq, 999, 311); + expect((await r.notificationDeliveries.get(send.seq))?.nextAttemptAt).toBeNull(); + }); + + it('deliveries: supersede, suppress a channel, recover sending, count, list, info backlog', async () => { + await r.notificationDeliveries.enqueue([ + job(), + job({ revision: 2, op: 'edit' }), + job({ revision: 3, op: 'edit' }), + job({ revision: 3, op: 'delete' }), + ]); + const [send, edit2, edit3] = await r.notificationDeliveries + .list({ limit: 10 }) + .then((rows) => [...rows].reverse()); + if (send === undefined || edit2 === undefined || edit3 === undefined) throw new Error('rows'); + expect( + await r.notificationDeliveries.supersede( + 'nc-000000000001', + 'n-000000000001', + 2, + 5, + 'covered', + send.seq, + ), + ).toBe(1); + expect((await r.notificationDeliveries.get(edit2.seq))?.status).toBe('superseded'); + expect((await r.notificationDeliveries.get(send.seq))?.status).toBe('pending'); + await r.notificationDeliveries.claim(send.seq, 6); + expect(await r.notificationDeliveries.recoverSending(7)).toBe(1); + expect(await r.notificationDeliveries.get(send.seq)).toMatchObject({ + status: 'retrying', + nextAttemptAt: 7, + }); + expect(await r.notificationDeliveries.count(['pending', 'retrying'])).toBe(3); + expect( + await r.notificationDeliveries.suppressChannel('nc-000000000001', 'channel_paused', 8), + ).toBe(3); + expect(await r.notificationDeliveries.count(['suppressed'])).toBe(3); + expect( + (await r.notificationDeliveries.list({ statuses: ['superseded'] })).map((d) => d.seq), + ).toEqual([edit2.seq]); + expect( + (await r.notificationDeliveries.list({ beforeSeq: edit3.seq })).map((d) => d.seq), + ).toEqual([edit2.seq, send.seq]); + await r.notifications.insert( + notification({ notificationId: 'n-info00000001', severity: 'info', thread: 't' }), + ); + await r.notificationDeliveries.enqueue([job({ notificationId: 'n-info00000001' })]); + expect( + (await r.notificationDeliveries.pendingInfoSends('nc-000000000001')).map( + (d) => d.notificationId, + ), + ).toEqual(['n-info00000001']); + }); + + it('channel messages: upsert, first of a thread, TTL due once, expiry and delete marks', async () => { + const message = { + channelId: 'nc-000000000001', + notificationId: 'n-000000000001', + thread: 'attention:a-000000000001', + messageRef: { chat_id: 1, message_id: 2 }, + lastRevision: 1, + sentAt: 10, + updatedAt: 10, + expiresAt: 50, + deletedAt: null, + }; + await r.notificationChannelMessages.upsert(message); + await r.notifications.insert(notification({ notificationId: 'n-000000000002' })); + await r.notificationChannelMessages.upsert({ + ...message, + notificationId: 'n-000000000002', + sentAt: 20, + expiresAt: null, + }); + expect( + ( + await r.notificationChannelMessages.firstInThread( + 'nc-000000000001', + 'attention:a-000000000001', + ) + )?.notificationId, + ).toBe('n-000000000001'); + expect(await r.notificationChannelMessages.dueForDelete(49, 10)).toEqual([]); + expect( + (await r.notificationChannelMessages.dueForDelete(50, 10)).map((m) => m.notificationId), + ).toEqual(['n-000000000001']); + await r.notificationDeliveries.enqueue([job({ op: 'delete', revision: 1 })]); + expect(await r.notificationChannelMessages.dueForDelete(50, 10)).toEqual([]); + await r.notificationChannelMessages.setExpiry('nc-000000000001', 'n-000000000002', 60); + expect( + (await r.notificationChannelMessages.get('nc-000000000001', 'n-000000000002'))?.expiresAt, + ).toBe(60); + await r.notificationChannelMessages.markDeleted('nc-000000000001', 'n-000000000002', 61); + expect( + await r.notificationChannelMessages.get('nc-000000000001', 'n-000000000002'), + ).toMatchObject({ + deletedAt: 61, + updatedAt: 61, + }); + expect(await r.notificationChannelMessages.dueForDelete(100, 10)).toEqual([]); + }); + + it('removing a channel removes its deliveries and messages', async () => { + await r.notificationDeliveries.enqueue([job()]); + await r.notificationChannelMessages.upsert({ + channelId: 'nc-000000000001', + notificationId: 'n-000000000001', + thread: 't', + messageRef: { id: 1 }, + lastRevision: 1, + sentAt: 1, + updatedAt: 1, + expiresAt: null, + deletedAt: null, + }); + expect(await r.notificationChannels.remove('nc-000000000001')).toBe(true); + expect(await r.notificationDeliveries.list({})).toEqual([]); + expect( + await r.notificationChannelMessages.get('nc-000000000001', 'n-000000000001'), + ).toBeNull(); + expect(await r.notificationChannels.remove('nc-000000000001')).toBe(false); + }); + }); +} diff --git a/packages/core/test/persistence/conformance-operations.test.ts b/packages/core/test/persistence/conformance-operations.test.ts index 388c746..20f29ba 100644 --- a/packages/core/test/persistence/conformance-operations.test.ts +++ b/packages/core/test/persistence/conformance-operations.test.ts @@ -211,6 +211,13 @@ describe('NotificationRepository and PreferenceRepository', () => { groupKey: null, readAt: null, dismissedAt: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'notification:n-1', + messageJson: null, }; it('lists inbox, counts unread, marks read and dismisses', async () => { @@ -270,6 +277,8 @@ describe('NotificationRepository and PreferenceRepository', () => { sourceEventId: 'e-2', count: 2, updatedAt: 9, + revision: 2, + messageJson: null, }; expect(await t.repos.notifications.updateGroup('n-1', patch)).toMatchObject({ ...patch, @@ -417,6 +426,7 @@ describe('SchemaMigrationRepository', () => { [2, 'notification-groups'], [3, 'harness-identity'], [4, 'session-browser'], + [5, 'notification-outbox'], ]); expect(await t.repos.schemaMigrations.currentVersion()).toBe(SCHEMA_VERSION); }); diff --git a/packages/core/test/persistence/fixtures/generate-fixture.ts b/packages/core/test/persistence/fixtures/generate-fixture.ts index 106a3a4..83a1136 100644 --- a/packages/core/test/persistence/fixtures/generate-fixture.ts +++ b/packages/core/test/persistence/fixtures/generate-fixture.ts @@ -214,6 +214,112 @@ export async function seedFixture(uow: SqliteUnitOfWork): Promise { groupKey: null, readAt: null, dismissedAt: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'notification:n-1', + messageJson: null, + }); + // Schema v5: a notification with its contract message, one channel, one delivery, one message. + await r.notifications.insert({ + notificationId: 'n-fixture00002', + principalId: null, + type: 'attention', + title: 'Attention requested', + body: 'captcha · takeover — agent blocked, lease frozen', + sessionId: 'shop-a1b2c3d4', + target: '/sessions/shop-a1b2c3d4?live=1&takeover=1', + sourceEventId: 'a-fixture00001', + createdAt: at + 3_000, + updatedAt: at + 3_000, + count: 1, + groupKey: null, + readAt: null, + dismissedAt: null, + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + revision: 1, + thread: 'attention:a-fixture00001', + messageJson: JSON.stringify({ + schema: 1, + id: 'n-fixture00002', + revision: 1, + thread: 'attention:a-fixture00001', + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + alert: true, + at: { created: at + 3_000, updated: at + 3_000 }, + title: 'Attention requested', + summary: 'captcha · takeover — agent blocked, lease frozen', + blocks: [ + { + type: 'fields', + items: [ + { + label: 'Session', + value: [{ type: 'link', text: 'shop', path: '/sessions/shop-a1b2c3d4' }], + }, + ], + }, + ], + actions: [ + { + kind: 'open', + id: 'take-over', + label: 'Take over', + style: 'primary', + path: '/sessions/shop-a1b2c3d4?live=1&takeover=1', + }, + ], + entities: { session_id: 'shop-a1b2c3d4', session_slug: 'shop', owner: 'local' }, + privacy: { level: 'full', has_image: false }, + }), + }); + await r.notificationChannels.upsert({ + channelId: 'nc-fixture00001', + name: 'phone', + kind: 'telegram', + mode: null, + source: 'db', + status: 'active', + target: { chat: '123456' }, + secretRefs: { token: 'BH_TG_TOKEN' }, + rules: { categories: ['needs-you', 'problems'], content: 'titles' }, + failureCount: 0, + lastError: null, + lastOkAt: at + 3_100, + lastFailureAt: null, + createdAt: at, + updatedAt: at, + }); + await r.notificationDeliveries.enqueue([ + { + channelId: 'nc-fixture00001', + notificationId: 'n-fixture00002', + revision: 1, + op: 'send', + status: 'pending', + reason: null, + nextAttemptAt: at + 3_000, + createdAt: at + 3_000, + }, + ]); + await r.notificationChannelMessages.upsert({ + channelId: 'nc-fixture00001', + notificationId: 'n-fixture00002', + thread: 'attention:a-fixture00001', + messageRef: { chat_id: 123456, message_id: 42 }, + lastRevision: 1, + sentAt: at + 3_100, + updatedAt: at + 3_100, + expiresAt: null, + deletedAt: null, }); await r.preferences.set('local', 'theme', 'dark', at); await r.systemEvents.record({ diff --git a/packages/core/test/persistence/fixtures/schema-v5.json b/packages/core/test/persistence/fixtures/schema-v5.json new file mode 100644 index 0000000..a213b65 --- /dev/null +++ b/packages/core/test/persistence/fixtures/schema-v5.json @@ -0,0 +1,4920 @@ +{ + "tables": [ + { + "name": "artifact_outbox", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "outbox_id", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "path", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "enqueued_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": "0", + "hidden": 0, + "name": "attempts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "auth_events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_auth_events_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "auth_sessions", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "auth_session_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "revoked_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_auth_sessions_principal", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "auth_session_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "blocked_requests", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "domain", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "pattern", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "source", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_blocked_domain", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 4, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "domain", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_pattern", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "pattern", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_blocked_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "credentials", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "credential_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "public_prefix", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "secret_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "display", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "scopes_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "last_used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "revoked_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_credentials_prefix", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "public_prefix", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "credential_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_credentials_principal", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "credential_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "actor_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "actor_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "payload_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_events_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_events_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_events_type_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "type", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "grants", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "grant_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "token_hash", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "auth_session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "route", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "resource_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "used_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "auth_session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "auth_sessions", + "to": "auth_session_id" + } + ] + }, + { + "name": "idempotency_keys", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "route", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "response_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "logs", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "level", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "module", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "msg", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "span_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "request_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "principal", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "fields_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_logs_session", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_logs_trace", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "trace_id", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_logs_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "mcp_connections", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "transport", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "mcp_session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "client_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "client_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "protocol_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "capabilities_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "agent_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "model", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "harness", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "ip", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "user_agent", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "connected_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "closed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "client_title", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "workspace", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "harness_source", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "model_source", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "harness_conflicts_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 21, + "dflt_value": null, + "hidden": 0, + "name": "meta_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_mcp_connections_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 14, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "last_seen_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "connection_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_mcp_connections_seen", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 14, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "last_seen_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "connection_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "meta", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "value", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "notification_channel_messages", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "thread", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "message_ref_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "last_revision", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "sent_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "expires_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "deleted_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_notification_channel_messages_expiry", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "expires_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "channel_id", + "seqno": 1 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_channel_messages_thread", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "thread", + "seqno": 1 + }, + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "sent_at", + "seqno": 2 + }, + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 3 + } + ] + } + ], + "foreignKeys": [ + { + "from": "notification_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notifications", + "to": "notification_id" + }, + { + "from": "channel_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notification_channels", + "to": "channel_id" + } + ] + }, + { + "name": "notification_channels", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "mode", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "'db'", + "hidden": 0, + "name": "source", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": "'active'", + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'{}'", + "hidden": 0, + "name": "target_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "'{}'", + "hidden": 0, + "name": "secret_refs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": "'{}'", + "hidden": 0, + "name": "rules_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "failure_count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "last_ok_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "last_failure_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "notification_deliveries", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 0, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "channel_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "revision", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "op", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "0", + "hidden": 0, + "name": "attempts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "next_attempt_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "last_error", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "message_ref_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_notification_deliveries_channel", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_deliveries_due", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "next_attempt_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_deliveries_idem", + "unique": 1, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "channel_id", + "seqno": 0 + }, + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "notification_id", + "seqno": 1 + }, + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "revision", + "seqno": 2 + }, + { + "cid": 4, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "op", + "seqno": 3 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 4 + } + ] + }, + { + "name": "idx_notification_deliveries_notification", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "notification_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "seq", + "seqno": 1 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 2 + } + ] + }, + { + "name": "idx_notification_deliveries_sending", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + }, + { + "name": "idx_notification_deliveries_updated", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "notification_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notifications", + "to": "notification_id" + }, + { + "from": "channel_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "notification_channels", + "to": "channel_id" + } + ] + }, + { + "name": "notifications", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "notification_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "body", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "target", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "source_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "read_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "dismissed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "0", + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": "1", + "hidden": 0, + "name": "count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "group_key", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "category", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "severity", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "state", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": "1", + "hidden": 0, + "name": "revision", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "thread", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "message_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_notifications_group", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 13, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "group_key", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_inbox", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_thread", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 19, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "thread", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_notifications_updated", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "principal_id", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "updated_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "notification_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "operator_actions", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "action", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "resource_kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "resource_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "occurred_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_operator_actions_time", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "occurred_at", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "operator_requests", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "request_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "owner", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "mode", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "entry_name", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "page_url", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "options_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "idempotency_key", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "status", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "message", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "resolved_by", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "resolution_reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "deadline_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "resolved_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_operator_requests_history", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "kind", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_idem", + "unique": 1, + "partial": 1, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 11, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "idempotency_key", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "kind", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_operator_requests_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 16, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "request_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "pages", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tab_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "domain", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "category", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_pages_category_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 6, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "category", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_domain", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "domain", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_session_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_pages_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "preferences", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "key", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "value_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "principal_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "principals", + "to": "principal_id" + } + ] + }, + { + "name": "principals", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "display", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "0", + "hidden": 0, + "name": "must_change_password", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "disabled_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "resource_samples", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 2, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "cpu_pct", + "notnull": 0, + "pk": 0, + "type": "REAL" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "rss_bytes", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "host_free_bytes", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "schema_migrations", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "applied_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "app_version", + "notnull": 1, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [], + "foreignKeys": [] + }, + { + "name": "screenshots", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "path", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "kind", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "content_type", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "width", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "height", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "size_bytes", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_screenshots_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 8, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + }, + { + "from": "event_id", + "id": 1, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "tool_calls", + "to": "event_id" + } + ] + }, + { + "name": "sessions", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "slug", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "owner", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": "'chromium'", + "hidden": 0, + "name": "engine", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "channel", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "headless", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "0", + "hidden": 0, + "name": "incognito", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "persistence_mode", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": "0", + "hidden": 0, + "name": "disable_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "1", + "hidden": 0, + "name": "vault_enabled", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "stealth", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "fingerprint", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "humanize", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "identity_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 16, + "dflt_value": null, + "hidden": 0, + "name": "proxy_label", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 17, + "dflt_value": null, + "hidden": 0, + "name": "state", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 18, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 19, + "dflt_value": null, + "hidden": 0, + "name": "launched_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 20, + "dflt_value": null, + "hidden": 0, + "name": "last_activity_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 21, + "dflt_value": null, + "hidden": 0, + "name": "lease_expires_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 22, + "dflt_value": null, + "hidden": 0, + "name": "lease_paused_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 23, + "dflt_value": null, + "hidden": 0, + "name": "closed_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 24, + "dflt_value": null, + "hidden": 0, + "name": "closed_reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 25, + "dflt_value": null, + "hidden": 0, + "name": "archived_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 26, + "dflt_value": null, + "hidden": 0, + "name": "last_url", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 27, + "dflt_value": null, + "hidden": 0, + "name": "launch_ms", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 28, + "dflt_value": null, + "hidden": 0, + "name": "config_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 29, + "dflt_value": null, + "hidden": 0, + "name": "harness", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 30, + "dflt_value": null, + "hidden": 0, + "name": "sandboxed", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 31, + "dflt_value": null, + "hidden": 0, + "name": "browser_version", + "notnull": 0, + "pk": 0, + "type": "TEXT" + } + ], + "indexes": [ + { + "name": "idx_sessions_archived", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 25, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "archived_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_closed", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 23, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "closed_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_created", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_harness", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 29, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "harness", + "seqno": 0 + }, + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_sessions_lease", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 21, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "lease_expires_at", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 17, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "state", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 1 + } + ] + }, + { + "name": "idx_sessions_owner", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "owner", + "seqno": 0 + }, + { + "cid": 18, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "created_at", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "session_id", + "seqno": 2 + } + ] + } + ], + "foreignKeys": [ + { + "from": "connection_id", + "id": 0, + "match": "NONE", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "seq": 0, + "table": "mcp_connections", + "to": "connection_id" + } + ] + }, + { + "name": "system_events", + "withoutRowid": false, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 1, + "type": "INTEGER" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "code", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "severity", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "message", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "first_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "last_seen_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "1", + "hidden": 0, + "name": "count", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "resolved_at", + "notnull": 0, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_system_events_open", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 2, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "code", + "seqno": 0 + }, + { + "cid": -1, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": null, + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "tool_calls", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "connection_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "tool", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "tab_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "args_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "ok", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "error_code", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "error_message", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "result_text", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "result_size_bytes", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "duration_ms", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": null, + "hidden": 0, + "name": "trace_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "span_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "seq", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_tool_calls_error", + "unique": 0, + "partial": 1, + "columns": [ + { + "cid": 7, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "error_code", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_session_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_tool_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "tool", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_tool_calls_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "vault_access", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "event_id", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "session_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tool_event_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "entry_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": null, + "hidden": 0, + "name": "handle", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "result", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": null, + "hidden": 0, + "name": "reason", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": null, + "hidden": 0, + "name": "evaluate_enabled", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": null, + "hidden": 0, + "name": "page_url", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": null, + "hidden": 0, + "name": "origin_check", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 10, + "dflt_value": null, + "hidden": 0, + "name": "principal_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "details_json", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "ts", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_vault_access_entry", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 3, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "entry_name", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_vault_access_session", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 1, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "session_id", + "seqno": 0 + }, + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 1 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "event_id", + "seqno": 2 + } + ] + }, + { + "name": "idx_vault_access_ts", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 12, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "ts", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "event_id", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [ + { + "from": "session_id", + "id": 0, + "match": "NONE", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "seq": 0, + "table": "sessions", + "to": "session_id" + } + ] + }, + { + "name": "vault_bindings", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "handle", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "title", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "item_name", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "''", + "hidden": 0, + "name": "item_id", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 5, + "dflt_value": null, + "hidden": 0, + "name": "group_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "allowed_origins_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_principals_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 8, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_session_slugs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "allow_all_sessions", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": "0", + "hidden": 0, + "name": "redact_username", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": "0", + "hidden": 0, + "name": "require_no_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": "0", + "hidden": 0, + "name": "dashboard_confirm", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 13, + "dflt_value": "1", + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 14, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 15, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [ + { + "name": "idx_vault_bindings_group", + "unique": 0, + "partial": 0, + "columns": [ + { + "cid": 5, + "coll": "BINARY", + "desc": 0, + "key": 1, + "name": "group_id", + "seqno": 0 + }, + { + "cid": 0, + "coll": "BINARY", + "desc": 0, + "key": 0, + "name": "handle", + "seqno": 1 + } + ] + } + ], + "foreignKeys": [] + }, + { + "name": "vault_group_policies", + "withoutRowid": true, + "columns": [ + { + "cid": 0, + "dflt_value": null, + "hidden": 0, + "name": "group_key", + "notnull": 1, + "pk": 1, + "type": "TEXT" + }, + { + "cid": 1, + "dflt_value": null, + "hidden": 0, + "name": "group_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 2, + "dflt_value": null, + "hidden": 0, + "name": "tenant_id", + "notnull": 0, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 3, + "dflt_value": null, + "hidden": 0, + "name": "access_mode", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 4, + "dflt_value": "0", + "hidden": 0, + "name": "allow_all_sessions", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 5, + "dflt_value": "'[]'", + "hidden": 0, + "name": "session_slug_globs_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 6, + "dflt_value": "'[]'", + "hidden": 0, + "name": "authorized_principals_json", + "notnull": 1, + "pk": 0, + "type": "TEXT" + }, + { + "cid": 7, + "dflt_value": "0", + "hidden": 0, + "name": "dashboard_confirm", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 8, + "dflt_value": "0", + "hidden": 0, + "name": "require_no_evaluate", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 9, + "dflt_value": "0", + "hidden": 0, + "name": "redact_username", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 10, + "dflt_value": "1", + "hidden": 0, + "name": "version", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 11, + "dflt_value": null, + "hidden": 0, + "name": "created_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + }, + { + "cid": 12, + "dflt_value": null, + "hidden": 0, + "name": "updated_at", + "notnull": 1, + "pk": 0, + "type": "INTEGER" + } + ], + "indexes": [], + "foreignKeys": [] + } + ], + "triggers": [], + "views": [] +} diff --git a/packages/core/test/persistence/fixtures/v5.db b/packages/core/test/persistence/fixtures/v5.db new file mode 100644 index 0000000..491db8c Binary files /dev/null and b/packages/core/test/persistence/fixtures/v5.db differ diff --git a/packages/core/test/persistence/notification-outbox-migration.test.ts b/packages/core/test/persistence/notification-outbox-migration.test.ts new file mode 100644 index 0000000..4464279 --- /dev/null +++ b/packages/core/test/persistence/notification-outbox-migration.test.ts @@ -0,0 +1,293 @@ +/** @module test/persistence/notification-outbox-migration.test — schema v5 (`0005-notification-outbox`) over a v4 database: the backfill from facts only, rows an older reader writes, the CHECKs, the delivery idempotency key, the cascades, and `purge` inventorying an older database (03 §7, §9; D-32, D-34). */ + +import { Database } from 'bun:sqlite'; +import { describe, expect, it } from 'bun:test'; +import { copyFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SqliteMaintenanceService } from '../../src/infra/persistence/maintenance.ts'; +import { SCHEMA_VERSION } from '../../src/infra/persistence/migrations/index.ts'; +import { openDatabase } from '../../src/infra/persistence/open.ts'; +import { SqliteUnitOfWork } from '../../src/infra/persistence/unit-of-work.ts'; +import { FakeClock, FakeLogger, tempDir } from './helpers.ts'; + +const FIXTURES = join(import.meta.dir, 'fixtures'); + +/** Copies a fixture, lets `prepare` write to it at its own version, then opens it (migrating). */ +async function openCopy(fixture: string, prepare?: (raw: Database) => void) { + const dir = tempDir(); + const path = join(dir.path, 'browserhive.db'); + copyFileSync(join(FIXTURES, fixture), path); + if (prepare !== undefined) { + const raw = new Database(path); + try { + prepare(raw); + } finally { + raw.close(); + } + } + const handle = await openDatabase({ + path, + dataDir: dir.path, + appVersion: 'test', + clock: new FakeClock(), + logger: new FakeLogger(), + }); + return { dir, path, handle, uow: new SqliteUnitOfWork(handle.db) }; +} + +type Row = Record; + +/** Inserts a v4-shaped notification row. */ +function legacy(raw: Database, row: Row): void { + const full: Row = { + principal_id: null, + body: null, + session_id: null, + target: null, + source_event_id: null, + created_at: 1, + updated_at: 1, + count: 1, + group_key: null, + read_at: null, + dismissed_at: null, + ...row, + }; + const cols = Object.keys(full); + raw + .query( + `INSERT INTO notifications (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`, + ) + .run(...(Object.values(full) as (string | number | null)[])); +} + +describe('migration 0005-notification-outbox', () => { + it('backfills every existing notification from facts only and leaves message_json NULL', async () => { + const { dir, handle } = await openCopy('v4.db', (raw) => { + // v4 rows of every producer, and requests/degradations in every state. + raw.exec( + "INSERT INTO operator_requests (request_id, kind, session_id, owner, reason, status, created_at) VALUES ('a-resolved0001','attention','shop-a1b2c3d4','local','x','resolved',1), ('a-timeout00001','attention','shop-a1b2c3d4','local','x','timeout',1), ('a-cancelled001','vault_confirm','shop-a1b2c3d4','local','x','cancelled',1)", + ); + raw.exec( + "INSERT INTO system_events (event_id, code, severity, message, first_seen_at, last_seen_at, resolved_at) VALUES ('e-sys-open', 'X', 'error', 'm', 1, 1, NULL), ('e-sys-done', 'Y', 'error', 'm', 1, 1, 5)", + ); + legacy(raw, { + notification_id: 'n-att-resolved', + type: 'attention', + title: 'Attention requested', + source_event_id: 'a-resolved0001', + }); + legacy(raw, { + notification_id: 'n-att-timeout0', + type: 'attention', + title: 'Attention requested', + source_event_id: 'a-timeout00001', + }); + legacy(raw, { + notification_id: 'n-att-orphan00', + type: 'attention', + title: 'Attention requested', + source_event_id: 'a-gone00000001', + }); + legacy(raw, { + notification_id: 'n-vault-cancel', + type: 'vault', + title: 'Vault fill awaiting confirm', + source_event_id: 'a-cancelled001', + }); + legacy(raw, { + notification_id: 'n-crash0000001', + type: 'error', + title: 'Session crashed', + session_id: 'shop-a1b2c3d4', + }); + legacy(raw, { + notification_id: 'n-toolerr00001', + type: 'error', + title: 'shop · 3 tool errors', + group_key: 'tool-errors:shop-a1b2c3d4', + }); + legacy(raw, { + notification_id: 'n-toolerrv1001', + type: 'error', + title: 'shop · 1 tool error', + session_id: 'shop-a1b2c3d4', + }); + legacy(raw, { + notification_id: 'n-reaped000001', + type: 'lifecycle', + title: 'Session reaped (lease expired)', + session_id: 'shop-a1b2c3d4', + }); + legacy(raw, { + notification_id: 'n-sys-open0001', + type: 'system', + title: 'm', + source_event_id: 'e-sys-open', + }); + legacy(raw, { + notification_id: 'n-sys-done0001', + type: 'system', + title: 'm', + source_event_id: 'e-sys-done', + }); + }); + try { + expect(handle.schemaVersion).toBe(SCHEMA_VERSION); + const rows = handle.raw + .query( + 'SELECT notification_id, kind, category, severity, state, revision, thread, message_json FROM notifications ORDER BY notification_id', + ) + .all(); + const byId = Object.fromEntries(rows.map((r) => [r['notification_id'], r])); + const expectRow = (id: string, want: Row) => + expect(byId[id]).toMatchObject({ revision: 1, message_json: null, ...want }); + // The fixture's own row: a pending request. + expectRow('n-1', { + kind: 'attention.requested', + category: 'needs-you', + severity: 'warn', + state: 'open', + thread: 'attention:a-fixture00001', + }); + expectRow('n-att-resolved', { state: 'resolved', thread: 'attention:a-resolved0001' }); + expectRow('n-att-timeout0', { state: 'expired' }); + expectRow('n-att-orphan00', { state: 'final' }); + expectRow('n-vault-cancel', { + kind: 'vault.confirm', + category: 'needs-you', + state: 'final', + thread: 'vault:a-cancelled001', + }); + expectRow('n-crash0000001', { + kind: 'session.crashed', + category: 'problems', + severity: 'error', + state: 'final', + thread: 'session:shop-a1b2c3d4', + }); + expectRow('n-toolerr00001', { + kind: 'tool.errors', + category: 'problems', + severity: 'warn', + state: 'open', + thread: 'tool-errors:shop-a1b2c3d4', + }); + expectRow('n-toolerrv1001', { kind: 'tool.errors', thread: 'tool-errors:shop-a1b2c3d4' }); + expectRow('n-reaped000001', { + kind: 'session.reaped', + category: 'problems', + severity: 'warn', + state: 'final', + }); + expectRow('n-sys-open0001', { + kind: 'system.degraded', + category: 'system', + severity: 'error', + state: 'open', + thread: 'system:e-sys-open', + }); + expectRow('n-sys-done0001', { state: 'resolved' }); + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('reads rows an older reader inserted (NULL classification) as derived from type', async () => { + const { dir, handle, uow } = await openCopy('v4.db'); + try { + legacy(handle.raw, { + notification_id: 'n-oldreader001', + type: 'error', + title: 'Session crashed', + session_id: 'shop-a1b2c3d4', + }); + const row = await uow.repos.notifications.get('n-oldreader001'); + expect(row).toMatchObject({ + kind: 'session.crashed', + category: 'problems', + severity: 'error', + state: 'final', + revision: 1, + thread: 'session:shop-a1b2c3d4', + messageJson: null, + }); + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('checks the enums, keeps delivery jobs idempotent and cascades', async () => { + const { dir, handle, uow } = await openCopy('v5.db'); + try { + const raw = handle.raw; + expect(() => + raw.exec("UPDATE notifications SET severity = 'loud' WHERE notification_id = 'n-1'"), + ).toThrow(); + expect(() => + raw.exec("UPDATE notifications SET state = 'done' WHERE notification_id = 'n-1'"), + ).toThrow(); + expect(() => raw.exec("UPDATE notification_channels SET status = 'off'")).toThrow(); + expect(() => raw.exec("UPDATE notification_deliveries SET op = 'post'")).toThrow(); + const job = { + channelId: 'nc-fixture00001', + notificationId: 'n-fixture00002', + revision: 1, + op: 'send' as const, + status: 'pending' as const, + reason: null, + nextAttemptAt: 1, + createdAt: 1, + }; + expect(await uow.repos.notificationDeliveries.enqueue([job])).toBe(0); + expect( + await uow.repos.notificationDeliveries.enqueue([{ ...job, revision: 2, op: 'edit' }]), + ).toBe(1); + raw.exec("DELETE FROM notifications WHERE notification_id = 'n-fixture00002'"); + expect(raw.query('SELECT COUNT(*) AS n FROM notification_deliveries').get()).toEqual({ + n: 0, + }); + expect(raw.query('SELECT COUNT(*) AS n FROM notification_channel_messages').get()).toEqual({ + n: 0, + }); + expect(await uow.repos.notificationChannels.remove('nc-fixture00001')).toBe(true); + } finally { + await handle.close(); + dir.dispose(); + } + }); + + it('purge inventories a database from before v5 without failing on the new tables', async () => { + const dir = tempDir(); + try { + const path = join(dir.path, 'browserhive.db'); + copyFileSync(join(FIXTURES, 'v4.db'), path); + const handle = await openDatabase({ + path, + dataDir: dir.path, + appVersion: 'test', + clock: new FakeClock(), + logger: new FakeLogger(), + readOnly: true, + }); + try { + const maintenance = new SqliteMaintenanceService({ + handle, + clock: new FakeClock(), + logger: new FakeLogger(), + appVersion: 'test', + }); + const inventory = await maintenance.inventory(); + const tables = inventory.tables.map((t) => t.table); + expect(tables).toContain('notifications'); + expect(tables).not.toContain('notification_channels'); + } finally { + await handle.close(); + } + } finally { + dir.dispose(); + } + }); +}); diff --git a/packages/core/test/persistence/notification-outbox.sqlite.test.ts b/packages/core/test/persistence/notification-outbox.sqlite.test.ts new file mode 100644 index 0000000..da34c7d --- /dev/null +++ b/packages/core/test/persistence/notification-outbox.sqlite.test.ts @@ -0,0 +1,102 @@ +/** @module test/persistence/notification-outbox.sqlite.test — the full notification path on real SQLite with a fake platform (D-34, D-35): bus event → row + outbox job in one transaction → send → lifecycle revision → edit → TTL delete; and the breaker without a degradation. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { DomainEvents } from '../../src/app/events/catalog.ts'; +import { ChannelRegistry } from '../../src/app/notifications/channel-registry.ts'; +import { createLocalLinkBuilder } from '../../src/app/notifications/links.ts'; +import { NotificationService } from '../../src/app/notifications/notification-service.ts'; +import { NotificationOutbox } from '../../src/app/notifications/outbox.ts'; +import { attentionCreated, attentionResolved } from '../../src/app/notifications/test-fixtures.ts'; +import { CollectingLogger } from '../helpers/collecting-logger.ts'; +import { channelRecord, FakeChannel } from '../helpers/fake-channel.ts'; +import { FakeIdGenerator } from '../helpers/fake-id-generator.ts'; +import { RecordingEventBus } from '../helpers/recording-event-bus.ts'; +import { sessionRecord } from './helpers.ts'; +import { openMemory, type TestDb } from './setup.ts'; + +let t: TestDb; +beforeEach(async () => { + t = await openMemory(); + await t.repos.sessions.insert(sessionRecord()); +}); +afterEach(async () => { + await t.close(); +}); + +async function wire(rules = {}) { + const bus = new RecordingEventBus(); + const logger = new CollectingLogger(); + const ids = new FakeIdGenerator(); + const fake = new FakeChannel(); + await t.repos.notificationChannels.upsert(channelRecord({ rules })); + const registry = new ChannelRegistry({ + repo: t.repos.notificationChannels, + clock: t.clock, + ids, + logger, + factories: new Map([['fake', () => fake]]), + }); + await registry.load(); + const outbox = new NotificationOutbox({ + uow: t.uow, + repos: t.repos, + registry, + links: createLocalLinkBuilder(() => 'http://127.0.0.1:9876'), + clock: t.clock, + logger, + bus, + }); + const service = new NotificationService({ + repo: t.repos.notifications, + bus, + clock: t.clock, + ids, + logger, + uow: t.uow, + outbox: { plan: (m, now) => outbox.plan(m, now), kick: () => undefined }, + }); + return { bus, fake, outbox, service }; +} + +describe('notification outbox on SQLite', () => { + it('sends, edits on resolution and deletes when the TTL is due', async () => { + const w = await wire({ ttl_ms: { 'needs-you': 3_600_000 } }); + await w.service.produce(attentionCreated('a-000000000001', 'takeover')); + const [job] = await t.repos.notificationDeliveries.list({}); + expect(job).toMatchObject({ op: 'send', status: 'pending', revision: 1 }); + await w.outbox.tick(); + t.clock.advance(5_000); + await w.service.produce(attentionResolved('a-000000000001', 'resolved')); + await w.outbox.tick(); + t.clock.advance(3_600_000); + await w.outbox.tick(); + expect(w.fake.calls.map((c) => c.op)).toEqual(['send', 'edit', 'delete']); + const log = await t.repos.notificationDeliveries.list({}); + expect(log.map((d) => [d.op, d.revision, d.status])).toEqual([ + ['delete', 2, 'sent'], + ['edit', 2, 'sent'], + ['send', 1, 'sent'], + ]); + const row = t.handle.raw + .query<{ last_revision: number; deleted_at: number | null }, []>( + 'SELECT last_revision, deleted_at FROM notification_channel_messages', + ) + .get(); + expect(row?.last_revision).toBe(2); + expect(row?.deleted_at).not.toBeNull(); + }); + + it('opens the breaker without raising a degradation', async () => { + const w = await wire(); + w.fake.script(...Array.from({ length: 5 }, () => new Error('down'))); + await w.service.produce(attentionCreated('a-000000000001', 'takeover')); + for (let i = 0; i < 5; i++) { + await w.outbox.tick(); + t.clock.advance(20 * 60_000); + } + expect((await t.repos.notificationChannels.get('nc-000000000001'))?.status).toBe('broken'); + expect(w.bus.names()).not.toContain('system.degraded'); + const events = t.handle.raw.query('SELECT COUNT(*) AS n FROM system_events').get(); + expect(events).toEqual({ n: 0 }); + }); +}); diff --git a/packages/core/test/persistence/retention.test.ts b/packages/core/test/persistence/retention.test.ts index 90cb244..b8cc7d1 100644 --- a/packages/core/test/persistence/retention.test.ts +++ b/packages/core/test/persistence/retention.test.ts @@ -147,6 +147,13 @@ async function seed(): Promise { groupKey: null, readAt: NOW - 31 * DAY, dismissedAt: null, + kind: 'system.degraded', + category: 'system', + severity: 'error', + state: 'final', + revision: 1, + thread: 'notification:n-read', + messageJson: null, }); await r.notifications.insert({ notificationId: 'n-stale', @@ -163,6 +170,13 @@ async function seed(): Promise { groupKey: null, readAt: null, dismissedAt: null, + kind: 'system.degraded', + category: 'system', + severity: 'error', + state: 'final', + revision: 1, + thread: 'notification:n-stale', + messageJson: null, }); await r.notifications.insert({ notificationId: 'n-keep', @@ -179,6 +193,13 @@ async function seed(): Promise { groupKey: null, readAt: null, dismissedAt: null, + kind: 'system.degraded', + category: 'system', + severity: 'error', + state: 'final', + revision: 1, + thread: 'notification:n-keep', + messageJson: null, }); } @@ -284,6 +305,96 @@ describe('retentionSweep', () => { expect(left.map((row) => row.connection_id)).toEqual(['c-fresh', 'c-open', 'c-referenced']); }); + it('prunes settled outbox history after 30 days and keeps channels, pending work and pending TTLs', async () => { + const r = t.repos; + const old = NOW - 31 * DAY; + await r.notifications.insert({ + notificationId: 'n-outbox00001', + principalId: null, + type: 'system', + title: 't', + body: null, + sessionId: null, + target: null, + sourceEventId: null, + createdAt: NOW - DAY, + updatedAt: NOW - DAY, + count: 1, + groupKey: null, + readAt: null, + dismissedAt: null, + kind: 'system.degraded', + category: 'system', + severity: 'error', + state: 'open', + revision: 1, + thread: 'system:x', + messageJson: null, + }); + const channel = { + channelId: 'nc-1', + name: 'phone', + kind: 'telegram', + mode: null, + source: 'db' as const, + status: 'active' as const, + target: {}, + secretRefs: {}, + rules: {}, + failureCount: 0, + lastError: null, + lastOkAt: null, + lastFailureAt: null, + createdAt: old, + updatedAt: old, + }; + await r.notificationChannels.upsert(channel); + await r.notificationChannels.upsert({ ...channel, channelId: 'nc-2', name: 'team' }); + const job = (channelId: string, revision: number, status: 'pending' | 'suppressed') => ({ + channelId, + notificationId: 'n-outbox00001', + revision, + op: 'send' as const, + status, + reason: null, + nextAttemptAt: old, + createdAt: old, + }); + // Old terminal, old pending (still work), and a fresh terminal row. + await r.notificationDeliveries.enqueue([ + job('nc-1', 1, 'suppressed'), + job('nc-1', 2, 'pending'), + ]); + await r.notificationDeliveries.enqueue([{ ...job('nc-2', 1, 'suppressed'), createdAt: NOW }]); + const message = { + notificationId: 'n-outbox00001', + thread: 'system:x', + messageRef: { id: 1 }, + lastRevision: 1, + sentAt: old, + updatedAt: old, + deletedAt: null, + }; + await r.notificationChannelMessages.upsert({ ...message, channelId: 'nc-1', expiresAt: null }); + await r.notificationChannelMessages.upsert({ + ...message, + channelId: 'nc-2', + expiresAt: NOW + DAY, + }); + const result = await maintenance.retentionSweep(policy); + expect(result.failures).toEqual([]); + expect(result.prunedRows['notification_deliveries']).toBe(1); + expect(result.prunedRows['notification_channel_messages']).toBe(1); + expect( + (await r.notificationDeliveries.list({})).map((d) => [d.channelId, d.revision, d.status]), + ).toEqual([ + ['nc-2', 1, 'suppressed'], + ['nc-1', 2, 'pending'], + ]); + expect(await r.notificationChannelMessages.get('nc-2', 'n-outbox00001')).not.toBeNull(); + expect((await r.notificationChannels.list()).map((c) => c.name)).toEqual(['phone', 'team']); + }); + it('enforces the byte cap by pruning progressively older telemetry', async () => { for (let i = 0; i < 300; i++) { await t.repos.toolCalls.insert( diff --git a/packages/dashboard/src/app/providers/NotificationsProvider.tsx b/packages/dashboard/src/app/providers/NotificationsProvider.tsx index dbd55c7..003a84f 100644 --- a/packages/dashboard/src/app/providers/NotificationsProvider.tsx +++ b/packages/dashboard/src/app/providers/NotificationsProvider.tsx @@ -1,9 +1,10 @@ -/** @module app/providers/NotificationsProvider — server-backed notifications: unread count query, `notifications` topic, toasts only from `notification.created` with a per-type policy: no tool-error toasts by default, titles name the session, a growing group updates its toast in place (spec 04 §4.3, D-16) */ +/** @module app/providers/NotificationsProvider — server-backed notifications: unread count query, `notifications` topic, toasts only from `notification.created` with a per-type policy: no tool-error toasts by default, titles name the session, a growing group updates its toast in place, a settled request closes it (spec 04 §4.3, D-16) */ import { NotificationType } from '@browserhive/contracts/enums'; import type { Notification } from '@browserhive/contracts/http'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useRouter } from '@tanstack/react-router'; import { createContext, type ReactNode, useContext, useMemo, useRef } from 'react'; +import { notificationOutcome } from '@/features/notifications/notification-meta.ts'; import { toAppError } from '@/lib/api/errors.ts'; import { keys } from '@/lib/api/keys.ts'; import { sessionSlug } from '@/lib/format/ids.ts'; @@ -109,6 +110,20 @@ export function planToast( }; } +/** + * Whether an updated notification's toast should close: it was read or dismissed, or the request + * it announced was settled somewhere else (resolved, rejected, timed out, cancelled). + */ +export function toastSettled( + notification: Pick, +): boolean { + return ( + notification.read_at !== null || + notification.dismissed_at !== null || + notificationOutcome(notification) !== null + ); +} + /** Is the operator already looking at `target` (same path, or a sub-path of it)? */ export function isAlreadyAt(pathname: string, target: string): boolean { const path = target.split(/[?#]/)[0] ?? target; @@ -159,9 +174,9 @@ export function NotificationsProvider({ children }: { readonly children: ReactNo const id = notificationToastId(notification); if (event.type === 'notification.updated') { // A growing group updates its toast in place while it is still shown; read or dismissed - // elsewhere (bell, another tab) closes it. Never a new toast. + // elsewhere (bell, another tab), or a request settled elsewhere, closes it. Never a new toast. if (!toasted.current.has(notification.notification_id)) return; - if (notification.read_at !== null || notification.dismissed_at !== null) toast.close(id); + if (toastSettled(notification)) toast.close(id); else toast.update(id, { title: withSession(notification.title, slugOf(notification)), diff --git a/packages/dashboard/src/app/providers/notifications-policy.test.ts b/packages/dashboard/src/app/providers/notifications-policy.test.ts index 81af6fe..9a6e8d6 100644 --- a/packages/dashboard/src/app/providers/notifications-policy.test.ts +++ b/packages/dashboard/src/app/providers/notifications-policy.test.ts @@ -2,11 +2,23 @@ import { describe, expect, it } from 'bun:test'; import { notification } from '../../../test/fixtures/ops.ts'; import { bellBadge } from '../shell/NotificationBell.tsx'; -import { DEFAULT_TOAST_TYPES, planToast } from './NotificationsProvider.tsx'; +import { DEFAULT_TOAST_TYPES, planToast, toastSettled } from './NotificationsProvider.tsx'; const SESSION = 'login-smoke-abcd1234' as never; describe('toast policy', () => { + it('closes a toast once the notification is read, dismissed or its request settled', () => { + const open = notification(9, { type: 'attention', state: 'open' }); + expect(toastSettled(open)).toBe(false); + expect(toastSettled({ ...open, read_at: 1 })).toBe(true); + expect(toastSettled({ ...open, dismissed_at: 1 })).toBe(true); + expect(toastSettled({ ...open, state: 'resolved' })).toBe(true); + expect(toastSettled({ ...open, state: 'expired' })).toBe(true); + // A cancelled request is final and closed; a one-shot fact is final from birth and is not. + expect(toastSettled({ ...open, kind: 'attention.requested', state: 'final' })).toBe(true); + expect(toastSettled({ ...open, kind: 'session.crashed', state: 'final' })).toBe(false); + }); + it('keeps tool errors in the bell by default and toasts attention', () => { expect(DEFAULT_TOAST_TYPES).not.toContain('error'); const error = notification(1, { type: 'error', title: 'login-smoke · 3 tool errors' }); diff --git a/packages/dashboard/src/app/shell/NotificationBell.tsx b/packages/dashboard/src/app/shell/NotificationBell.tsx index 012d2a7..08e4fe5 100644 --- a/packages/dashboard/src/app/shell/NotificationBell.tsx +++ b/packages/dashboard/src/app/shell/NotificationBell.tsx @@ -8,12 +8,16 @@ import { useConfirm } from '@/app/providers/ConfirmProvider.tsx'; import { useNotifications } from '@/app/providers/NotificationsProvider.tsx'; import { EmptyState } from '@/components/shared/EmptyState.tsx'; import { RelativeTime } from '@/components/shared/RelativeTime.tsx'; +import { StatusBadge } from '@/components/shared/StatusBadge.tsx'; import { TONE_CLASSES } from '@/components/shared/tones.ts'; import { Button, buttonVariants } from '@/components/ui/button.tsx'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.tsx'; import { Skeleton } from '@/components/ui/skeleton.tsx'; import { Hint } from '@/components/ui/tooltip.tsx'; -import { notificationMeta } from '@/features/notifications/notification-meta.ts'; +import { + notificationMeta, + notificationOutcome, +} from '@/features/notifications/notification-meta.ts'; import { keys } from '@/lib/api/keys.ts'; import { ICONS } from '@/lib/icons.ts'; import { NOTIFICATION_TYPE } from '@/lib/status-registry.ts'; @@ -192,6 +196,7 @@ function BellRow({ const Icon = ICONS[entry.icon ?? 'notifications']; const unread = n.read_at === null; const meta = notificationMeta(n); + const outcome = notificationOutcome(n); return (