diff --git a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index 2876244f7..d5db503de 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -370,11 +370,13 @@ outcome. For SSE the response-created event marks successful dispatch, not stream termination; close/cancel/error observability remains part of the browser/realtime lifecycle slice. Client cancellation is informational and carries neither a failure fingerprint nor a server-error outcome. Bun's outer 640 KiB pre-dispatch body ceiling -supports the largest reviewed task-content request and may reject before application correlation -exists. The raw tRPC boundary selects exact registered-procedure ceilings before parsing or -authentication: 16 KiB for authentication, 32 KiB for WebAuthn, 64 KiB by default, 128 KiB for -task progress, and 640 KiB for task create/content update. Unknown procedures retain the default -ceiling, while unknown authentication-namespace procedures retain the stricter authentication +supports the largest reviewed task-content and monitoring mutation requests and may reject before +application correlation exists. The raw tRPC boundary selects exact registered-procedure ceilings +before parsing or authentication: 16 KiB for authentication, 32 KiB for WebAuthn, 64 KiB by +default, 128 KiB for task progress, and 640 KiB for task create/content update, complete monitoring +snapshots, and +immutable report upserts. Unknown procedures retain the default ceiling, while unknown +authentication-namespace procedures retain the stricter authentication profile. The raw handler receives the generated ID and resolves direct-client provenance against the exact trusted-proxy allowlist before context construction. `createContext` then authenticates the already parsed session or automation credential and establishes identity plus audit correlation @@ -387,7 +389,7 @@ once. Reusable procedure builders are limited to: Expected errors use a small stable code set such as `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `NOT_FOUND`, `PRECONDITION_FAILED`, `TOO_MANY_REQUESTS`, and `SERVICE_UNAVAILABLE` with safe -structured details. The `ContractErrorCode` union, all 55 actual router paths, the server-owned +structured details. The `ContractErrorCode` union, all 68 actual router paths, the server-owned runtime allowlist, and generated contract metadata must match exactly. The base procedure middleware enforces that allowlist for immediate and deferred subscription failures; an implemented procedure missing from the policy or an undeclared code becomes a redacted internal diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 1c5877e66..fb7198ce7 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -12,7 +12,7 @@ closes a phase; dated entries below provide the evidence, not a second status so | 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | | 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | | 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Report, incident, notification, schedule/job, cache/metrics, overview, and worker-domain parity remain open. | +| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server parity are implemented. Their browser workflows, schedules/jobs, overview, cache/metrics, and the real worker remain open. | | 4 — Gateway and chat | Not started | The Phase 2 verifier is one-shot only. Persistent native Gateway lifecycle, current-protocol re-audit, sessions, chat journal/recovery, attachments, and frontend remain open. | | 5 — Privileged and external domains | Not started | Worker-owned file/media, Docker, database, OpenClaw, GitHub, deployment, backup, and other privileged adapters remain open. | | 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | @@ -841,3 +841,30 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o Persistent OpenClaw/Gateway availability and session state remain Phase 4 work; reports, incidents, notifications, schedules/jobs, overview, cache/metrics, and the real worker remain open Phase 3 gates. + +### 2026-08-07 — Phase 3 monitoring ingestion and catalog slice + +- One automation-only `monitoring.submitCompleteSnapshot` boundary now exposes the complete-run + state machine through `monitoring:write`. It shares the production database repository, + immediate-write admission, domain clock, and post-commit realtime wake path with the report, + incident, and notification catalogs. +- Twelve catalog procedures provide keyset-paginated incident, notification, and report reads; + immutable report upserts for scoped browser sessions or automation callers; automation-only + notification producer upserts; session-owned notification read/delete actions; and bounded + report deletion. Exact `reports:*` and `notifications:*` capabilities and principal-kind + middleware keep browser-session actions separate from automation-only ingestion. +- Complete snapshots and report upserts have a qualified 640 KiB transport profile plus a stricter + 512 KiB semantic aggregate budget. Each embedded JSON object retains its separate 64 KiB budget. + Exact registered procedure matching prevents unknown, malformed, or batched names from inheriting + the larger allowance, while authentication and WebAuthn retain their stricter profiles. +- Typed validation, replay, catalog conflict, not-found, precondition, and database-admission + failures map to the declared tRPC error policy. Mutations clamp durable timestamps across clock + regressions, cap report-linked deletion work, and keep catalog writes, audit state, and compact + realtime invalidations atomic. +- Generated procedure and realtime documentation now comes from the same Valibot contracts. The + reviewed parity inventory marks all six notification and four report legacy operations + implemented; incident reads and complete monitoring ingestion are net-new. Incident-generation + notifications retain a forward deep link to the immediately stacked authenticated `/incidents` + reader rather than choosing one arbitrary observation report; greenfield stays inactive until + the complete cutover stack lands. Browser workflows, schedules/jobs, overview, cache/metrics, + and real worker execution remain open Phase 3 gates. diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index a56580581..8f94cae31 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -46,6 +46,19 @@ | `automationSecurity.revokeCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.revokeCredential.input.schema.json) | [output](./schemas/automationSecurity.revokeCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Explicitly revokes one automation credential after client cutover. | | `automationSecurity.rotateCredential` | mutation | automation-security | MFA enrollment required; recent MFA when enabled | [input](./schemas/automationSecurity.rotateCredential.input.schema.json) | [output](./schemas/automationSecurity.rotateCredential.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `mfa_enrollment_required`, `step_up_required` | Stages a linked replacement credential without revoking its predecessor. | | `events.stream` | subscription | events | Authenticated; per-topic: agents:read, notifications:read, reports:read, tasks:read | [input](./schemas/events.stream.input.schema.json) | [output](./schemas/events.stream.output.schema.json) | `BAD_REQUEST`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Streams authorized durable changes with tracked resume cursors. | +| `incidents.get` | query | incidents | Authenticated: reports:read | [input](./schemas/incidents.get.input.schema.json) | [output](./schemas/incidents.get.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | None | Loads one exact incident lifecycle record. | +| `incidents.list` | query | incidents | Authenticated: reports:read | [input](./schemas/incidents.list.input.schema.json) | [output](./schemas/incidents.list.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists stable incident lifecycle rows for report navigation. | +| `monitoring.submitCompleteSnapshot` | mutation | monitoring | Authenticated automation principal: monitoring:write | [input](./schemas/monitoring.submitCompleteSnapshot.input.schema.json) | [output](./schemas/monitoring.submitCompleteSnapshot.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Atomically ingests one complete monitor snapshot. | +| `notifications.clearRead` | mutation | notifications | Authenticated browser session: notifications:write | [input](./schemas/notifications.clearRead.input.schema.json) | [output](./schemas/notifications.clearRead.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Deletes one bounded page of matching read notifications. | +| `notifications.delete` | mutation | notifications | Authenticated browser session: notifications:write | [input](./schemas/notifications.delete.input.schema.json) | [output](./schemas/notifications.delete.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Deletes one exact Dashboard notification. | +| `notifications.list` | query | notifications | Authenticated: notifications:read | [input](./schemas/notifications.list.input.schema.json) | [output](./schemas/notifications.list.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists a stable filtered notification page and unread count. | +| `notifications.markAllRead` | mutation | notifications | Authenticated browser session: notifications:write | [input](./schemas/notifications.markAllRead.input.schema.json) | [output](./schemas/notifications.markAllRead.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Marks one bounded page of matching notifications read. | +| `notifications.markRead` | mutation | notifications | Authenticated browser session: notifications:write | [input](./schemas/notifications.markRead.input.schema.json) | [output](./schemas/notifications.markRead.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Marks one notification read idempotently. | +| `notifications.upsert` | mutation | notifications | Authenticated automation principal: notifications:write | [input](./schemas/notifications.upsert.input.schema.json) | [output](./schemas/notifications.upsert.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Creates a notification or accepts an exact idempotent replay. | +| `reports.delete` | mutation | reports | Authenticated: reports:write | [input](./schemas/reports.delete.input.schema.json) | [output](./schemas/reports.delete.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `PRECONDITION_FAILED`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Deletes one report while preserving its monitor-run history. | +| `reports.get` | query | reports | Authenticated: reports:read | [input](./schemas/reports.get.input.schema.json) | [output](./schemas/reports.get.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `UNAUTHORIZED` | None | Loads one complete immutable Markdown report. | +| `reports.list` | query | reports | Authenticated: reports:read | [input](./schemas/reports.list.input.schema.json) | [output](./schemas/reports.list.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists a stable filtered page of immutable report summaries. | +| `reports.upsert` | mutation | reports | Authenticated: reports:write | [input](./schemas/reports.upsert.input.schema.json) | [output](./schemas/reports.upsert.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Creates a report or accepts an exact idempotent replay. | | `securityAudit.listEvents` | query | securityAudit | Authenticated browser session | [input](./schemas/securityAudit.listEvents.input.schema.json) | [output](./schemas/securityAudit.listEvents.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Lists redacted immutable security events in stable newest-first order. | | `system.runtimeIdentity` | query | system | Public | [input](./schemas/system.runtimeIdentity.input.schema.json) | [output](./schemas/system.runtimeIdentity.output.schema.json) | None | None | Returns the Bun runtime identity of the serving process. | | `tasks.addUpdate` | mutation | tasks | Authenticated: tasks:write | [input](./schemas/tasks.addUpdate.input.schema.json) | [output](./schemas/tasks.addUpdate.output.schema.json) | `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Appends one authenticated progress update to a task. | diff --git a/greenfield/docs/generated/realtime-events.md b/greenfield/docs/generated/realtime-events.md index 5a136756f..f46503a5d 100644 --- a/greenfield/docs/generated/realtime-events.md +++ b/greenfield/docs/generated/realtime-events.md @@ -2,4 +2,10 @@ > Generated by `bun run docs:generate`. Do not edit manually. -No standalone realtime topic references are published until their snapshot procedures are implemented. +| Topic | Payload | Snapshot | Retention | Summary | +| --- | --- | --- | --- | --- | +| `agents.status` | [payload](./schemas/agents.status.realtime.payload.schema.json) | `agents.listStatuses` | 7 days | Invalidates one agent status row after a durable metadata change. | +| `monitoring.incidents` | [payload](./schemas/monitoring.incidents.realtime.payload.schema.json) | `incidents.list` | 7 days | Invalidates incident lifecycle rows after a complete monitor snapshot. | +| `monitoring.notifications` | [payload](./schemas/monitoring.notifications.realtime.payload.schema.json) | `notifications.list` | 7 days | Invalidates Dashboard notifications after catalog changes. | +| `monitoring.reports` | [payload](./schemas/monitoring.reports.realtime.payload.schema.json) | `reports.list` | 7 days | Invalidates immutable reports after catalog changes. | +| `tasks.records` | [payload](./schemas/tasks.records.realtime.payload.schema.json) | `tasks.list` | 7 days | Invalidates one task row after a durable task-domain change. | diff --git a/greenfield/docs/generated/schemas/agents.status.realtime.payload.schema.json b/greenfield/docs/generated/schemas/agents.status.realtime.payload.schema.json new file mode 100644 index 000000000..750ec69b0 --- /dev/null +++ b/greenfield/docs/generated/schemas/agents.status.realtime.payload.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "urn:mira-dashboard:agents.status.realtime.payload", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json index caa1b3eef..a0d4a5672 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json @@ -8,14 +8,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "id": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json index ee69c82a8..2975797d2 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json @@ -77,14 +77,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { @@ -150,14 +153,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json index 6a9f473ce..80fa539de 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json @@ -26,14 +26,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { @@ -99,14 +102,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json index 883779c99..5d155f62a 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json @@ -51,14 +51,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { @@ -124,14 +127,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json index 8784a52b9..2622ef2e1 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json @@ -19,14 +19,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true } }, diff --git a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json index b9e4a26e3..5e96ab396 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json @@ -26,14 +26,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { @@ -99,14 +102,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/events.stream.output.schema.json b/greenfield/docs/generated/schemas/events.stream.output.schema.json index acf81be25..d561c4dc1 100644 --- a/greenfield/docs/generated/schemas/events.stream.output.schema.json +++ b/greenfield/docs/generated/schemas/events.stream.output.schema.json @@ -131,6 +131,8 @@ "operation": { "enum": [ "created", + "deleted", + "snapshot-required", "updated" ], "type": "string" @@ -180,7 +182,11 @@ "maximum": 8640000000000000 }, "operation": { - "const": "created" + "enum": [ + "created", + "deleted" + ], + "type": "string" }, "payload": { "type": "object", diff --git a/greenfield/docs/generated/schemas/incidents.get.input.schema.json b/greenfield/docs/generated/schemas/incidents.get.input.schema.json new file mode 100644 index 000000000..3079d74cd --- /dev/null +++ b/greenfield/docs/generated/schemas/incidents.get.input.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "urn:mira-dashboard:incidents.get.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/incidents.get.output.schema.json b/greenfield/docs/generated/schemas/incidents.get.output.schema.json new file mode 100644 index 000000000..5d9109ac5 --- /dev/null +++ b/greenfield/docs/generated/schemas/incidents.get.output.schema.json @@ -0,0 +1,178 @@ +{ + "$id": "urn:mira-dashboard:incidents.get.output", + "oneOf": [ + { + "type": "object", + "properties": { + "details": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget." + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "firstSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "lastSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "monitorKey": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "occurrenceCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "state": { + "const": "active" + } + }, + "required": [ + "details", + "fingerprint", + "firstSeenAtMs", + "generation", + "id", + "kind", + "lastSeenAtMs", + "monitorKey", + "occurrenceCount", + "severity", + "title", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires active-incident observation timestamps to be monotonic." + }, + { + "type": "object", + "properties": { + "details": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget." + }, + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "firstSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "lastSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "monitorKey": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "occurrenceCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "resolvedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "resolved" + } + }, + "required": [ + "details", + "fingerprint", + "firstSeenAtMs", + "generation", + "id", + "kind", + "lastSeenAtMs", + "monitorKey", + "occurrenceCount", + "severity", + "title", + "resolvedAtMs", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires resolved-incident observation and resolution timestamps to be monotonic." + } + ], + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/incidents.list.input.schema.json b/greenfield/docs/generated/schemas/incidents.list.input.schema.json new file mode 100644 index 000000000..04f4e0a95 --- /dev/null +++ b/greenfield/docs/generated/schemas/incidents.list.input.schema.json @@ -0,0 +1,94 @@ +{ + "$id": "urn:mira-dashboard:incidents.list.input", + "type": "object", + "properties": { + "cursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "lastSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "lastSeenAtMs" + ], + "additionalProperties": false + }, + "filters": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "monitorKeys": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "severities": { + "type": "array", + "items": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "states": { + "type": "array", + "items": { + "enum": [ + "active", + "resolved" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": false + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/incidents.list.output.schema.json b/greenfield/docs/generated/schemas/incidents.list.output.schema.json new file mode 100644 index 000000000..b7df731a8 --- /dev/null +++ b/greenfield/docs/generated/schemas/incidents.list.output.schema.json @@ -0,0 +1,205 @@ +{ + "$id": "urn:mira-dashboard:incidents.list.output", + "type": "object", + "properties": { + "incidents": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "firstSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "lastSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "monitorKey": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "occurrenceCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "state": { + "const": "active" + } + }, + "required": [ + "fingerprint", + "firstSeenAtMs", + "generation", + "id", + "kind", + "lastSeenAtMs", + "monitorKey", + "occurrenceCount", + "severity", + "title", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires active-incident summary timestamps to be monotonic." + }, + { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "firstSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "lastSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "monitorKey": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "occurrenceCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "resolvedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "resolved" + } + }, + "required": [ + "fingerprint", + "firstSeenAtMs", + "generation", + "id", + "kind", + "lastSeenAtMs", + "monitorKey", + "occurrenceCount", + "severity", + "title", + "resolvedAtMs", + "state" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires resolved-incident summary timestamps to be monotonic." + } + ] + }, + "maxItems": 100, + "$comment": "Live Valibot validation additionally requires strict newest-first incident ordering by last-seen timestamp and ID." + }, + "nextCursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "lastSeenAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "lastSeenAtMs" + ], + "additionalProperties": false + } + }, + "required": [ + "incidents" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires an incident continuation cursor to identify the returned last row.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/monitoring.incidents.realtime.payload.schema.json b/greenfield/docs/generated/schemas/monitoring.incidents.realtime.payload.schema.json new file mode 100644 index 000000000..c8ab54654 --- /dev/null +++ b/greenfield/docs/generated/schemas/monitoring.incidents.realtime.payload.schema.json @@ -0,0 +1,16 @@ +{ + "$id": "urn:mira-dashboard:monitoring.incidents.realtime.payload", + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/monitoring.notifications.realtime.payload.schema.json b/greenfield/docs/generated/schemas/monitoring.notifications.realtime.payload.schema.json new file mode 100644 index 000000000..12ec1ffbe --- /dev/null +++ b/greenfield/docs/generated/schemas/monitoring.notifications.realtime.payload.schema.json @@ -0,0 +1,16 @@ +{ + "$id": "urn:mira-dashboard:monitoring.notifications.realtime.payload", + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/monitoring.reports.realtime.payload.schema.json b/greenfield/docs/generated/schemas/monitoring.reports.realtime.payload.schema.json new file mode 100644 index 000000000..34e2ef425 --- /dev/null +++ b/greenfield/docs/generated/schemas/monitoring.reports.realtime.payload.schema.json @@ -0,0 +1,16 @@ +{ + "$id": "urn:mira-dashboard:monitoring.reports.realtime.payload", + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/monitoring.submitCompleteSnapshot.input.schema.json b/greenfield/docs/generated/schemas/monitoring.submitCompleteSnapshot.input.schema.json new file mode 100644 index 000000000..055317c76 --- /dev/null +++ b/greenfield/docs/generated/schemas/monitoring.submitCompleteSnapshot.input.schema.json @@ -0,0 +1,147 @@ +{ + "$id": "urn:mira-dashboard:monitoring.submitCompleteSnapshot.input", + "type": "object", + "properties": { + "completedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "monitorKey": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "problems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "condition": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "details": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget.", + "default": {} + }, + "entityKey": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + } + }, + "required": [ + "condition", + "entityKey", + "kind", + "severity", + "title" + ], + "additionalProperties": false + }, + "maxItems": 100 + }, + "report": { + "type": "object", + "properties": { + "bodyMarkdown": { + "type": "string", + "maxLength": 1000000, + "pattern": "\\S" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "metadata": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget.", + "default": {} + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "sourceJobId": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "summary": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "bodyMarkdown", + "kind", + "source", + "sourceJobId", + "title" + ], + "additionalProperties": false + }, + "runId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "completedAtMs", + "monitorKey", + "problems", + "report", + "runId", + "startedAtMs" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires snapshot completion not to precede snapshot start. Live Valibot validation additionally limits the complete snapshot to its reviewed aggregate UTF-8 byte budget.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/monitoring.submitCompleteSnapshot.output.schema.json b/greenfield/docs/generated/schemas/monitoring.submitCompleteSnapshot.output.schema.json new file mode 100644 index 000000000..b2380c308 --- /dev/null +++ b/greenfield/docs/generated/schemas/monitoring.submitCompleteSnapshot.output.schema.json @@ -0,0 +1,76 @@ +{ + "$id": "urn:mira-dashboard:monitoring.submitCompleteSnapshot.output", + "type": "object", + "properties": { + "createdIncidents": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "duplicateRunId": { + "type": "boolean" + }, + "observedIncidents": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reopenedIncidents": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reportId": { + "anyOf": [ + { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + { + "type": "null" + } + ] + }, + "resolvedIncidents": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "realtimeEvents": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "runId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "status": { + "enum": [ + "accepted", + "duplicate", + "stale" + ], + "type": "string" + } + }, + "required": [ + "createdIncidents", + "duplicateRunId", + "observedIncidents", + "reopenedIncidents", + "reportId", + "resolvedIncidents", + "realtimeEvents", + "runId", + "status" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.clearRead.input.schema.json b/greenfield/docs/generated/schemas/notifications.clearRead.input.schema.json new file mode 100644 index 000000000..f6b448e9b --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.clearRead.input.schema.json @@ -0,0 +1,61 @@ +{ + "$id": "urn:mira-dashboard:notifications.clearRead.input", + "type": "object", + "properties": { + "filters": { + "type": "object", + "properties": { + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kinds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "severities": { + "type": "array", + "items": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "sources": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": false, + "default": {} + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.clearRead.output.schema.json b/greenfield/docs/generated/schemas/notifications.clearRead.output.schema.json new file mode 100644 index 000000000..a246a45d0 --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.clearRead.output.schema.json @@ -0,0 +1,26 @@ +{ + "$id": "urn:mira-dashboard:notifications.clearRead.output", + "type": "object", + "properties": { + "affectedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "completedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "remaining": { + "type": "boolean" + } + }, + "required": [ + "affectedCount", + "completedAtMs", + "remaining" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.delete.input.schema.json b/greenfield/docs/generated/schemas/notifications.delete.input.schema.json new file mode 100644 index 000000000..9d2a5f664 --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.delete.input.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "urn:mira-dashboard:notifications.delete.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.delete.output.schema.json b/greenfield/docs/generated/schemas/notifications.delete.output.schema.json new file mode 100644 index 000000000..881a8555c --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.delete.output.schema.json @@ -0,0 +1,24 @@ +{ + "$id": "urn:mira-dashboard:notifications.delete.output", + "type": "object", + "properties": { + "deletedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "deletedAtMs", + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.list.input.schema.json b/greenfield/docs/generated/schemas/notifications.list.input.schema.json new file mode 100644 index 000000000..59a674aad --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.list.input.schema.json @@ -0,0 +1,97 @@ +{ + "$id": "urn:mira-dashboard:notifications.list.input", + "type": "object", + "properties": { + "cursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "occurredAtMs" + ], + "additionalProperties": false + }, + "filters": { + "type": "object", + "properties": { + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kinds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "readState": { + "enum": [ + "all", + "read", + "unread" + ], + "type": "string", + "default": "all" + }, + "severities": { + "type": "array", + "items": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "sources": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": false + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.list.output.schema.json b/greenfield/docs/generated/schemas/notifications.list.output.schema.json new file mode 100644 index 000000000..926913af0 --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.list.output.schema.json @@ -0,0 +1,145 @@ +{ + "$id": "urn:mira-dashboard:notifications.list.output", + "type": "object", + "properties": { + "nextCursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "occurredAtMs" + ], + "additionalProperties": false + }, + "notifications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "incidentGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "linkUrl": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "pattern": "^\\/(?!\\/)[^\\s\\\\]*$", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "message": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "readAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "reportId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "id", + "kind", + "message", + "occurredAtMs", + "severity", + "title" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires notification incident ID and generation to be present together. Live Valibot validation additionally requires notification read time not to precede occurrence time." + }, + "maxItems": 100, + "$comment": "Live Valibot validation additionally requires strict newest-first notification ordering by occurrence timestamp and ID." + }, + "readCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unreadCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "notifications", + "readCount", + "unreadCount" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires a notification continuation cursor to identify the returned last row.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.markAllRead.input.schema.json b/greenfield/docs/generated/schemas/notifications.markAllRead.input.schema.json new file mode 100644 index 000000000..037df52fc --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.markAllRead.input.schema.json @@ -0,0 +1,61 @@ +{ + "$id": "urn:mira-dashboard:notifications.markAllRead.input", + "type": "object", + "properties": { + "filters": { + "type": "object", + "properties": { + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kinds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "severities": { + "type": "array", + "items": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "sources": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": false, + "default": {} + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.markAllRead.output.schema.json b/greenfield/docs/generated/schemas/notifications.markAllRead.output.schema.json new file mode 100644 index 000000000..dadbd5f1e --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.markAllRead.output.schema.json @@ -0,0 +1,26 @@ +{ + "$id": "urn:mira-dashboard:notifications.markAllRead.output", + "type": "object", + "properties": { + "affectedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "completedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "remaining": { + "type": "boolean" + } + }, + "required": [ + "affectedCount", + "completedAtMs", + "remaining" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.markRead.input.schema.json b/greenfield/docs/generated/schemas/notifications.markRead.input.schema.json new file mode 100644 index 000000000..fe0c3ccf8 --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.markRead.input.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "urn:mira-dashboard:notifications.markRead.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.markRead.output.schema.json b/greenfield/docs/generated/schemas/notifications.markRead.output.schema.json new file mode 100644 index 000000000..1572cba71 --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.markRead.output.schema.json @@ -0,0 +1,96 @@ +{ + "$id": "urn:mira-dashboard:notifications.markRead.output", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "incidentGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "linkUrl": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "pattern": "^\\/(?!\\/)[^\\s\\\\]*$", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "message": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "readAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "reportId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "id", + "kind", + "message", + "occurredAtMs", + "severity", + "title" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires notification incident ID and generation to be present together. Live Valibot validation additionally requires notification read time not to precede occurrence time.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.upsert.input.schema.json b/greenfield/docs/generated/schemas/notifications.upsert.input.schema.json new file mode 100644 index 000000000..7173f0bdc --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.upsert.input.schema.json @@ -0,0 +1,91 @@ +{ + "$id": "urn:mira-dashboard:notifications.upsert.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "incidentGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "linkUrl": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "pattern": "^\\/(?!\\/)[^\\s\\\\]*$", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "message": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "reportId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "id", + "kind", + "message", + "occurredAtMs", + "severity", + "title" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires producer incident ID and generation to be present together.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/notifications.upsert.output.schema.json b/greenfield/docs/generated/schemas/notifications.upsert.output.schema.json new file mode 100644 index 000000000..2e180dab6 --- /dev/null +++ b/greenfield/docs/generated/schemas/notifications.upsert.output.schema.json @@ -0,0 +1,96 @@ +{ + "$id": "urn:mira-dashboard:notifications.upsert.output", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "incidentGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "incidentId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "linkUrl": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "pattern": "^\\/(?!\\/)[^\\s\\\\]*$", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "message": { + "type": "string", + "maxLength": 1000, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "readAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "reportId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "severity": { + "enum": [ + "critical", + "error", + "info", + "warning" + ], + "type": "string" + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "id", + "kind", + "message", + "occurredAtMs", + "severity", + "title" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires notification incident ID and generation to be present together. Live Valibot validation additionally requires notification read time not to precede occurrence time.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.delete.input.schema.json b/greenfield/docs/generated/schemas/reports.delete.input.schema.json new file mode 100644 index 000000000..35354ba04 --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.delete.input.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "urn:mira-dashboard:reports.delete.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.delete.output.schema.json b/greenfield/docs/generated/schemas/reports.delete.output.schema.json new file mode 100644 index 000000000..b2c8b6d6a --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.delete.output.schema.json @@ -0,0 +1,24 @@ +{ + "$id": "urn:mira-dashboard:reports.delete.output", + "type": "object", + "properties": { + "deletedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "deletedAtMs", + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.get.input.schema.json b/greenfield/docs/generated/schemas/reports.get.input.schema.json new file mode 100644 index 000000000..32445b7f5 --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.get.input.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "urn:mira-dashboard:reports.get.input", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.get.output.schema.json b/greenfield/docs/generated/schemas/reports.get.output.schema.json new file mode 100644 index 000000000..7547ed182 --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.get.output.schema.json @@ -0,0 +1,81 @@ +{ + "$id": "urn:mira-dashboard:reports.get.output", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "sourceJobId": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "status": { + "enum": [ + "error", + "ok", + "warning" + ], + "type": "string" + }, + "summary": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + }, + "bodyMarkdown": { + "type": "string", + "maxLength": 1000000, + "pattern": "\\S" + }, + "metadata": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget." + } + }, + "required": [ + "id", + "kind", + "occurredAtMs", + "source", + "status", + "title", + "bodyMarkdown", + "metadata" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.list.input.schema.json b/greenfield/docs/generated/schemas/reports.list.input.schema.json new file mode 100644 index 000000000..ab416fd15 --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.list.input.schema.json @@ -0,0 +1,91 @@ +{ + "$id": "urn:mira-dashboard:reports.list.input", + "type": "object", + "properties": { + "cursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "occurredAtMs" + ], + "additionalProperties": false + }, + "filters": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "sourceJobIds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "sources": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + }, + "statuses": { + "type": "array", + "items": { + "enum": [ + "error", + "ok", + "warning" + ], + "type": "string" + }, + "minItems": 1, + "maxItems": 16, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": false + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 100 + } + }, + "required": [], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.list.output.schema.json b/greenfield/docs/generated/schemas/reports.list.output.schema.json new file mode 100644 index 000000000..c464988e4 --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.list.output.schema.json @@ -0,0 +1,107 @@ +{ + "$id": "urn:mira-dashboard:reports.list.output", + "type": "object", + "properties": { + "nextCursor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + } + }, + "required": [ + "id", + "occurredAtMs" + ], + "additionalProperties": false + }, + "reports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "sourceJobId": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "status": { + "enum": [ + "error", + "ok", + "warning" + ], + "type": "string" + }, + "summary": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "id", + "kind", + "occurredAtMs", + "source", + "status", + "title" + ], + "additionalProperties": false + }, + "maxItems": 200, + "$comment": "Live Valibot validation additionally requires strict newest-first report ordering by occurrence timestamp and ID." + } + }, + "required": [ + "reports" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires a report continuation cursor to identify the returned last row.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.upsert.input.schema.json b/greenfield/docs/generated/schemas/reports.upsert.input.schema.json new file mode 100644 index 000000000..56a4ba69c --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.upsert.input.schema.json @@ -0,0 +1,82 @@ +{ + "$id": "urn:mira-dashboard:reports.upsert.input", + "type": "object", + "properties": { + "bodyMarkdown": { + "type": "string", + "maxLength": 1000000, + "pattern": "\\S" + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "metadata": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget.", + "default": {} + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "sourceJobId": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "status": { + "enum": [ + "error", + "ok", + "warning" + ], + "type": "string", + "default": "ok" + }, + "summary": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + } + }, + "required": [ + "bodyMarkdown", + "id", + "kind", + "occurredAtMs", + "source", + "title" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally limits report producer input to its reviewed aggregate UTF-8 byte budget.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/reports.upsert.output.schema.json b/greenfield/docs/generated/schemas/reports.upsert.output.schema.json new file mode 100644 index 000000000..8183984be --- /dev/null +++ b/greenfield/docs/generated/schemas/reports.upsert.output.schema.json @@ -0,0 +1,81 @@ +{ + "$id": "urn:mira-dashboard:reports.upsert.output", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "kind": { + "type": "string", + "maxLength": 100, + "pattern": "\\S" + }, + "occurredAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "source": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "sourceJobId": { + "type": "string", + "maxLength": 200, + "pattern": "\\S" + }, + "status": { + "enum": [ + "error", + "ok", + "warning" + ], + "type": "string" + }, + "summary": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ] + }, + "title": { + "type": "string", + "maxLength": 500, + "pattern": "\\S" + }, + "bodyMarkdown": { + "type": "string", + "maxLength": 1000000, + "pattern": "\\S" + }, + "metadata": { + "type": "object", + "$comment": "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays. Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget." + } + }, + "required": [ + "id", + "kind", + "occurredAtMs", + "source", + "status", + "title", + "bodyMarkdown", + "metadata" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json index b7c16afc9..59764a04f 100644 --- a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json +++ b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json @@ -135,14 +135,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "method": { @@ -187,14 +190,17 @@ "enum": [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write" ], "type": "string" }, - "maxItems": 6, + "maxItems": 9, "uniqueItems": true }, "replacementCredentialId": { diff --git a/greenfield/docs/generated/schemas/tasks.records.realtime.payload.schema.json b/greenfield/docs/generated/schemas/tasks.records.realtime.payload.schema.json new file mode 100644 index 000000000..c5bf857cd --- /dev/null +++ b/greenfield/docs/generated/schemas/tasks.records.realtime.payload.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "urn:mira-dashboard:tasks.records.realtime.payload", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql index 0f3f2bf86..a22601f75 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql +++ b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql @@ -139,7 +139,7 @@ CREATE TABLE `automation_principal_capabilities` ( `principal_id` text NOT NULL, CONSTRAINT `automation_principal_capabilities_pk` PRIMARY KEY(`principal_id`, `capability`), CONSTRAINT `fk_automation_principal_capabilities_principal_id_automation_principals_id_fk` FOREIGN KEY (`principal_id`) REFERENCES `automation_principals`(`id`) ON DELETE CASCADE, - CONSTRAINT "automation_principal_capabilities_capability_check" CHECK("capability" IN ('agents:read', 'agents:write', 'notifications:read', 'reports:read', 'tasks:read', 'tasks:write')), + CONSTRAINT "automation_principal_capabilities_capability_check" CHECK("capability" IN ('agents:read', 'agents:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'reports:read', 'reports:write', 'tasks:read', 'tasks:write')), CONSTRAINT "automation_principal_capabilities_granted_at_check" CHECK("granted_at" BETWEEN 0 AND 8640000000000000) ) STRICT; --> statement-breakpoint @@ -225,9 +225,12 @@ CREATE TABLE `notifications` ( `message` text NOT NULL, `occurred_at` integer NOT NULL, `read_at` integer, + `report_id` text, `severity` text NOT NULL, + `source` text, `title` text NOT NULL, CONSTRAINT `fk_notifications_incident_id_incidents_id_fk` FOREIGN KEY (`incident_id`) REFERENCES `incidents`(`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_notifications_report_id_reports_id_fk` FOREIGN KEY (`report_id`) REFERENCES `reports`(`id`) ON DELETE CASCADE, CONSTRAINT "notifications_channel_check" CHECK("channel" = 'dashboard'), CONSTRAINT "notifications_incident_pair_check" CHECK(("incident_id" IS NULL AND "incident_generation" IS NULL) OR ("incident_id" IS NOT NULL AND "incident_generation" IS NOT NULL)), CONSTRAINT "notifications_incident_generation_check" CHECK("incident_generation" IS NULL OR "incident_generation" >= 1), @@ -257,8 +260,11 @@ CREATE TABLE `reports` ( `occurred_at` integer NOT NULL, `source` text NOT NULL, `source_job_id` text, + `status` text DEFAULT 'ok' NOT NULL, + `summary` text, `title` text NOT NULL, - CONSTRAINT "reports_metadata_json_check" CHECK(CASE WHEN json_valid("metadata_json") THEN json_type("metadata_json") = 'object' ELSE 0 END) + CONSTRAINT "reports_metadata_json_check" CHECK(CASE WHEN json_valid("metadata_json") THEN json_type("metadata_json") = 'object' ELSE 0 END), + CONSTRAINT "reports_status_check" CHECK("status" IN ('error', 'ok', 'warning')) ) STRICT; --> statement-breakpoint CREATE TABLE `schema_migrations` ( @@ -586,11 +592,15 @@ CREATE INDEX `incident_observations_incident_observed_id_idx` ON `incident_obser CREATE INDEX `incident_observations_run_idx` ON `incident_observations` (`monitor_run_id`,`id`);--> statement-breakpoint CREATE UNIQUE INDEX `incidents_monitor_fingerprint_unique` ON `incidents` (`monitor_key`,`fingerprint`);--> statement-breakpoint CREATE INDEX `incidents_active_monitor_seen_idx` ON `incidents` (`monitor_key`,`last_seen_at`) WHERE "incidents"."state" = 'active';--> statement-breakpoint +CREATE INDEX `incidents_last_seen_id_idx` ON `incidents` (`last_seen_at`,`id`);--> statement-breakpoint CREATE INDEX `monitor_runs_monitor_completed_id_idx` ON `monitor_runs` (`monitor_key`,`completed_at`,`id`) WHERE "monitor_runs"."complete_snapshot" = 1 AND "monitor_runs"."state" = 'succeeded';--> statement-breakpoint CREATE UNIQUE INDEX `notifications_incident_generation_channel_unique` ON `notifications` (`incident_id`,`incident_generation`,`channel`) WHERE "notifications"."incident_id" IS NOT NULL;--> statement-breakpoint CREATE INDEX `notifications_unread_occurred_idx` ON `notifications` (`occurred_at`) WHERE "notifications"."read_at" IS NULL;--> statement-breakpoint +CREATE INDEX `notifications_report_id_idx` ON `notifications` (`report_id`);--> statement-breakpoint +CREATE INDEX `notifications_occurred_id_idx` ON `notifications` (`occurred_at`,`id`);--> statement-breakpoint CREATE INDEX `realtime_events_expires_id_idx` ON `realtime_events` (`expires_at`,`id`);--> statement-breakpoint CREATE INDEX `realtime_events_topic_id_idx` ON `realtime_events` (`topic`,`id`);--> statement-breakpoint +CREATE INDEX `reports_occurred_id_idx` ON `reports` (`occurred_at`,`id`);--> statement-breakpoint CREATE INDEX `reports_kind_occurred_id_idx` ON `reports` (`kind`,`occurred_at`,`id`);--> statement-breakpoint CREATE INDEX `reports_source_job_occurred_id_idx` ON `reports` (`source`,`source_job_id`,`occurred_at`,`id`);--> statement-breakpoint CREATE UNIQUE INDEX `user_recovery_codes_user_selector_unique` ON `user_recovery_codes` (`user_id`,`selector`);--> statement-breakpoint diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json index 7ae3f156b..163f8bcac 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json +++ b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "17f69d2d-da53-412b-ba2b-4f803e96050c", - "prevIds": [], + "id": "fccb7937-15cf-4628-a561-1093e2ee23ed", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], "ddl": [ { "name": "agent_task_runs", @@ -1338,6 +1340,16 @@ "entityType": "columns", "table": "notifications" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "report_id", + "entityType": "columns", + "table": "notifications" + }, { "type": "text", "notNull": true, @@ -1348,6 +1360,16 @@ "entityType": "columns", "table": "notifications" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "notifications" + }, { "type": "text", "notNull": true, @@ -1508,6 +1530,26 @@ "entityType": "columns", "table": "reports" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'ok'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary", + "entityType": "columns", + "table": "reports" + }, { "type": "text", "notNull": true, @@ -2518,6 +2560,21 @@ "entityType": "fks", "table": "notifications" }, + { + "columns": [ + "report_id" + ], + "tableTo": "reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_report_id_reports_id_fk", + "entityType": "fks", + "table": "notifications" + }, { "columns": [ "task_id" @@ -3411,6 +3468,24 @@ "entityType": "indexes", "table": "incidents" }, + { + "columns": [ + { + "value": "last_seen_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "incidents_last_seen_id_idx", + "entityType": "indexes", + "table": "incidents" + }, { "columns": [ { @@ -3469,6 +3544,38 @@ "entityType": "indexes", "table": "notifications" }, + { + "columns": [ + { + "value": "report_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "notifications_report_id_idx", + "entityType": "indexes", + "table": "notifications" + }, + { + "columns": [ + { + "value": "occurred_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "notifications_occurred_id_idx", + "entityType": "indexes", + "table": "notifications" + }, { "columns": [ { @@ -3505,6 +3612,24 @@ "entityType": "indexes", "table": "realtime_events" }, + { + "columns": [ + { + "value": "occurred_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_occurred_id_idx", + "entityType": "indexes", + "table": "reports" + }, { "columns": [ { @@ -4210,7 +4335,7 @@ "table": "automation_credentials" }, { - "value": "\"capability\" IN ('agents:read', 'agents:write', 'notifications:read', 'reports:read', 'tasks:read', 'tasks:write')", + "value": "\"capability\" IN ('agents:read', 'agents:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'reports:read', 'reports:write', 'tasks:read', 'tasks:write')", "name": "automation_principal_capabilities_capability_check", "entityType": "checks", "table": "automation_principal_capabilities" @@ -4401,6 +4526,12 @@ "entityType": "checks", "table": "reports" }, + { + "value": "\"status\" IN ('error', 'ok', 'warning')", + "name": "reports_status_check", + "entityType": "checks", + "table": "reports" + }, { "value": "\"applied_at\" BETWEEN 0 AND 8640000000000000", "name": "schema_migrations_applied_at_check", diff --git a/greenfield/scripts/documentation/artifacts.test.ts b/greenfield/scripts/documentation/artifacts.test.ts index 011d46f71..000a88cc6 100644 --- a/greenfield/scripts/documentation/artifacts.test.ts +++ b/greenfield/scripts/documentation/artifacts.test.ts @@ -95,9 +95,61 @@ describe("generated contract documentation", () => { expect(rawHttpDocumentation).toContain( "| HEAD | `/api/health/ready` | Public | 200, 503 | No body |" ); - expect(first.get("realtime-events.md")).toContain( - "No standalone realtime topic references are published" - ); + const realtimeDocumentation = first.get("realtime-events.md"); + for (const [topic, snapshot, idSchema] of [ + [ + "agents.status", + "agents.listStatuses", + { + maxLength: 64, + minLength: 1, + pattern: "^[a-z0-9][a-z0-9._-]*$", + type: "string", + }, + ], + [ + "monitoring.incidents", + "incidents.list", + { maxLength: 200, pattern: "\\S", type: "string" }, + ], + [ + "monitoring.notifications", + "notifications.list", + { maxLength: 200, pattern: "\\S", type: "string" }, + ], + [ + "monitoring.reports", + "reports.list", + { maxLength: 200, pattern: "\\S", type: "string" }, + ], + [ + "tasks.records", + "tasks.list", + { + format: "uuid", + maxLength: 36, + minLength: 36, + pattern: + "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + type: "string", + }, + ], + ] as const) { + expect(realtimeDocumentation).toContain( + `| \`${topic}\` | [payload](./schemas/${topic}.realtime.payload.schema.json) | \`${snapshot}\` | 7 days |` + ); + const payloadSchema = JSON.parse( + first.get(`schemas/${topic}.realtime.payload.schema.json`) ?? "null" + ) as unknown; + expect(payloadSchema).toMatchObject({ + $id: `urn:mira-dashboard:${topic}.realtime.payload`, + additionalProperties: false, + properties: { id: idSchema }, + required: ["id"], + type: "object", + }); + } + expect(realtimeDocumentation?.match(/^\| `/gmu)).toHaveLength(5); }); test("emits JSON Schema from the same Valibot transport schemas", () => { diff --git a/greenfield/scripts/documentation/jsonSchema.test.ts b/greenfield/scripts/documentation/jsonSchema.test.ts index 26b9d5e30..3209443b9 100644 --- a/greenfield/scripts/documentation/jsonSchema.test.ts +++ b/greenfield/scripts/documentation/jsonSchema.test.ts @@ -22,6 +22,13 @@ import { createAutomationPrincipalResultSchema, listAutomationPrincipalsResultSchema, } from "../../src/contracts/automationSecurity.ts"; +import { listIncidentsResultSchema } from "../../src/contracts/incidents.ts"; +import { + completeMonitoringSnapshotInputSchema, + monitoringJsonObjectSchema, +} from "../../src/contracts/monitoring.ts"; +import { listNotificationsResultSchema } from "../../src/contracts/notifications.ts"; +import { listReportsResultSchema } from "../../src/contracts/reports.ts"; import { applicationCapabilityListSchema } from "../../src/contracts/security.ts"; import { listSecurityAuditEventsResultSchema } from "../../src/contracts/securityAudit.ts"; import { @@ -37,6 +44,7 @@ import { webAuthnAuthenticationResponseSchema, webAuthnTransportListSchema, } from "../../src/contracts/webauthn.ts"; +import { jsonObjectSchema } from "../../src/shared/json.ts"; import { boundedNonBlankTextSchema, hasUniqueArrayItems, @@ -105,13 +113,16 @@ describe("contract JSON Schema conversion", () => { enum: [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write", ], }, - maxItems: 6, + maxItems: 9, type: "array", uniqueItems: true, }); @@ -255,6 +266,64 @@ describe("contract JSON Schema conversion", () => { expect(historyDocument).toContain("task-history cursor"); }); + test("documents monitoring JSON, aggregate, ordering, and cursor constraints", () => { + const jsonObjectDocument = convertContractSchema( + jsonObjectSchema, + "test.jsonObject", + "input" + ); + expect(jsonObjectDocument).toMatchObject({ type: "object" }); + expect(jsonObjectDocument.$comment).toContain("acyclic plain JSON object"); + + const monitoringJsonDocument = convertContractSchema( + monitoringJsonObjectSchema, + "test.monitoringJsonObject", + "input" + ); + expect(monitoringJsonDocument).toMatchObject({ type: "object" }); + expect(monitoringJsonDocument.$comment).toContain("acyclic plain JSON object"); + expect(monitoringJsonDocument.$comment).toContain( + "serialized JSON object to its reviewed UTF-8 byte budget" + ); + + const snapshotDocument = JSON.stringify( + convertContractSchema( + completeMonitoringSnapshotInputSchema, + "test.monitoringSnapshot", + "input" + ) + ); + expect(snapshotDocument).toContain("snapshot completion not to precede"); + expect(snapshotDocument).toContain("aggregate UTF-8 byte budget"); + + for (const [schema, schemaId, rowOrder, cursor] of [ + [ + listIncidentsResultSchema, + "test.incidentPage", + "strict newest-first incident ordering", + "incident continuation cursor", + ], + [ + listNotificationsResultSchema, + "test.notificationPage", + "strict newest-first notification ordering", + "notification continuation cursor", + ], + [ + listReportsResultSchema, + "test.reportPage", + "strict newest-first report ordering", + "report continuation cursor", + ], + ] as const) { + const document = JSON.stringify( + convertContractSchema(schema, schemaId, "output") + ); + expect(document).toContain(rowOrder); + expect(document).toContain(cursor); + } + }); + test("documents task bounds, canonicalization, and runtime relationships", () => { const titleDocument = JSON.stringify( convertContractSchema(taskTitleSchema, "test.taskTitle", "input") @@ -383,5 +452,10 @@ describe("contract JSON Schema conversion", () => { expect(() => convertContractSchema(schema, "test.unknown", "input")).toThrow( 'The "check" action cannot be converted to JSON Schema.' ); + + const customSchema = v.custom>(() => true); + expect(() => + convertContractSchema(customSchema, "test.unknownCustom", "input") + ).toThrow('The "custom" schema cannot be converted to JSON Schema.'); }); }); diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index 50598ffe2..bc883a1ac 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -37,7 +37,32 @@ import { revokedAutomationCredentialResultIsConsistent, rotatedAutomationCredentialResultIsConsistent, } from "../../src/contracts/automationSecurity.ts"; +import { + incidentPageCursorIsConsistent, + newestIncidentOrderIsStable, +} from "../../src/contracts/incidents.ts"; +import { + activeIncidentSummaryTimesAreConsistent, + activeIncidentTimesAreConsistent, + completeMonitoringSnapshotFitsBudget, + completeMonitoringSnapshotTimesAreConsistent, + monitoringJsonObjectFitsBudget, + notificationIncidentReferenceIsConsistent, + notificationTimesAreConsistent, + resolvedIncidentSummaryTimesAreConsistent, + resolvedIncidentTimesAreConsistent, +} from "../../src/contracts/monitoring.ts"; +import { + newestNotificationOrderIsStable, + notificationInputIncidentReferenceIsConsistent, + notificationPageCursorIsConsistent, +} from "../../src/contracts/notifications.ts"; import type { ContractSchema } from "../../src/contracts/registry.ts"; +import { + newestReportOrderIsStable, + reportPageCursorIsConsistent, + upsertReportInputFitsBudget, +} from "../../src/contracts/reports.ts"; import { isValidSecurityLabel, securityLabelMaximumLength, @@ -69,6 +94,7 @@ import { isCanonicalWebAuthnBase64Url, sortWebAuthnTransports, } from "../../src/contracts/webauthn.ts"; +import { jsonObjectSchema } from "../../src/shared/json.ts"; import { getBoundedNonBlankTextMaximumLength, hasNoNulCharacter, @@ -123,6 +149,74 @@ const runtimeCheckComments = new Map([ canonicalAgentStatuses, "Live Valibot validation additionally requires one canonically ordered status per configured agent ID.", ], + [ + monitoringJsonObjectFitsBudget, + "Live Valibot validation additionally limits the serialized JSON object to its reviewed UTF-8 byte budget.", + ], + [ + completeMonitoringSnapshotTimesAreConsistent, + "Live Valibot validation additionally requires snapshot completion not to precede snapshot start.", + ], + [ + completeMonitoringSnapshotFitsBudget, + "Live Valibot validation additionally limits the complete snapshot to its reviewed aggregate UTF-8 byte budget.", + ], + [ + activeIncidentTimesAreConsistent, + "Live Valibot validation additionally requires active-incident observation timestamps to be monotonic.", + ], + [ + resolvedIncidentTimesAreConsistent, + "Live Valibot validation additionally requires resolved-incident observation and resolution timestamps to be monotonic.", + ], + [ + activeIncidentSummaryTimesAreConsistent, + "Live Valibot validation additionally requires active-incident summary timestamps to be monotonic.", + ], + [ + resolvedIncidentSummaryTimesAreConsistent, + "Live Valibot validation additionally requires resolved-incident summary timestamps to be monotonic.", + ], + [ + notificationIncidentReferenceIsConsistent, + "Live Valibot validation additionally requires notification incident ID and generation to be present together.", + ], + [ + notificationTimesAreConsistent, + "Live Valibot validation additionally requires notification read time not to precede occurrence time.", + ], + [ + newestIncidentOrderIsStable, + "Live Valibot validation additionally requires strict newest-first incident ordering by last-seen timestamp and ID.", + ], + [ + incidentPageCursorIsConsistent, + "Live Valibot validation additionally requires an incident continuation cursor to identify the returned last row.", + ], + [ + newestNotificationOrderIsStable, + "Live Valibot validation additionally requires strict newest-first notification ordering by occurrence timestamp and ID.", + ], + [ + notificationPageCursorIsConsistent, + "Live Valibot validation additionally requires a notification continuation cursor to identify the returned last row.", + ], + [ + notificationInputIncidentReferenceIsConsistent, + "Live Valibot validation additionally requires producer incident ID and generation to be present together.", + ], + [ + newestReportOrderIsStable, + "Live Valibot validation additionally requires strict newest-first report ordering by occurrence timestamp and ID.", + ], + [ + reportPageCursorIsConsistent, + "Live Valibot validation additionally requires a report continuation cursor to identify the returned last row.", + ], + [ + upsertReportInputFitsBudget, + "Live Valibot validation additionally limits report producer input to its reviewed aggregate UTF-8 byte budget.", + ], [ automationCredentialTimesAreOrdered, "Live Valibot validation additionally requires credential expiry after creation and revocation no earlier than creation.", @@ -294,6 +388,15 @@ export function convertContractSchema( $id: `urn:mira-dashboard:${schemaId}`, ...toJsonSchema(schema, { errorMode: "throw", + overrideSchema({ jsonSchema, valibotSchema }) { + if (valibotSchema === jsonObjectSchema) { + return appendJsonSchemaComment( + { ...jsonSchema, type: "object" }, + "Live Valibot validation additionally requires an acyclic plain JSON object with bounded depth, finite safe-magnitude numbers, and no sparse arrays." + ); + } + return null; + }, overrideAction({ jsonSchema, valibotAction }) { const requirement = readActionRequirement(valibotAction); const operation = readActionOperation(valibotAction); diff --git a/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts index bfbd38019..fcf11a050 100644 --- a/greenfield/src/app/dashboardServer.test.ts +++ b/greenfield/src/app/dashboardServer.test.ts @@ -6,6 +6,12 @@ import path from "node:path"; import * as v from "valibot"; import { listAutomationPrincipalsResultSchema } from "../contracts/automationSecurity.ts"; +import { + monitoringSubmissionResultSchema, + reportDetailSchema, +} from "../contracts/monitoring.ts"; +import { automationPrincipalCapabilities } from "../server/database/schema/automationPrincipalCapabilities.ts"; +import { automationPrincipalCapabilityInsertSchema } from "../server/database/validation/automationPrincipalCapabilities.ts"; import { createWebAuthnRelyingPartyConfiguration } from "../server/domains/security/mfa/webauthn/relyingPartyConfiguration.ts"; import { authenticationTestNow, @@ -110,6 +116,16 @@ describe("Dashboard security composition", () => { database, authenticationTestNow ); + database + .insert(automationPrincipalCapabilities) + .values( + v.parse(automationPrincipalCapabilityInsertSchema, { + capability: "monitoring:write", + grantedAt: authenticationTestNow, + principalId: authenticationTestPrincipalId, + }) + ) + .run(); server = await createDashboardServer({ applicationRuntime, browserOrigin: "https://dashboard.example", @@ -147,10 +163,79 @@ describe("Dashboard security composition", () => { result.principals.find(({ id }) => id === authenticationTestPrincipalId) ).toMatchObject({ activeCredentialCount: 1, - capabilities: ["reports:read"], + capabilities: ["monitoring:write", "reports:read"], disabled: false, id: authenticationTestPrincipalId, }); + + const nowMs = authenticationTestNow.getTime(); + const snapshot = { + completedAtMs: nowMs - 1000, + monitorKey: "dashboard-composition", + problems: [], + report: { + bodyMarkdown: "# Production composition", + kind: "composition", + metadata: { source: "dashboard-server-test" }, + source: "dashboard", + sourceJobId: "composition-test", + title: "Production composition", + }, + runId: "018f6f50-6a9e-7b88-8000-000000000001", + startedAtMs: nowMs - 2000, + }; + const ingestionResponse = await fetch( + new URL("/trpc/monitoring.submitCompleteSnapshot", server.url), + { + body: JSON.stringify({ json: snapshot }), + headers: { + authorization: `Bearer ${fixture.automation.token}`, + "content-type": "application/json", + }, + method: "POST", + } + ); + const ingestionBody = (await ingestionResponse.json()) as { + readonly error?: unknown; + readonly result?: { readonly data?: { readonly json?: unknown } }; + }; + expect(ingestionResponse.status).toBe(200); + expect(ingestionBody.error).toBeUndefined(); + const ingestion = v.parse( + monitoringSubmissionResultSchema, + ingestionBody.result?.data?.json + ); + expect(ingestion).toMatchObject({ + createdIncidents: 0, + observedIncidents: 0, + status: "accepted", + }); + expect(ingestion.reportId).not.toBeNull(); + + const reportInput = encodeURIComponent( + JSON.stringify({ json: { id: ingestion.reportId } }) + ); + const reportResponse = await fetch( + new URL(`/trpc/reports.get?input=${reportInput}`, server.url), + { + headers: { + cookie: `${dashboardSessionCookieName}=${fixture.session.token}`, + }, + } + ); + const reportBody = (await reportResponse.json()) as { + readonly error?: unknown; + readonly result?: { readonly data?: { readonly json?: unknown } }; + }; + expect(reportResponse.status).toBe(200); + expect(reportBody.error).toBeUndefined(); + expect( + v.parse(reportDetailSchema, reportBody.result?.data?.json) + ).toMatchObject({ + bodyMarkdown: snapshot.report.bodyMarkdown, + id: ingestion.reportId, + title: snapshot.report.title, + }); } finally { try { await (server === undefined diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 54c3b8fed..99f2775c5 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -5,6 +5,9 @@ import { Redacted } from "effect"; import { createAgentRepository } from "../server/domains/agents/repository.ts"; import { createAgentService } from "../server/domains/agents/service.ts"; +import { createMonitoringCatalogService } from "../server/domains/monitoring/catalogService.ts"; +import { createMonitoringRepository } from "../server/domains/monitoring/repository.ts"; +import { createMonitoringService } from "../server/domains/monitoring/service.ts"; import { createAuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; import { createAuthenticationLifecycleRepository } from "../server/domains/security/authenticationLifecycleRepository.ts"; import { @@ -83,6 +86,8 @@ export interface DashboardServerOptions extends Omit< | "hostname" | "mfaAccountLifecycle" | "mfaLoginLifecycle" + | "monitoringCatalogService" + | "monitoringService" | "securityAuditLifecycle" | "taskService" > { @@ -246,17 +251,42 @@ export async function createDashboardServer( sessionIdleDurationMs: options.sessionIdleDurationMs, }); const domainNow = options.now; + const wakeEventPump = async (): Promise => { + try { + await options.applicationRuntime.services.realtimeEvents.wake(); + } catch (error) { + options.applicationRuntime.logger.warn({ + component: "realtime-event-pump", + event: "realtime.wake.failed", + failure: error, + outcome: "server-error", + }); + throw error; + } + }; const agentService = createAgentService({ ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), repository: createAgentRepository(database, databaseRuntime), - wakeEventPump: () => - options.applicationRuntime.services.realtimeEvents.wake(), + wakeEventPump, }); const taskService = createTaskService({ ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), repository: createTaskRepository(database, databaseRuntime), - wakeEventPump: () => - options.applicationRuntime.services.realtimeEvents.wake(), + wakeEventPump, + }); + const monitoringRepository = createMonitoringRepository( + database, + databaseRuntime + ); + const monitoringService = createMonitoringService({ + ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), + repository: monitoringRepository, + wakeEventPump, + }); + const monitoringCatalogService = createMonitoringCatalogService({ + ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), + repository: monitoringRepository, + wakeEventPump, }); const serverOptions: ServerOptions = { agentService, @@ -271,6 +301,8 @@ export async function createDashboardServer( hostname: "127.0.0.1", mfaAccountLifecycle, mfaLoginLifecycle, + monitoringCatalogService, + monitoringService, port: options.port, readiness: options.readiness, securityAuditLifecycle, diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index 015aaf7f7..54473f5fd 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -3,6 +3,8 @@ import * as v from "valibot"; import { healthLivenessPath, healthReadinessPath } from "../contracts/system.ts"; import type { AgentService } from "../server/domains/agents/service.ts"; +import type { MonitoringCatalogService } from "../server/domains/monitoring/catalogService.ts"; +import type { MonitoringService } from "../server/domains/monitoring/service.ts"; import type { AuthenticationLifecycleService } from "../server/domains/security/authenticationLifecycle.ts"; import type { AutomationSecurityLifecycleService } from "../server/domains/security/automation/lifecycle.ts"; import type { MfaAccountLifecycleService } from "../server/domains/security/mfa/accountLifecycle.ts"; @@ -138,6 +140,8 @@ export interface ServerOptions { readonly hostname?: string; readonly mfaAccountLifecycle: MfaAccountLifecycleService; readonly mfaLoginLifecycle: MfaLoginLifecycleService; + readonly monitoringCatalogService: MonitoringCatalogService["Service"]; + readonly monitoringService: MonitoringService["Service"]; readonly port: number; readonly readiness: ReadinessController; readonly securityAuditLifecycle: SecurityAuditLifecycleService; @@ -179,6 +183,8 @@ export async function createServer(options: ServerOptions): Promise { rejectsBatch: false, requestBodyMaximumBytes: taskProgressRequestBodyMaximumBytes, }); + expect(policy("/trpc/monitoring.submitCompleteSnapshot")).toEqual({ + rejectsBatch: false, + requestBodyMaximumBytes: monitoringRequestBodyMaximumBytes, + }); + expect(policy("/trpc/reports.upsert")).toEqual({ + rejectsBatch: false, + requestBodyMaximumBytes: monitoringRequestBodyMaximumBytes, + }); + expect(policy("/trpc/reports.delete")).toEqual({ + rejectsBatch: false, + requestBodyMaximumBytes: trpcRequestBodyMaximumBytes, + }); + expect(serverRequestBodyMaximumBytes).toBe( + Math.max( + taskContentRequestBodyMaximumBytes, + monitoringRequestBodyMaximumBytes + ) + ); + for (const procedureLimit of [ + authenticationRequestBodyMaximumBytes, + monitoringRequestBodyMaximumBytes, + taskContentRequestBodyMaximumBytes, + taskProgressRequestBodyMaximumBytes, + trpcRequestBodyMaximumBytes, + webAuthnRequestBodyMaximumBytes, + ]) { + expect(procedureLimit).toBeLessThanOrEqual(serverRequestBodyMaximumBytes); + } }); test("combines mixed batches using the strictest applicable policies", () => { + expect( + policy("/trpc/monitoring.submitCompleteSnapshot,tasks.updateProgress?batch=1") + ).toEqual({ + rejectsBatch: true, + requestBodyMaximumBytes: monitoringRequestBodyMaximumBytes, + }); + expect( + policy("/trpc/monitoring.submitCompleteSnapshot,auth.status?batch=1") + ).toEqual({ + handlerIdleTimeoutSeconds: authenticationHandlerIdleTimeoutSeconds, + rejectsBatch: true, + requestBodyMaximumBytes: authenticationRequestBodyMaximumBytes, + }); expect(policy("/trpc/auth.status,auth.login?batch=1")).toEqual({ handlerIdleTimeoutSeconds: authenticationHandlerIdleTimeoutSeconds, rejectsBatch: true, diff --git a/greenfield/src/app/trpcRequestPolicy.ts b/greenfield/src/app/trpcRequestPolicy.ts index 37d5c2e89..529e0f756 100644 --- a/greenfield/src/app/trpcRequestPolicy.ts +++ b/greenfield/src/app/trpcRequestPolicy.ts @@ -9,8 +9,13 @@ export const trpcRequestBodyMaximumBytes = 64 * 1024; export const taskContentRequestBodyMaximumBytes = 640 * 1024; /** Raw body ceiling for task progress create and update procedures. */ export const taskProgressRequestBodyMaximumBytes = 128 * 1024; +/** Raw body ceiling for aggregate-bounded monitoring ingestion and report writes. */ +export const monitoringRequestBodyMaximumBytes = 640 * 1024; /** Bun-level ceiling applied before the Fetch handler is invoked. */ -export const serverRequestBodyMaximumBytes = taskContentRequestBodyMaximumBytes; +export const serverRequestBodyMaximumBytes = Math.max( + taskContentRequestBodyMaximumBytes, + monitoringRequestBodyMaximumBytes +); /** Raw body ceiling for authentication and account-security procedures. */ export const authenticationRequestBodyMaximumBytes = 16 * 1024; /** Raw body ceiling for bounded WebAuthn authentication responses. */ @@ -32,17 +37,17 @@ function procedureNamespace(name: string): string | undefined { return separator <= 0 ? undefined : name.slice(0, separator); } -function usesAuthenticationTransport(contract: ProcedureContract): boolean { +function usesAuthenticationRequestBody(contract: ProcedureContract): boolean { return ( - contract.transport.handler === "authentication" || - usesAuthenticationRequestBody(contract) + contract.transport.requestBody === "authentication" || + contract.transport.requestBody === "webauthn" ); } -function usesAuthenticationRequestBody(contract: ProcedureContract): boolean { +function usesAuthenticationTransport(contract: ProcedureContract): boolean { return ( - contract.transport.requestBody === "authentication" || - contract.transport.requestBody === "webauthn" + contract.transport.handler === "authentication" || + usesAuthenticationRequestBody(contract) ); } @@ -125,6 +130,7 @@ function effectivePolicy(input: { readonly containsAuthenticationProcedure: boolean; readonly containsForbiddenBatchProcedure: boolean; readonly containsLongLivedProcedure: boolean; + readonly containsMonitoringProcedure: boolean; readonly containsTaskContentProcedure: boolean; readonly containsTaskProgressProcedure: boolean; readonly containsWebAuthnProcedure: boolean; @@ -138,12 +144,26 @@ function effectivePolicy(input: { } const handlerPolicy = handlerIdleTimeoutSeconds === undefined ? {} : { handlerIdleTimeoutSeconds }; + // Ordinary body profiles are allowances and compose by maximum. Authentication + // profiles below remain intentional restrictive ceilings for their namespaces. let requestBodyMaximumBytes = trpcRequestBodyMaximumBytes; + if (input.containsMonitoringProcedure) { + requestBodyMaximumBytes = Math.max( + requestBodyMaximumBytes, + monitoringRequestBodyMaximumBytes + ); + } if (input.containsTaskContentProcedure) { - requestBodyMaximumBytes = taskContentRequestBodyMaximumBytes; + requestBodyMaximumBytes = Math.max( + requestBodyMaximumBytes, + taskContentRequestBodyMaximumBytes + ); } if (input.containsTaskProgressProcedure) { - requestBodyMaximumBytes = taskProgressRequestBodyMaximumBytes; + requestBodyMaximumBytes = Math.max( + requestBodyMaximumBytes, + taskProgressRequestBodyMaximumBytes + ); } if (input.containsAuthenticationProcedure) { requestBodyMaximumBytes = authenticationRequestBodyMaximumBytes; @@ -184,6 +204,7 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { let containsAuthenticationProcedure = false; let containsForbiddenBatchProcedure = false; let containsLongLivedProcedure = false; + let containsMonitoringProcedure = false; let containsTaskContentProcedure = false; let containsTaskProgressProcedure = false; let containsWebAuthnProcedure = false; @@ -196,6 +217,8 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { contract.transport.batching === "forbidden"; containsLongLivedProcedure ||= contract.transport.handler === "long-lived"; + containsMonitoringProcedure ||= + contract.transport.requestBody === "monitoring"; containsTaskContentProcedure ||= contract.transport.requestBody === "task-content"; containsTaskProgressProcedure ||= @@ -214,6 +237,7 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { containsAuthenticationProcedure, containsForbiddenBatchProcedure, containsLongLivedProcedure, + containsMonitoringProcedure, containsTaskContentProcedure, containsTaskProgressProcedure, containsWebAuthnProcedure, @@ -226,6 +250,7 @@ export function readTrpcRequestPolicy(url: URL): TrpcRequestPolicy { containsAuthenticationProcedure, containsForbiddenBatchProcedure: containsAuthenticationProcedure, containsLongLivedProcedure: false, + containsMonitoringProcedure: false, containsTaskContentProcedure: false, containsTaskProgressProcedure: false, containsWebAuthnProcedure: false, diff --git a/greenfield/src/contracts/agentRealtime.ts b/greenfield/src/contracts/agentRealtime.ts index 95ab5893e..be806a741 100644 --- a/greenfield/src/contracts/agentRealtime.ts +++ b/greenfield/src/contracts/agentRealtime.ts @@ -2,7 +2,8 @@ import * as v from "valibot"; import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; import { agentIdSchema } from "./agentModel.ts"; -import type { RealtimeTopicDefinition } from "./realtime.ts"; +import { realtimeEventRetentionLabel, type RealtimeTopicDefinition } from "./realtime.ts"; +import type { RealtimeEventContract } from "./registry.ts"; /** Durable topic carrying compact agent current-task invalidations. */ export const agentRealtimeTopic = "agents.status"; @@ -29,6 +30,16 @@ export const agentRealtimeTopicDefinition = { topic: agentRealtimeTopic, } as const satisfies RealtimeTopicDefinition; +/** Standalone agent invalidation topic tied to its authoritative snapshot query. */ +export const agentRealtimeEventContract = { + payload: agentChangePayloadSchema, + payloadSchemaId: "agents.status.realtime.payload", + retention: realtimeEventRetentionLabel, + snapshotProcedure: "agents.listStatuses", + summary: "Invalidates one agent status row after a durable metadata change.", + topic: agentRealtimeTopic, +} as const satisfies RealtimeEventContract; + /** Client-visible validated agent status change event. */ export const agentRealtimeChangeSchema = v.strictObject({ entityId: agentIdSchema, diff --git a/greenfield/src/contracts/contractRegistry.ts b/greenfield/src/contracts/contractRegistry.ts index e5635e0af..95ca05e2e 100644 --- a/greenfield/src/contracts/contractRegistry.ts +++ b/greenfield/src/contracts/contractRegistry.ts @@ -1,16 +1,23 @@ import { accountSecurityProcedureContracts } from "./accountSecurity.ts"; +import { agentRealtimeEventContract } from "./agentRealtime.ts"; import { agentProcedureContracts } from "./agents.ts"; import { authProcedureContracts } from "./auth.ts"; import { automationSecurityProcedureContracts } from "./automationSecurity.ts"; import { eventsStreamContract } from "./events.ts"; +import { incidentProcedureContracts } from "./incidents.ts"; +import { monitoringProcedureContracts } from "./monitoringIngestion.ts"; +import { monitoringRealtimeEventContracts } from "./monitoringRealtime.ts"; +import { notificationProcedureContracts } from "./notifications.ts"; import { assertProcedureContractErrors, type ProcedureContract, type RawHttpContract, type RealtimeEventContract, } from "./registry.ts"; +import { reportProcedureContracts } from "./reports.ts"; import { securityAuditProcedureContracts } from "./securityAudit.ts"; import { systemProcedureContracts, systemRawHttpContracts } from "./system.ts"; +import { taskRealtimeEventContract } from "./taskRealtime.ts"; import { taskProcedureContracts } from "./tasks.ts"; /** Implemented tRPC procedure metadata used by runtime wiring and docs. */ @@ -20,6 +27,10 @@ const registeredProcedureContracts = [ ...authProcedureContracts, ...automationSecurityProcedureContracts, eventsStreamContract, + ...incidentProcedureContracts, + ...monitoringProcedureContracts, + ...notificationProcedureContracts, + ...reportProcedureContracts, ...securityAuditProcedureContracts, ...systemProcedureContracts, ...taskProcedureContracts, @@ -61,4 +72,8 @@ export const procedureContracts = Object.freeze( export const rawHttpContracts: readonly RawHttpContract[] = [...systemRawHttpContracts]; /** Implemented realtime topics used by runtime wiring and docs. */ -export const realtimeEventContracts: readonly RealtimeEventContract[] = []; +export const realtimeEventContracts: readonly RealtimeEventContract[] = Object.freeze([ + agentRealtimeEventContract, + ...monitoringRealtimeEventContracts, + taskRealtimeEventContract, +]); diff --git a/greenfield/src/contracts/filterSchemas.ts b/greenfield/src/contracts/filterSchemas.ts new file mode 100644 index 000000000..97fd456bd --- /dev/null +++ b/greenfield/src/contracts/filterSchemas.ts @@ -0,0 +1,33 @@ +import * as v from "valibot"; + +import { hasUniqueArrayItems } from "../shared/validation.ts"; + +/** @returns One bounded, non-empty filter with unique string values. */ +export function uniqueFilterSchema< + TOutput extends string, + TSchema extends v.GenericSchema, +>(item: TSchema, label: string, maximum: number) { + return v.pipe( + v.array(item, `${label} filter is invalid`), + v.minLength(1, `${label} filter cannot be empty`), + v.maxLength(maximum, `${label} filter is outside its budget`), + v.check(hasUniqueArrayItems, `${label} filter values must be unique`) + ); +} + +/** @returns One bounded, non-empty filter from a fixed string vocabulary. */ +export function enumFilterSchema( + values: TValues, + label: string, + maximum: number +) { + return v.pipe( + v.array(v.picklist(values, `${label} value is invalid`)), + v.minLength(1, `${label} filter cannot be empty`), + v.maxLength(maximum, `${label} filter is outside its budget`), + v.check( + hasUniqueArrayItems, + `${label} filter values must be unique` + ) + ); +} diff --git a/greenfield/src/contracts/incidents.ts b/greenfield/src/contracts/incidents.ts new file mode 100644 index 000000000..f30bdf6a5 --- /dev/null +++ b/greenfield/src/contracts/incidents.ts @@ -0,0 +1,161 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { enumFilterSchema, uniqueFilterSchema } from "./filterSchemas.ts"; +import { + type IncidentSummary, + incidentRecordSchema, + incidentSummarySchema, + monitoringKindSchema, + monitoringMonitorKeySchema, + monitoringRecordIdSchema, + monitoringSeverities, +} from "./monitoring.ts"; +import type { ProcedureContract } from "./registry.ts"; + +/** Default incidents returned by one list request. */ +export const incidentPageDefault = 50; + +/** Hard incident-row budget for one response. */ +export const incidentPageMaximum = 100; + +const incidentFilterMaximum = 16; +const incidentTimestampSchema = timestampMillisecondsSchema( + "Incident timestamp is invalid" +); +const incidentLimitSchema = v.pipe( + v.number("Incident page limit is invalid"), + v.safeInteger("Incident page limit is invalid"), + v.minValue(1, "Incident page limit is invalid"), + v.maxValue(incidentPageMaximum, "Incident page limit is outside its budget") +); + +/** Stable newest-first incident cursor. */ +export const incidentCursorSchema = v.strictObject({ + id: monitoringRecordIdSchema, + lastSeenAtMs: incidentTimestampSchema, +}); + +/** Bounded filters supported by incident lifecycle reads. */ +export const incidentListFiltersSchema = v.strictObject({ + kinds: v.optional( + uniqueFilterSchema(monitoringKindSchema, "Incident kind", incidentFilterMaximum) + ), + monitorKeys: v.optional( + uniqueFilterSchema( + monitoringMonitorKeySchema, + "Incident monitor", + incidentFilterMaximum + ) + ), + severities: v.optional( + enumFilterSchema(monitoringSeverities, "Incident severity", incidentFilterMaximum) + ), + states: v.optional( + enumFilterSchema(["active", "resolved"], "Incident state", incidentFilterMaximum) + ), +}); + +/** One stable keyset-paginated incident request. */ +export const listIncidentsInputSchema = v.strictObject({ + cursor: v.optional(incidentCursorSchema), + filters: v.optional(incidentListFiltersSchema), + limit: v.optional(incidentLimitSchema, incidentPageDefault), +}); + +/** + * @param incidents Candidate incident page. + * @returns Whether incidents use strict newest-first last-seen ordering. + */ +export function newestIncidentOrderIsStable(incidents: IncidentSummary[]): boolean { + return incidents.every((incident, index) => { + const previous = incidents[index - 1]; + return ( + previous === undefined || + incident.lastSeenAtMs < previous.lastSeenAtMs || + (incident.lastSeenAtMs === previous.lastSeenAtMs && incident.id < previous.id) + ); + }); +} + +const incidentRowsSchema = v.pipe( + v.array(incidentSummarySchema, "Incident page is invalid"), + v.maxLength(incidentPageMaximum, "Incident page is outside its budget"), + v.check(newestIncidentOrderIsStable, "Incident page order is invalid") +); + +const listIncidentsResultObjectSchema = v.strictObject({ + incidents: incidentRowsSchema, + nextCursor: v.optional(incidentCursorSchema), +}); + +type ListIncidentsResultValue = v.InferOutput; + +/** @returns Whether an optional incident cursor identifies the final row. */ +export function incidentPageCursorIsConsistent( + result: ListIncidentsResultValue +): boolean { + if (result.nextCursor === undefined) return true; + const last = result.incidents.at(-1); + return ( + last !== undefined && + last.id === result.nextCursor.id && + last.lastSeenAtMs === result.nextCursor.lastSeenAtMs + ); +} + +/** One bounded incident page plus an exact continuation cursor. */ +export const listIncidentsResultSchema = v.pipe( + listIncidentsResultObjectSchema, + v.check(incidentPageCursorIsConsistent, "Incident page cursor is inconsistent") +); + +/** Exact incident lookup request. */ +export const getIncidentInputSchema = v.strictObject({ + id: monitoringRecordIdSchema, +}); + +const incidentReadAccess = { + capabilities: ["reports:read"], + capabilityPolicy: "all", + kind: "authenticated", +} as const; +const queryTransport = { + batching: "adapter-default", + handler: "default", + requestBody: "default", +} as const; + +/** Implemented incident lifecycle read procedure metadata. */ +export const incidentProcedureContracts = [ + { + access: incidentReadAccess, + domain: "incidents", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: listIncidentsInputSchema, + inputSchemaId: "incidents.list.input", + kind: "query", + name: "incidents.list", + output: listIncidentsResultSchema, + outputSchemaId: "incidents.list.output", + summary: "Lists stable incident lifecycle rows for report navigation.", + transport: queryTransport, + }, + { + access: incidentReadAccess, + domain: "incidents", + errors: ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + input: getIncidentInputSchema, + inputSchemaId: "incidents.get.input", + kind: "query", + name: "incidents.get", + output: incidentRecordSchema, + outputSchemaId: "incidents.get.output", + summary: "Loads one exact incident lifecycle record.", + transport: queryTransport, + }, +] as const satisfies readonly ProcedureContract[]; + +export type GetIncidentInput = v.InferOutput; +export type ListIncidentsInput = v.InferOutput; +export type ListIncidentsResult = v.InferOutput; diff --git a/greenfield/src/contracts/monitoring.test.ts b/greenfield/src/contracts/monitoring.test.ts new file mode 100644 index 000000000..961bc98c0 --- /dev/null +++ b/greenfield/src/contracts/monitoring.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { utf8ByteLength } from "../shared/encoding.ts"; +import { + completeMonitoringSnapshotInputSchema, + monitoringMutationInputMaximumBytes, +} from "./monitoring.ts"; + +const runId = "018f6f50-6a9e-7b88-8000-000000000001"; + +function snapshot(bodyMarkdown: string) { + return { + completedAtMs: 2000, + monitorKey: "dashboard-health", + problems: [], + report: { + bodyMarkdown, + kind: "health", + metadata: {}, + source: "dashboard", + sourceJobId: "health", + title: "Dashboard health", + }, + runId, + startedAtMs: 1000, + }; +} + +describe("monitoring mutation input budgets", () => { + test("accepts a substantial snapshot below the aggregate encoded budget", () => { + const result = v.safeParse( + completeMonitoringSnapshotInputSchema, + snapshot("A".repeat(400 * 1024)) + ); + + expect(result.success).toBeTrue(); + if (result.success) { + expect(utf8ByteLength(JSON.stringify(result.output))).toBeLessThan( + monitoringMutationInputMaximumBytes + ); + } + }); + + test("rejects a field-valid snapshot above the aggregate encoded budget", () => { + const input = snapshot("A".repeat(600 * 1024)); + expect(utf8ByteLength(JSON.stringify(input))).toBeGreaterThan( + monitoringMutationInputMaximumBytes + ); + + const result = v.safeParse(completeMonitoringSnapshotInputSchema, input); + expect(result.success).toBeFalse(); + if (!result.success) { + expect(result.issues.at(-1)?.message).toContain( + String(monitoringMutationInputMaximumBytes) + ); + } + }); +}); diff --git a/greenfield/src/contracts/monitoring.ts b/greenfield/src/contracts/monitoring.ts index f24cd6256..1dc5911d0 100644 --- a/greenfield/src/contracts/monitoring.ts +++ b/greenfield/src/contracts/monitoring.ts @@ -5,6 +5,7 @@ import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; import { utf8ByteLength } from "../shared/encoding.ts"; import { jsonObjectSchema, type JsonObject } from "../shared/json.ts"; import { + boundedControlSafeTextSchema, boundedNonBlankStringSchema, lowercaseUuidV7Schema, } from "../shared/validation.ts"; @@ -12,27 +13,63 @@ import { export type { JsonObject, JsonValue } from "../shared/json.ts"; export const monitoringJsonObjectMaximumBytes = 64 * 1024; +/** Aggregate parsed-input budget retained below the 640 KiB monitoring transport ceiling. */ +export const monitoringMutationInputMaximumBytes = 512 * 1024; const maximumReportBodyCharacters = 1_000_000; function encodedJsonBytes(value: JsonObject): number { return utf8ByteLength(JSON.stringify(value)); } +/** @returns Whether one monitoring JSON object fits its encoded field budget. */ +export function monitoringJsonObjectFitsBudget(value: JsonObject): boolean { + return encodedJsonBytes(value) <= monitoringJsonObjectMaximumBytes; +} + +/** + * @param value Parsed monitoring mutation input. + * @returns Whether its canonical JSON representation fits the aggregate semantic budget. + */ +export function monitoringMutationInputFitsBudget(value: unknown): boolean { + return utf8ByteLength(JSON.stringify(value)) <= monitoringMutationInputMaximumBytes; +} + /** JSON object accepted by monitoring report metadata and incident details. */ export const monitoringJsonObjectSchema = v.pipe( jsonObjectSchema, v.check( - (value) => encodedJsonBytes(value) <= monitoringJsonObjectMaximumBytes, + monitoringJsonObjectFitsBudget, `Expected JSON no larger than ${monitoringJsonObjectMaximumBytes} encoded bytes.` ) ); const monitoringTimestampMillisecondsSchema = timestampMillisecondsSchema(); +/** Stable UUIDv7 identity shared by monitoring records. */ +export const monitoringRecordIdSchema = lowercaseUuidV7Schema( + "Expected a lowercase UUIDv7 monitoring record id." +); + const monitoringRunIdSchema = lowercaseUuidV7Schema( "Expected a lowercase UUIDv7 monitor run id." ); +export const monitoringSeverities = ["critical", "error", "info", "warning"] as const; + +export const monitoringReportStatuses = ["error", "ok", "warning"] as const; + +/** Shared incident and notification severity vocabulary. */ +export const monitoringSeveritySchema = v.picklist( + monitoringSeverities, + "Monitoring severity is invalid" +); + +/** Stable operator-facing report status vocabulary. */ +export const monitoringReportStatusSchema = v.picklist( + monitoringReportStatuses, + "Monitoring report status is invalid" +); + /** Shared persisted monitoring kind policy. */ export const monitoringKindSchema = boundedNonBlankStringSchema(100); @@ -54,16 +91,28 @@ export const monitoringReportSourceSchema = boundedNonBlankStringSchema(200); /** Shared persisted report source-job policy. */ export const monitoringReportSourceJobIdSchema = boundedNonBlankStringSchema(200); +/** Optional compact report summary shown in list surfaces. */ +export const monitoringReportSummarySchema = boundedControlSafeTextSchema( + 2000, + "Monitoring report summary is invalid" +); + /** Shared persisted report title policy. */ export const monitoringReportTitleSchema = boundedNonBlankStringSchema(500); +/** Safe same-origin route retained by one Dashboard notification. */ +export const monitoringLinkPathSchema = v.pipe( + boundedControlSafeTextSchema(2048, "Monitoring link is invalid"), + v.regex(/^\/(?!\/)[^\s\\]*$/u, "Monitoring link is invalid") +); + /** One observed problem in a complete monitor snapshot. */ export const monitoringProblemInputSchema = v.strictObject({ condition: boundedNonBlankStringSchema(200), details: v.optional(monitoringJsonObjectSchema, {}), entityKey: boundedNonBlankStringSchema(200), kind: monitoringKindSchema, - severity: v.picklist(["critical", "error", "info", "warning"]), + severity: monitoringSeveritySchema, title: monitoringProblemTitleSchema, }); @@ -74,6 +123,7 @@ export const monitoringReportInputSchema = v.strictObject({ metadata: v.optional(monitoringJsonObjectSchema, {}), source: monitoringReportSourceSchema, sourceJobId: monitoringReportSourceJobIdSchema, + summary: v.optional(monitoringReportSummarySchema), title: monitoringReportTitleSchema, }); @@ -82,28 +132,284 @@ const monitoringProblemsInputSchema = v.pipe( v.maxLength(100) ); +const completeMonitoringSnapshotInputObjectSchema = v.strictObject({ + completedAtMs: monitoringTimestampMillisecondsSchema, + monitorKey: monitoringMonitorKeySchema, + problems: monitoringProblemsInputSchema, + report: monitoringReportInputSchema, + runId: monitoringRunIdSchema, + startedAtMs: monitoringTimestampMillisecondsSchema, +}); + +type CompleteMonitoringSnapshotInputValue = v.InferOutput< + typeof completeMonitoringSnapshotInputObjectSchema +>; + +/** @returns Whether one complete snapshot has monotonic run timestamps. */ +export function completeMonitoringSnapshotTimesAreConsistent( + input: CompleteMonitoringSnapshotInputValue +): boolean { + return compareAsc(input.completedAtMs, input.startedAtMs) >= 0; +} + +/** @returns Whether one complete snapshot fits its aggregate encoded budget. */ +export function completeMonitoringSnapshotFitsBudget( + input: CompleteMonitoringSnapshotInputValue +): boolean { + return monitoringMutationInputFitsBudget(input); +} + /** Successful full-replacement snapshot accepted by the monitoring domain service. */ export const completeMonitoringSnapshotInputSchema = v.pipe( - v.strictObject({ - completedAtMs: monitoringTimestampMillisecondsSchema, - monitorKey: monitoringMonitorKeySchema, - problems: monitoringProblemsInputSchema, - report: monitoringReportInputSchema, - runId: monitoringRunIdSchema, - startedAtMs: monitoringTimestampMillisecondsSchema, - }), + completeMonitoringSnapshotInputObjectSchema, v.check( - (input) => compareAsc(input.completedAtMs, input.startedAtMs) >= 0, + completeMonitoringSnapshotTimesAreConsistent, "Expected completedAtMs to be greater than or equal to startedAtMs." + ), + v.check( + completeMonitoringSnapshotFitsBudget, + `Expected monitoring mutation input no larger than ${monitoringMutationInputMaximumBytes} encoded bytes.` ) ); -/** Compact durable outbox payload used to invalidate one changed entity. */ +/** + * Compact durable outbox payload used to invalidate changed catalog state. + * For `snapshot-required`, the id is causal correlation only; consumers refetch the + * topic's complete snapshot instead of resolving it as one entity identity. + */ export const monitoringChangePayloadSchema = v.strictObject({ id: boundedNonBlankStringSchema(200), }); +const monitoringTransportTimestampSchema = timestampMillisecondsSchema( + "Monitoring timestamp is invalid" +); + +/** Bounded report row used by list surfaces without the potentially large body. */ +export const reportSummarySchema = v.strictObject({ + id: monitoringRecordIdSchema, + kind: monitoringKindSchema, + occurredAtMs: monitoringTransportTimestampSchema, + source: monitoringReportSourceSchema, + sourceJobId: v.optional(monitoringReportSourceJobIdSchema), + status: monitoringReportStatusSchema, + summary: v.optional(monitoringReportSummarySchema), + title: monitoringReportTitleSchema, +}); + +/** Complete immutable report document loaded by exact identity. */ +export const reportDetailSchema = v.strictObject({ + ...reportSummarySchema.entries, + bodyMarkdown: monitoringReportBodyMarkdownSchema, + metadata: monitoringJsonObjectSchema, +}); + +const incidentSummaryBaseEntries = { + fingerprint: v.pipe( + v.string("Incident fingerprint is invalid"), + v.regex(/^[0-9a-f]{64}$/u, "Incident fingerprint is invalid") + ), + firstSeenAtMs: monitoringTransportTimestampSchema, + generation: v.pipe( + v.number("Incident generation is invalid"), + v.safeInteger("Incident generation is invalid"), + v.minValue(1, "Incident generation is invalid") + ), + id: monitoringRecordIdSchema, + kind: monitoringKindSchema, + lastSeenAtMs: monitoringTransportTimestampSchema, + monitorKey: monitoringMonitorKeySchema, + occurrenceCount: v.pipe( + v.number("Incident occurrence count is invalid"), + v.safeInteger("Incident occurrence count is invalid"), + v.minValue(1, "Incident occurrence count is invalid") + ), + severity: monitoringSeveritySchema, + title: monitoringProblemTitleSchema, +}; + +const incidentBaseEntries = { + details: monitoringJsonObjectSchema, + ...incidentSummaryBaseEntries, +}; + +const activeIncidentSchema = v.strictObject({ + ...incidentBaseEntries, + state: v.literal("active"), +}); + +const resolvedIncidentSchema = v.strictObject({ + ...incidentBaseEntries, + resolvedAtMs: monitoringTransportTimestampSchema, + state: v.literal("resolved"), +}); + +const activeIncidentSummarySchema = v.strictObject({ + ...incidentSummaryBaseEntries, + state: v.literal("active"), +}); + +const resolvedIncidentSummarySchema = v.strictObject({ + ...incidentSummaryBaseEntries, + resolvedAtMs: monitoringTransportTimestampSchema, + state: v.literal("resolved"), +}); + +type ActiveIncident = v.InferOutput; +type ResolvedIncident = v.InferOutput; +type ActiveIncidentSummary = v.InferOutput; +type ResolvedIncidentSummary = v.InferOutput; + +function activeIncidentLifecycleTimesAreConsistent(incident: { + readonly firstSeenAtMs: number; + readonly lastSeenAtMs: number; +}): boolean { + return incident.lastSeenAtMs >= incident.firstSeenAtMs; +} + +function resolvedIncidentLifecycleTimesAreConsistent(incident: { + readonly firstSeenAtMs: number; + readonly lastSeenAtMs: number; + readonly resolvedAtMs: number; +}): boolean { + return ( + activeIncidentLifecycleTimesAreConsistent(incident) && + incident.resolvedAtMs >= incident.lastSeenAtMs + ); +} + +/** @returns Whether an active incident's observations are monotonic. */ +export function activeIncidentTimesAreConsistent(incident: ActiveIncident): boolean { + return activeIncidentLifecycleTimesAreConsistent(incident); +} + +/** @returns Whether a resolved incident's lifecycle timestamps are monotonic. */ +export function resolvedIncidentTimesAreConsistent(incident: ResolvedIncident): boolean { + return resolvedIncidentLifecycleTimesAreConsistent(incident); +} + +/** @returns Whether an active incident summary's observations are monotonic. */ +export function activeIncidentSummaryTimesAreConsistent( + incident: ActiveIncidentSummary +): boolean { + return activeIncidentLifecycleTimesAreConsistent(incident); +} + +/** @returns Whether a resolved incident summary's lifecycle is monotonic. */ +export function resolvedIncidentSummaryTimesAreConsistent( + incident: ResolvedIncidentSummary +): boolean { + return resolvedIncidentLifecycleTimesAreConsistent(incident); +} + +/** Complete public incident lifecycle record. */ +export const incidentRecordSchema = v.variant("state", [ + v.pipe( + activeIncidentSchema, + v.check(activeIncidentTimesAreConsistent, "Incident timestamps are inconsistent") + ), + v.pipe( + resolvedIncidentSchema, + v.check( + resolvedIncidentTimesAreConsistent, + "Incident timestamps are inconsistent" + ) + ), +]); + +/** Bounded incident list row without the potentially large details document. */ +export const incidentSummarySchema = v.variant("state", [ + v.pipe( + activeIncidentSummarySchema, + v.check( + activeIncidentSummaryTimesAreConsistent, + "Incident timestamps are inconsistent" + ) + ), + v.pipe( + resolvedIncidentSummarySchema, + v.check( + resolvedIncidentSummaryTimesAreConsistent, + "Incident timestamps are inconsistent" + ) + ), +]); + +const notificationRecordObjectSchema = v.strictObject({ + id: monitoringRecordIdSchema, + incidentGeneration: v.optional( + v.pipe( + v.number("Notification incident generation is invalid"), + v.safeInteger("Notification incident generation is invalid"), + v.minValue(1, "Notification incident generation is invalid") + ) + ), + incidentId: v.optional(monitoringRecordIdSchema), + kind: monitoringKindSchema, + linkUrl: v.optional(monitoringLinkPathSchema), + message: monitoringProblemTitleSchema, + occurredAtMs: monitoringTransportTimestampSchema, + readAtMs: v.optional(monitoringTransportTimestampSchema), + reportId: v.optional(monitoringRecordIdSchema), + severity: monitoringSeveritySchema, + source: v.optional(monitoringReportSourceSchema), + title: monitoringReportTitleSchema, +}); + +type NotificationRecordValue = v.InferOutput; + +/** @returns Whether optional incident identity and generation are present together. */ +export function notificationIncidentReferenceIsConsistent( + notification: NotificationRecordValue +): boolean { + return ( + (notification.incidentId === undefined) === + (notification.incidentGeneration === undefined) + ); +} + +/** @returns Whether read time is absent or no earlier than notification creation. */ +export function notificationTimesAreConsistent( + notification: NotificationRecordValue +): boolean { + return ( + notification.readAtMs === undefined || + notification.readAtMs >= notification.occurredAtMs + ); +} + +/** Complete public notification record. */ +export const notificationRecordSchema = v.pipe( + notificationRecordObjectSchema, + v.check( + notificationIncidentReferenceIsConsistent, + "Notification incident reference is inconsistent" + ), + v.check(notificationTimesAreConsistent, "Notification timestamps are inconsistent") +); + +/** Stable result returned by complete monitoring snapshot ingestion. */ +export const monitoringSubmissionResultSchema = v.strictObject({ + createdIncidents: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + duplicateRunId: v.boolean(), + observedIncidents: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + reopenedIncidents: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + reportId: v.nullable(monitoringRecordIdSchema), + resolvedIncidents: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + realtimeEvents: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + runId: monitoringRunIdSchema, + status: v.picklist(["accepted", "duplicate", "stale"]), +}); + export type CompleteMonitoringSnapshotInput = v.InferOutput< typeof completeMonitoringSnapshotInputSchema >; export type MonitoringProblemInput = v.InferOutput; +export type IncidentRecord = v.InferOutput; +export type IncidentSummary = v.InferOutput; +export type MonitoringSubmissionResult = v.InferOutput< + typeof monitoringSubmissionResultSchema +>; +export type NotificationRecord = v.InferOutput; +export type ReportDetail = v.InferOutput; +export type ReportSummary = v.InferOutput; diff --git a/greenfield/src/contracts/monitoringIngestion.ts b/greenfield/src/contracts/monitoringIngestion.ts new file mode 100644 index 000000000..0d1d3a655 --- /dev/null +++ b/greenfield/src/contracts/monitoringIngestion.ts @@ -0,0 +1,39 @@ +import { + completeMonitoringSnapshotInputSchema, + monitoringSubmissionResultSchema, +} from "./monitoring.ts"; +import type { ProcedureContract } from "./registry.ts"; + +const monitoringProducerAccess = { + capabilities: ["monitoring:write"], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["automation"], +} as const; + +/** Complete-snapshot monitor ingestion contract. */ +export const monitoringProcedureContracts = [ + { + access: monitoringProducerAccess, + domain: "monitoring", + errors: [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + input: completeMonitoringSnapshotInputSchema, + inputSchemaId: "monitoring.submitCompleteSnapshot.input", + kind: "mutation", + name: "monitoring.submitCompleteSnapshot", + output: monitoringSubmissionResultSchema, + outputSchemaId: "monitoring.submitCompleteSnapshot.output", + summary: "Atomically ingests one complete monitor snapshot.", + transport: { + batching: "forbidden", + handler: "default", + requestBody: "monitoring", + }, + }, +] as const satisfies readonly ProcedureContract[]; diff --git a/greenfield/src/contracts/monitoringRealtime.ts b/greenfield/src/contracts/monitoringRealtime.ts index df719c90f..196343a35 100644 --- a/greenfield/src/contracts/monitoringRealtime.ts +++ b/greenfield/src/contracts/monitoringRealtime.ts @@ -3,7 +3,8 @@ import * as v from "valibot"; import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; import { boundedNonBlankStringSchema } from "../shared/validation.ts"; import { monitoringChangePayloadSchema } from "./monitoring.ts"; -import type { RealtimeTopicDefinition } from "./realtime.ts"; +import { realtimeEventRetentionLabel, type RealtimeTopicDefinition } from "./realtime.ts"; +import type { RealtimeEventContract } from "./registry.ts"; /** Stable monitoring topics written to the durable realtime outbox. */ export const monitoringRealtimeTopics = Object.freeze({ @@ -16,8 +17,13 @@ const incidentEntityType = "incident"; const notificationEntityType = "notification"; const reportEntityType = "report"; const incidentOperations = ["created", "updated"] as const; -const notificationOperations = ["created", "updated"] as const; -const reportOperations = ["created"] as const; +const notificationOperations = [ + "created", + "deleted", + "snapshot-required", + "updated", +] as const; +const reportOperations = ["created", "deleted"] as const; const incidentRoutingEntries = { entityType: v.literal(incidentEntityType), operation: v.picklist(incidentOperations), @@ -30,7 +36,7 @@ const notificationRoutingEntries = { }; const reportRoutingEntries = { entityType: v.literal(reportEntityType), - operation: v.literal(reportOperations[0]), + operation: v.picklist(reportOperations), topic: v.literal(monitoringRealtimeTopics.reports), }; @@ -66,6 +72,34 @@ export const monitoringRealtimeTopicDefinitions = [ }, ] as const satisfies readonly RealtimeTopicDefinition[]; +/** Standalone monitoring invalidation topics and their authoritative snapshots. */ +export const monitoringRealtimeEventContracts = [ + { + payload: monitoringChangePayloadSchema, + payloadSchemaId: "monitoring.incidents.realtime.payload", + retention: realtimeEventRetentionLabel, + snapshotProcedure: "incidents.list", + summary: "Invalidates incident lifecycle rows after a complete monitor snapshot.", + topic: monitoringRealtimeTopics.incidents, + }, + { + payload: monitoringChangePayloadSchema, + payloadSchemaId: "monitoring.notifications.realtime.payload", + retention: realtimeEventRetentionLabel, + snapshotProcedure: "notifications.list", + summary: "Invalidates Dashboard notifications after catalog changes.", + topic: monitoringRealtimeTopics.notifications, + }, + { + payload: monitoringChangePayloadSchema, + payloadSchemaId: "monitoring.reports.realtime.payload", + retention: realtimeEventRetentionLabel, + snapshotProcedure: "reports.list", + summary: "Invalidates immutable reports after catalog changes.", + topic: monitoringRealtimeTopics.reports, + }, +] as const satisfies readonly RealtimeEventContract[]; + /** * Finds one exact monitoring topic policy. * @param topic Candidate durable topic. diff --git a/greenfield/src/contracts/notifications.ts b/greenfield/src/contracts/notifications.ts new file mode 100644 index 000000000..b1035fc12 --- /dev/null +++ b/greenfield/src/contracts/notifications.ts @@ -0,0 +1,341 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { enumFilterSchema, uniqueFilterSchema } from "./filterSchemas.ts"; +import { + type NotificationRecord, + monitoringKindSchema, + monitoringLinkPathSchema, + monitoringProblemTitleSchema, + monitoringRecordIdSchema, + monitoringReportSourceSchema, + monitoringReportTitleSchema, + monitoringSeverities, + monitoringSeveritySchema, + notificationRecordSchema, +} from "./monitoring.ts"; +import type { ProcedureContract } from "./registry.ts"; + +/** Default notifications returned by one list request. */ +export const notificationPageDefault = 50; + +/** Hard notification-row budget for one response or bulk mutation. */ +export const notificationPageMaximum = 100; + +const notificationFilterMaximum = 16; +const notificationTimestampSchema = timestampMillisecondsSchema( + "Notification timestamp is invalid" +); +const notificationLimitSchema = v.pipe( + v.number("Notification page limit is invalid"), + v.safeInteger("Notification page limit is invalid"), + v.minValue(1, "Notification page limit is invalid"), + v.maxValue(notificationPageMaximum, "Notification page limit is outside its budget") +); + +/** Stable newest-first notification cursor. */ +export const notificationCursorSchema = v.strictObject({ + id: monitoringRecordIdSchema, + occurredAtMs: notificationTimestampSchema, +}); + +/** Bounded notification filters shared by reads and bulk actions. */ +export const notificationFiltersSchema = v.strictObject({ + incidentId: v.optional(monitoringRecordIdSchema), + kinds: v.optional( + uniqueFilterSchema( + monitoringKindSchema, + "Notification kind", + notificationFilterMaximum + ) + ), + readState: v.optional( + v.picklist(["all", "read", "unread"], "Notification read state is invalid"), + "all" + ), + severities: v.optional( + enumFilterSchema( + monitoringSeverities, + "Notification severity", + notificationFilterMaximum + ) + ), + sources: v.optional( + uniqueFilterSchema( + monitoringReportSourceSchema, + "Notification source", + notificationFilterMaximum + ) + ), +}); + +/** One stable keyset-paginated notification request. */ +export const listNotificationsInputSchema = v.strictObject({ + cursor: v.optional(notificationCursorSchema), + filters: v.optional(notificationFiltersSchema), + limit: v.optional(notificationLimitSchema, notificationPageDefault), +}); + +/** + * @param notifications Candidate notification page. + * @returns Whether notifications use strict newest-first occurrence ordering. + */ +export function newestNotificationOrderIsStable( + notifications: NotificationRecord[] +): boolean { + return notifications.every((notification, index) => { + const previous = notifications[index - 1]; + return ( + previous === undefined || + notification.occurredAtMs < previous.occurredAtMs || + (notification.occurredAtMs === previous.occurredAtMs && + notification.id < previous.id) + ); + }); +} + +const notificationRowsSchema = v.pipe( + v.array(notificationRecordSchema, "Notification page is invalid"), + v.maxLength(notificationPageMaximum, "Notification page is outside its budget"), + v.check(newestNotificationOrderIsStable, "Notification page order is invalid") +); + +const listNotificationsResultObjectSchema = v.strictObject({ + nextCursor: v.optional(notificationCursorSchema), + notifications: notificationRowsSchema, + readCount: v.pipe( + v.number("Notification read count is invalid"), + v.safeInteger("Notification read count is invalid"), + v.minValue(0, "Notification read count is invalid") + ), + unreadCount: v.pipe( + v.number("Notification unread count is invalid"), + v.safeInteger("Notification unread count is invalid"), + v.minValue(0, "Notification unread count is invalid") + ), +}); + +type ListNotificationsResultValue = v.InferOutput< + typeof listNotificationsResultObjectSchema +>; + +/** @returns Whether an optional notification cursor identifies the final row. */ +export function notificationPageCursorIsConsistent( + result: ListNotificationsResultValue +): boolean { + if (result.nextCursor === undefined) return true; + const last = result.notifications.at(-1); + return ( + last !== undefined && + last.id === result.nextCursor.id && + last.occurredAtMs === result.nextCursor.occurredAtMs + ); +} + +/** One bounded notification page plus an exact cursor and global unread count. */ +export const listNotificationsResultSchema = v.pipe( + listNotificationsResultObjectSchema, + v.check( + notificationPageCursorIsConsistent, + "Notification page cursor is inconsistent" + ) +); + +const notificationIncidentReferenceEntries = { + incidentGeneration: v.optional( + v.pipe( + v.number("Notification incident generation is invalid"), + v.safeInteger("Notification incident generation is invalid"), + v.minValue(1, "Notification incident generation is invalid") + ) + ), + incidentId: v.optional(monitoringRecordIdSchema), +}; + +const upsertNotificationInputObjectSchema = v.strictObject({ + id: monitoringRecordIdSchema, + ...notificationIncidentReferenceEntries, + kind: monitoringKindSchema, + linkUrl: v.optional(monitoringLinkPathSchema), + message: monitoringProblemTitleSchema, + occurredAtMs: notificationTimestampSchema, + reportId: v.optional(monitoringRecordIdSchema), + severity: monitoringSeveritySchema, + source: v.optional(monitoringReportSourceSchema), + title: monitoringReportTitleSchema, +}); + +type UpsertNotificationInputValue = v.InferOutput< + typeof upsertNotificationInputObjectSchema +>; + +/** @returns Whether optional incident identity and generation are present together. */ +export function notificationInputIncidentReferenceIsConsistent( + notification: UpsertNotificationInputValue +): boolean { + return ( + (notification.incidentId === undefined) === + (notification.incidentGeneration === undefined) + ); +} + +/** Idempotent immutable notification producer input. */ +export const upsertNotificationInputSchema = v.pipe( + upsertNotificationInputObjectSchema, + v.check( + notificationInputIncidentReferenceIsConsistent, + "Notification incident reference is inconsistent" + ) +); + +/** Exact notification read/delete request. */ +export const notificationIdentityInputSchema = v.strictObject({ + id: monitoringRecordIdSchema, +}); + +/** Bulk user action over a bounded set of matching notifications. */ +export const bulkNotificationInputSchema = v.strictObject({ + filters: v.optional(v.omit(notificationFiltersSchema, ["readState"]), {}), +}); + +/** Stable bounded bulk-action acknowledgement. */ +export const bulkNotificationResultSchema = v.strictObject({ + affectedCount: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), + completedAtMs: notificationTimestampSchema, + remaining: v.boolean(), +}); + +/** Stable single-notification deletion acknowledgement. */ +export const deleteNotificationResultSchema = v.strictObject({ + deletedAtMs: notificationTimestampSchema, + id: monitoringRecordIdSchema, +}); + +const notificationReadAccess = { + capabilities: ["notifications:read"], + capabilityPolicy: "all", + kind: "authenticated", +} as const; +const notificationProducerAccess = { + capabilities: ["notifications:write"], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["automation"], +} as const; +const notificationSessionWriteAccess = { + capabilities: ["notifications:write"], + capabilityPolicy: "all", + kind: "authenticated", + principalKinds: ["session"], +} as const; +const queryTransport = { + batching: "adapter-default", + handler: "default", + requestBody: "default", +} as const; +const mutationTransport = { + batching: "forbidden", + handler: "default", + requestBody: "default", +} as const; + +/** Implemented notification inventory and acknowledgement procedure metadata. */ +export const notificationProcedureContracts = [ + { + access: notificationReadAccess, + domain: "notifications", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: listNotificationsInputSchema, + inputSchemaId: "notifications.list.input", + kind: "query", + name: "notifications.list", + output: listNotificationsResultSchema, + outputSchemaId: "notifications.list.output", + summary: "Lists a stable filtered notification page and unread count.", + transport: queryTransport, + }, + { + access: notificationProducerAccess, + domain: "notifications", + errors: [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + input: upsertNotificationInputSchema, + inputSchemaId: "notifications.upsert.input", + kind: "mutation", + name: "notifications.upsert", + output: notificationRecordSchema, + outputSchemaId: "notifications.upsert.output", + summary: "Creates a notification or accepts an exact idempotent replay.", + transport: mutationTransport, + }, + { + access: notificationSessionWriteAccess, + domain: "notifications", + errors: ["FORBIDDEN", "NOT_FOUND", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: notificationIdentityInputSchema, + inputSchemaId: "notifications.markRead.input", + kind: "mutation", + name: "notifications.markRead", + output: notificationRecordSchema, + outputSchemaId: "notifications.markRead.output", + summary: "Marks one notification read idempotently.", + transport: mutationTransport, + }, + { + access: notificationSessionWriteAccess, + domain: "notifications", + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: bulkNotificationInputSchema, + inputSchemaId: "notifications.markAllRead.input", + kind: "mutation", + name: "notifications.markAllRead", + output: bulkNotificationResultSchema, + outputSchemaId: "notifications.markAllRead.output", + summary: "Marks one bounded page of matching notifications read.", + transport: mutationTransport, + }, + { + access: notificationSessionWriteAccess, + domain: "notifications", + errors: ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: bulkNotificationInputSchema, + inputSchemaId: "notifications.clearRead.input", + kind: "mutation", + name: "notifications.clearRead", + output: bulkNotificationResultSchema, + outputSchemaId: "notifications.clearRead.output", + summary: "Deletes one bounded page of matching read notifications.", + transport: mutationTransport, + }, + { + access: notificationSessionWriteAccess, + domain: "notifications", + errors: ["FORBIDDEN", "NOT_FOUND", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + input: notificationIdentityInputSchema, + inputSchemaId: "notifications.delete.input", + kind: "mutation", + name: "notifications.delete", + output: deleteNotificationResultSchema, + outputSchemaId: "notifications.delete.output", + summary: "Deletes one exact Dashboard notification.", + transport: mutationTransport, + }, +] as const satisfies readonly ProcedureContract[]; + +export type BulkNotificationInput = v.InferOutput; +export type BulkNotificationResult = v.InferOutput; +export type DeleteNotificationResult = v.InferOutput< + typeof deleteNotificationResultSchema +>; +export type ListNotificationsInput = v.InferOutput; +export type ListNotificationsResult = v.InferOutput; +export type NotificationIdentityInput = v.InferOutput< + typeof notificationIdentityInputSchema +>; +export type UpsertNotificationInput = v.InferOutput; diff --git a/greenfield/src/contracts/realtime.ts b/greenfield/src/contracts/realtime.ts index 80508ab98..3f6499ee8 100644 --- a/greenfield/src/contracts/realtime.ts +++ b/greenfield/src/contracts/realtime.ts @@ -9,6 +9,10 @@ export const realtimeEventDeliveryMaximumBytes = 8 * 1024; export const realtimeSubscriptionMaximumTopics = 64; /** Canonical upper bound for one durable realtime topic. */ export const realtimeTopicMaximumCharacters = 128; +/** Runtime retention shared by durable producers and client-facing documentation. */ +export const realtimeEventRetentionMilliseconds = 7 * 24 * 60 * 60 * 1000; +/** Human-readable form of the reviewed durable retention policy. */ +export const realtimeEventRetentionLabel = "7 days"; /** Runtime and documentation metadata for one authorized realtime topic. */ export interface RealtimeTopicDefinition { diff --git a/greenfield/src/contracts/registry.ts b/greenfield/src/contracts/registry.ts index d4d4a4a04..4f26b1082 100644 --- a/greenfield/src/contracts/registry.ts +++ b/greenfield/src/contracts/registry.ts @@ -52,6 +52,7 @@ export interface ProcedureTransportContract { requestBody: | "authentication" | "default" + | "monitoring" | "task-content" | "task-progress" | "webauthn"; diff --git a/greenfield/src/contracts/reports.test.ts b/greenfield/src/contracts/reports.test.ts new file mode 100644 index 000000000..f172c4994 --- /dev/null +++ b/greenfield/src/contracts/reports.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; + +import * as v from "valibot"; + +import { utf8ByteLength } from "../shared/encoding.ts"; +import { monitoringMutationInputMaximumBytes } from "./monitoring.ts"; +import { upsertReportInputSchema } from "./reports.ts"; + +const reportId = "018f6f50-6a9e-7b88-8000-000000000002"; + +function report(bodyMarkdown: string) { + return { + bodyMarkdown, + id: reportId, + kind: "daily-summary", + metadata: {}, + occurredAtMs: 1000, + source: "openclaw", + sourceJobId: "daily-summary", + status: "ok", + title: "Daily summary", + }; +} + +describe("report mutation input budgets", () => { + test("accepts a substantial report below the aggregate encoded budget", () => { + const result = v.safeParse( + upsertReportInputSchema, + report("A".repeat(400 * 1024)) + ); + + expect(result.success).toBeTrue(); + if (result.success) { + expect(utf8ByteLength(JSON.stringify(result.output))).toBeLessThan( + monitoringMutationInputMaximumBytes + ); + } + }); + + test("rejects a field-valid report above the aggregate encoded budget", () => { + const input = report("A".repeat(600 * 1024)); + expect(utf8ByteLength(JSON.stringify(input))).toBeGreaterThan( + monitoringMutationInputMaximumBytes + ); + + const result = v.safeParse(upsertReportInputSchema, input); + expect(result.success).toBeFalse(); + if (!result.success) { + expect(result.issues.at(-1)?.message).toContain( + String(monitoringMutationInputMaximumBytes) + ); + } + }); +}); diff --git a/greenfield/src/contracts/reports.ts b/greenfield/src/contracts/reports.ts new file mode 100644 index 000000000..a1fdf7d2f --- /dev/null +++ b/greenfield/src/contracts/reports.ts @@ -0,0 +1,268 @@ +import * as v from "valibot"; + +import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; +import { hasUniqueArrayItems } from "../shared/validation.ts"; +import { uniqueFilterSchema } from "./filterSchemas.ts"; +import { + type ReportSummary, + monitoringJsonObjectSchema, + monitoringKindSchema, + monitoringMutationInputFitsBudget, + monitoringMutationInputMaximumBytes, + monitoringRecordIdSchema, + monitoringReportBodyMarkdownSchema, + monitoringReportSourceJobIdSchema, + monitoringReportSourceSchema, + monitoringReportStatusSchema, + monitoringReportSummarySchema, + monitoringReportTitleSchema, + reportDetailSchema, + reportSummarySchema, +} from "./monitoring.ts"; +import type { ProcedureContract } from "./registry.ts"; + +/** Default reports returned by one list request. */ +export const reportPageDefault = 100; + +/** Hard report-row budget for one list response. */ +export const reportPageMaximum = 200; + +const reportFilterMaximum = 16; +const reportTimestampSchema = timestampMillisecondsSchema("Report timestamp is invalid"); +const reportLimitSchema = v.pipe( + v.number("Report page limit is invalid"), + v.safeInteger("Report page limit is invalid"), + v.minValue(1, "Report page limit is invalid"), + v.maxValue(reportPageMaximum, "Report page limit is outside its budget") +); + +/** Stable newest-first report cursor. */ +export const reportCursorSchema = v.strictObject({ + id: monitoringRecordIdSchema, + occurredAtMs: reportTimestampSchema, +}); + +/** Bounded filters supported by the immutable report catalog. */ +export const reportListFiltersSchema = v.strictObject({ + kinds: v.optional( + uniqueFilterSchema(monitoringKindSchema, "Report kind", reportFilterMaximum) + ), + sourceJobIds: v.optional( + uniqueFilterSchema( + monitoringReportSourceJobIdSchema, + "Report source-job", + reportFilterMaximum + ) + ), + sources: v.optional( + uniqueFilterSchema( + monitoringReportSourceSchema, + "Report source", + reportFilterMaximum + ) + ), + statuses: v.optional( + v.pipe( + v.array(monitoringReportStatusSchema, "Report status filter is invalid"), + v.minLength(1, "Report status filter cannot be empty"), + v.maxLength( + reportFilterMaximum, + "Report status filter is outside its budget" + ), + v.check(hasUniqueArrayItems, "Report status filter values must be unique") + ) + ), +}); + +/** One stable keyset-paginated report request. */ +export const listReportsInputSchema = v.strictObject({ + cursor: v.optional(reportCursorSchema), + filters: v.optional(reportListFiltersSchema), + limit: v.optional(reportLimitSchema, reportPageDefault), +}); + +/** + * @param reports Candidate report page. + * @returns Whether report summaries use strict newest-first cursor order. + */ +export function newestReportOrderIsStable(reports: ReportSummary[]): boolean { + return reports.every((report, index) => { + const previous = reports[index - 1]; + return ( + previous === undefined || + report.occurredAtMs < previous.occurredAtMs || + (report.occurredAtMs === previous.occurredAtMs && report.id < previous.id) + ); + }); +} + +const reportRowsSchema = v.pipe( + v.array(reportSummarySchema, "Report page is invalid"), + v.maxLength(reportPageMaximum, "Report page is outside its budget"), + v.check(newestReportOrderIsStable, "Report page order is invalid") +); + +const listReportsResultObjectSchema = v.strictObject({ + nextCursor: v.optional(reportCursorSchema), + reports: reportRowsSchema, +}); + +type ListReportsResultValue = v.InferOutput; + +/** @returns Whether an optional report cursor identifies the final returned row. */ +export function reportPageCursorIsConsistent(result: ListReportsResultValue): boolean { + if (result.nextCursor === undefined) return true; + const last = result.reports.at(-1); + return ( + last !== undefined && + last.id === result.nextCursor.id && + last.occurredAtMs === result.nextCursor.occurredAtMs + ); +} + +/** One bounded report page plus an exact continuation cursor. */ +export const listReportsResultSchema = v.pipe( + listReportsResultObjectSchema, + v.check(reportPageCursorIsConsistent, "Report page cursor is inconsistent") +); + +/** Exact report lookup request. */ +export const getReportInputSchema = v.strictObject({ id: monitoringRecordIdSchema }); + +const upsertReportInputObjectSchema = v.strictObject({ + bodyMarkdown: monitoringReportBodyMarkdownSchema, + id: monitoringRecordIdSchema, + kind: monitoringKindSchema, + metadata: v.optional(monitoringJsonObjectSchema, {}), + occurredAtMs: reportTimestampSchema, + source: monitoringReportSourceSchema, + sourceJobId: v.optional(monitoringReportSourceJobIdSchema), + status: v.optional(monitoringReportStatusSchema, "ok"), + summary: v.optional(monitoringReportSummarySchema), + title: monitoringReportTitleSchema, +}); + +type UpsertReportInputValue = v.InferOutput; + +/** @returns Whether one report producer input fits its aggregate encoded budget. */ +export function upsertReportInputFitsBudget(input: UpsertReportInputValue): boolean { + return monitoringMutationInputFitsBudget(input); +} + +/** Idempotent immutable report producer input. */ +export const upsertReportInputSchema = v.pipe( + upsertReportInputObjectSchema, + v.check( + upsertReportInputFitsBudget, + `Expected monitoring mutation input no larger than ${monitoringMutationInputMaximumBytes} encoded bytes.` + ) +); + +/** Exact immutable report deletion request. */ +export const deleteReportInputSchema = getReportInputSchema; + +/** Stable report deletion acknowledgement. */ +export const deleteReportResultSchema = v.strictObject({ + deletedAtMs: reportTimestampSchema, + id: monitoringRecordIdSchema, +}); + +const reportReadAccess = { + capabilities: ["reports:read"], + capabilityPolicy: "all", + kind: "authenticated", +} as const; +const reportWriteAccess = { + capabilities: ["reports:write"], + capabilityPolicy: "all", + kind: "authenticated", +} as const; +const queryTransport = { + batching: "adapter-default", + handler: "default", + requestBody: "default", +} as const; +const mutationTransport = { + batching: "forbidden", + handler: "default", + requestBody: "default", +} as const; +const largeMutationTransport = { + ...mutationTransport, + requestBody: "monitoring", +} as const; + +/** Implemented immutable report-catalog procedure metadata. */ +export const reportProcedureContracts = [ + { + access: reportReadAccess, + domain: "reports", + errors: ["FORBIDDEN", "UNAUTHORIZED"], + input: listReportsInputSchema, + inputSchemaId: "reports.list.input", + kind: "query", + name: "reports.list", + output: listReportsResultSchema, + outputSchemaId: "reports.list.output", + summary: "Lists a stable filtered page of immutable report summaries.", + transport: queryTransport, + }, + { + access: reportReadAccess, + domain: "reports", + errors: ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + input: getReportInputSchema, + inputSchemaId: "reports.get.input", + kind: "query", + name: "reports.get", + output: reportDetailSchema, + outputSchemaId: "reports.get.output", + summary: "Loads one complete immutable Markdown report.", + transport: queryTransport, + }, + { + access: reportWriteAccess, + domain: "reports", + errors: [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + input: upsertReportInputSchema, + inputSchemaId: "reports.upsert.input", + kind: "mutation", + name: "reports.upsert", + output: reportDetailSchema, + outputSchemaId: "reports.upsert.output", + summary: "Creates a report or accepts an exact idempotent replay.", + transport: largeMutationTransport, + }, + { + access: reportWriteAccess, + domain: "reports", + errors: [ + "FORBIDDEN", + "NOT_FOUND", + "PRECONDITION_FAILED", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + input: deleteReportInputSchema, + inputSchemaId: "reports.delete.input", + kind: "mutation", + name: "reports.delete", + output: deleteReportResultSchema, + outputSchemaId: "reports.delete.output", + summary: "Deletes one report while preserving its monitor-run history.", + transport: mutationTransport, + }, +] as const satisfies readonly ProcedureContract[]; + +export type DeleteReportInput = v.InferOutput; +export type DeleteReportResult = v.InferOutput; +export type GetReportInput = v.InferOutput; +export type ListReportsInput = v.InferOutput; +export type ListReportsResult = v.InferOutput; +export type UpsertReportInput = v.InferOutput; diff --git a/greenfield/src/contracts/security.ts b/greenfield/src/contracts/security.ts index 9d6e451d5..6ff84578a 100644 --- a/greenfield/src/contracts/security.ts +++ b/greenfield/src/contracts/security.ts @@ -94,8 +94,11 @@ export const securityRecordIdSchema = lowercaseUuidV7Schema( export const applicationCapabilities = [ "agents:read", "agents:write", + "monitoring:write", "notifications:read", + "notifications:write", "reports:read", + "reports:write", "tasks:read", "tasks:write", ] as const; diff --git a/greenfield/src/contracts/taskRealtime.ts b/greenfield/src/contracts/taskRealtime.ts index ba06e3f80..9535e41b7 100644 --- a/greenfield/src/contracts/taskRealtime.ts +++ b/greenfield/src/contracts/taskRealtime.ts @@ -1,7 +1,8 @@ import * as v from "valibot"; import { timestampMillisecondsSchema } from "../shared/dateTime.ts"; -import type { RealtimeTopicDefinition } from "./realtime.ts"; +import { realtimeEventRetentionLabel, type RealtimeTopicDefinition } from "./realtime.ts"; +import type { RealtimeEventContract } from "./registry.ts"; import { taskIdSchema } from "./taskModel.ts"; /** Durable topic carrying compact task cache invalidations. */ @@ -29,6 +30,16 @@ export const taskRealtimeTopicDefinition = { topic: taskRealtimeTopic, } as const satisfies RealtimeTopicDefinition; +/** Standalone task invalidation topic tied to its authoritative snapshot query. */ +export const taskRealtimeEventContract = { + payload: taskChangePayloadSchema, + payloadSchemaId: "tasks.records.realtime.payload", + retention: realtimeEventRetentionLabel, + snapshotProcedure: "tasks.list", + summary: "Invalidates one task row after a durable task-domain change.", + topic: taskRealtimeTopic, +} as const satisfies RealtimeEventContract; + /** Client-visible validated task change event. */ export const taskRealtimeChangeSchema = v.strictObject({ entityId: taskIdSchema, diff --git a/greenfield/src/server/database/migrations/monitoringSchema.test.ts b/greenfield/src/server/database/migrations/monitoringSchema.test.ts index 8f796162e..55cec9336 100644 --- a/greenfield/src/server/database/migrations/monitoringSchema.test.ts +++ b/greenfield/src/server/database/migrations/monitoringSchema.test.ts @@ -6,6 +6,14 @@ interface QueryPlanRow { detail: string; } +function expectUsesIndexWithoutTemporarySort( + plan: readonly QueryPlanRow[], + indexName: string +): void { + expect(plan.some((row) => row.detail.includes(indexName))).toBeTrue(); + expect(plan.some((row) => row.detail.includes("USE TEMP B-TREE"))).toBeFalse(); +} + const filesystemFingerprint = "a".repeat(64); const memoryFingerprint = "b".repeat(64); const cpuFingerprint = "c".repeat(64); @@ -335,7 +343,7 @@ describe("monitoring schema", () => { } }); - test("uses the declared partial indexes for monitoring reads", async () => { + test("uses the declared indexes for filtered and keyset monitoring reads", async () => { const database = await openFreshMigratedDatabase(); try { @@ -371,6 +379,63 @@ describe("monitoring schema", () => { LIMIT 1 `) .all("ops-check"); + const incidentPagePlan = database.sqlite + .query(` + EXPLAIN QUERY PLAN + SELECT id + FROM incidents + ORDER BY last_seen_at DESC, id DESC + LIMIT 101 + `) + .all(); + const incidentCursorPlan = database.sqlite + .query(` + EXPLAIN QUERY PLAN + SELECT id + FROM incidents + WHERE last_seen_at < ? OR (last_seen_at = ? AND id < ?) + ORDER BY last_seen_at DESC, id DESC + LIMIT 101 + `) + .all(1000, 1000, "incident-2"); + const notificationPagePlan = database.sqlite + .query(` + EXPLAIN QUERY PLAN + SELECT id + FROM notifications + ORDER BY occurred_at DESC, id DESC + LIMIT 101 + `) + .all(); + const notificationCursorPlan = database.sqlite + .query(` + EXPLAIN QUERY PLAN + SELECT id + FROM notifications + WHERE occurred_at < ? OR (occurred_at = ? AND id < ?) + ORDER BY occurred_at DESC, id DESC + LIMIT 101 + `) + .all(1000, 1000, "notification-2"); + const reportPagePlan = database.sqlite + .query(` + EXPLAIN QUERY PLAN + SELECT id + FROM reports + ORDER BY occurred_at DESC, id DESC + LIMIT 201 + `) + .all(); + const reportCursorPlan = database.sqlite + .query(` + EXPLAIN QUERY PLAN + SELECT id + FROM reports + WHERE occurred_at < ? OR (occurred_at = ? AND id < ?) + ORDER BY occurred_at DESC, id DESC + LIMIT 201 + `) + .all(1000, 1000, "report-2"); expect( incidentPlan.some((row) => @@ -387,6 +452,18 @@ describe("monitoring schema", () => { row.detail.includes("monitor_runs_monitor_completed_id_idx") ) ).toBeTrue(); + for (const plan of [incidentPagePlan, incidentCursorPlan]) { + expectUsesIndexWithoutTemporarySort(plan, "incidents_last_seen_id_idx"); + } + for (const plan of [notificationPagePlan, notificationCursorPlan]) { + expectUsesIndexWithoutTemporarySort( + plan, + "notifications_occurred_id_idx" + ); + } + for (const plan of [reportPagePlan, reportCursorPlan]) { + expectUsesIndexWithoutTemporarySort(plan, "reports_occurred_id_idx"); + } } finally { database.sqlite.close(true); } diff --git a/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts b/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts index c7a05dc05..7f109f897 100644 --- a/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts +++ b/greenfield/src/server/database/schema/automationPrincipalCapabilities.ts @@ -18,7 +18,7 @@ export const automationPrincipalCapabilities = sqliteTable( (table) => [ check( "automation_principal_capabilities_capability_check", - sql`${table.capability} IN ('agents:read', 'agents:write', 'notifications:read', 'reports:read', 'tasks:read', 'tasks:write')` + sql`${table.capability} IN ('agents:read', 'agents:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'reports:read', 'reports:write', 'tasks:read', 'tasks:write')` ), check( "automation_principal_capabilities_granted_at_check", diff --git a/greenfield/src/server/database/schema/incidents.ts b/greenfield/src/server/database/schema/incidents.ts index da2aaf3f5..9dda6c77c 100644 --- a/greenfield/src/server/database/schema/incidents.ts +++ b/greenfield/src/server/database/schema/incidents.ts @@ -65,5 +65,6 @@ export const incidents = sqliteTable( index("incidents_active_monitor_seen_idx") .on(table.monitorKey, table.lastSeenAt) .where(sql`${table.state} = 'active'`), + index("incidents_last_seen_id_idx").on(table.lastSeenAt, table.id), ] ); diff --git a/greenfield/src/server/database/schema/notifications.ts b/greenfield/src/server/database/schema/notifications.ts index 5f0f43c78..cc31e6b89 100644 --- a/greenfield/src/server/database/schema/notifications.ts +++ b/greenfield/src/server/database/schema/notifications.ts @@ -9,6 +9,7 @@ import { } from "drizzle-orm/sqlite-core"; import { incidents } from "./incidents.ts"; +import { reports } from "./reports.ts"; /** User-visible notifications, optionally tied to one incident generation. */ export const notifications = sqliteTable( @@ -25,9 +26,13 @@ export const notifications = sqliteTable( message: text("message").notNull(), occurredAt: integer("occurred_at", { mode: "timestamp_ms" }).notNull(), readAt: integer("read_at", { mode: "timestamp_ms" }), + reportId: text("report_id").references(() => reports.id, { + onDelete: "cascade", + }), severity: text("severity", { enum: ["critical", "error", "info", "warning"], }).notNull(), + source: text("source"), title: text("title").notNull(), }, (table) => [ @@ -54,5 +59,7 @@ export const notifications = sqliteTable( index("notifications_unread_occurred_idx") .on(table.occurredAt) .where(sql`${table.readAt} IS NULL`), + index("notifications_report_id_idx").on(table.reportId), + index("notifications_occurred_id_idx").on(table.occurredAt, table.id), ] ); diff --git a/greenfield/src/server/database/schema/reports.ts b/greenfield/src/server/database/schema/reports.ts index 7670e2b3a..8a23de649 100644 --- a/greenfield/src/server/database/schema/reports.ts +++ b/greenfield/src/server/database/schema/reports.ts @@ -12,6 +12,10 @@ export const reports = sqliteTable( occurredAt: integer("occurred_at", { mode: "timestamp_ms" }).notNull(), source: text("source").notNull(), sourceJobId: text("source_job_id"), + status: text("status", { enum: ["error", "ok", "warning"] }) + .notNull() + .default("ok"), + summary: text("summary"), title: text("title").notNull(), }, (table) => [ @@ -19,6 +23,8 @@ export const reports = sqliteTable( "reports_metadata_json_check", sql`CASE WHEN json_valid(${table.metadataJson}) THEN json_type(${table.metadataJson}) = 'object' ELSE 0 END` ), + check("reports_status_check", sql`${table.status} IN ('error', 'ok', 'warning')`), + index("reports_occurred_id_idx").on(table.occurredAt, table.id), index("reports_kind_occurred_id_idx").on(table.kind, table.occurredAt, table.id), index("reports_source_job_occurred_id_idx").on( table.source, diff --git a/greenfield/src/server/database/validation/notifications.test.ts b/greenfield/src/server/database/validation/notifications.test.ts index e834607e7..122a79ce4 100644 --- a/greenfield/src/server/database/validation/notifications.test.ts +++ b/greenfield/src/server/database/validation/notifications.test.ts @@ -19,6 +19,30 @@ test("accepts notifications with both incident reference fields", () => { ).toBeDefined(); }); +test("accepts nullable and omitted report-link fields on notification inserts", () => { + const { + reportId: _reportId, + source: _source, + ...withoutReportLink + } = validNotificationValues; + + expect( + v.parse(notificationInsertSchema, { + ...validNotificationValues, + reportId: null, + source: null, + }) + ).toBeDefined(); + expect(v.parse(notificationInsertSchema, withoutReportLink)).toBeDefined(); + expect( + v.parse(notificationSelectSchema, { + ...validNotificationSelectValues, + reportId: null, + source: null, + }) + ).toBeDefined(); +}); + test("rejects notifications with only half of the incident reference pair", () => { const { incidentGeneration: _generation, ...withoutGeneration } = validNotificationValues; @@ -60,6 +84,8 @@ test("reuses monitoring text policies and enforces notification read order", () { kind: "k".repeat(101) }, { message: "m".repeat(1001) }, { readAt: subMilliseconds(validNotificationValues.occurredAt, 1) }, + { reportId: "not-a-report-id" }, + { source: "s".repeat(201) }, { title: "t".repeat(501) }, ]) { expect(() => diff --git a/greenfield/src/server/database/validation/notifications.ts b/greenfield/src/server/database/validation/notifications.ts index 18a129732..078f8e662 100644 --- a/greenfield/src/server/database/validation/notifications.ts +++ b/greenfield/src/server/database/validation/notifications.ts @@ -10,6 +10,7 @@ import * as v from "valibot"; import { monitoringKindSchema, monitoringProblemTitleSchema, + monitoringReportSourceSchema, monitoringReportTitleSchema, } from "../../../contracts/monitoring.ts"; import { notifications } from "../schema/notifications.ts"; @@ -44,6 +45,8 @@ const notificationRefinements = { message: () => monitoringProblemTitleSchema, occurredAt: nonnegativeDateSchema, readAt: nonnegativeDateSchema, + reportId: uuidV7TextSchema, + source: () => monitoringReportSourceSchema, title: () => monitoringReportTitleSchema, }; diff --git a/greenfield/src/server/database/validation/reports.test.ts b/greenfield/src/server/database/validation/reports.test.ts index 8c677c906..acb749c36 100644 --- a/greenfield/src/server/database/validation/reports.test.ts +++ b/greenfield/src/server/database/validation/reports.test.ts @@ -14,6 +14,8 @@ const validReportValues = Object.freeze({ occurredAt: observedAt, source: "openclaw", sourceJobId: "ops-check:primary", + status: "ok" as const, + summary: null, title: "Heartbeat", }); @@ -29,6 +31,8 @@ test("rejects reports outside shared monitoring policies", () => { { occurredAt: toDate(-1) }, { source: "s".repeat(201) }, { sourceJobId: "j".repeat(201) }, + { status: "unknown" }, + { summary: "s".repeat(2001) }, { title: "t".repeat(501) }, ]) { expect(() => diff --git a/greenfield/src/server/database/validation/reports.ts b/greenfield/src/server/database/validation/reports.ts index c5bf678fa..224e1674d 100644 --- a/greenfield/src/server/database/validation/reports.ts +++ b/greenfield/src/server/database/validation/reports.ts @@ -6,6 +6,8 @@ import { monitoringReportBodyMarkdownSchema, monitoringReportSourceJobIdSchema, monitoringReportSourceSchema, + monitoringReportStatusSchema, + monitoringReportSummarySchema, monitoringReportTitleSchema, } from "../../../contracts/monitoring.ts"; import { reports } from "../schema/reports.ts"; @@ -23,6 +25,8 @@ const reportRefinements = { occurredAt: nonnegativeDateSchema, source: () => monitoringReportSourceSchema, sourceJobId: () => monitoringReportSourceJobIdSchema, + status: () => monitoringReportStatusSchema, + summary: () => monitoringReportSummarySchema, title: () => monitoringReportTitleSchema, }; diff --git a/greenfield/src/server/database/validation/testSupport/rows.ts b/greenfield/src/server/database/validation/testSupport/rows.ts index 450fe332c..bee8fd764 100644 --- a/greenfield/src/server/database/validation/testSupport/rows.ts +++ b/greenfield/src/server/database/validation/testSupport/rows.ts @@ -49,7 +49,9 @@ export const validNotificationValues = Object.freeze({ kind: "incident-opened", message: "Root filesystem usage exceeded the warning threshold.", occurredAt: observedAt, + reportId, severity: "warning" as const, + source: "ops-check", title: "Root filesystem pressure", }); diff --git a/greenfield/src/server/domains/agents/routes.ts b/greenfield/src/server/domains/agents/routes.ts index 9b2d7e419..91a2cf230 100644 --- a/greenfield/src/server/domains/agents/routes.ts +++ b/greenfield/src/server/domains/agents/routes.ts @@ -13,7 +13,7 @@ import { listAgentTaskHistoryResultSchema, updateAgentMetadataInputSchema, } from "../../../contracts/agents.ts"; -import { capabilityProcedure } from "../../trpc/trpc.ts"; +import { capabilityProcedure, principalKindProcedure } from "../../trpc/trpc.ts"; import { AgentNotFoundError } from "./errors.ts"; async function runAgentEffect(effect: Effect.Effect): Promise { @@ -32,15 +32,11 @@ async function runAgentEffect(effect: Effect.Effect): Promise { } const readProcedure = capabilityProcedure("agents:read"); -const writeProcedure = capabilityProcedure("agents:write").use(({ ctx, next }) => { - if (ctx.principal.kind !== "automation") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "An automation principal is required", - }); - } - return next({ ctx }); -}); +const writeProcedure = principalKindProcedure( + "agents:write", + "automation", + "An automation principal is required" +); /** Capability-scoped Dashboard agent status and task-history routes. */ export const agentRoutes = { diff --git a/greenfield/src/server/domains/monitoring/catalogErrors.ts b/greenfield/src/server/domains/monitoring/catalogErrors.ts new file mode 100644 index 000000000..e7d08515d --- /dev/null +++ b/greenfield/src/server/domains/monitoring/catalogErrors.ts @@ -0,0 +1,48 @@ +import { Schema } from "effect"; + +const TaggedErrorClass = Schema.TaggedError; + +export const monitoringCatalogResourceKinds = [ + "incident", + "notification", + "report", +] as const; + +export type MonitoringCatalogResourceKind = + (typeof monitoringCatalogResourceKinds)[number]; + +/** Expected exact-record lookup failure. */ +export class MonitoringCatalogNotFoundError extends TaggedErrorClass( + "mira-dashboard/server/domains/monitoring/MonitoringCatalogNotFoundError" +)("MonitoringCatalogNotFoundError", { + id: Schema.String, + resource: Schema.Literals(monitoringCatalogResourceKinds), +}) {} + +/** Expected immutable-id replay conflict. */ +export class MonitoringCatalogConflictError extends TaggedErrorClass( + "mira-dashboard/server/domains/monitoring/MonitoringCatalogConflictError" +)("MonitoringCatalogConflictError", { + id: Schema.String, + resource: Schema.Literals(["notification", "report"]), +}) {} + +/** Expected producer-input failure at a catalog-specific mutation boundary. */ +export class MonitoringCatalogValidationError extends TaggedErrorClass( + "mira-dashboard/server/domains/monitoring/MonitoringCatalogValidationError" +)("MonitoringCatalogValidationError", { + id: Schema.String, + maximumOccurredAtMs: Schema.Number, + occurredAtMs: Schema.Number, + resource: Schema.Literals(["notification", "report"]), +}) {} + +/** Expected bounded-work precondition failure for one catalog mutation. */ +export class MonitoringCatalogPreconditionError extends TaggedErrorClass( + "mira-dashboard/server/domains/monitoring/MonitoringCatalogPreconditionError" +)("MonitoringCatalogPreconditionError", { + id: Schema.String, + linkedNotificationCount: Schema.Number, + maximumLinkedNotifications: Schema.Number, + resource: Schema.Literal("report"), +}) {} diff --git a/greenfield/src/server/domains/monitoring/catalogRecords.ts b/greenfield/src/server/domains/monitoring/catalogRecords.ts new file mode 100644 index 000000000..1294e3da1 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/catalogRecords.ts @@ -0,0 +1,127 @@ +import { getTime } from "date-fns"; +import * as v from "valibot"; + +import { + incidentRecordSchema, + incidentSummarySchema, + notificationRecordSchema, + reportDetailSchema, + reportSummarySchema, + type IncidentRecord, + type IncidentSummary, + type NotificationRecord, + type ReportDetail, + type ReportSummary, +} from "../../../contracts/monitoring.ts"; +import type { + IncidentRecord as IncidentPersistenceRecord, + NotificationRecord as NotificationPersistenceRecord, + ReportRecord as ReportPersistenceRecord, +} from "./repository.ts"; +import { parseMonitoringJsonObject } from "./serialization.ts"; + +/** + * Converts one validated persistence row to its public report summary. + * @param record Validated persistence row. + * @returns Public report summary. + */ +export function toReportSummary(record: ReportPersistenceRecord): ReportSummary { + return v.parse(reportSummarySchema, { + id: record.id, + kind: record.kind, + occurredAtMs: getTime(record.occurredAt), + source: record.source, + ...(record.sourceJobId === null ? {} : { sourceJobId: record.sourceJobId }), + status: record.status, + ...(record.summary === null ? {} : { summary: record.summary }), + title: record.title, + }); +} + +/** + * Converts one validated persistence row to its complete public report. + * @param record Validated persistence row. + * @returns Complete public report. + */ +export function toReportDetail(record: ReportPersistenceRecord): ReportDetail { + return v.parse(reportDetailSchema, { + ...toReportSummary(record), + bodyMarkdown: record.bodyMarkdown, + metadata: parseMonitoringJsonObject(record.metadataJson), + }); +} + +/** + * Converts one validated lifecycle row to its public incident variant. + * @param record Validated persistence row. + * @returns Public incident record. + */ +export function toIncidentRecord(record: IncidentPersistenceRecord): IncidentRecord { + return v.parse(incidentRecordSchema, { + details: parseMonitoringJsonObject(record.detailsJson), + fingerprint: record.fingerprint, + firstSeenAtMs: getTime(record.firstSeenAt), + generation: record.generation, + id: record.id, + kind: record.kind, + lastSeenAtMs: getTime(record.lastSeenAt), + monitorKey: record.monitorKey, + occurrenceCount: record.occurrenceCount, + ...(record.resolvedAt === null + ? {} + : { resolvedAtMs: getTime(record.resolvedAt) }), + severity: record.severity, + state: record.state, + title: record.title, + }); +} + +/** + * Converts one validated lifecycle row to its bounded public list summary. + * @param record Validated persistence row. + * @returns Public incident summary without the details document. + */ +export function toIncidentSummary(record: IncidentPersistenceRecord): IncidentSummary { + return v.parse(incidentSummarySchema, { + fingerprint: record.fingerprint, + firstSeenAtMs: getTime(record.firstSeenAt), + generation: record.generation, + id: record.id, + kind: record.kind, + lastSeenAtMs: getTime(record.lastSeenAt), + monitorKey: record.monitorKey, + occurrenceCount: record.occurrenceCount, + ...(record.resolvedAt === null + ? {} + : { resolvedAtMs: getTime(record.resolvedAt) }), + severity: record.severity, + state: record.state, + title: record.title, + }); +} + +/** + * Converts one validated Dashboard notification row to its public record. + * @param record Validated persistence row. + * @returns Public notification record. + */ +export function toNotificationRecord( + record: NotificationPersistenceRecord +): NotificationRecord { + return v.parse(notificationRecordSchema, { + id: record.id, + ...(record.incidentGeneration === null + ? {} + : { incidentGeneration: record.incidentGeneration }), + ...(record.incidentId === null ? {} : { incidentId: record.incidentId }), + kind: record.kind, + ...(record.linkUrl === null ? {} : { linkUrl: record.linkUrl }), + message: record.message, + occurredAtMs: getTime(record.occurredAt), + ...(record.readAt === null ? {} : { readAtMs: getTime(record.readAt) }), + ...(record.reportId === null ? {} : { reportId: record.reportId }), + severity: record.severity, + ...(record.source === null ? {} : { source: record.source }), + title: record.title, + }); +} diff --git a/greenfield/src/server/domains/monitoring/catalogRoutes.ts b/greenfield/src/server/domains/monitoring/catalogRoutes.ts new file mode 100644 index 000000000..a52e73f3d --- /dev/null +++ b/greenfield/src/server/domains/monitoring/catalogRoutes.ts @@ -0,0 +1,132 @@ +import { + getIncidentInputSchema, + listIncidentsInputSchema, + listIncidentsResultSchema, +} from "../../../contracts/incidents.ts"; +import { + incidentRecordSchema, + notificationRecordSchema, + reportDetailSchema, +} from "../../../contracts/monitoring.ts"; +import { + bulkNotificationInputSchema, + bulkNotificationResultSchema, + deleteNotificationResultSchema, + listNotificationsInputSchema, + listNotificationsResultSchema, + notificationIdentityInputSchema, + upsertNotificationInputSchema, +} from "../../../contracts/notifications.ts"; +import { + deleteReportInputSchema, + deleteReportResultSchema, + getReportInputSchema, + listReportsInputSchema, + listReportsResultSchema, + upsertReportInputSchema, +} from "../../../contracts/reports.ts"; +import { capabilityProcedure, principalKindProcedure } from "../../trpc/trpc.ts"; +import { runMonitoringEffect } from "./routeEffects.ts"; + +const incidentReadProcedure = capabilityProcedure("reports:read"); +const notificationReadProcedure = capabilityProcedure("notifications:read"); +const reportReadProcedure = capabilityProcedure("reports:read"); +const reportWriteProcedure = capabilityProcedure("reports:write"); +const notificationProducerProcedure = principalKindProcedure( + "notifications:write", + "automation", + "An automation principal is required" +); +const notificationSessionWriteProcedure = principalKindProcedure( + "notifications:write", + "session", + "A browser session is required" +); + +/** Capability-scoped incident lifecycle read routes. */ +export const incidentRoutes = { + get: incidentReadProcedure + .input(getIncidentInputSchema) + .output(incidentRecordSchema) + .query(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.getIncident(input)) + ), + list: incidentReadProcedure + .input(listIncidentsInputSchema) + .output(listIncidentsResultSchema) + .query(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.listIncidents(input)) + ), +}; + +/** Capability- and principal-kind-scoped Dashboard notification routes. */ +export const notificationRoutes = { + clearRead: notificationSessionWriteProcedure + .input(bulkNotificationInputSchema) + .output(bulkNotificationResultSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect( + ctx.monitoringCatalogService.clearReadNotifications(input) + ) + ), + delete: notificationSessionWriteProcedure + .input(notificationIdentityInputSchema) + .output(deleteNotificationResultSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.deleteNotification(input)) + ), + list: notificationReadProcedure + .input(listNotificationsInputSchema) + .output(listNotificationsResultSchema) + .query(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.listNotifications(input)) + ), + markAllRead: notificationSessionWriteProcedure + .input(bulkNotificationInputSchema) + .output(bulkNotificationResultSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect( + ctx.monitoringCatalogService.markAllNotificationsRead(input) + ) + ), + markRead: notificationSessionWriteProcedure + .input(notificationIdentityInputSchema) + .output(notificationRecordSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.markNotificationRead(input)) + ), + upsert: notificationProducerProcedure + .input(upsertNotificationInputSchema) + .output(notificationRecordSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.upsertNotification(input)) + ), +}; + +/** Capability-scoped immutable report catalog routes. */ +export const reportRoutes = { + delete: reportWriteProcedure + .input(deleteReportInputSchema) + .output(deleteReportResultSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.deleteReport(input)) + ), + get: reportReadProcedure + .input(getReportInputSchema) + .output(reportDetailSchema) + .query(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.getReport(input)) + ), + list: reportReadProcedure + .input(listReportsInputSchema) + .output(listReportsResultSchema) + .query(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.listReports(input)) + ), + upsert: reportWriteProcedure + .input(upsertReportInputSchema) + .output(reportDetailSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringCatalogService.upsertReport(input)) + ), +}; diff --git a/greenfield/src/server/domains/monitoring/catalogService.test.ts b/greenfield/src/server/domains/monitoring/catalogService.test.ts new file mode 100644 index 000000000..85eac7fc0 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/catalogService.test.ts @@ -0,0 +1,714 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { toDate } from "date-fns"; +import { maxTime } from "date-fns/constants"; +import { Effect } from "effect"; + +import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; +import { openFreshMigratedDatabase } from "../../test/support/freshDatabase.ts"; +import { captureFailure } from "../../test/support/promise.ts"; +import { + MonitoringCatalogConflictError, + MonitoringCatalogNotFoundError, + MonitoringCatalogPreconditionError, + MonitoringCatalogValidationError, +} from "./catalogErrors.ts"; +import { createMonitoringCatalogService } from "./catalogService.ts"; +import { createMonitoringRepository } from "./repository.ts"; +import { + oneDayMs, + problem, + serviceFor, + snapshot, + submitSnapshot, + uuid, +} from "./testSupport/monitoringService.ts"; + +const catalogNowMs = 10_000; +type FreshMigratedDatabase = Awaited>; + +describe("monitoring catalog service", () => { + let database: FreshMigratedDatabase | undefined; + + afterEach(() => { + database?.sqlite.close(true); + database = undefined; + }); + + async function openCatalog( + overrides: { + nowMs?: () => number; + realtimeRetentionMs?: number; + wakeEventPump?: () => Promise | void; + } = {} + ) { + database = await openFreshMigratedDatabase(); + const repository = createMonitoringRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); + const service = createMonitoringCatalogService({ + nowMs: overrides.nowMs ?? (() => catalogNowMs), + realtimeRetentionMs: overrides.realtimeRetentionMs ?? oneDayMs, + repository, + wakeEventPump: overrides.wakeEventPump, + }); + return { database, repository, service }; + } + + test("rejects incompatible clock and realtime retention during construction", async () => { + const failure = await captureFailure(() => + openCatalog({ nowMs: () => 1, realtimeRetentionMs: maxTime }) + ); + expect(failure).toBeInstanceOf(RangeError); + expect((failure as RangeError).message).toContain( + "Monitoring catalog mutation time leaves no room for realtime retention" + ); + }); + + test("lists and loads stable report, incident, and notification catalogs", async () => { + const fixture = await openCatalog(); + const ingestion = serviceFor(fixture.database); + await submitSnapshot(ingestion, snapshot({ completedAtMs: 1000, run: 1 })); + await submitSnapshot( + ingestion, + snapshot({ + completedAtMs: 2000, + problems: [problem("backup"), problem("filesystem")], + run: 2, + }) + ); + + const firstReports = await Effect.runPromise( + fixture.service.listReports({ limit: 1 }) + ); + expect(firstReports.reports).toHaveLength(1); + expect(firstReports.nextCursor).toEqual({ + id: firstReports.reports[0]!.id, + occurredAtMs: firstReports.reports[0]!.occurredAtMs, + }); + const secondReports = await Effect.runPromise( + fixture.service.listReports({ + cursor: firstReports.nextCursor, + limit: 1, + }) + ); + expect(secondReports.reports).toHaveLength(1); + expect(secondReports.nextCursor).toBeUndefined(); + expect(secondReports.reports[0]!.occurredAtMs).toBeLessThan( + firstReports.reports[0]!.occurredAtMs + ); + + const report = await Effect.runPromise( + fixture.service.getReport({ id: firstReports.reports[0]!.id }) + ); + expect(report).toMatchObject({ + bodyMarkdown: "# Health at 2000", + metadata: { complete: true }, + }); + + const incidents = await Effect.runPromise( + fixture.service.listIncidents({ + filters: { severities: ["error"] }, + limit: 10, + }) + ); + expect(incidents.incidents).toHaveLength(1); + expect(incidents.incidents[0]).toMatchObject({ + occurrenceCount: 1, + severity: "error", + state: "active", + }); + const incidentDetail = await Effect.runPromise( + fixture.service.getIncident({ id: incidents.incidents[0]!.id }) + ); + expect(incidentDetail).toMatchObject(incidents.incidents[0]!); + expect(incidentDetail.details).toEqual({ hours: 26 }); + + const notifications = await Effect.runPromise( + fixture.service.listNotifications({ + filters: { readState: "unread" }, + limit: 10, + }) + ); + expect(notifications.notifications).toHaveLength(2); + expect(notifications.unreadCount).toBe(2); + + const missing = await Effect.runPromise( + Effect.flip(fixture.service.getReport({ id: uuid(999_999) })) + ); + expect(missing).toBeInstanceOf(MonitoringCatalogNotFoundError); + expect(missing).toMatchObject({ + id: uuid(999_999), + resource: "report", + }); + }); + + test("accepts exact producer replays and rejects immutable identity conflicts", async () => { + let wakes = 0; + const fixture = await openCatalog({ + wakeEventPump: () => { + wakes += 1; + }, + }); + const reportInput = { + bodyMarkdown: "# Delivery", + id: uuid(500), + kind: "daily-summary", + metadata: { nested: { a: 1, z: 2 } }, + occurredAtMs: 5000, + source: "openclaw", + sourceJobId: "daily-summary", + status: "ok", + title: "Daily summary", + } as const; + const createdReport = await Effect.runPromise( + fixture.service.upsertReport(reportInput) + ); + expect(createdReport.metadata).toEqual(reportInput.metadata); + expect( + await Effect.runPromise( + fixture.service.upsertReport({ + ...reportInput, + metadata: { nested: { z: 2, a: 1 } }, + }) + ) + ).toEqual(createdReport); + const reportConflict = await Effect.runPromise( + Effect.flip( + fixture.service.upsertReport({ + ...reportInput, + title: "Changed title", + }) + ) + ); + expect(reportConflict).toBeInstanceOf(MonitoringCatalogConflictError); + + const notificationInput = { + id: uuid(501), + kind: "release", + message: "Release available", + occurredAtMs: 5100, + severity: "info", + title: "Dashboard update", + } as const; + const createdNotification = await Effect.runPromise( + fixture.service.upsertNotification(notificationInput) + ); + expect( + await Effect.runPromise(fixture.service.upsertNotification(notificationInput)) + ).toEqual(createdNotification); + const notificationConflict = await Effect.runPromise( + Effect.flip( + fixture.service.upsertNotification({ + ...notificationInput, + severity: "warning", + }) + ) + ); + expect(notificationConflict).toBeInstanceOf(MonitoringCatalogConflictError); + + expect(wakes).toBe(2); + expect( + fixture.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM realtime_events" + ) + .get()!.count + ).toBe(2); + }); + + test("rejects notification references to a nonexistent incident generation", async () => { + const fixture = await openCatalog(); + const ingestion = serviceFor(fixture.database); + await submitSnapshot( + ingestion, + snapshot({ + completedAtMs: 1000, + problems: [problem("filesystem")], + run: 1, + }) + ); + const incidentPage = await Effect.runPromise( + fixture.service.listIncidents({ limit: 10 }) + ); + const incident = incidentPage.incidents[0]!; + const notificationId = uuid(502); + const notificationCountBefore = fixture.database.sqlite + .query<{ count: number }, []>("SELECT count(*) AS count FROM notifications") + .get()!.count; + const eventCountBefore = fixture.database.sqlite + .query<{ count: number }, []>("SELECT count(*) AS count FROM realtime_events") + .get()!.count; + + const failure = await Effect.runPromise( + Effect.flip( + fixture.service.upsertNotification({ + id: notificationId, + incidentGeneration: incident.generation + 1, + incidentId: incident.id, + kind: "monitoring.incident", + message: "References a nonexistent generation", + occurredAtMs: 2000, + severity: "warning", + title: "Invalid incident generation", + }) + ) + ); + + expect(failure).toBeInstanceOf(MonitoringCatalogNotFoundError); + expect(failure).toMatchObject({ id: incident.id, resource: "incident" }); + expect(fixture.repository.findNotification(notificationId)).toBeUndefined(); + expect( + fixture.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM notifications" + ) + .get()!.count + ).toBe(notificationCountBefore); + expect( + fixture.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM realtime_events" + ) + .get()!.count + ).toBe(eventCountBefore); + }); + + test("does not turn an event-pump wake failure into a failed catalog commit", async () => { + const fixture = await openCatalog({ + wakeEventPump: () => Promise.reject(new Error("pump unavailable")), + }); + const reportId = uuid(750); + + const report = await Effect.runPromise( + fixture.service.upsertReport({ + bodyMarkdown: "# Durable before wake", + id: reportId, + kind: "health", + metadata: {}, + occurredAtMs: 5000, + source: "dashboard", + status: "ok", + title: "Durable before wake", + }) + ); + + expect(report.id).toBe(reportId); + expect(fixture.repository.findReport(reportId)).toBeDefined(); + }); + + test("rejects producer timestamps outside realtime retention atomically", async () => { + let wakes = 0; + const fixture = await openCatalog({ + wakeEventPump: () => { + wakes += 1; + }, + }); + const reportId = uuid(600); + const reportFailure = await Effect.runPromise( + Effect.flip( + fixture.service.upsertReport({ + bodyMarkdown: "# Outside retention", + id: reportId, + kind: "health", + metadata: {}, + occurredAtMs: maxTime, + source: "dashboard", + status: "ok", + title: "Outside retention", + }) + ) + ); + expect(reportFailure).toBeInstanceOf(MonitoringCatalogValidationError); + expect(reportFailure).toMatchObject({ + id: reportId, + maximumOccurredAtMs: maxTime - oneDayMs, + occurredAtMs: maxTime, + resource: "report", + }); + + const notificationId = uuid(601); + const notificationFailure = await Effect.runPromise( + Effect.flip( + fixture.service.upsertNotification({ + id: notificationId, + kind: "health", + message: "Outside retention", + occurredAtMs: maxTime, + severity: "warning", + title: "Outside retention", + }) + ) + ); + expect(notificationFailure).toBeInstanceOf(MonitoringCatalogValidationError); + expect(notificationFailure).toMatchObject({ + id: notificationId, + maximumOccurredAtMs: maxTime - oneDayMs, + occurredAtMs: maxTime, + resource: "notification", + }); + + expect(fixture.repository.findReport(reportId)).toBeUndefined(); + expect(fixture.repository.findNotification(notificationId)).toBeUndefined(); + expect(wakes).toBe(0); + expect( + fixture.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM realtime_events" + ) + .get()!.count + ).toBe(0); + }); + + test("bounds bulk notification acknowledgement and deletion", async () => { + let wakes = 0; + const fixture = await openCatalog({ + wakeEventPump: () => { + wakes += 1; + }, + }); + await fixture.repository.withImmediateTransaction((unit) => { + for (let index = 0; index < 101; index += 1) { + unit.insertNotification({ + channel: "dashboard", + id: uuid(1000 + index), + incidentGeneration: null, + incidentId: null, + kind: "bulk-test", + linkUrl: null, + message: `Notification ${index}`, + occurredAt: toDate(20_000 + index), + reportId: null, + severity: "info", + source: null, + title: "Bulk notification", + }); + } + }); + + const firstMark = await Effect.runPromise( + fixture.service.markAllNotificationsRead({ filters: {} }) + ); + expect(firstMark).toEqual({ + affectedCount: 100, + completedAtMs: 20_100, + remaining: true, + }); + const secondMark = await Effect.runPromise( + fixture.service.markAllNotificationsRead({ filters: {} }) + ); + expect(secondMark).toEqual({ + affectedCount: 1, + completedAtMs: 20_000, + remaining: false, + }); + + const alreadyRead = await Effect.runPromise( + fixture.service.markNotificationRead({ id: uuid(1000) }) + ); + expect(alreadyRead.readAtMs).toBe(20_000); + + const firstClear = await Effect.runPromise( + fixture.service.clearReadNotifications({ filters: {} }) + ); + expect(firstClear).toMatchObject({ + affectedCount: 100, + remaining: true, + }); + const secondClear = await Effect.runPromise( + fixture.service.clearReadNotifications({ filters: {} }) + ); + expect(secondClear).toMatchObject({ + affectedCount: 1, + remaining: false, + }); + + const listed = await Effect.runPromise( + fixture.service.listNotifications({ limit: 10 }) + ); + expect(listed).toEqual({ + notifications: [], + readCount: 0, + unreadCount: 0, + }); + expect(wakes).toBe(4); + expect( + fixture.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM realtime_events" + ) + .get()!.count + ).toBe(202); + }); + + test("deletes reports without deleting immutable monitor-run history", async () => { + const fixture = await openCatalog(); + const ingestion = serviceFor(fixture.database); + const submission = await submitSnapshot( + ingestion, + snapshot({ completedAtMs: 1000, run: 1 }) + ); + expect(submission.reportId).not.toBeNull(); + + const deleted = await Effect.runPromise( + fixture.service.deleteReport({ id: submission.reportId! }) + ); + expect(deleted).toEqual({ + deletedAtMs: catalogNowMs, + id: submission.reportId, + }); + expect( + fixture.database.sqlite + .query<{ reportId: string | null }, []>( + "SELECT report_id AS reportId FROM monitor_runs" + ) + .get() + ).toEqual({ reportId: null }); + expect( + await Effect.runPromise( + Effect.flip(fixture.service.getReport({ id: submission.reportId! })) + ) + ).toBeInstanceOf(MonitoringCatalogNotFoundError); + }); + + test("does not backdate report events or deletion when the clock regresses", async () => { + const fixture = await openCatalog({ nowMs: () => 1000 }); + const reportInput = { + bodyMarkdown: "# Clock-safe report", + id: uuid(2000), + kind: "health", + metadata: {}, + occurredAtMs: 5000, + source: "dashboard", + status: "ok", + title: "Clock-safe report", + } as const; + + await Effect.runPromise(fixture.service.upsertReport(reportInput)); + const deleted = await Effect.runPromise( + fixture.service.deleteReport({ id: reportInput.id }) + ); + expect(deleted.deletedAtMs).toBe(reportInput.occurredAtMs); + + const eventTimes = fixture.database.sqlite + .query<{ occurredAtMs: number }, []>( + "SELECT occurred_at AS occurredAtMs FROM realtime_events ORDER BY id" + ) + .all() + .map((row) => row.occurredAtMs); + expect(eventTimes).toEqual([5000, 5000]); + }); + + test("does not backdate notification or linked-report deletion after reads", async () => { + let nowMs = 10_000; + const fixture = await openCatalog({ nowMs: () => nowMs }); + const reportId = uuid(2500); + await Effect.runPromise( + fixture.service.upsertReport({ + bodyMarkdown: "# Read-linked deletion", + id: reportId, + kind: "health", + metadata: {}, + occurredAtMs: 5000, + source: "dashboard", + status: "ok", + title: "Read-linked deletion", + }) + ); + const notificationIds = { + bulk: uuid(2502), + linked: uuid(2503), + single: uuid(2501), + } as const; + for (const [source, id, occurredAtMs] of [ + ["single", notificationIds.single, 7000], + ["bulk", notificationIds.bulk, 8000], + ["linked", notificationIds.linked, 6000], + ] as const) { + await Effect.runPromise( + fixture.service.upsertNotification({ + id, + kind: "clock-regression", + message: `${source} notification`, + occurredAtMs, + ...(source === "linked" ? { reportId } : {}), + severity: "info", + source, + title: "Clock regression", + }) + ); + } + expect( + await Effect.runPromise( + fixture.service.markAllNotificationsRead({ filters: {} }) + ) + ).toMatchObject({ affectedCount: 3, completedAtMs: 10_000 }); + + nowMs = 1000; + expect( + await Effect.runPromise( + fixture.service.deleteNotification({ id: notificationIds.single }) + ) + ).toMatchObject({ deletedAtMs: 10_000 }); + expect( + await Effect.runPromise( + fixture.service.clearReadNotifications({ + filters: { sources: ["bulk"] }, + }) + ) + ).toMatchObject({ affectedCount: 1, completedAtMs: 10_000 }); + expect( + await Effect.runPromise(fixture.service.deleteReport({ id: reportId })) + ).toMatchObject({ deletedAtMs: 10_000 }); + + const deletionEventTimes = fixture.database.sqlite + .query<{ occurredAtMs: number }, []>( + "SELECT occurred_at AS occurredAtMs FROM realtime_events ORDER BY id DESC LIMIT 4" + ) + .all() + .map((row) => row.occurredAtMs); + expect(deletionEventTimes).toEqual([10_000, 10_000, 10_000, 10_000]); + }); + + test("invalidates one notification snapshot after a bounded report cascade", async () => { + const fixture = await openCatalog(); + const reportId = uuid(2900); + await Effect.runPromise( + fixture.service.upsertReport({ + bodyMarkdown: "# Cascade report", + id: reportId, + kind: "health", + metadata: {}, + occurredAtMs: 5000, + source: "dashboard", + status: "ok", + title: "Cascade report", + }) + ); + const linkedNotificationIds = [uuid(2901), uuid(2902)] as const; + const unrelatedNotificationId = uuid(2903); + for (const [id, linkedReportId] of [ + [linkedNotificationIds[0], reportId], + [linkedNotificationIds[1], reportId], + [unrelatedNotificationId, undefined], + ] as const) { + await Effect.runPromise( + fixture.service.upsertNotification({ + id, + kind: "report-link", + message: "Report notification", + occurredAtMs: 6000, + ...(linkedReportId === undefined ? {} : { reportId: linkedReportId }), + severity: "info", + source: "dashboard", + title: "Linked report", + }) + ); + } + const eventIdBeforeDelete = fixture.database.sqlite + .query<{ id: number }, []>( + "SELECT id FROM realtime_events ORDER BY id DESC LIMIT 1" + ) + .get()!.id; + + await Effect.runPromise(fixture.service.deleteReport({ id: reportId })); + + for (const id of linkedNotificationIds) { + expect(fixture.repository.findNotification(id)).toBeUndefined(); + } + expect( + fixture.repository.findNotification(unrelatedNotificationId) + ).toBeDefined(); + expect( + fixture.database.sqlite + .query< + { + entityId: string; + entityType: string; + operation: string; + topic: string; + }, + [number] + >(` + SELECT + entity_id AS entityId, + entity_type AS entityType, + operation, + topic + FROM realtime_events + WHERE id > ? + ORDER BY id + `) + .all(eventIdBeforeDelete) + ).toEqual([ + { + entityId: reportId, + entityType: "notification", + operation: "snapshot-required", + topic: "monitoring.notifications", + }, + { + entityId: reportId, + entityType: "report", + operation: "deleted", + topic: "monitoring.reports", + }, + ]); + }); + + test("rejects report deletion before an unbounded notification cascade", async () => { + const fixture = await openCatalog(); + const reportInput = { + bodyMarkdown: "# Bounded deletion", + id: uuid(3000), + kind: "health", + metadata: {}, + occurredAtMs: 5000, + source: "dashboard", + status: "ok", + title: "Bounded deletion", + } as const; + await Effect.runPromise(fixture.service.upsertReport(reportInput)); + await fixture.repository.withImmediateTransaction((unit) => { + for (let index = 0; index < 101; index += 1) { + unit.insertNotification({ + channel: "dashboard", + id: uuid(4000 + index), + incidentGeneration: null, + incidentId: null, + kind: "report-link", + linkUrl: null, + message: `Notification ${index}`, + occurredAt: toDate(6000 + index), + reportId: reportInput.id, + severity: "info", + source: "dashboard", + title: "Linked notification", + }); + } + }); + const eventCountBefore = fixture.database.sqlite + .query<{ count: number }, []>("SELECT count(*) AS count FROM realtime_events") + .get()!.count; + + const failure = await Effect.runPromise( + Effect.flip(fixture.service.deleteReport({ id: reportInput.id })) + ); + expect(failure).toBeInstanceOf(MonitoringCatalogPreconditionError); + expect(failure).toMatchObject({ + id: reportInput.id, + linkedNotificationCount: 101, + maximumLinkedNotifications: 100, + resource: "report", + }); + expect(fixture.repository.findReport(reportInput.id)).toBeDefined(); + expect( + fixture.database.sqlite + .query<{ count: number }, []>( + "SELECT count(*) AS count FROM realtime_events" + ) + .get()!.count + ).toBe(eventCountBefore); + }); +}); diff --git a/greenfield/src/server/domains/monitoring/catalogService.ts b/greenfield/src/server/domains/monitoring/catalogService.ts new file mode 100644 index 000000000..6feb69a97 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/catalogService.ts @@ -0,0 +1,749 @@ +import { addMilliseconds, getTime, max as maximumDate, toDate } from "date-fns"; +import { maxTime } from "date-fns/constants"; +import { Context, Data, Effect, Layer } from "effect"; +import * as v from "valibot"; + +import { + type GetIncidentInput, + type ListIncidentsInput, + type ListIncidentsResult, + listIncidentsResultSchema, +} from "../../../contracts/incidents.ts"; +import type { + IncidentRecord, + NotificationRecord, + ReportDetail, +} from "../../../contracts/monitoring.ts"; +import { + type BulkNotificationInput, + type BulkNotificationResult, + type ListNotificationsInput, + type ListNotificationsResult, + type NotificationIdentityInput, + type UpsertNotificationInput, + bulkNotificationResultSchema, + deleteNotificationResultSchema, + listNotificationsResultSchema, + notificationPageMaximum, + type DeleteNotificationResult, +} from "../../../contracts/notifications.ts"; +import { + type DeleteReportInput, + type DeleteReportResult, + type GetReportInput, + type ListReportsInput, + type ListReportsResult, + type UpsertReportInput, + deleteReportResultSchema, + listReportsResultSchema, +} from "../../../contracts/reports.ts"; +import { timestampMillisecondsSchema } from "../../../shared/dateTime.ts"; +import { + parseSchemaWithRangeError, + positiveSafeIntegerSchema, +} from "../../../shared/validation.ts"; +import { + isDatabaseRuntimeWriteUnavailableError, + type DatabaseRuntimeWriteUnavailableError, +} from "../../database/runtime/databaseErrors.ts"; +import { defaultRealtimeRetentionMilliseconds } from "../realtime/retention.ts"; +import { + MonitoringCatalogConflictError, + MonitoringCatalogNotFoundError, + MonitoringCatalogPreconditionError, + MonitoringCatalogValidationError, +} from "./catalogErrors.ts"; +import { + toIncidentRecord, + toIncidentSummary, + toNotificationRecord, + toReportDetail, + toReportSummary, +} from "./catalogRecords.ts"; +import { + appendMonitoringRealtimeEvent, + monitoringRealtimeTopics, +} from "./realtimeEvents.ts"; +import type { + MonitoringRepository, + MonitoringUnitOfWork, + NotificationRecord as NotificationPersistenceRecord, + ReportRecord as ReportPersistenceRecord, +} from "./repository.ts"; +import { + parseMonitoringJsonObject, + serializeMonitoringJsonObject, +} from "./serialization.ts"; + +const catalogClockSchema = timestampMillisecondsSchema( + "Monitoring catalog clock must return valid Date milliseconds" +); +const catalogRealtimeExpirySchema = timestampMillisecondsSchema( + "Monitoring catalog realtime expiry must be valid Date milliseconds" +); +const catalogRealtimeRetentionSchema = v.pipe( + positiveSafeIntegerSchema( + "Monitoring catalog realtime retention must be a positive integer" + ), + v.maxValue(maxTime, "Monitoring catalog realtime retention exceeds the Date range") +); + +export type MonitoringCatalogOperationError = + | DatabaseRuntimeWriteUnavailableError + | MonitoringCatalogConflictError + | MonitoringCatalogNotFoundError + | MonitoringCatalogPreconditionError + | MonitoringCatalogValidationError; + +interface MonitoringCatalogServiceShape { + readonly clearReadNotifications: ( + input: BulkNotificationInput + ) => Effect.Effect; + readonly deleteNotification: ( + input: NotificationIdentityInput + ) => Effect.Effect; + readonly deleteReport: ( + input: DeleteReportInput + ) => Effect.Effect; + readonly getIncident: ( + input: GetIncidentInput + ) => Effect.Effect; + readonly getReport: ( + input: GetReportInput + ) => Effect.Effect; + readonly listIncidents: ( + input: ListIncidentsInput + ) => Effect.Effect; + readonly listNotifications: ( + input: ListNotificationsInput + ) => Effect.Effect; + readonly listReports: (input: ListReportsInput) => Effect.Effect; + readonly markAllNotificationsRead: ( + input: BulkNotificationInput + ) => Effect.Effect; + readonly markNotificationRead: ( + input: NotificationIdentityInput + ) => Effect.Effect; + readonly upsertNotification: ( + input: UpsertNotificationInput + ) => Effect.Effect; + readonly upsertReport: ( + input: UpsertReportInput + ) => Effect.Effect; +} + +/** Effect service for report, incident, and Dashboard-notification catalogs. */ +export class MonitoringCatalogService extends Context.Service< + MonitoringCatalogService, + MonitoringCatalogServiceShape +>()("mira-dashboard/server/domains/monitoring/MonitoringCatalogService") {} + +export interface MonitoringCatalogServiceDependencies { + readonly nowMs?: () => number; + readonly realtimeRetentionMs?: number; + readonly repository: MonitoringRepository; + readonly wakeEventPump?: () => Promise | void; +} + +interface CatalogMutationResult { + readonly changed: boolean; + readonly value: T; +} + +interface CatalogMutationTiming { + readonly expiresAt: Date; + readonly occurredAt: Date; +} + +class MonitoringCatalogUnexpectedError extends Data.TaggedError( + "MonitoringCatalogUnexpectedError" +)<{ readonly cause: unknown }> {} + +function isCatalogOperationError( + error: unknown +): error is MonitoringCatalogOperationError { + return ( + error instanceof MonitoringCatalogConflictError || + error instanceof MonitoringCatalogNotFoundError || + error instanceof MonitoringCatalogPreconditionError || + error instanceof MonitoringCatalogValidationError || + isDatabaseRuntimeWriteUnavailableError(error) + ); +} + +function readEffect( + operation: () => T +): Effect.Effect { + return Effect.try({ + catch: (error) => + error instanceof MonitoringCatalogNotFoundError + ? error + : new MonitoringCatalogUnexpectedError({ cause: error }), + try: operation, + }).pipe( + Effect.catchTag("MonitoringCatalogUnexpectedError", (error) => + Effect.die(error.cause) + ) + ); +} + +function listEffect(operation: () => T): Effect.Effect { + return Effect.try({ + catch: (error) => new MonitoringCatalogUnexpectedError({ cause: error }), + try: operation, + }).pipe( + Effect.catchTag("MonitoringCatalogUnexpectedError", (error) => + Effect.die(error.cause) + ) + ); +} + +function mutationEffect( + operation: () => Promise +): Effect.Effect { + return Effect.tryPromise({ + catch: (error) => + isCatalogOperationError(error) + ? error + : new MonitoringCatalogUnexpectedError({ cause: error }), + try: operation, + }).pipe( + Effect.catchTag("MonitoringCatalogUnexpectedError", (error) => + Effect.die(error.cause) + ) + ); +} + +function notFound( + resource: "incident" | "notification" | "report", + id: string +): MonitoringCatalogNotFoundError { + return new MonitoringCatalogNotFoundError({ id, resource }); +} + +function conflict( + resource: "notification" | "report", + id: string +): MonitoringCatalogConflictError { + return new MonitoringCatalogConflictError({ id, resource }); +} + +function reportMatchesInput( + record: ReportPersistenceRecord, + input: UpsertReportInput +): boolean { + return ( + record.bodyMarkdown === input.bodyMarkdown && + record.id === input.id && + record.kind === input.kind && + serializeMonitoringJsonObject(parseMonitoringJsonObject(record.metadataJson)) === + serializeMonitoringJsonObject(input.metadata) && + getTime(record.occurredAt) === input.occurredAtMs && + record.source === input.source && + record.sourceJobId === (input.sourceJobId ?? null) && + record.status === input.status && + record.summary === (input.summary ?? null) && + record.title === input.title + ); +} + +function notificationMatchesInput( + record: NotificationPersistenceRecord, + input: UpsertNotificationInput +): boolean { + return ( + record.channel === "dashboard" && + record.id === input.id && + record.incidentGeneration === (input.incidentGeneration ?? null) && + record.incidentId === (input.incidentId ?? null) && + record.kind === input.kind && + record.linkUrl === (input.linkUrl ?? null) && + record.message === input.message && + getTime(record.occurredAt) === input.occurredAtMs && + record.reportId === (input.reportId ?? null) && + record.severity === input.severity && + record.source === (input.source ?? null) && + record.title === input.title + ); +} + +function notificationLatestStateAt(record: NotificationPersistenceRecord): Date { + return record.readAt ?? record.occurredAt; +} + +function appendCatalogRealtimeEvent( + unit: MonitoringUnitOfWork, + timing: CatalogMutationTiming, + input: { + readonly entityId: string; + readonly entityType: "notification" | "report"; + readonly operation: "created" | "deleted" | "snapshot-required" | "updated"; + } +): void { + appendMonitoringRealtimeEvent(unit, { + entityId: input.entityId, + entityType: input.entityType, + expiresAt: timing.expiresAt, + occurredAt: timing.occurredAt, + operation: input.operation, + topic: + input.entityType === "notification" + ? monitoringRealtimeTopics.notifications + : monitoringRealtimeTopics.reports, + }); +} + +/** + * Creates catalog operations over validated monitoring persistence. + * @param dependencies Repository plus replaceable clock, retention, and wakeup boundaries. + * @returns Effect service with typed expected catalog failures. + */ +export function createMonitoringCatalogService( + dependencies: MonitoringCatalogServiceDependencies +): MonitoringCatalogService["Service"] { + const nowMs = dependencies.nowMs ?? Date.now; + const realtimeRetentionMs = parseSchemaWithRangeError( + catalogRealtimeRetentionSchema, + dependencies.realtimeRetentionMs ?? defaultRealtimeRetentionMilliseconds + ); + const maximumRetainedTimestampMs = maxTime - realtimeRetentionMs; + const retainableMutationTimestampSchema = v.pipe( + catalogClockSchema, + v.maxValue( + maximumRetainedTimestampMs, + "Monitoring catalog mutation time leaves no room for realtime retention" + ) + ); + const readClockMs = (): number => + parseSchemaWithRangeError(catalogClockSchema, nowMs()); + parseSchemaWithRangeError(retainableMutationTimestampSchema, readClockMs()); + const now = (): Date => toDate(readClockMs()); + const mutationTiming = ( + referenceDates: readonly Date[] = [] + ): CatalogMutationTiming => { + const occurredAt = toDate( + parseSchemaWithRangeError( + retainableMutationTimestampSchema, + getTime(maximumDate([now(), ...referenceDates])) + ) + ); + const expiresAt = addMilliseconds(occurredAt, realtimeRetentionMs); + parseSchemaWithRangeError(catalogRealtimeExpirySchema, getTime(expiresAt)); + return { expiresAt, occurredAt }; + }; + const producerMutationTiming = ( + resource: "notification" | "report", + id: string, + occurredAtMs: number + ): CatalogMutationTiming => { + if (occurredAtMs > maximumRetainedTimestampMs) { + throw new MonitoringCatalogValidationError({ + id, + maximumOccurredAtMs: maximumRetainedTimestampMs, + occurredAtMs, + resource, + }); + } + return mutationTiming([toDate(occurredAtMs)]); + }; + const wakeAfterChange = async (changed: boolean): Promise => { + if (!changed || dependencies.wakeEventPump === undefined) return; + try { + await dependencies.wakeEventPump(); + } catch { + // SQLite remains authoritative; adaptive polling recovers the wakeup. + } + }; + const commitResult = async ( + operation: (unit: MonitoringUnitOfWork) => CatalogMutationResult + ): Promise => { + const result = await dependencies.repository.withImmediateTransaction(operation); + await wakeAfterChange(result.changed); + return result.value; + }; + return MonitoringCatalogService.of({ + clearReadNotifications: (input) => + mutationEffect(() => + commitResult((unit) => { + const candidates = unit.listNotificationMutationCandidates( + input.filters, + "read", + notificationPageMaximum + 1 + ); + const remaining = candidates.length > notificationPageMaximum; + const page = candidates.slice(0, notificationPageMaximum); + const timing = mutationTiming( + page.map((notification) => + notificationLatestStateAt(notification) + ) + ); + const deleted = unit.deleteNotifications( + page.map((notification) => notification.id) + ); + if (deleted.length !== page.length) { + throw new Error( + "Monitoring notification deletion changed unexpectedly" + ); + } + for (const notification of deleted) { + appendCatalogRealtimeEvent(unit, timing, { + entityId: notification.id, + entityType: "notification", + operation: "deleted", + }); + } + return { + changed: deleted.length > 0, + value: v.parse(bulkNotificationResultSchema, { + affectedCount: deleted.length, + completedAtMs: getTime(timing.occurredAt), + remaining, + }), + }; + }) + ), + deleteNotification: (input) => + mutationEffect(() => + commitResult((unit) => { + const current = unit.findNotification(input.id); + if (current === undefined) { + throw notFound("notification", input.id); + } + const timing = mutationTiming([notificationLatestStateAt(current)]); + const deleted = unit.deleteNotification(input.id); + if (deleted === undefined) { + throw new Error( + "Monitoring notification deletion changed unexpectedly" + ); + } + appendCatalogRealtimeEvent(unit, timing, { + entityId: deleted.id, + entityType: "notification", + operation: "deleted", + }); + return { + changed: true, + value: v.parse(deleteNotificationResultSchema, { + deletedAtMs: getTime(timing.occurredAt), + id: deleted.id, + }), + }; + }) + ), + deleteReport: (input) => + mutationEffect(() => + commitResult((unit) => { + const current = unit.findReport(input.id); + if (current === undefined) throw notFound("report", input.id); + const linkedNotifications = unit.listReportNotifications( + input.id, + notificationPageMaximum + 1 + ); + if (linkedNotifications.length > notificationPageMaximum) { + throw new MonitoringCatalogPreconditionError({ + id: input.id, + linkedNotificationCount: linkedNotifications.length, + maximumLinkedNotifications: notificationPageMaximum, + resource: "report", + }); + } + const timing = mutationTiming([ + current.occurredAt, + ...linkedNotifications.map((notification) => + notificationLatestStateAt(notification) + ), + ]); + const deleted = unit.deleteReport(input.id); + if (deleted === undefined) { + throw new Error( + "Monitoring report deletion changed unexpectedly" + ); + } + if (linkedNotifications.length > 0) { + appendCatalogRealtimeEvent(unit, timing, { + entityId: deleted.id, + entityType: "notification", + operation: "snapshot-required", + }); + } + appendCatalogRealtimeEvent(unit, timing, { + entityId: deleted.id, + entityType: "report", + operation: "deleted", + }); + return { + changed: true, + value: v.parse(deleteReportResultSchema, { + deletedAtMs: getTime(timing.occurredAt), + id: deleted.id, + }), + }; + }) + ), + getIncident: (input) => + readEffect(() => { + const record = dependencies.repository.findIncident(input.id); + if (record === undefined) throw notFound("incident", input.id); + return toIncidentRecord(record); + }), + getReport: (input) => + readEffect(() => { + const record = dependencies.repository.findReport(input.id); + if (record === undefined) throw notFound("report", input.id); + return toReportDetail(record); + }), + listIncidents: (input) => + listEffect(() => { + const records = dependencies.repository.listIncidents(input); + const hasNextPage = records.length > input.limit; + const page = records + .slice(0, input.limit) + .map((record) => toIncidentSummary(record)); + const last = page.at(-1); + return v.parse(listIncidentsResultSchema, { + incidents: page, + ...(hasNextPage && last !== undefined + ? { + nextCursor: { + id: last.id, + lastSeenAtMs: last.lastSeenAtMs, + }, + } + : {}), + }); + }), + listNotifications: (input) => + listEffect(() => + dependencies.repository.withReadTransaction((reader) => { + const records = reader.listNotifications(input); + const hasNextPage = records.length > input.limit; + const page = records + .slice(0, input.limit) + .map((record) => toNotificationRecord(record)); + const last = page.at(-1); + return v.parse(listNotificationsResultSchema, { + ...(hasNextPage && last !== undefined + ? { + nextCursor: { + id: last.id, + occurredAtMs: last.occurredAtMs, + }, + } + : {}), + notifications: page, + readCount: reader.countReadNotifications(), + unreadCount: reader.countUnreadNotifications(), + }); + }) + ), + listReports: (input) => + listEffect(() => { + const records = dependencies.repository.listReports(input); + const hasNextPage = records.length > input.limit; + const page = records + .slice(0, input.limit) + .map((record) => toReportSummary(record)); + const last = page.at(-1); + return v.parse(listReportsResultSchema, { + ...(hasNextPage && last !== undefined + ? { + nextCursor: { + id: last.id, + occurredAtMs: last.occurredAtMs, + }, + } + : {}), + reports: page, + }); + }), + markAllNotificationsRead: (input) => + mutationEffect(() => + commitResult((unit) => { + const candidates = unit.listNotificationMutationCandidates( + input.filters, + "unread", + notificationPageMaximum + 1 + ); + const page = candidates.slice(0, notificationPageMaximum); + const timing = mutationTiming( + page.map((notification) => notification.occurredAt) + ); + const updated = unit.markNotificationsRead( + page.map((notification) => notification.id), + timing.occurredAt + ); + if (updated.length !== page.length) { + throw new Error( + "Monitoring notification update changed unexpectedly" + ); + } + for (const notification of updated) { + appendCatalogRealtimeEvent(unit, timing, { + entityId: notification.id, + entityType: "notification", + operation: "updated", + }); + } + return { + changed: updated.length > 0, + value: v.parse(bulkNotificationResultSchema, { + affectedCount: updated.length, + completedAtMs: getTime(timing.occurredAt), + remaining: candidates.length > notificationPageMaximum, + }), + }; + }) + ), + markNotificationRead: (input) => + mutationEffect(() => + commitResult((unit) => { + const current = unit.findNotification(input.id); + if (current === undefined) { + throw notFound("notification", input.id); + } + if (current.readAt !== null) { + return { + changed: false, + value: toNotificationRecord(current), + }; + } + const timing = mutationTiming([current.occurredAt]); + const updated = unit.markNotificationsRead( + [current.id], + timing.occurredAt + )[0]; + if (updated === undefined) { + throw new Error( + "Monitoring notification update changed unexpectedly" + ); + } + appendCatalogRealtimeEvent(unit, timing, { + entityId: updated.id, + entityType: "notification", + operation: "updated", + }); + return { + changed: true, + value: toNotificationRecord(updated), + }; + }) + ), + upsertNotification: (input) => + mutationEffect(() => + commitResult((unit) => { + const existing = unit.findNotification(input.id); + if (existing !== undefined) { + if (!notificationMatchesInput(existing, input)) { + throw conflict("notification", input.id); + } + return { + changed: false, + value: toNotificationRecord(existing), + }; + } + const timing = producerMutationTiming( + "notification", + input.id, + input.occurredAtMs + ); + if ( + input.incidentId !== undefined && + input.incidentGeneration !== undefined + ) { + const incident = unit.findIncident(input.incidentId); + if ( + incident === undefined || + incident.generation !== input.incidentGeneration + ) { + throw notFound("incident", input.incidentId); + } + const related = unit.findIncidentNotification( + input.incidentId, + input.incidentGeneration + ); + if (related !== undefined) { + throw conflict("notification", input.id); + } + } + if ( + input.reportId !== undefined && + unit.findReport(input.reportId) === undefined + ) { + throw notFound("report", input.reportId); + } + const inserted = unit.insertNotification({ + channel: "dashboard", + id: input.id, + incidentGeneration: input.incidentGeneration ?? null, + incidentId: input.incidentId ?? null, + kind: input.kind, + linkUrl: input.linkUrl ?? null, + message: input.message, + occurredAt: toDate(input.occurredAtMs), + reportId: input.reportId ?? null, + severity: input.severity, + source: input.source ?? null, + title: input.title, + }); + appendCatalogRealtimeEvent(unit, timing, { + entityId: inserted.id, + entityType: "notification", + operation: "created", + }); + return { + changed: true, + value: toNotificationRecord(inserted), + }; + }) + ), + upsertReport: (input) => + mutationEffect(() => + commitResult((unit) => { + const existing = unit.findReport(input.id); + if (existing !== undefined) { + if (!reportMatchesInput(existing, input)) { + throw conflict("report", input.id); + } + return { changed: false, value: toReportDetail(existing) }; + } + const timing = producerMutationTiming( + "report", + input.id, + input.occurredAtMs + ); + const inserted = unit.insertReport({ + bodyMarkdown: input.bodyMarkdown, + id: input.id, + kind: input.kind, + metadataJson: serializeMonitoringJsonObject(input.metadata), + occurredAt: toDate(input.occurredAtMs), + source: input.source, + sourceJobId: input.sourceJobId ?? null, + status: input.status, + summary: input.summary ?? null, + title: input.title, + }); + appendCatalogRealtimeEvent(unit, timing, { + entityId: inserted.id, + entityType: "report", + operation: "created", + }); + return { changed: true, value: toReportDetail(inserted) }; + }) + ), + }); +} + +/** + * Provides the monitoring catalog service as an Effect layer. + * @param dependencies Repository plus replaceable clock, retention, and wakeup boundaries. + * @returns Layer containing the catalog service. + */ +export function monitoringCatalogServiceLayer( + dependencies: MonitoringCatalogServiceDependencies +): Layer.Layer { + return Layer.succeed( + MonitoringCatalogService, + createMonitoringCatalogService(dependencies) + ); +} diff --git a/greenfield/src/server/domains/monitoring/ingestionRoutes.ts b/greenfield/src/server/domains/monitoring/ingestionRoutes.ts new file mode 100644 index 000000000..ffd5498c5 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/ingestionRoutes.ts @@ -0,0 +1,22 @@ +import { + completeMonitoringSnapshotInputSchema, + monitoringSubmissionResultSchema, +} from "../../../contracts/monitoring.ts"; +import { principalKindProcedure } from "../../trpc/trpc.ts"; +import { runMonitoringEffect } from "./routeEffects.ts"; + +const monitoringProducerProcedure = principalKindProcedure( + "monitoring:write", + "automation", + "An automation principal is required" +); + +/** Automation-only complete-snapshot ingestion routes. */ +export const monitoringRoutes = { + submitCompleteSnapshot: monitoringProducerProcedure + .input(completeMonitoringSnapshotInputSchema) + .output(monitoringSubmissionResultSchema) + .mutation(({ ctx, input }) => + runMonitoringEffect(ctx.monitoringService.submitCompleteSnapshot(input)) + ), +}; diff --git a/greenfield/src/server/domains/monitoring/normalization.ts b/greenfield/src/server/domains/monitoring/normalization.ts index 5e173a223..0a41f6b65 100644 --- a/greenfield/src/server/domains/monitoring/normalization.ts +++ b/greenfield/src/server/domains/monitoring/normalization.ts @@ -6,6 +6,7 @@ import { type JsonObject, } from "../../../contracts/monitoring.ts"; import { sha256Hex } from "../../shared/crypto.ts"; +import { serializeCanonicalMonitoringJson } from "./serialization.ts"; const TaggedErrorClass = Schema.TaggedError; const fingerprintVersion = "monitoring-incident-fingerprint:v1"; @@ -66,6 +67,7 @@ export interface NormalizedMonitoringSnapshot { metadata: JsonObject; source: string; sourceJobId: string; + summary?: string; title: string; }; runId: string; @@ -84,27 +86,6 @@ export class MonitoringSnapshotValidationError extends TaggedErrorClass canonicalJson(entry)).join(",")}]`; - } - - const record = value as Record; - return `{${Object.keys(record) - .toSorted() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(",")}}`; -} - function parseNormalizedIdentifier( schema: v.GenericSchema, value: string @@ -130,7 +111,7 @@ export function deriveIncidentFingerprint(input: { kind: string; }): string { return sha256Hex( - `${fingerprintVersion}\0${canonicalJson([ + `${fingerprintVersion}\0${serializeCanonicalMonitoringJson([ input.kind, input.entityKey, input.condition, @@ -205,6 +186,9 @@ export function normalizeMonitoringSnapshot( metadata: parsed.report.metadata, source: v.parse(trimmedTextSchema, parsed.report.source), sourceJobId: v.parse(trimmedTextSchema, parsed.report.sourceJobId), + ...(parsed.report.summary === undefined + ? {} + : { summary: parsed.report.summary }), title: v.parse(trimmedTextSchema, parsed.report.title), }, runId: parsed.runId, @@ -213,6 +197,6 @@ export function normalizeMonitoringSnapshot( return { snapshot, - submissionSha256: sha256Hex(canonicalJson(snapshot)), + submissionSha256: sha256Hex(serializeCanonicalMonitoringJson(snapshot)), }; } diff --git a/greenfield/src/server/domains/monitoring/procedures.test.ts b/greenfield/src/server/domains/monitoring/procedures.test.ts new file mode 100644 index 000000000..4b5a99786 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/procedures.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, test } from "bun:test"; + +import { TRPCError } from "@trpc/server"; +import { maxTime } from "date-fns/constants"; +import { Effect } from "effect"; +import * as v from "valibot"; + +import { completeMonitoringSnapshotInputSchema } from "../../../contracts/monitoring.ts"; +import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; +import { captureFailure } from "../../test/support/promise.ts"; +import { + createTestApplicationRuntime, + createTestAutomationAuthentication, + createTestRequestContext, + createTestSessionAuthentication, +} from "../../test/support/requestContext.ts"; +import { appRouter } from "../../trpc/appRouter.ts"; +import { MonitoringCatalogPreconditionError } from "./catalogErrors.ts"; +import { createMonitoringCatalogService } from "./catalogService.ts"; +import { createMonitoringRepository } from "./repository.ts"; +import { createMonitoringService } from "./service.ts"; +import { + oneDayMs, + openFreshMigratedDatabase, + snapshot, + uuid, +} from "./testSupport/monitoringService.ts"; +import { createTestMonitoringCatalogService } from "./testSupport/services.ts"; + +type FreshDatabase = Awaited>; + +function monitoringServicesFor(database: FreshDatabase) { + const repository = createMonitoringRepository( + database.orm, + testImmediateDatabaseWriteAdmission + ); + let nextId = 10_000; + return { + monitoringCatalogService: createMonitoringCatalogService({ + nowMs: () => 10_000, + realtimeRetentionMs: oneDayMs, + repository, + }), + monitoringService: createMonitoringService({ + generateId: () => uuid(nextId++), + nowMs: () => 10_000, + realtimeRetentionMs: oneDayMs, + repository, + }), + }; +} + +function parsedSnapshot(input: ReturnType) { + return v.parse(completeMonitoringSnapshotInputSchema, input); +} + +async function expectTrpcCode( + operation: () => Promise, + code: TRPCError["code"] +): Promise { + const failure = await captureFailure(operation); + expect(failure).toBeInstanceOf(TRPCError); + expect((failure as TRPCError).code).toBe(code); +} + +describe("monitoring procedures", () => { + test("enforces exact capabilities and principal kinds before service access", async () => { + const validSnapshot = parsedSnapshot(snapshot({ completedAtMs: 1000, run: 1 })); + const anonymous = appRouter.createCaller(await createTestRequestContext()); + await expectTrpcCode( + () => anonymous.incidents.list({ limit: 10 }), + "UNAUTHORIZED" + ); + + const wrongReadCapability = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication(["notifications:read"]) + ) + ); + await expectTrpcCode( + () => wrongReadCapability.reports.list({ limit: 10 }), + "FORBIDDEN" + ); + + const sessionProducer = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication([ + "monitoring:write", + "notifications:write", + ]) + ) + ); + await expectTrpcCode( + () => sessionProducer.monitoring.submitCompleteSnapshot(validSnapshot), + "FORBIDDEN" + ); + await expectTrpcCode( + () => + sessionProducer.notifications.upsert({ + id: uuid(100), + kind: "test", + message: "Session must not produce notifications", + occurredAtMs: 1000, + severity: "info", + title: "Forbidden producer", + }), + "FORBIDDEN" + ); + + const automationAcknowledgement = appRouter.createCaller( + await createTestRequestContext( + createTestAutomationAuthentication(["notifications:write"]) + ) + ); + await expectTrpcCode( + () => automationAcknowledgement.notifications.markAllRead({}), + "FORBIDDEN" + ); + }); + + test("routes ingestion into readable catalogs and session notification actions", async () => { + const database = await openFreshMigratedDatabase(); + const services = monitoringServicesFor(database); + try { + const producer = appRouter.createCaller( + await createTestRequestContext( + createTestAutomationAuthentication(["monitoring:write"]), + createTestApplicationRuntime(), + services + ) + ); + const submission = await producer.monitoring.submitCompleteSnapshot( + parsedSnapshot(snapshot({ completedAtMs: 1000, run: 10 })) + ); + expect(submission).toMatchObject({ + createdIncidents: 1, + observedIncidents: 1, + status: "accepted", + }); + + const session = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication([ + "notifications:read", + "notifications:write", + "reports:read", + "reports:write", + ]), + createTestApplicationRuntime(), + services + ) + ); + const reports = await session.reports.list({ limit: 10 }); + expect(reports.reports).toHaveLength(1); + expect( + await session.reports.get({ id: reports.reports[0]!.id }) + ).toMatchObject({ + bodyMarkdown: "# Health at 1000", + id: submission.reportId, + }); + expect( + await session.reports.upsert({ + bodyMarkdown: "# Manual report", + id: uuid(150), + kind: "manual", + metadata: {}, + occurredAtMs: 2000, + source: "browser", + status: "ok", + title: "Manual report", + }) + ).toMatchObject({ id: uuid(150), source: "browser" }); + + const incidents = await session.incidents.list({ limit: 10 }); + expect(incidents.incidents).toHaveLength(1); + expect( + await session.incidents.get({ id: incidents.incidents[0]!.id }) + ).toMatchObject({ state: "active" }); + + const notifications = await session.notifications.list({ limit: 10 }); + expect(notifications).toMatchObject({ unreadCount: 1 }); + const markedRead = await session.notifications.markRead({ + id: notifications.notifications[0]!.id, + }); + expect(markedRead.readAtMs).toBe(10_000); + expect(await session.notifications.clearRead({ filters: {} })).toMatchObject({ + affectedCount: 1, + remaining: false, + }); + } finally { + database.sqlite.close(true); + } + }); + + test("maps ingestion conflicts, validation failures, and missing catalog links", async () => { + const database = await openFreshMigratedDatabase(); + const services = monitoringServicesFor(database); + try { + const producer = appRouter.createCaller( + await createTestRequestContext( + createTestAutomationAuthentication([ + "monitoring:write", + "notifications:write", + ]), + createTestApplicationRuntime(), + services + ) + ); + const initial = parsedSnapshot(snapshot({ completedAtMs: 1000, run: 20 })); + await producer.monitoring.submitCompleteSnapshot(initial); + await expectTrpcCode( + () => + producer.monitoring.submitCompleteSnapshot( + v.parse(completeMonitoringSnapshotInputSchema, { + ...initial, + report: { ...initial.report, title: "Conflicting replay" }, + }) + ), + "CONFLICT" + ); + await expectTrpcCode( + () => + producer.monitoring.submitCompleteSnapshot( + parsedSnapshot(snapshot({ completedAtMs: 310_001, run: 21 })) + ), + "BAD_REQUEST" + ); + await expectTrpcCode( + () => + producer.notifications.upsert({ + id: uuid(200), + kind: "test", + message: "References a missing report", + occurredAtMs: 2000, + reportId: uuid(999_999), + severity: "warning", + title: "Missing report", + }), + "NOT_FOUND" + ); + + const reader = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication(["reports:read"]), + createTestApplicationRuntime(), + services + ) + ); + const incidentPage = await reader.incidents.list({ limit: 10 }); + const incident = incidentPage.incidents[0]!; + await expectTrpcCode( + () => + producer.notifications.upsert({ + id: uuid(201), + incidentGeneration: incident.generation + 1, + incidentId: incident.id, + kind: "test", + message: "References a nonexistent incident generation", + occurredAtMs: 2000, + severity: "warning", + title: "Missing incident generation", + }), + "NOT_FOUND" + ); + await expectTrpcCode( + () => reader.reports.get({ id: uuid(999_998) }), + "NOT_FOUND" + ); + } finally { + database.sqlite.close(true); + } + }); + + test("maps retention-overflowing producer timestamps to BAD_REQUEST", async () => { + const database = await openFreshMigratedDatabase(); + const services = monitoringServicesFor(database); + try { + const producer = appRouter.createCaller( + await createTestRequestContext( + createTestAutomationAuthentication([ + "notifications:write", + "reports:write", + ]), + createTestApplicationRuntime(), + services + ) + ); + + await expectTrpcCode( + () => + producer.reports.upsert({ + bodyMarkdown: "# Outside retention", + id: uuid(210), + kind: "health", + metadata: {}, + occurredAtMs: maxTime, + source: "dashboard", + status: "ok", + title: "Outside retention", + }), + "BAD_REQUEST" + ); + await expectTrpcCode( + () => + producer.notifications.upsert({ + id: uuid(211), + kind: "health", + message: "Outside retention", + occurredAtMs: maxTime, + severity: "warning", + title: "Outside retention", + }), + "BAD_REQUEST" + ); + } finally { + database.sqlite.close(true); + } + }); + + test("maps bounded report deletion refusal to PRECONDITION_FAILED", async () => { + const reportId = uuid(300); + const monitoringCatalogService = createTestMonitoringCatalogService({ + deleteReport: () => + Effect.fail( + new MonitoringCatalogPreconditionError({ + id: reportId, + linkedNotificationCount: 101, + maximumLinkedNotifications: 100, + resource: "report", + }) + ), + }); + const caller = appRouter.createCaller( + await createTestRequestContext( + createTestSessionAuthentication(["reports:write"]), + createTestApplicationRuntime(), + { monitoringCatalogService } + ) + ); + + await expectTrpcCode( + () => caller.reports.delete({ id: reportId }), + "PRECONDITION_FAILED" + ); + }); +}); diff --git a/greenfield/src/server/domains/monitoring/procedures.ts b/greenfield/src/server/domains/monitoring/procedures.ts new file mode 100644 index 000000000..91d6729f4 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/procedures.ts @@ -0,0 +1,17 @@ +import { router } from "../../trpc/trpc.ts"; +import { incidentRoutes, notificationRoutes, reportRoutes } from "./catalogRoutes.ts"; +import { monitoringRoutes } from "./ingestionRoutes.ts"; + +export const incidentProcedureNames = Object.freeze(Object.keys(incidentRoutes)); +export const monitoringProcedureNames = Object.freeze(Object.keys(monitoringRoutes)); +export const notificationProcedureNames = Object.freeze(Object.keys(notificationRoutes)); +export const reportProcedureNames = Object.freeze(Object.keys(reportRoutes)); + +/** Capability-scoped incident lifecycle router. */ +export const incidentRouter = router(incidentRoutes); +/** Automation-only complete-snapshot ingestion router. */ +export const monitoringRouter = router(monitoringRoutes); +/** Dashboard notification inventory and acknowledgement router. */ +export const notificationRouter = router(notificationRoutes); +/** Immutable monitoring report catalog router. */ +export const reportRouter = router(reportRoutes); diff --git a/greenfield/src/server/domains/monitoring/realtimeEvents.ts b/greenfield/src/server/domains/monitoring/realtimeEvents.ts index fbd6f8dc1..d07bd474c 100644 --- a/greenfield/src/server/domains/monitoring/realtimeEvents.ts +++ b/greenfield/src/server/domains/monitoring/realtimeEvents.ts @@ -60,12 +60,24 @@ function createRealtimeEvent(input: { }; } +/** + * Appends one validated monitoring invalidation to the current transaction. + * @param unit Monitoring transaction that owns the related state change. + * @param input Validated routing, identity, time, and retention metadata. + */ +export function appendMonitoringRealtimeEvent( + unit: MonitoringUnitOfWork, + input: Parameters[0] +): void { + unit.insertRealtimeEvent(createRealtimeEvent(input)); +} + export function insertRealtimeEvent( unit: MonitoringUnitOfWork, counts: MutableSubmissionCounts, input: Parameters[0] ): void { - unit.insertRealtimeEvent(createRealtimeEvent(input)); + appendMonitoringRealtimeEvent(unit, input); counts.realtimeEvents += 1; } @@ -78,6 +90,7 @@ export function insertIncidentNotification(input: { outboxOccurredAt: Date; problem: NormalizedMonitoringProblem; reportTitle: string; + source: string; unit: MonitoringUnitOfWork; }): void { const notification = input.unit.insertNotification({ @@ -89,7 +102,9 @@ export function insertIncidentNotification(input: { linkUrl: incidentLink(input.incident.id), message: input.problem.title, occurredAt: input.occurredAt, + reportId: null, severity: input.problem.severity, + source: input.source, title: input.reportTitle, }); insertRealtimeEvent(input.unit, input.counts, { diff --git a/greenfield/src/server/domains/monitoring/repository.ts b/greenfield/src/server/domains/monitoring/repository.ts index e633b0a2f..7704e5241 100644 --- a/greenfield/src/server/domains/monitoring/repository.ts +++ b/greenfield/src/server/domains/monitoring/repository.ts @@ -1,7 +1,29 @@ -import { and, desc, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; +import { toDate } from "date-fns"; +import { + and, + count, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + or, + type SQL, +} from "drizzle-orm"; import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"; import * as v from "valibot"; +import { + incidentPageMaximum, + type ListIncidentsInput, +} from "../../../contracts/incidents.ts"; +import type { + BulkNotificationInput, + ListNotificationsInput, +} from "../../../contracts/notifications.ts"; +import { notificationPageMaximum } from "../../../contracts/notifications.ts"; +import { reportPageMaximum, type ListReportsInput } from "../../../contracts/reports.ts"; import type { ImmediateDatabaseWriteAdmission } from "../../database/immediateWriteAdmission.ts"; import { incidentObservations } from "../../database/schema/incidentObservations.ts"; import { incidents } from "../../database/schema/incidents.ts"; @@ -46,15 +68,36 @@ export type MonitorRunInsert = v.InferOutput; export type MonitorRunRecord = v.InferOutput; export type NotificationInsert = v.InferOutput; export type NotificationRecord = v.InferOutput; +export type NotificationUpdate = v.InferOutput; export type RealtimeEventInsert = v.InferOutput; export type ReportInsert = v.InferOutput; export type ReportRecord = v.InferOutput; type TransactionCallback = Parameters[0]; type MonitoringTransaction = Parameters[0]; +type MonitoringPersistenceDatabase = MonitoringTransaction | SQLiteBunDatabase; type SynchronousResult = T extends Promise ? never : T; -export interface MonitoringUnitOfWork { +export interface MonitoringReader { + countReadNotifications(): number; + countUnreadNotifications(): number; + findIncident(id: string): IncidentRecord | undefined; + findIncidentNotification( + incidentId: string, + incidentGeneration: number + ): NotificationRecord | undefined; + findNotification(id: string): NotificationRecord | undefined; + findReport(id: string): ReportRecord | undefined; + listIncidents(input: ListIncidentsInput): IncidentRecord[]; + listNotifications(input: ListNotificationsInput): NotificationRecord[]; + listReportNotifications(reportId: string, limit: number): NotificationRecord[]; + listReports(input: ListReportsInput): ReportRecord[]; +} + +export interface MonitoringUnitOfWork extends MonitoringReader { + deleteNotification(id: string): NotificationRecord | undefined; + deleteNotifications(ids: readonly string[]): NotificationRecord[]; + deleteReport(id: string): ReportRecord | undefined; findLatestCompleteRun(monitorKey: string): MonitorRunRecord | undefined; findLifecycleIncidents( monitorKey: string, @@ -67,18 +110,27 @@ export interface MonitoringUnitOfWork { insertObservation(input: IncidentObservationInsert): number; insertRealtimeEvent(input: RealtimeEventInsert): number; insertReport(input: ReportInsert): ReportRecord; + listNotificationMutationCandidates( + filters: BulkNotificationInput["filters"], + readState: "read" | "unread", + limit: number + ): NotificationRecord[]; markIncidentNotificationRead( incidentId: string, incidentGeneration: number, readAt: Date ): NotificationRecord | undefined; + markNotificationsRead(ids: readonly string[], readAt: Date): NotificationRecord[]; updateIncident(id: string, input: IncidentUpdate): IncidentRecord; } -export interface MonitoringRepository { +export interface MonitoringRepository extends MonitoringReader { withImmediateTransaction( callback: (unit: MonitoringUnitOfWork) => SynchronousResult ): Promise; + withReadTransaction( + callback: (reader: MonitoringReader) => SynchronousResult + ): T; } function requiredRow(row: T | undefined, operation: string): T { @@ -88,13 +140,291 @@ function requiredRow(row: T | undefined, operation: string): T { return row; } -class DrizzleMonitoringUnitOfWork implements MonitoringUnitOfWork { +function assertPageLimit(limit: number, maximum: number, operation: string): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) { + throw new RangeError(`Monitoring repository ${operation} limit is invalid`); + } +} + +function reportCursorBoundary(input: ListReportsInput): SQL | undefined { + if (input.cursor === undefined) return undefined; + const occurredAt = toDate(input.cursor.occurredAtMs); + return or( + lt(reports.occurredAt, occurredAt), + and(eq(reports.occurredAt, occurredAt), lt(reports.id, input.cursor.id)) + ); +} + +function reportFilterConditions(input: ListReportsInput): SQL[] { + const filters = input.filters; + if (filters === undefined) return []; + return [ + ...(filters.kinds === undefined + ? [] + : [inArray(reports.kind, [...filters.kinds])]), + ...(filters.sourceJobIds === undefined + ? [] + : [inArray(reports.sourceJobId, [...filters.sourceJobIds])]), + ...(filters.sources === undefined + ? [] + : [inArray(reports.source, [...filters.sources])]), + ...(filters.statuses === undefined + ? [] + : [inArray(reports.status, [...filters.statuses])]), + ]; +} + +function incidentCursorBoundary(input: ListIncidentsInput): SQL | undefined { + if (input.cursor === undefined) return undefined; + const lastSeenAt = toDate(input.cursor.lastSeenAtMs); + return or( + lt(incidents.lastSeenAt, lastSeenAt), + and(eq(incidents.lastSeenAt, lastSeenAt), lt(incidents.id, input.cursor.id)) + ); +} + +function incidentFilterConditions(input: ListIncidentsInput): SQL[] { + const filters = input.filters; + if (filters === undefined) return []; + return [ + ...(filters.kinds === undefined + ? [] + : [inArray(incidents.kind, [...filters.kinds])]), + ...(filters.monitorKeys === undefined + ? [] + : [inArray(incidents.monitorKey, [...filters.monitorKeys])]), + ...(filters.severities === undefined + ? [] + : [inArray(incidents.severity, [...filters.severities])]), + ...(filters.states === undefined + ? [] + : [inArray(incidents.state, [...filters.states])]), + ]; +} + +function notificationCursorBoundary(input: ListNotificationsInput): SQL | undefined { + if (input.cursor === undefined) return undefined; + const occurredAt = toDate(input.cursor.occurredAtMs); + return or( + lt(notifications.occurredAt, occurredAt), + and( + eq(notifications.occurredAt, occurredAt), + lt(notifications.id, input.cursor.id) + ) + ); +} + +function notificationFilterConditions( + filters: ListNotificationsInput["filters"] | BulkNotificationInput["filters"], + readState?: "read" | "unread" +): SQL[] { + if (filters === undefined) { + return readState === undefined + ? [] + : [ + readState === "read" + ? isNotNull(notifications.readAt) + : isNull(notifications.readAt), + ]; + } + const requestedReadState = + readState ?? + ("readState" in filters && filters.readState !== "all" + ? filters.readState + : undefined); + return [ + ...(filters.incidentId === undefined + ? [] + : [eq(notifications.incidentId, filters.incidentId)]), + ...(filters.kinds === undefined + ? [] + : [inArray(notifications.kind, [...filters.kinds])]), + ...(filters.severities === undefined + ? [] + : [inArray(notifications.severity, [...filters.severities])]), + ...(filters.sources === undefined + ? [] + : [inArray(notifications.source, [...filters.sources])]), + ...(requestedReadState === undefined + ? [] + : [ + requestedReadState === "read" + ? isNotNull(notifications.readAt) + : isNull(notifications.readAt), + ]), + ]; +} + +class DrizzleMonitoringReader implements MonitoringReader { + protected readonly database: MonitoringPersistenceDatabase; + + public constructor(database: MonitoringPersistenceDatabase) { + this.database = database; + } + + public countReadNotifications(): number { + const row = this.database + .select({ value: count() }) + .from(notifications) + .where(isNotNull(notifications.readAt)) + .get(); + const value = requiredRow(row, "read notification count").value; + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("Monitoring repository read count is invalid"); + } + return value; + } + + public countUnreadNotifications(): number { + const row = this.database + .select({ value: count() }) + .from(notifications) + .where(isNull(notifications.readAt)) + .get(); + const value = requiredRow(row, "unread notification count").value; + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("Monitoring repository unread count is invalid"); + } + return value; + } + + public findIncident(id: string): IncidentRecord | undefined { + const row = this.database + .select() + .from(incidents) + .where(eq(incidents.id, id)) + .get(); + return row === undefined ? undefined : v.parse(incidentSelectSchema, row); + } + + public findIncidentNotification( + incidentId: string, + incidentGeneration: number + ): NotificationRecord | undefined { + const row = this.database + .select() + .from(notifications) + .where( + and( + eq(notifications.incidentId, incidentId), + eq(notifications.incidentGeneration, incidentGeneration) + ) + ) + .get(); + return row === undefined ? undefined : v.parse(notificationSelectSchema, row); + } + + public findNotification(id: string): NotificationRecord | undefined { + const row = this.database + .select() + .from(notifications) + .where(eq(notifications.id, id)) + .get(); + return row === undefined ? undefined : v.parse(notificationSelectSchema, row); + } + + public findReport(id: string): ReportRecord | undefined { + const row = this.database.select().from(reports).where(eq(reports.id, id)).get(); + return row === undefined ? undefined : v.parse(reportSelectSchema, row); + } + + public listIncidents(input: ListIncidentsInput): IncidentRecord[] { + assertPageLimit(input.limit, incidentPageMaximum, "incident page"); + return this.database + .select() + .from(incidents) + .where(and(incidentCursorBoundary(input), ...incidentFilterConditions(input))) + .orderBy(desc(incidents.lastSeenAt), desc(incidents.id)) + .limit(input.limit + 1) + .all() + .map((row) => v.parse(incidentSelectSchema, row)); + } + + public listNotifications(input: ListNotificationsInput): NotificationRecord[] { + assertPageLimit(input.limit, notificationPageMaximum, "notification page"); + return this.database + .select() + .from(notifications) + .where( + and( + notificationCursorBoundary(input), + ...notificationFilterConditions(input.filters) + ) + ) + .orderBy(desc(notifications.occurredAt), desc(notifications.id)) + .limit(input.limit + 1) + .all() + .map((row) => v.parse(notificationSelectSchema, row)); + } + + public listReportNotifications( + reportId: string, + limit: number + ): NotificationRecord[] { + assertPageLimit(limit, notificationPageMaximum + 1, "report notification page"); + return this.database + .select() + .from(notifications) + .where(eq(notifications.reportId, reportId)) + .orderBy(desc(notifications.occurredAt), desc(notifications.id)) + .limit(limit) + .all() + .map((row) => v.parse(notificationSelectSchema, row)); + } + + public listReports(input: ListReportsInput): ReportRecord[] { + assertPageLimit(input.limit, reportPageMaximum, "report page"); + return this.database + .select() + .from(reports) + .where(and(reportCursorBoundary(input), ...reportFilterConditions(input))) + .orderBy(desc(reports.occurredAt), desc(reports.id)) + .limit(input.limit + 1) + .all() + .map((row) => v.parse(reportSelectSchema, row)); + } +} + +class DrizzleMonitoringUnitOfWork + extends DrizzleMonitoringReader + implements MonitoringUnitOfWork +{ readonly #transaction: MonitoringTransaction; constructor(transaction: MonitoringTransaction) { + super(transaction); this.#transaction = transaction; } + deleteNotification(id: string): NotificationRecord | undefined { + const row = this.#transaction + .delete(notifications) + .where(eq(notifications.id, id)) + .returning() + .get(); + return row === undefined ? undefined : v.parse(notificationSelectSchema, row); + } + + deleteNotifications(ids: readonly string[]): NotificationRecord[] { + if (ids.length === 0) return []; + assertPageLimit(ids.length, notificationPageMaximum, "notification delete"); + return this.#transaction + .delete(notifications) + .where(inArray(notifications.id, [...ids])) + .returning() + .all() + .map((row) => v.parse(notificationSelectSchema, row)); + } + + deleteReport(id: string): ReportRecord | undefined { + const row = this.#transaction + .delete(reports) + .where(eq(reports.id, id)) + .returning() + .get(); + return row === undefined ? undefined : v.parse(reportSelectSchema, row); + } + findLatestCompleteRun(monitorKey: string): MonitorRunRecord | undefined { const row = this.#transaction .select() @@ -201,6 +531,22 @@ class DrizzleMonitoringUnitOfWork implements MonitoringUnitOfWork { return v.parse(reportSelectSchema, requiredRow(row, "report insert")); } + listNotificationMutationCandidates( + filters: BulkNotificationInput["filters"], + readState: "read" | "unread", + limit: number + ): NotificationRecord[] { + assertPageLimit(limit, notificationPageMaximum + 1, "notification mutation page"); + return this.#transaction + .select() + .from(notifications) + .where(and(...notificationFilterConditions(filters, readState))) + .orderBy(desc(notifications.occurredAt), desc(notifications.id)) + .limit(limit) + .all() + .map((row) => v.parse(notificationSelectSchema, row)); + } + markIncidentNotificationRead( incidentId: string, incidentGeneration: number, @@ -222,6 +568,19 @@ class DrizzleMonitoringUnitOfWork implements MonitoringUnitOfWork { return row === undefined ? undefined : v.parse(notificationSelectSchema, row); } + markNotificationsRead(ids: readonly string[], readAt: Date): NotificationRecord[] { + if (ids.length === 0) return []; + assertPageLimit(ids.length, notificationPageMaximum, "notification update"); + const update = v.parse(notificationUpdateSchema, { readAt }); + return this.#transaction + .update(notifications) + .set(update) + .where(and(inArray(notifications.id, [...ids]), isNull(notifications.readAt))) + .returning() + .all() + .map((row) => v.parse(notificationSelectSchema, row)); + } + updateIncident(id: string, input: IncidentUpdate): IncidentRecord { const row = this.#transaction .update(incidents) @@ -248,10 +607,41 @@ export function createMonitoringRepository( // while adapting that upstream declaration at this composition boundary. const runTransaction = database.transaction.bind(database) as unknown as ( callback: (transaction: MonitoringTransaction) => T, - config: { behavior: "immediate" } + config: { behavior: "deferred" | "immediate" } ) => T; + const withReadTransaction = ( + callback: (reader: MonitoringReader) => SynchronousResult + ): T => + runTransaction( + (transaction) => callback(new DrizzleMonitoringReader(transaction)), + { behavior: "deferred" } + ); - return { + return Object.freeze({ + countReadNotifications: () => + withReadTransaction((reader) => reader.countReadNotifications()), + countUnreadNotifications: () => + withReadTransaction((reader) => reader.countUnreadNotifications()), + findIncident: (id: string) => + withReadTransaction((reader) => reader.findIncident(id)), + findIncidentNotification: (incidentId: string, incidentGeneration: number) => + withReadTransaction((reader) => + reader.findIncidentNotification(incidentId, incidentGeneration) + ), + findNotification: (id: string) => + withReadTransaction((reader) => reader.findNotification(id)), + findReport: (id: string) => + withReadTransaction((reader) => reader.findReport(id)), + listIncidents: (input: ListIncidentsInput) => + withReadTransaction((reader) => reader.listIncidents(input)), + listNotifications: (input: ListNotificationsInput) => + withReadTransaction((reader) => reader.listNotifications(input)), + listReportNotifications: (reportId: string, limit: number) => + withReadTransaction((reader) => + reader.listReportNotifications(reportId, limit) + ), + listReports: (input: ListReportsInput) => + withReadTransaction((reader) => reader.listReports(input)), withImmediateTransaction( callback: (unit: MonitoringUnitOfWork) => SynchronousResult ): Promise { @@ -265,5 +655,6 @@ export function createMonitoringRepository( ) ); }, - }; + withReadTransaction, + }); } diff --git a/greenfield/src/server/domains/monitoring/routeEffects.ts b/greenfield/src/server/domains/monitoring/routeEffects.ts new file mode 100644 index 000000000..bd9840eea --- /dev/null +++ b/greenfield/src/server/domains/monitoring/routeEffects.ts @@ -0,0 +1,64 @@ +import { TRPCError } from "@trpc/server"; +import { Effect } from "effect"; + +import { + MonitoringCatalogConflictError, + MonitoringCatalogNotFoundError, + MonitoringCatalogPreconditionError, + MonitoringCatalogValidationError, +} from "./catalogErrors.ts"; +import { + MonitoringRunConflictError, + MonitoringSnapshotValidationError, +} from "./service.ts"; + +/** + * Runs one monitoring Effect while translating reviewed domain failures to stable tRPC codes. + * Infrastructure admission failures remain typed for the shared expected-error boundary. + * @param effect Monitoring operation to execute at the tRPC boundary. + * @returns The successful monitoring operation result. + */ +export async function runMonitoringEffect(effect: Effect.Effect): Promise { + try { + return await Effect.runPromise(effect); + } catch (error) { + if ( + error instanceof MonitoringSnapshotValidationError || + error instanceof MonitoringCatalogValidationError + ) { + throw new TRPCError({ + cause: error, + code: "BAD_REQUEST", + message: + error instanceof MonitoringSnapshotValidationError + ? "Monitoring snapshot is invalid" + : "Monitoring catalog input is invalid", + }); + } + if ( + error instanceof MonitoringRunConflictError || + error instanceof MonitoringCatalogConflictError + ) { + throw new TRPCError({ + cause: error, + code: "CONFLICT", + message: "Monitoring resource conflicts with existing state", + }); + } + if (error instanceof MonitoringCatalogNotFoundError) { + throw new TRPCError({ + cause: error, + code: "NOT_FOUND", + message: "Monitoring resource was not found", + }); + } + if (error instanceof MonitoringCatalogPreconditionError) { + throw new TRPCError({ + cause: error, + code: "PRECONDITION_FAILED", + message: "Monitoring operation precondition failed", + }); + } + throw error; + } +} diff --git a/greenfield/src/server/domains/monitoring/serialization.ts b/greenfield/src/server/domains/monitoring/serialization.ts index fbaed036f..c417942b3 100644 --- a/greenfield/src/server/domains/monitoring/serialization.ts +++ b/greenfield/src/server/domains/monitoring/serialization.ts @@ -1,4 +1,63 @@ -import type { JsonObject } from "../../../contracts/monitoring.ts"; +import * as v from "valibot"; + +import { + monitoringJsonObjectSchema, + type JsonObject, +} from "../../../contracts/monitoring.ts"; + +function serializeCanonicalJson(value: unknown, ancestors: Set): string { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) { + throw new TypeError("Monitoring JSON number is invalid"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || ancestors.has(value)) { + throw new TypeError("Monitoring JSON value is invalid"); + } + + const prototype = Object.getPrototypeOf(value) as unknown; + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Monitoring JSON object is invalid"); + } + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const entries: string[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!(index in value)) { + throw new TypeError("Monitoring JSON array is sparse"); + } + entries.push(serializeCanonicalJson(value[index], ancestors)); + } + return `[${entries.join(",")}]`; + } + + const record = value as Record; + return `{${Object.keys(record) + .toSorted() + .map( + (key) => + `${JSON.stringify(key)}:${serializeCanonicalJson(record[key], ancestors)}` + ) + .join(",")}}`; + } finally { + ancestors.delete(value); + } +} + +/** + * Serializes validated JSON with recursively sorted object keys. + * @param value Validated JSON value. + * @returns Canonical JSON text suitable for identity and persistence checks. + */ +export function serializeCanonicalMonitoringJson(value: unknown): string { + return serializeCanonicalJson(value, new Set()); +} /** * Serializes a previously validated monitoring object as durable plain JSON. @@ -6,5 +65,14 @@ import type { JsonObject } from "../../../contracts/monitoring.ts"; * @returns Durable plain-JSON text. */ export function serializeMonitoringJsonObject(value: JsonObject): string { - return JSON.stringify(value); + return serializeCanonicalMonitoringJson(value); +} + +/** + * Parses and revalidates one persisted monitoring JSON object. + * @param text Persisted JSON bytes. + * @returns Validated bounded monitoring object. + */ +export function parseMonitoringJsonObject(text: string): JsonObject { + return v.parse(monitoringJsonObjectSchema, JSON.parse(text) as unknown); } diff --git a/greenfield/src/server/domains/monitoring/service.ts b/greenfield/src/server/domains/monitoring/service.ts index 996e62750..1d22e2fca 100644 --- a/greenfield/src/server/domains/monitoring/service.ts +++ b/greenfield/src/server/domains/monitoring/service.ts @@ -21,6 +21,7 @@ import { defaultRealtimeRetentionMilliseconds } from "../realtime/retention.ts"; import { MonitoringSnapshotValidationError, normalizeMonitoringSnapshot, + type NormalizedMonitoringProblem, } from "./normalization.ts"; import { insertRealtimeEvent, @@ -64,10 +65,16 @@ export interface MonitoringServiceDependencies { generateId?: () => string; nowMs?: () => number; realtimeRetentionMs?: number; - repository: MonitoringRepository; - wakeEventPump?: () => void; + repository: MonitoringSubmissionRepository; + wakeEventPump?: () => Promise | void; } +/** Minimal persistence port owned by complete-snapshot ingestion. */ +export type MonitoringSubmissionRepository = Pick< + MonitoringRepository, + "withImmediateTransaction" +>; + export type MonitoringSubmissionError = | DatabaseRuntimeWriteUnavailableError | MonitoringRunConflictError @@ -126,6 +133,19 @@ function isNewerThanLatestRun( return completedAtOrder > 0 || (completedAtOrder === 0 && runId > latestRunId); } +function reportStatusForProblems( + problems: readonly NormalizedMonitoringProblem[] +): "error" | "ok" | "warning" { + if ( + problems.some( + (problem) => problem.severity === "critical" || problem.severity === "error" + ) + ) { + return "error"; + } + return problems.some((problem) => problem.severity === "warning") ? "warning" : "ok"; +} + /** * Creates the business service for complete monitor snapshots. * All lifecycle, report, notification, observation, and outbox writes share one immediate @@ -142,6 +162,14 @@ export function createMonitoringService( realtimeRetentionSchema, dependencies.realtimeRetentionMs ?? defaultRealtimeRetentionMilliseconds ); + const wakeAfterCommit = async (changed: boolean): Promise => { + if (!changed || dependencies.wakeEventPump === undefined) return; + try { + await dependencies.wakeEventPump(); + } catch { + // SQLite is authoritative; adaptive polling recovers a missed wakeup. + } + }; const commitCompleteSnapshot = async ( input: unknown @@ -197,6 +225,8 @@ export function createMonitoringService( occurredAt: snapshotOccurredAt, source: normalized.snapshot.report.source, sourceJobId: normalized.snapshot.report.sourceJobId, + status: reportStatusForProblems(normalized.snapshot.problems), + summary: normalized.snapshot.report.summary ?? null, title: normalized.snapshot.report.title, }); unit.insertMonitorRun({ @@ -276,15 +306,7 @@ export function createMonitoringService( ) ); - if (committed.realtimeEvents > 0 && dependencies.wakeEventPump) { - yield* Effect.sync(() => { - try { - dependencies.wakeEventPump?.(); - } catch { - // SQLite is authoritative; adaptive polling recovers a missed wakeup. - } - }); - } + yield* Effect.promise(() => wakeAfterCommit(committed.realtimeEvents > 0)); return committed; } ); diff --git a/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts b/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts index 75ee15e93..4ecb9963e 100644 --- a/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts +++ b/greenfield/src/server/domains/monitoring/serviceBoundary.test.ts @@ -6,11 +6,12 @@ import { Cause, Effect, Exit } from "effect"; import { incidents } from "../../database/schema/incidents.ts"; import { notifications } from "../../database/schema/notifications.ts"; import { testImmediateDatabaseWriteAdmission } from "../../test/support/databaseWriteAdmission.ts"; -import { createMonitoringRepository, type MonitoringRepository } from "./repository.ts"; +import { createMonitoringRepository } from "./repository.ts"; import { createMonitoringService, MonitoringService, MonitoringSnapshotValidationError, + type MonitoringSubmissionRepository, monitoringServiceLayer, } from "./service.ts"; import { @@ -62,7 +63,7 @@ describe("monitoring service", () => { test("rejects malformed snapshots before entering the repository", async () => { let repositoryEntries = 0; - const repository: MonitoringRepository = { + const repository: MonitoringSubmissionRepository = { withImmediateTransaction() { repositoryEntries += 1; throw new Error("repository should not be entered"); @@ -82,7 +83,7 @@ describe("monitoring service", () => { test("rejects a future watermark before entering the repository", async () => { let repositoryEntries = 0; - const repository: MonitoringRepository = { + const repository: MonitoringSubmissionRepository = { withImmediateTransaction() { repositoryEntries += 1; throw new Error("repository should not be entered"); @@ -107,9 +108,7 @@ describe("monitoring service", () => { test("does not turn an event-pump wakeup failure into a failed commit", async () => { const database = await openFreshMigratedDatabase(); const service = serviceFor(database, { - wakeEventPump: () => { - throw new Error("pump unavailable"); - }, + wakeEventPump: () => Promise.reject(new Error("pump unavailable")), }); try { @@ -130,7 +129,7 @@ describe("monitoring service", () => { test("rejects invalid realtime retention before repository work", () => { let repositoryEntries = 0; - const repository: MonitoringRepository = { + const repository: MonitoringSubmissionRepository = { withImmediateTransaction() { repositoryEntries += 1; throw new Error("repository should not be entered"); @@ -148,7 +147,7 @@ describe("monitoring service", () => { test("rejects a realtime expiry outside the Date range before repository work", async () => { let repositoryEntries = 0; - const repository: MonitoringRepository = { + const repository: MonitoringSubmissionRepository = { withImmediateTransaction() { repositoryEntries += 1; throw new Error("repository should not be entered"); @@ -177,7 +176,7 @@ describe("monitoring service", () => { test("keeps unknown repository failures in the defect channel", async () => { const repositoryFailure = new Error("repository unavailable"); - const repository: MonitoringRepository = { + const repository: MonitoringSubmissionRepository = { withImmediateTransaction() { throw repositoryFailure; }, diff --git a/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts b/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts index 445917be1..4c5c430cf 100644 --- a/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts +++ b/greenfield/src/server/domains/monitoring/serviceLifecycle.test.ts @@ -192,11 +192,26 @@ describe("monitoring service", () => { .all() .map((notification) => ({ generation: notification.incidentGeneration, + incidentId: notification.incidentId, + linkUrl: notification.linkUrl, readAt: notification.readAt, + reportId: notification.reportId, })) ).toEqual([ - { generation: 1, readAt: manuallyReadAt }, - { generation: 2, readAt: null }, + { + generation: 1, + incidentId: filesystemIncident.id, + linkUrl: `/incidents?incidentId=${filesystemIncident.id}`, + readAt: manuallyReadAt, + reportId: null, + }, + { + generation: 2, + incidentId: filesystemIncident.id, + linkUrl: `/incidents?incidentId=${filesystemIncident.id}`, + readAt: null, + reportId: null, + }, ]); expect(allRowCounts(database)).toEqual({ diff --git a/greenfield/src/server/domains/monitoring/snapshotLifecycle.ts b/greenfield/src/server/domains/monitoring/snapshotLifecycle.ts index 8d2df7299..87b4f8885 100644 --- a/greenfield/src/server/domains/monitoring/snapshotLifecycle.ts +++ b/greenfield/src/server/domains/monitoring/snapshotLifecycle.ts @@ -121,6 +121,7 @@ export function applyMonitoringSnapshotLifecycle(input: { outboxOccurredAt: input.outboxOccurredAt, problem, reportTitle: input.snapshot.report.title, + source: input.snapshot.report.source, unit: input.unit, }); } diff --git a/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts b/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts index 9b32492c7..790be27ea 100644 --- a/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts +++ b/greenfield/src/server/domains/monitoring/testSupport/monitoringService.ts @@ -89,7 +89,7 @@ export function serviceFor( database: TestDatabase, overrides: { generateId?: () => string; - wakeEventPump?: () => void; + wakeEventPump?: () => Promise | void; } = {} ) { return createMonitoringService({ diff --git a/greenfield/src/server/domains/monitoring/testSupport/services.test.ts b/greenfield/src/server/domains/monitoring/testSupport/services.test.ts new file mode 100644 index 000000000..1d030b0ed --- /dev/null +++ b/greenfield/src/server/domains/monitoring/testSupport/services.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; + +import { Effect } from "effect"; + +import { captureFailure } from "../../../test/support/promise.ts"; +import { uuid } from "./monitoringService.ts"; +import { + createTestMonitoringCatalogService, + createTestMonitoringService, +} from "./services.ts"; + +describe("monitoring service test doubles", () => { + test("names the unexpected catalog method", async () => { + const service = createTestMonitoringCatalogService(); + const failure = await captureFailure(() => + Effect.runPromise(service.deleteReport({ id: uuid(1) })) + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("deleteReport"); + }); + + test("names the unexpected ingestion method", async () => { + const service = createTestMonitoringService(); + const failure = await captureFailure(() => + Effect.runPromise(service.submitCompleteSnapshot({})) + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("submitCompleteSnapshot"); + }); +}); diff --git a/greenfield/src/server/domains/monitoring/testSupport/services.ts b/greenfield/src/server/domains/monitoring/testSupport/services.ts new file mode 100644 index 000000000..43dbc0600 --- /dev/null +++ b/greenfield/src/server/domains/monitoring/testSupport/services.ts @@ -0,0 +1,52 @@ +import { Effect } from "effect"; + +import { MonitoringCatalogService } from "../catalogService.ts"; +import { MonitoringService } from "../service.ts"; + +function unexpectedMonitoringServiceCall(method: string): () => Effect.Effect { + return () => + Effect.die( + new Error(`Test monitoring service received an unexpected call: ${method}`) + ); +} + +/** + * Creates an inert catalog service for tests whose subject does not include monitoring. + * @param overrides Exact catalog methods exercised by the current test. + * @returns Complete fail-closed monitoring catalog test double. + */ +export function createTestMonitoringCatalogService( + overrides: Partial = {} +): MonitoringCatalogService["Service"] { + return MonitoringCatalogService.of({ + clearReadNotifications: unexpectedMonitoringServiceCall("clearReadNotifications"), + deleteNotification: unexpectedMonitoringServiceCall("deleteNotification"), + deleteReport: unexpectedMonitoringServiceCall("deleteReport"), + getIncident: unexpectedMonitoringServiceCall("getIncident"), + getReport: unexpectedMonitoringServiceCall("getReport"), + listIncidents: unexpectedMonitoringServiceCall("listIncidents"), + listNotifications: unexpectedMonitoringServiceCall("listNotifications"), + listReports: unexpectedMonitoringServiceCall("listReports"), + markAllNotificationsRead: unexpectedMonitoringServiceCall( + "markAllNotificationsRead" + ), + markNotificationRead: unexpectedMonitoringServiceCall("markNotificationRead"), + upsertNotification: unexpectedMonitoringServiceCall("upsertNotification"), + upsertReport: unexpectedMonitoringServiceCall("upsertReport"), + ...overrides, + }); +} + +/** + * Creates an inert ingestion service for tests whose subject does not include monitoring. + * @param overrides Exact ingestion methods exercised by the current test. + * @returns Complete fail-closed monitoring ingestion test double. + */ +export function createTestMonitoringService( + overrides: Partial = {} +): MonitoringService["Service"] { + return MonitoringService.of({ + submitCompleteSnapshot: unexpectedMonitoringServiceCall("submitCompleteSnapshot"), + ...overrides, + }); +} diff --git a/greenfield/src/server/domains/realtime/retention.ts b/greenfield/src/server/domains/realtime/retention.ts index 3e375c9a2..bd068c08c 100644 --- a/greenfield/src/server/domains/realtime/retention.ts +++ b/greenfield/src/server/domains/realtime/retention.ts @@ -1,4 +1,2 @@ -import { hoursToMilliseconds } from "date-fns"; - /** Default durable retention shared by application realtime producers. */ -export const defaultRealtimeRetentionMilliseconds = hoursToMilliseconds(168); +export { realtimeEventRetentionMilliseconds as defaultRealtimeRetentionMilliseconds } from "../../../contracts/realtime.ts"; diff --git a/greenfield/src/server/platform/observability/structuredLogger.test.ts b/greenfield/src/server/platform/observability/structuredLogger.test.ts index 3ef55e009..0b459e91d 100644 --- a/greenfield/src/server/platform/observability/structuredLogger.test.ts +++ b/greenfield/src/server/platform/observability/structuredLogger.test.ts @@ -55,6 +55,34 @@ test("writes bounded NDJSON with fixed envelope fields and selected details", () expect(lines[0]).not.toContain("never-log-this"); }); +test("records realtime wake failures without exposing raw failure text", () => { + const lines: string[] = []; + const logger = createStructuredLogger({ + identity, + sink: { + write(line) { + lines.push(line); + }, + }, + }); + + logger.warn({ + component: "realtime-event-pump", + event: "realtime.wake.failed", + failure: new Error("never-log-this"), + outcome: "server-error", + }); + + expect(JSON.parse(lines[0] ?? "null")).toMatchObject({ + component: "realtime-event-pump", + event: "realtime.wake.failed", + level: "warn", + outcome: "server-error", + }); + expect(JSON.parse(lines[0] ?? "null")).toHaveProperty("failure"); + expect(lines[0]).not.toContain("never-log-this"); +}); + test("normalizes unknown events and drops extra fields instead of relying on secret names", () => { const lines: string[] = []; const logger = createStructuredLogger({ diff --git a/greenfield/src/server/platform/observability/structuredLogger.ts b/greenfield/src/server/platform/observability/structuredLogger.ts index 5c8d7e837..24aba2631 100644 --- a/greenfield/src/server/platform/observability/structuredLogger.ts +++ b/greenfield/src/server/platform/observability/structuredLogger.ts @@ -112,6 +112,7 @@ const structuredEventComponents = Object.freeze({ "http.request.failed": "http", "http.response.created": "http", "realtime.runner.failed": "realtime-event-pump", + "realtime.wake.failed": "realtime-event-pump", "runtime.logger.connected": "application-runtime", "runtime.start_failed": "runtime", "runtime.started": "runtime", diff --git a/greenfield/src/server/test/support/requestContext.ts b/greenfield/src/server/test/support/requestContext.ts index 909af6bf2..1adfeaa83 100644 --- a/greenfield/src/server/test/support/requestContext.ts +++ b/greenfield/src/server/test/support/requestContext.ts @@ -7,6 +7,12 @@ import type { } from "../../../contracts/security.ts"; import type { AgentService } from "../../domains/agents/service.ts"; import { createTestAgentService } from "../../domains/agents/testSupport/service.ts"; +import type { MonitoringCatalogService } from "../../domains/monitoring/catalogService.ts"; +import type { MonitoringService } from "../../domains/monitoring/service.ts"; +import { + createTestMonitoringCatalogService, + createTestMonitoringService, +} from "../../domains/monitoring/testSupport/services.ts"; import type { AuthenticationLifecycleService } from "../../domains/security/authenticationLifecycle.ts"; import type { AuthenticationResolution } from "../../domains/security/authenticationResolution.ts"; import type { @@ -388,6 +394,8 @@ export interface TestServerSecurityServices { readonly automationSecurityLifecycle: AutomationSecurityLifecycleService; readonly mfaAccountLifecycle: MfaAccountLifecycleService; readonly mfaLoginLifecycle: MfaLoginLifecycleService; + readonly monitoringCatalogService: MonitoringCatalogService["Service"]; + readonly monitoringService: MonitoringService["Service"]; readonly securityAuditLifecycle: SecurityAuditLifecycleService; readonly taskService: TaskService["Service"]; } @@ -415,6 +423,9 @@ export function createTestServerSecurityServices( overrides.mfaAccountLifecycle ?? createTestMfaAccountLifecycleService(), mfaLoginLifecycle: overrides.mfaLoginLifecycle ?? createTestMfaLoginLifecycleService(), + monitoringCatalogService: + overrides.monitoringCatalogService ?? createTestMonitoringCatalogService(), + monitoringService: overrides.monitoringService ?? createTestMonitoringService(), securityAuditLifecycle: overrides.securityAuditLifecycle ?? createTestSecurityAuditLifecycleService(), taskService: overrides.taskService ?? createTestTaskService(), @@ -500,6 +511,8 @@ export function createTestRequestContext( readonly automationSecurityLifecycle?: AutomationSecurityLifecycleService; readonly mfaAccountLifecycle?: MfaAccountLifecycleService; readonly mfaLoginLifecycle?: MfaLoginLifecycleService; + readonly monitoringCatalogService?: MonitoringCatalogService["Service"]; + readonly monitoringService?: MonitoringService["Service"]; readonly request?: Request; readonly requestId?: string; readonly responseHeaders?: Headers; @@ -525,6 +538,9 @@ export function createTestRequestContext( options.mfaAccountLifecycle ?? createTestMfaAccountLifecycleService(), mfaLoginLifecycle: options.mfaLoginLifecycle ?? createTestMfaLoginLifecycleService(), + monitoringCatalogService: + options.monitoringCatalogService ?? createTestMonitoringCatalogService(), + monitoringService: options.monitoringService ?? createTestMonitoringService(), pendingLoginCredential: credentials.pendingLogin, request, requestId: options.requestId ?? "test-request-id", diff --git a/greenfield/src/server/trpc/appRouter.ts b/greenfield/src/server/trpc/appRouter.ts index 863bd0c8c..067a4a06c 100644 --- a/greenfield/src/server/trpc/appRouter.ts +++ b/greenfield/src/server/trpc/appRouter.ts @@ -1,4 +1,14 @@ import { agentProcedureNames, agentRouter } from "../domains/agents/procedures.ts"; +import { + incidentProcedureNames, + incidentRouter, + monitoringProcedureNames, + monitoringRouter, + notificationProcedureNames, + notificationRouter, + reportProcedureNames, + reportRouter, +} from "../domains/monitoring/procedures.ts"; import { eventsProcedureNames, eventsRouter } from "../domains/realtime/procedures.ts"; import { automationSecurityProcedureNames, @@ -31,6 +41,10 @@ export const appRouter = router({ auth: authRouter, automationSecurity: automationSecurityRouter, events: eventsRouter, + incidents: incidentRouter, + monitoring: monitoringRouter, + notifications: notificationRouter, + reports: reportRouter, securityAudit: securityAuditRouter, system: systemRouter, tasks: taskRouter, @@ -43,6 +57,10 @@ export const appRouterProcedureNames = Object.freeze([ ...namespacedProcedureNames("auth", authProcedureNames), ...namespacedProcedureNames("automationSecurity", automationSecurityProcedureNames), ...namespacedProcedureNames("events", eventsProcedureNames), + ...namespacedProcedureNames("incidents", incidentProcedureNames), + ...namespacedProcedureNames("monitoring", monitoringProcedureNames), + ...namespacedProcedureNames("notifications", notificationProcedureNames), + ...namespacedProcedureNames("reports", reportProcedureNames), ...namespacedProcedureNames("securityAudit", securityAuditProcedureNames), ...namespacedProcedureNames("system", systemProcedureNames), ...namespacedProcedureNames("tasks", taskProcedureNames), diff --git a/greenfield/src/server/trpc/context.test.ts b/greenfield/src/server/trpc/context.test.ts index 0339c31ef..460b3d783 100644 --- a/greenfield/src/server/trpc/context.test.ts +++ b/greenfield/src/server/trpc/context.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import { createTestAgentService } from "../domains/agents/testSupport/service.ts"; +import { + createTestMonitoringCatalogService, + createTestMonitoringService, +} from "../domains/monitoring/testSupport/services.ts"; import { createTestTaskService } from "../domains/tasks/testSupport/service.ts"; import { readAuthenticationHttpCredentials } from "../rawHttp/authenticationCredentials.ts"; import { generateOpaqueToken } from "../shared/opaqueToken.ts"; @@ -29,6 +33,8 @@ describe("tRPC request context", () => { const authenticationLifecycle = createTestAuthenticationLifecycleService(); const automationSecurityLifecycle = createTestAutomationSecurityLifecycleService(); + const monitoringCatalogService = createTestMonitoringCatalogService(); + const monitoringService = createTestMonitoringService(); const responseHeaders = new Headers(); const context = await createRequestContext({ @@ -59,6 +65,8 @@ describe("tRPC request context", () => { }, mfaAccountLifecycle: createTestMfaAccountLifecycleService(), mfaLoginLifecycle: createTestMfaLoginLifecycleService(), + monitoringCatalogService, + monitoringService, pendingLoginCredential: credentials.pendingLogin, request, requestId: "request-context-1", @@ -84,6 +92,8 @@ describe("tRPC request context", () => { expect(context.services).toBe(applicationRuntime.services); expect(context.authenticationLifecycle).toBe(authenticationLifecycle); expect(context.automationSecurityLifecycle).toBe(automationSecurityLifecycle); + expect(context.monitoringCatalogService).toBe(monitoringCatalogService); + expect(context.monitoringService).toBe(monitoringService); expect(context.authenticationClientSourceId).toBe("client-source-1"); expect(context.pendingLoginCredential).toEqual({ kind: "present", @@ -117,6 +127,8 @@ describe("tRPC request context", () => { authenticateCredential: () => ({ authentication: { kind: "anonymous" } }), mfaAccountLifecycle: createTestMfaAccountLifecycleService(), mfaLoginLifecycle: createTestMfaLoginLifecycleService(), + monitoringCatalogService: createTestMonitoringCatalogService(), + monitoringService: createTestMonitoringService(), pendingLoginCredential: credentials.pendingLogin, request, requestId: "request-context-2", @@ -156,6 +168,8 @@ describe("tRPC request context", () => { }), mfaAccountLifecycle: createTestMfaAccountLifecycleService(), mfaLoginLifecycle: createTestMfaLoginLifecycleService(), + monitoringCatalogService: createTestMonitoringCatalogService(), + monitoringService: createTestMonitoringService(), pendingLoginCredential: credentials.pendingLogin, request, requestId: "request-context-3", diff --git a/greenfield/src/server/trpc/context.ts b/greenfield/src/server/trpc/context.ts index ff007d3b8..03b888299 100644 --- a/greenfield/src/server/trpc/context.ts +++ b/greenfield/src/server/trpc/context.ts @@ -1,5 +1,7 @@ import type { RequestAuthentication } from "../../contracts/security.ts"; import type { AgentService } from "../domains/agents/service.ts"; +import type { MonitoringCatalogService } from "../domains/monitoring/catalogService.ts"; +import type { MonitoringService } from "../domains/monitoring/service.ts"; import type { AuthenticationLifecycleService } from "../domains/security/authenticationLifecycle.ts"; import { type AuthenticationLease, @@ -33,6 +35,8 @@ export interface RequestContextOptions { readonly authenticateCredential: AuthenticateCredential; readonly mfaAccountLifecycle: MfaAccountLifecycleService; readonly mfaLoginLifecycle: MfaLoginLifecycleService; + readonly monitoringCatalogService: MonitoringCatalogService["Service"]; + readonly monitoringService: MonitoringService["Service"]; readonly pendingLoginCredential: PendingLoginCredential; readonly request: Request; readonly requestId: string; @@ -51,6 +55,8 @@ export interface RequestContext { readonly authenticationLease?: AuthenticationLease; readonly mfaAccountLifecycle: MfaAccountLifecycleService; readonly mfaLoginLifecycle: MfaLoginLifecycleService; + readonly monitoringCatalogService: MonitoringCatalogService["Service"]; + readonly monitoringService: MonitoringService["Service"]; readonly pendingLoginCredential: PendingLoginCredential; readonly requestId: string; readonly responseHeaders: Headers; @@ -80,6 +86,8 @@ export async function createRequestContext( automationSecurityLifecycle: options.automationSecurityLifecycle, mfaAccountLifecycle: options.mfaAccountLifecycle, mfaLoginLifecycle: options.mfaLoginLifecycle, + monitoringCatalogService: options.monitoringCatalogService, + monitoringService: options.monitoringService, ...(resolution.lease && { authenticationLease: resolution.lease }), pendingLoginCredential: options.pendingLoginCredential, requestId: options.requestId, diff --git a/greenfield/src/server/trpc/procedureErrorPolicy.ts b/greenfield/src/server/trpc/procedureErrorPolicy.ts index f65af88f1..fb097dda8 100644 --- a/greenfield/src/server/trpc/procedureErrorPolicy.ts +++ b/greenfield/src/server/trpc/procedureErrorPolicy.ts @@ -206,6 +206,54 @@ export const procedureExpectedErrorPolicy = freezeProcedureExpectedErrorPolicy({ "TOO_MANY_REQUESTS", "UNAUTHORIZED", ], + "incidents.get": ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + "incidents.list": ["FORBIDDEN", "UNAUTHORIZED"], + "monitoring.submitCompleteSnapshot": [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "notifications.clearRead": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "notifications.delete": [ + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "notifications.list": ["FORBIDDEN", "UNAUTHORIZED"], + "notifications.markAllRead": ["FORBIDDEN", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"], + "notifications.markRead": [ + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "notifications.upsert": [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "NOT_FOUND", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "reports.delete": [ + "FORBIDDEN", + "NOT_FOUND", + "PRECONDITION_FAILED", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], + "reports.get": ["FORBIDDEN", "NOT_FOUND", "UNAUTHORIZED"], + "reports.list": ["FORBIDDEN", "UNAUTHORIZED"], + "reports.upsert": [ + "BAD_REQUEST", + "CONFLICT", + "FORBIDDEN", + "SERVICE_UNAVAILABLE", + "UNAUTHORIZED", + ], "securityAudit.listEvents": ["FORBIDDEN", "UNAUTHORIZED"], "system.runtimeIdentity": [], "tasks.addUpdate": [ diff --git a/greenfield/src/server/trpc/trpc.ts b/greenfield/src/server/trpc/trpc.ts index 4435dcf62..1105f02ab 100644 --- a/greenfield/src/server/trpc/trpc.ts +++ b/greenfield/src/server/trpc/trpc.ts @@ -6,7 +6,10 @@ import { contractAuthenticationErrorReasons, type ContractAuthenticationErrorReason, } from "../../contracts/registry.ts"; -import type { ApplicationCapability } from "../../contracts/security.ts"; +import type { + ApplicationCapability, + AuthenticatedPrincipal, +} from "../../contracts/security.ts"; import type { RequestContext } from "./context.ts"; import { applyProcedureExpectedErrorPolicy, @@ -153,6 +156,26 @@ export function capabilityProcedure(capability: ApplicationCapability) { }); } +/** + * Builds a capability-scoped procedure restricted to one authenticated principal kind. + * @param capability Registered application capability required by the procedure. + * @param kind Exact authenticated principal kind allowed through the boundary. + * @param message Safe rejection message exposed to callers of the procedure. + * @returns Authenticated procedure builder narrowed to the expected principal kind. + */ +export function principalKindProcedure( + capability: ApplicationCapability, + kind: AuthenticatedPrincipal["kind"], + message: string +) { + return capabilityProcedure(capability).use(({ ctx, next }) => { + if (ctx.principal.kind !== kind) { + throw new TRPCError({ code: "FORBIDDEN", message }); + } + return next({ ctx }); + }); +} + /** Procedure builder restricted to an authenticated browser session. */ export const sessionProcedure = authenticatedProcedure.use(({ ctx, next }) => { if (ctx.principal.kind !== "session") { diff --git a/greenfield/src/shared/databaseMigrationManifest.ts b/greenfield/src/shared/databaseMigrationManifest.ts index 570d253b4..eec3a194d 100644 --- a/greenfield/src/shared/databaseMigrationManifest.ts +++ b/greenfield/src/shared/databaseMigrationManifest.ts @@ -13,8 +13,8 @@ export const migrationManifest = Object.freeze