diff --git a/bun.lock b/bun.lock index d08b1c09c..7e3390687 100644 --- a/bun.lock +++ b/bun.lock @@ -269,6 +269,22 @@ "typescript": "catalog:", }, }, + "packages/notify": { + "name": "@corbits/notify", + "version": "0.0.1", + "dependencies": { + "@intx/authz": "workspace:*", + "@intx/hub-common": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "postgres": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/onboarding": { "name": "@workbench/onboarding", "version": "0.0.1", @@ -743,6 +759,8 @@ "@corbits/folded-runs": ["@corbits/folded-runs@workspace:packages/folded-runs"], + "@corbits/notify": ["@corbits/notify@workspace:packages/notify"], + "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#bebe1ed", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-bebe1ed", "sha512-gQswotVhBuFuqiT8/Xy4vJXkJI0EBdxaXLnWGoH5fLH7M1O0OGuLDBdhSwN89SFb3gOYGDHwj9QngHOxssrBEg=="], "@corbits/schedules": ["@corbits/schedules@workspace:packages/schedules"], diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 000000000..8f6a15c39 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,90 @@ +# Notifications + +A notification is mail. There is no notification bus, no notification feed, +and no second copy of "things that need you" — the durable record is always a +message in somebody's mailbox, and everything else (an unread count, a push to +Slack, an end-of-day digest) is a read of that mailbox or a fan-out from it. + +## Approval is "needs you" + +The platform already has the concept. A workflow run that parks on a signal is +represented by a `signal_correlation` row and an `approval` row, written +together by Interchange's own register co-write. That pair **is** the needs-you +state. `@corbits/notify` invents no sibling concept, widens no approval kind, +and registers no correlation of its own. It adds exactly one step the platform +was missing: an approval exists, therefore the people who can resolve it have +mail. + +Two other things reach a person the same way and are simpler, because they +have nothing to resolve: a run that failed, and a mention in a thread. + +## The shape + +``` +approval / run failure / mention + │ + ▼ parse (arktype) → render → one message per recipient + mailbox (the durable record, per human principal) + │ + ▼ post-commit, one row per (message, enabled sink) + notify_dispatch → dispatch worker → a sink +``` + +`deliverNotification` parses its input with `NotificationEvent` before anything +is written, so an unvalidated shape can never reach a mailbox. It writes one +message per recipient, keyed on a stable external id — an approval keys off the +approval itself, so a redelivered register frame mails once and only once. + +Sink fan-out is queued strictly after the mail commits, never called inline. +A sink going down cannot cost anybody a notification: the message is already in +the mailbox, and `notify_dispatch` remembers what still owes a copy. + +## `notify_dispatch` + +The one table this package owns, and the only new table in the design. A row is +one attempt stream for one (message, sink) pair: `pending` → `delivered`, or +`failed` with an exponential backoff, or `dead` once the attempt ceiling is hit, +the failure is not retryable, or the sink is no longer registered at all. It is +bookkeeping, not an event log — the fact lives in the mail row it points at. + +With no sink registered, `deliverNotification` queues zero rows and the worker +finds nothing due. That is the correct steady state of a fresh install. + +## Adding a sink + +A sink is a package, not a case in a switch. It exports a +`NotificationSinkPlugin` — a name, `isEnabledFor(scope)`, and `deliver(ctx)` — +and the hub's composition root registers it: + +```ts +import { createSlackNotificationSink } from "@corbits/notify-sink-slack"; + +sinks.register(createSlackNotificationSink(deps)); +``` + +That one line is also the approval gate. Installing a sink is a reviewed change +to the composition root, which is why there is no per-send approval prompt: a +notification about needing a human cannot itself wait on a human. + +Nothing inside `@corbits/notify` changes when a sink is added. The registry +holds plugins by name, refuses two sinks with the same name, and the worker +resolves a queued row's sink by that name. + +## Authorization + +Reading is structural. A mailbox is scoped to a single principal by +construction, so there is no cross-principal read path to guard. + +Emitting is checked. `resolveNotifyContext` authorizes the principal against +the platform's own grants — resource `notify:`, action `deliver`, +evaluated by `@intx/authz` exactly like `approval:` is — and then +resolves the sink's credential. Each of the three ways this can fail has its +own named error: `NotifyGrantMissingError`, +`NotifySinkNotConfiguredError`, `NotifySinkCredentialInvalidError`. + +## What a person sees + +Subjects and bodies are written for a reader: `Approve "send_invoice"?`, +`"Nightly digest" failed`, `Sawyer mentioned you in "Launch plan"`. Identifiers +never appear in what is displayed — they travel in the message's `refs`, where +the interface uses them to navigate and nothing else. diff --git a/packages/notify/package.json b/packages/notify/package.json new file mode 100644 index 000000000..f63b5fcb8 --- /dev/null +++ b/packages/notify/package.json @@ -0,0 +1,28 @@ +{ + "name": "@corbits/notify", + "private": true, + "description": "Turns things that need a human — a parked approval, a failed run, a mention — into mail in the recipient's mailbox, and fans that mail out to registered sinks", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./migrations": "./src/migrations.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@intx/authz": "workspace:*", + "@intx/hub-common": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "postgres": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/notify/src/approval-bridge.ts b/packages/notify/src/approval-bridge.ts new file mode 100644 index 000000000..90b247865 --- /dev/null +++ b/packages/notify/src/approval-bridge.ts @@ -0,0 +1,63 @@ +// The step the platform is missing: an approval exists, therefore somebody +// has mail. The approval and its signal correlation are already written by +// the platform's own register co-write; this reads that pair back and +// delivers it, registering nothing and widening nothing. +// +// Delivery keys off `approval.id`, which is also the mailbox dedupe key, so a +// redelivered register frame — sidecar reconnect, log replay, supervisor +// restart — mails once and only once. +import { deliverApprovalMail, type NotifyDeliveryDeps } from "./deliver"; +import type { NotifyRecipient } from "./events"; + +export interface ParkedApproval { + readonly approvalId: string; + readonly tenantId: string; + readonly runId: string; + readonly deploymentId: string; + /** The tool the run is asking to call, named the way a person would read it. */ + readonly toolName: string; + readonly toolArguments: object; + readonly createdAt: Date; +} + +export interface ApprovalNotificationBridgeDeps { + readonly delivery: NotifyDeliveryDeps; + readonly findParkedApproval: ( + correlationId: string, + ) => Promise; + /** Who may resolve this approval, and therefore who should hear about it. */ + readonly listApprovers: ( + approval: ParkedApproval, + ) => Promise; +} + +export type ApprovalNotificationBridge = ( + correlationId: string, +) => Promise; + +/** + * Build the "an approval was created, so mail it" step. A correlation with no + * approval row, or an approval nobody can resolve, delivers nothing rather + * than mailing into the void. + */ +export function createApprovalNotificationBridge( + deps: ApprovalNotificationBridgeDeps, +): ApprovalNotificationBridge { + return async (correlationId) => { + const approval = await deps.findParkedApproval(correlationId); + if (approval === null) return; + const recipients = await deps.listApprovers(approval); + if (recipients.length === 0) return; + await deliverApprovalMail(deps.delivery, { + kind: "approval", + approvalId: approval.approvalId, + tenantId: approval.tenantId, + runId: approval.runId, + deploymentId: approval.deploymentId, + toolName: approval.toolName, + toolArguments: approval.toolArguments, + recipients: [...recipients], + createdAt: approval.createdAt.toISOString(), + }); + }; +} diff --git a/packages/notify/src/context.ts b/packages/notify/src/context.ts new file mode 100644 index 000000000..6144f3218 --- /dev/null +++ b/packages/notify/src/context.ts @@ -0,0 +1,112 @@ +// Emit-time authorization and credential resolution for one sink, in one +// place. Read-time authorization needs nothing here: a mailbox is scoped to a +// single principal by construction, so there is no cross-principal read to +// guard. What has to be checked is the other direction — whether this install +// may push a principal's notification out to an external place at all. +import { authorize } from "@intx/authz"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; + +export type NotifyCredential = { + readonly id: string; + readonly name: string; + readonly kind: string; +}; + +export type NotifyContext = { + readonly tenantId: string; + readonly principalId: string; + readonly sinkName: string; + readonly credential: NotifyCredential; +}; + +export class NotifyGrantMissingError extends Error { + constructor(sinkName: string, tenantId: string) { + super( + `No grant allows delivering notifications through the ${JSON.stringify(sinkName)} ` + + `sink in this workspace. Grant "notify:${sinkName}" in ${tenantId} first.`, + ); + this.name = "NotifyGrantMissingError"; + } +} + +export class NotifySinkNotConfiguredError extends Error { + constructor(sinkName: string) { + super( + `The ${JSON.stringify(sinkName)} notification sink has no credential configured ` + + "in this workspace, so there is nothing to deliver through.", + ); + this.name = "NotifySinkNotConfiguredError"; + } +} + +export class NotifySinkCredentialInvalidError extends Error { + constructor(sinkName: string, expectedKind: string, actualKind: string) { + super( + `The credential configured for the ${JSON.stringify(sinkName)} notification sink is a ` + + `${JSON.stringify(actualKind)} credential, but the sink needs a ${JSON.stringify(expectedKind)} one.`, + ); + this.name = "NotifySinkCredentialInvalidError"; + } +} + +export const NOTIFY_DELIVER_ACTION = "deliver"; + +export interface ResolveNotifyContextDeps { + readonly grantStore: GrantStore; + readonly conditionRegistry?: ConditionRegistry; + /** The credential an operator configured for this sink in this workspace, if any. */ + readonly findSinkCredential: (args: { + tenantId: string; + sinkName: string; + }) => Promise; +} + +export interface ResolveNotifyContextArgs { + readonly tenantId: string; + readonly principalId: string; + readonly sinkName: string; + readonly credentialKind: string; +} + +/** + * Resolve the grant and credential one sink delivery needs, or throw a named + * error saying exactly which of the three is missing. Grants are the platform's + * own — the resource string is `notify:`, matched by `@intx/authz` + * the same way `approval:` is. + */ +export async function resolveNotifyContext( + deps: ResolveNotifyContextDeps, + args: ResolveNotifyContextArgs, +): Promise { + const result = await authorize( + deps.grantStore, + args.principalId, + args.tenantId, + `notify:${args.sinkName}`, + NOTIFY_DELIVER_ACTION, + deps.conditionRegistry, + ); + if (result.effect !== "allow") { + throw new NotifyGrantMissingError(args.sinkName, args.tenantId); + } + const credential = await deps.findSinkCredential({ + tenantId: args.tenantId, + sinkName: args.sinkName, + }); + if (credential === null) { + throw new NotifySinkNotConfiguredError(args.sinkName); + } + if (credential.kind !== args.credentialKind) { + throw new NotifySinkCredentialInvalidError( + args.sinkName, + args.credentialKind, + credential.kind, + ); + } + return { + tenantId: args.tenantId, + principalId: args.principalId, + sinkName: args.sinkName, + credential, + }; +} diff --git a/packages/notify/src/deliver.ts b/packages/notify/src/deliver.ts new file mode 100644 index 000000000..3028e4890 --- /dev/null +++ b/packages/notify/src/deliver.ts @@ -0,0 +1,124 @@ +// The one delivery step this package adds to the platform: something needs a +// human, so it becomes mail in that human's mailbox. Nothing else here is new +// state — an approval's parked run already lives in `signal_correlation` and +// `approval`, and this never registers a correlation of its own. +// +// Fan-out to external sinks is queued strictly after the mail commits, one +// dispatch row per (mail row, enabled sink). A sink is never called from this +// path: the mail is the durable record, and a copy of it is the worker's job. +import { + parseNotificationEvent, + type ApprovalNotification, + type MentionNotification, + type NotificationEvent, + type RunFailureNotification, +} from "./events"; +import type { + MailboxDelivery, + NotifyAddressing, + NotifyInboxItem, +} from "./mailbox"; +import { notificationExternalId, renderNotification } from "./render"; +import type { SinkRegistry } from "./sinks"; +import type { EnqueueDispatchInput, NotifyDispatchStore } from "./store"; + +/** Every notification is mail from the same place, so a mailbox can group it. */ +export const NOTIFY_MAIL_SOURCE = "notify"; + +export interface NotifyDeliveryDeps { + readonly mail: MailboxDelivery; + readonly addressing: NotifyAddressing; + readonly dispatch: NotifyDispatchStore; + readonly sinks: SinkRegistry; +} + +export interface NotifyDeliveryReport { + /** Mail rows newly written by this call; a deduped recipient contributes none. */ + readonly deliveredMailboxRowIds: readonly string[]; + /** Dispatch rows queued for external sinks; zero until a sink is registered. */ + readonly queuedDispatchCount: number; +} + +function toInboxItems( + event: NotificationEvent, + addressing: NotifyAddressing, +): NotifyInboxItem[] { + const rendered = renderNotification(event); + const externalId = notificationExternalId(event); + return event.recipients.map((recipient) => ({ + tenantId: recipient.tenantId, + principalId: recipient.principalId, + address: addressing.inbox(recipient), + fromAddress: addressing.from(event.kind), + subject: rendered.subject, + body: rendered.body, + source: NOTIFY_MAIL_SOURCE, + externalId, + refs: rendered.refs, + })); +} + +/** + * Parse, write mail, then queue sink fan-out. The event is parsed here and + * nowhere else, so no caller can push an unvalidated shape into a mailbox. + */ +export async function deliverNotification( + deps: NotifyDeliveryDeps, + input: unknown, +): Promise { + const event = parseNotificationEvent(input); + const written: { id: string; tenantId: string; principalId: string }[] = []; + await deps.mail(toInboxItems(event, deps.addressing), { + enqueue: ({ id, item }) => { + written.push({ + id, + tenantId: item.tenantId, + principalId: item.principalId, + }); + }, + }); + + const queued: EnqueueDispatchInput[] = []; + for (const row of written) { + const enabled = await deps.sinks.listEnabledFor({ + tenantId: row.tenantId, + principalId: row.principalId, + }); + for (const sink of enabled) { + queued.push({ + mailboxRowId: row.id, + tenantId: row.tenantId, + principalId: row.principalId, + sinkName: sink.name, + }); + } + } + await deps.dispatch.enqueue(queued); + + return { + deliveredMailboxRowIds: written.map((row) => row.id), + queuedDispatchCount: queued.length, + }; +} + +/** A workflow parked on an approval, delivered to the people who can resolve it. */ +export function deliverApprovalMail( + deps: NotifyDeliveryDeps, + event: ApprovalNotification, +): Promise { + return deliverNotification(deps, event); +} + +export function deliverRunFailureMail( + deps: NotifyDeliveryDeps, + event: RunFailureNotification, +): Promise { + return deliverNotification(deps, event); +} + +export function deliverMentionMail( + deps: NotifyDeliveryDeps, + event: MentionNotification, +): Promise { + return deliverNotification(deps, event); +} diff --git a/packages/notify/src/dispatcher.ts b/packages/notify/src/dispatcher.ts new file mode 100644 index 000000000..feb0a01ea --- /dev/null +++ b/packages/notify/src/dispatcher.ts @@ -0,0 +1,146 @@ +// The worker that carries a mail row out to whatever external places a +// principal has turned on. With no sink registered it has nothing to carry and +// finds nothing due, which is the correct steady state of a fresh install — a +// sink only exists once an operator adds one to the composition root. +import type { NotificationEvent } from "./events"; +import type { SinkRegistry } from "./sinks"; +import type { NotifyDispatchRow, NotifyDispatchStore } from "./store"; + +export interface NotifyDispatchLogger { + warn(message: string): void; + error(message: string): void; +} + +export interface NotifyDispatcherDeps { + readonly store: NotifyDispatchStore; + readonly sinks: SinkRegistry; + readonly log: NotifyDispatchLogger; + /** Rebuilds the event a mail row came from, so a sink renders the same thing the mailbox shows. */ + readonly loadEvent: ( + row: NotifyDispatchRow, + ) => Promise; + readonly tickIntervalMs: number; + readonly batchSize: number; + readonly maxAttempts: number; + readonly retryBackoffMs: number; +} + +export interface NotifyDispatcher { + /** Claim everything due once and deliver it; returns how many rows were settled. */ + runOnce(now: Date): Promise; + start(): void; + stop(): void; +} + +function backoffFrom(now: Date, attempts: number, baseMs: number): Date { + return new Date(now.getTime() + baseMs * 2 ** (attempts - 1)); +} + +export function createNotifyDispatcher( + deps: NotifyDispatcherDeps, +): NotifyDispatcher { + let timer: ReturnType | undefined; + + async function settleOne(row: NotifyDispatchRow, now: Date): Promise { + const attempts = row.attempts + 1; + const sink = deps.sinks.get(row.sinkName); + if (sink === undefined) { + // The sink was removed from the composition root while rows were still + // queued for it. Nothing can ever carry them, so they stop rather than + // spinning forever. + deps.log.warn( + `Notification sink ${JSON.stringify(row.sinkName)} is no longer registered; ` + + "its queued deliveries are closed out.", + ); + await deps.store.settle({ + id: row.id, + status: "dead", + attempts, + lastError: "sink is not registered", + nextAttemptAt: now, + }); + return; + } + + const event = await deps.loadEvent(row); + if (event === null) { + await deps.store.settle({ + id: row.id, + status: "dead", + attempts, + lastError: "the notification this delivery belongs to is gone", + nextAttemptAt: now, + }); + return; + } + + const result = await sink + .deliver({ + tenantId: row.tenantId, + principalId: row.principalId, + mailboxRowId: row.mailboxRowId, + event, + }) + .catch((error: unknown) => ({ + status: "failed" as const, + error: error instanceof Error ? error.message : String(error), + retryable: true, + })); + + if (result.status === "delivered") { + await deps.store.settle({ + id: row.id, + status: "delivered", + attempts, + lastError: null, + nextAttemptAt: now, + }); + return; + } + if (result.status === "skipped") { + await deps.store.settle({ + id: row.id, + status: "delivered", + attempts, + lastError: result.reason, + nextAttemptAt: now, + }); + return; + } + const exhausted = !result.retryable || attempts >= deps.maxAttempts; + await deps.store.settle({ + id: row.id, + status: exhausted ? "dead" : "failed", + attempts, + lastError: result.error, + nextAttemptAt: exhausted + ? now + : backoffFrom(now, attempts, deps.retryBackoffMs), + }); + } + + async function runOnce(now: Date): Promise { + const due = await deps.store.findDue(now, deps.batchSize); + for (const row of due) await settleOne(row, now); + return due.length; + } + + return { + runOnce, + start() { + if (timer !== undefined) return; + timer = setInterval(() => { + void runOnce(new Date()).catch((error: unknown) => { + deps.log.error( + `Notification dispatch tick failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, deps.tickIntervalMs); + }, + stop() { + if (timer === undefined) return; + clearInterval(timer); + timer = undefined; + }, + }; +} diff --git a/packages/notify/src/events.ts b/packages/notify/src/events.ts new file mode 100644 index 000000000..b45df8e4d --- /dev/null +++ b/packages/notify/src/events.ts @@ -0,0 +1,71 @@ +// The three things that can need a human's attention, parsed at the +// boundary before anything is written. An approval carries no new state +// of its own: the platform's `signal_correlation` + `approval` row pair +// already IS the parked run, and these events only describe the delivery +// of that fact into somebody's mail. +import { type } from "arktype"; + +export const NotifyRecipient = type({ + tenantId: "string > 0", + principalId: "string > 0", +}); + +export const ApprovalNotification = type({ + kind: '"approval"', + approvalId: "string > 0", + tenantId: "string > 0", + runId: "string > 0", + deploymentId: "string > 0", + toolName: "string > 0", + toolArguments: "object", + recipients: NotifyRecipient.array(), + createdAt: "string.date.iso", +}); + +export const RunFailureNotification = type({ + kind: '"run-failure"', + tenantId: "string > 0", + runId: "string > 0", + deploymentId: "string > 0", + runLabel: "string > 0", + error: "string", + recipients: NotifyRecipient.array(), + createdAt: "string.date.iso", +}); + +export const MentionNotification = type({ + kind: '"mention"', + tenantId: "string > 0", + threadId: "string > 0", + threadLabel: "string > 0", + mentionedBy: "string > 0", + excerpt: "string", + recipients: NotifyRecipient.array(), + createdAt: "string.date.iso", +}); + +export const NotificationEvent = ApprovalNotification.or( + RunFailureNotification, +).or(MentionNotification); + +export type NotifyRecipient = typeof NotifyRecipient.infer; +export type ApprovalNotification = typeof ApprovalNotification.infer; +export type RunFailureNotification = typeof RunFailureNotification.infer; +export type MentionNotification = typeof MentionNotification.infer; +export type NotificationEvent = typeof NotificationEvent.infer; + +export class InvalidNotificationEventError extends Error { + constructor(summary: string) { + super(`Notification event does not parse: ${summary}`); + this.name = "InvalidNotificationEventError"; + } +} + +/** Parse-or-throw at the delivery boundary; nothing downstream casts. */ +export function parseNotificationEvent(input: unknown): NotificationEvent { + const parsed = NotificationEvent(input); + if (parsed instanceof type.errors) { + throw new InvalidNotificationEventError(parsed.summary); + } + return parsed; +} diff --git a/packages/notify/src/index.ts b/packages/notify/src/index.ts new file mode 100644 index 000000000..8fe428b07 --- /dev/null +++ b/packages/notify/src/index.ts @@ -0,0 +1,80 @@ +export { + createApprovalNotificationBridge, + type ApprovalNotificationBridge, + type ApprovalNotificationBridgeDeps, + type ParkedApproval, +} from "./approval-bridge"; +export { + ApprovalNotification, + InvalidNotificationEventError, + MentionNotification, + NotificationEvent, + NotifyRecipient, + RunFailureNotification, + parseNotificationEvent, +} from "./events"; +export { + applyNotifyMigrations, + notifyMigrations, + type ApplyNotifyMigrationsReport, + type NotifyMigration, +} from "./migrations"; +export { notifyDispatch } from "./schema"; +export { + createDrizzleNotifyDispatchStore, + createInMemoryNotifyDispatchStore, + type EnqueueDispatchInput, + type NotifyDb, + type NotifyDispatchRow, + type NotifyDispatchStatus, + type NotifyDispatchStore, + type SettleDispatchInput, +} from "./store"; +export { + createSinkRegistry, + DuplicateSinkNameError, + type NotificationSinkPlugin, + type SinkDeliveryContext, + type SinkDeliveryResult, + type SinkRegistry, + type SinkScope, +} from "./sinks"; +export { + NOTIFY_MAIL_SOURCE, + deliverApprovalMail, + deliverMentionMail, + deliverNotification, + deliverRunFailureMail, + type NotifyDeliveryDeps, + type NotifyDeliveryReport, +} from "./deliver"; +export { + createNotifyDispatcher, + type NotifyDispatcher, + type NotifyDispatcherDeps, + type NotifyDispatchLogger, +} from "./dispatcher"; +export { + NOTIFY_DELIVER_ACTION, + NotifyGrantMissingError, + NotifySinkCredentialInvalidError, + NotifySinkNotConfiguredError, + resolveNotifyContext, + type NotifyContext, + type NotifyCredential, + type ResolveNotifyContextArgs, + type ResolveNotifyContextDeps, +} from "./context"; +export { + notificationExternalId, + renderNotification, + type RenderedNotification, +} from "./render"; +export type { + MailboxDelivery, + NotifyAddressing, + NotifyDeliveredItem, + NotifyDeliverOpts, + NotifyInboxItem, + NotifyMailRef, +} from "./mailbox"; diff --git a/packages/notify/src/mailbox.ts b/packages/notify/src/mailbox.ts new file mode 100644 index 000000000..7e37fd079 --- /dev/null +++ b/packages/notify/src/mailbox.ts @@ -0,0 +1,48 @@ +// The mail substrate this package writes through, expressed as the one +// function it calls. The shape is `@corbits/mailbox`'s `deliverInboxItems` +// bound to a database handle: same fields, same dedupe contract, same +// post-commit `enqueue` callback. Naming it here is what keeps `@corbits/notify` +// free of a direct dependency on any one mailbox build while still refusing to +// invent a second delivery mechanism — there is exactly one seam, and it is mail. +export type NotifyMailRef = { + readonly kind: string; + readonly id: string; +}; + +export type NotifyInboxItem = { + readonly tenantId: string; + readonly principalId: string; + readonly address: string; + readonly fromAddress: string; + readonly subject: string; + readonly body: string; + readonly source: string; + readonly externalId: string; + readonly refs?: readonly NotifyMailRef[]; +}; + +/** `id` is null exactly when the item deduped and no row was written. */ +export type NotifyDeliveredItem = { + readonly messageKey: string; + readonly id: string | null; +}; + +export type NotifyDeliverOpts = { + /** Called once per newly written row, strictly after the batch commits. */ + readonly enqueue?: (delivered: { id: string; item: NotifyInboxItem }) => void; +}; + +export type MailboxDelivery = ( + items: NotifyInboxItem[], + opts?: NotifyDeliverOpts, +) => Promise; + +/** + * How a notification addresses mail. The host owns its own address space — + * a mailbox address is a deployment fact, not a product rule — so both halves + * are supplied rather than assembled from a hardcoded domain here. + */ +export type NotifyAddressing = { + inbox(recipient: { tenantId: string; principalId: string }): string; + from(kind: string): string; +}; diff --git a/packages/notify/src/migrations.ts b/packages/notify/src/migrations.ts new file mode 100644 index 000000000..62ae547ab --- /dev/null +++ b/packages/notify/src/migrations.ts @@ -0,0 +1,89 @@ +// Package-owned migrations for `@corbits/notify`'s one product table, +// following the same ledger pattern as `@corbits/schedules`: bookkeeping in +// this package's own table so its history stays extractable on its own. +// One migration, in final shape — the table is new, so there is nothing to +// alter and nothing to backfill. +import postgres from "postgres"; + +export interface NotifyMigration { + name: string; + sql: string; +} + +export const notifyMigrations: readonly NotifyMigration[] = [ + { + name: "0001_notify_dispatch", + sql: ` + CREATE TABLE IF NOT EXISTS "notify_dispatch" ( + "id" text PRIMARY KEY, + "mailbox_row_id" text NOT NULL, + "tenant_id" text NOT NULL, + "principal_id" text NOT NULL, + "sink_name" text NOT NULL, + "status" text NOT NULL, + "attempts" integer NOT NULL DEFAULT 0, + "last_error" text, + "next_attempt_at" timestamptz NOT NULL DEFAULT now(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "notify_dispatch_row_sink_unique" + UNIQUE ("mailbox_row_id", "sink_name") + ); + CREATE INDEX IF NOT EXISTS "notify_dispatch_due_idx" + ON "notify_dispatch" ("status", "next_attempt_at"); + `, + }, +]; + +const LEDGER_TABLE = "notify_migrations"; + +function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +export interface ApplyNotifyMigrationsReport { + applied: string[]; + alreadyApplied: string[]; +} + +/** + * Apply `notifyMigrations` against `databaseUrl`, idempotently. Failures name + * the migration and the underlying error, so a partial apply is loud here + * rather than silent at the next boot. + */ +export async function applyNotifyMigrations( + databaseUrl: string, +): Promise { + const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); + try { + await sql.unsafe( + `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(LEDGER_TABLE)} (` + + `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, + ); + const rows = await sql.unsafe( + `SELECT name FROM ${quoteIdentifier(LEDGER_TABLE)}`, + ); + const alreadyApplied = new Set(rows.map((row) => String(row["name"]))); + const applied: string[] = []; + for (const migration of notifyMigrations) { + if (alreadyApplied.has(migration.name)) continue; + try { + await sql.unsafe(migration.sql); + await sql.unsafe( + `INSERT INTO ${quoteIdentifier(LEDGER_TABLE)} (name) VALUES ($1)`, + [migration.name], + ); + applied.push(migration.name); + } catch (error) { + throw new Error( + `@corbits/notify migration ${JSON.stringify(migration.name)} failed: ` + + `${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + return { applied, alreadyApplied: [...alreadyApplied] }; + } finally { + await sql.end(); + } +} diff --git a/packages/notify/src/render.ts b/packages/notify/src/render.ts new file mode 100644 index 000000000..167cdc27f --- /dev/null +++ b/packages/notify/src/render.ts @@ -0,0 +1,80 @@ +// What a notification reads like in a mailbox. Every line here is written for +// a person: tool names, run labels, thread titles and handles — never an +// identifier. The identifiers travel in `refs`, where the interface uses them +// to navigate and never to display. +import type { NotificationEvent } from "./events"; +import type { NotifyMailRef } from "./mailbox"; + +export type RenderedNotification = { + readonly subject: string; + readonly body: string; + readonly refs: readonly NotifyMailRef[]; +}; + +const MAX_ARGUMENT_LINES = 12; + +function describeToolArguments(args: object): string { + const entries = Object.entries(args as Record); + if (entries.length === 0) return "It takes no arguments."; + const shown = entries.slice(0, MAX_ARGUMENT_LINES); + const lines = shown.map(([key, value]) => ` ${key}: ${format(value)}`); + if (entries.length > shown.length) { + lines.push(` …and ${entries.length - shown.length} more`); + } + return ["With:", ...lines].join("\n"); +} + +function format(value: unknown): string { + if (typeof value === "string") return value; + return JSON.stringify(value) ?? String(value); +} + +export function renderNotification( + event: NotificationEvent, +): RenderedNotification { + if (event.kind === "approval") { + return { + subject: `Approve “${event.toolName}”?`, + body: [ + `A workflow is paused and waiting on you before it runs “${event.toolName}”.`, + describeToolArguments(event.toolArguments), + "Approve or decline it from your inbox to let the workflow continue.", + ].join("\n\n"), + refs: [ + { kind: "approval", id: event.approvalId }, + { kind: "run", id: event.runId }, + ], + }; + } + if (event.kind === "run-failure") { + return { + subject: `“${event.runLabel}” failed`, + body: [ + `The run “${event.runLabel}” stopped with an error.`, + event.error === "" ? "No error detail was reported." : event.error, + ].join("\n\n"), + refs: [{ kind: "run", id: event.runId }], + }; + } + return { + subject: `${event.mentionedBy} mentioned you in “${event.threadLabel}”`, + body: [ + `${event.mentionedBy} mentioned you in “${event.threadLabel}”.`, + event.excerpt === "" ? "The message has no text." : event.excerpt, + ].join("\n\n"), + refs: [{ kind: "thread", id: event.threadId }], + }; +} + +/** + * The stable external identity of a notification, which is also its dedupe key + * inside the mailbox. An approval keys off the approval itself, so a redelivered + * register frame never mails twice; the other kinds key off the run or thread + * plus the moment they happened, since a run can fail once per attempt and a + * thread can mention someone repeatedly. + */ +export function notificationExternalId(event: NotificationEvent): string { + if (event.kind === "approval") return event.approvalId; + if (event.kind === "run-failure") return `${event.runId}:${event.createdAt}`; + return `${event.threadId}:${event.createdAt}`; +} diff --git a/packages/notify/src/schema.ts b/packages/notify/src/schema.ts new file mode 100644 index 000000000..b06d32fb2 --- /dev/null +++ b/packages/notify/src/schema.ts @@ -0,0 +1,31 @@ +// The one product table this package owns: bookkeeping for one attempt +// stream per (mail row, sink). It is deliberately not an event bus — the +// durable fact is always the mail row itself, and a row here only records +// whether a copy of that mail made it to one external place yet. +import { index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +export const notifyDispatch = pgTable( + "notify_dispatch", + { + id: text("id").primaryKey(), + mailboxRowId: text("mailbox_row_id").notNull(), + tenantId: text("tenant_id").notNull(), + principalId: text("principal_id").notNull(), + sinkName: text("sink_name").notNull(), + status: text("status", { + enum: ["pending", "delivered", "failed", "dead"], + }).notNull(), + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [index("notify_dispatch_due_idx").on(t.status, t.nextAttemptAt)], +); diff --git a/packages/notify/src/sinks.ts b/packages/notify/src/sinks.ts new file mode 100644 index 000000000..6c726fd0a --- /dev/null +++ b/packages/notify/src/sinks.ts @@ -0,0 +1,76 @@ +// The seam a notification sink plugs into, shaped exactly like +// `@corbits/commands`' command-plugin contract: a set of named factory +// exports, a registry that holds plugins by name, and an explicit +// registration call in the host's composition root. There is no dispatch +// table and no switch — adding Slack, email or anything else is one +// package plus one `register(...)` line, and nothing in this file changes. +import type { NotificationEvent } from "./events"; + +export type SinkScope = { + readonly tenantId: string; + readonly principalId: string; +}; + +export type SinkDeliveryContext = { + readonly tenantId: string; + readonly principalId: string; + /** The durable mail row this delivery is a fan-out of; the mail is the record, the sink is a copy. */ + readonly mailboxRowId: string; + readonly event: NotificationEvent; +}; + +export type SinkDeliveryResult = + | { readonly status: "delivered" } + | { readonly status: "skipped"; readonly reason: string } + | { + readonly status: "failed"; + readonly error: string; + readonly retryable: boolean; + }; + +export type NotificationSinkPlugin = { + readonly name: string; + isEnabledFor(scope: SinkScope): Promise; + deliver(ctx: SinkDeliveryContext): Promise; +}; + +export type SinkRegistry = { + register(plugin: NotificationSinkPlugin): void; + get(name: string): NotificationSinkPlugin | undefined; + list(): readonly NotificationSinkPlugin[]; + listEnabledFor(scope: SinkScope): Promise; +}; + +export class DuplicateSinkNameError extends Error { + constructor(name: string) { + super( + `A notification sink named ${JSON.stringify(name)} is already registered; ` + + "each sink owns its name outright so a delivery row can always name one plugin.", + ); + this.name = "DuplicateSinkNameError"; + } +} + +export function createSinkRegistry(): SinkRegistry { + const plugins = new Map(); + return { + register(plugin) { + if (plugins.has(plugin.name)) + throw new DuplicateSinkNameError(plugin.name); + plugins.set(plugin.name, plugin); + }, + get(name) { + return plugins.get(name); + }, + list() { + return [...plugins.values()]; + }, + async listEnabledFor(scope) { + const enabled: NotificationSinkPlugin[] = []; + for (const plugin of plugins.values()) { + if (await plugin.isEnabledFor(scope)) enabled.push(plugin); + } + return enabled; + }, + }; +} diff --git a/packages/notify/src/store.ts b/packages/notify/src/store.ts new file mode 100644 index 000000000..e6a9a5a2a --- /dev/null +++ b/packages/notify/src/store.ts @@ -0,0 +1,180 @@ +// Persistence for the one dispatch table, kept apart from delivery and the +// worker so neither touches drizzle directly. `createInMemoryNotifyDispatchStore` +// is the fake this package's own tests drive the worker through. +import { and, asc, eq, inArray, lte } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { generateId } from "@intx/hub-common"; + +import { notifyDispatch } from "./schema"; + +export type NotifyDb< + TSchema extends Record = Record, +> = PostgresJsDatabase; + +export type NotifyDispatchStatus = "pending" | "delivered" | "failed" | "dead"; + +export interface NotifyDispatchRow { + readonly id: string; + readonly mailboxRowId: string; + readonly tenantId: string; + readonly principalId: string; + readonly sinkName: string; + readonly status: NotifyDispatchStatus; + readonly attempts: number; + readonly lastError: string | null; + readonly nextAttemptAt: Date; +} + +export interface EnqueueDispatchInput { + readonly mailboxRowId: string; + readonly tenantId: string; + readonly principalId: string; + readonly sinkName: string; +} + +export interface SettleDispatchInput { + readonly id: string; + readonly status: NotifyDispatchStatus; + readonly attempts: number; + readonly lastError: string | null; + readonly nextAttemptAt: Date; +} + +export interface NotifyDispatchStore { + /** One row per (mail row, sink); a redelivered notification never queues a second copy. */ + enqueue(inputs: readonly EnqueueDispatchInput[]): Promise; + /** Rows whose next attempt is due, oldest first, across every tenant. */ + findDue(now: Date, limit: number): Promise; + settle(input: SettleDispatchInput): Promise; + listFor(mailboxRowId: string): Promise; +} + +const ACTIVE_STATUSES: NotifyDispatchStatus[] = ["pending", "failed"]; + +export function createDrizzleNotifyDispatchStore( + db: NotifyDb, +): NotifyDispatchStore { + return { + async enqueue(inputs) { + if (inputs.length === 0) return; + await db + .insert(notifyDispatch) + .values( + inputs.map((input) => ({ + id: generateId("signal"), + mailboxRowId: input.mailboxRowId, + tenantId: input.tenantId, + principalId: input.principalId, + sinkName: input.sinkName, + status: "pending" as const, + })), + ) + .onConflictDoNothing(); + }, + + async findDue(now, limit) { + const rows = await db + .select() + .from(notifyDispatch) + .where( + and( + inArray(notifyDispatch.status, ACTIVE_STATUSES), + lte(notifyDispatch.nextAttemptAt, now), + ), + ) + .orderBy(asc(notifyDispatch.nextAttemptAt)) + .limit(limit); + return rows.map(toRow); + }, + + async settle(input) { + await db + .update(notifyDispatch) + .set({ + status: input.status, + attempts: input.attempts, + lastError: input.lastError, + nextAttemptAt: input.nextAttemptAt, + updatedAt: new Date(), + }) + .where(eq(notifyDispatch.id, input.id)); + }, + + async listFor(mailboxRowId) { + const rows = await db + .select() + .from(notifyDispatch) + .where(eq(notifyDispatch.mailboxRowId, mailboxRowId)); + return rows.map(toRow); + }, + }; +} + +function toRow(row: typeof notifyDispatch.$inferSelect): NotifyDispatchRow { + return { + id: row.id, + mailboxRowId: row.mailboxRowId, + tenantId: row.tenantId, + principalId: row.principalId, + sinkName: row.sinkName, + status: row.status, + attempts: row.attempts, + lastError: row.lastError, + nextAttemptAt: row.nextAttemptAt, + }; +} + +export function createInMemoryNotifyDispatchStore(): NotifyDispatchStore { + const rows = new Map(); + const keyOf = (mailboxRowId: string, sinkName: string) => + `${mailboxRowId}\u0000${sinkName}`; + return { + async enqueue(inputs) { + for (const input of inputs) { + const key = keyOf(input.mailboxRowId, input.sinkName); + if (rows.has(key)) continue; + rows.set(key, { + id: generateId("signal"), + mailboxRowId: input.mailboxRowId, + tenantId: input.tenantId, + principalId: input.principalId, + sinkName: input.sinkName, + status: "pending", + attempts: 0, + lastError: null, + nextAttemptAt: new Date(0), + }); + } + }, + async findDue(now, limit) { + return [...rows.values()] + .filter( + (row) => + ACTIVE_STATUSES.includes(row.status) && row.nextAttemptAt <= now, + ) + .sort((a, b) => a.nextAttemptAt.getTime() - b.nextAttemptAt.getTime()) + .slice(0, limit); + }, + async settle(input) { + for (const [key, row] of rows) { + if (row.id !== input.id) continue; + rows.set(key, { + id: row.id, + mailboxRowId: row.mailboxRowId, + tenantId: row.tenantId, + principalId: row.principalId, + sinkName: row.sinkName, + status: input.status, + attempts: input.attempts, + lastError: input.lastError, + nextAttemptAt: input.nextAttemptAt, + }); + } + }, + async listFor(mailboxRowId) { + return [...rows.values()].filter( + (row) => row.mailboxRowId === mailboxRowId, + ); + }, + }; +} diff --git a/packages/notify/test/approval-bridge.test.ts b/packages/notify/test/approval-bridge.test.ts new file mode 100644 index 000000000..c42487835 --- /dev/null +++ b/packages/notify/test/approval-bridge.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; + +import { + createApprovalNotificationBridge, + createInMemoryNotifyDispatchStore, + createSinkRegistry, + type MailboxDelivery, + type NotifyAddressing, + type NotifyInboxItem, + type ParkedApproval, +} from "../src/index"; + +const addressing: NotifyAddressing = { + inbox: (recipient) => `${recipient.principalId}@bench.invalid`, + from: (kind) => `${kind}@notify.invalid`, +}; + +const parked: ParkedApproval = { + approvalId: "apr_7", + tenantId: "tnt_1", + runId: "run_1", + deploymentId: "dep_1", + toolName: "post_to_slack", + toolArguments: { channel: "#general" }, + createdAt: new Date("2026-08-08T09:00:00.000Z"), +}; + +function bridgeOver( + findParkedApproval: (id: string) => Promise, + approvers: { tenantId: string; principalId: string }[], +): { run: (id: string) => Promise; written: NotifyInboxItem[] } { + const written: NotifyInboxItem[] = []; + const seen = new Set(); + const mail: MailboxDelivery = async (items, opts) => + items.map((item) => { + const deduped = seen.has(item.externalId + item.principalId); + seen.add(item.externalId + item.principalId); + if (deduped) return { messageKey: item.externalId, id: null }; + written.push(item); + const id = `mail-${written.length}`; + opts?.enqueue?.({ id, item }); + return { messageKey: item.externalId, id }; + }); + const run = createApprovalNotificationBridge({ + delivery: { + mail, + addressing, + dispatch: createInMemoryNotifyDispatchStore(), + sinks: createSinkRegistry(), + }, + findParkedApproval, + listApprovers: async () => approvers, + }); + return { run, written }; +} + +describe("createApprovalNotificationBridge", () => { + test("mails every approver once, even when the register frame is redelivered", async () => { + const { run, written } = bridgeOver( + async () => parked, + [ + { tenantId: "tnt_1", principalId: "prn_1" }, + { tenantId: "tnt_1", principalId: "prn_2" }, + ], + ); + await run("cor_1"); + await run("cor_1"); + expect(written).toHaveLength(2); + expect(written.map((item) => item.principalId)).toEqual(["prn_1", "prn_2"]); + expect(written[0]?.externalId).toBe("apr_7"); + expect(written[0]?.subject).toBe("Approve “post_to_slack”?"); + }); + + test("mails nobody when the correlation has no approval", async () => { + const { run, written } = bridgeOver( + async () => null, + [{ tenantId: "tnt_1", principalId: "prn_1" }], + ); + await run("cor_missing"); + expect(written).toHaveLength(0); + }); + + test("mails nobody when no one can resolve the approval", async () => { + const { run, written } = bridgeOver(async () => parked, []); + await run("cor_1"); + expect(written).toHaveLength(0); + }); +}); diff --git a/packages/notify/test/context.test.ts b/packages/notify/test/context.test.ts new file mode 100644 index 000000000..eb8b9eb24 --- /dev/null +++ b/packages/notify/test/context.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import type { GrantRule, GrantStore } from "@intx/types/authz"; + +import { + NotifyGrantMissingError, + NotifySinkCredentialInvalidError, + NotifySinkNotConfiguredError, + resolveNotifyContext, + type NotifyCredential, +} from "../src/index"; + +function grantStoreWith(grants: GrantRule[]): GrantStore { + return { + collectGrants: async () => grants, + collectGrantsInChain: async () => grants, + }; +} + +const slackCredential: NotifyCredential = { + id: "crd_1", + name: "team-slack", + kind: "slack-bot-token", +}; + +function deliverGrant(resource: string): GrantRule { + return { + id: "grt_1", + resource, + action: "deliver", + effect: "allow", + origin: "role", + conditions: null, + expiresAt: null, + roleId: null, + principalId: "prn_1", + }; +} + +describe("resolveNotifyContext", () => { + test("resolves the credential when a matching grant allows delivery", async () => { + const context = await resolveNotifyContext( + { + grantStore: grantStoreWith([deliverGrant("notify:slack")]), + findSinkCredential: async () => slackCredential, + }, + { + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "slack", + credentialKind: "slack-bot-token", + }, + ); + expect(context.credential.name).toBe("team-slack"); + expect(context.sinkName).toBe("slack"); + }); + + test("fails closed when no grant covers the sink", async () => { + await expect( + resolveNotifyContext( + { + grantStore: grantStoreWith([deliverGrant("notify:email")]), + findSinkCredential: async () => slackCredential, + }, + { + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "slack", + credentialKind: "slack-bot-token", + }, + ), + ).rejects.toBeInstanceOf(NotifyGrantMissingError); + }); + + test("names the missing configuration when no credential is set up", async () => { + await expect( + resolveNotifyContext( + { + grantStore: grantStoreWith([deliverGrant("notify:slack")]), + findSinkCredential: async () => null, + }, + { + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "slack", + credentialKind: "slack-bot-token", + }, + ), + ).rejects.toBeInstanceOf(NotifySinkNotConfiguredError); + }); + + test("rejects a credential of the wrong kind rather than trying it", async () => { + await expect( + resolveNotifyContext( + { + grantStore: grantStoreWith([deliverGrant("notify:slack")]), + findSinkCredential: async () => slackCredential, + }, + { + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "slack", + credentialKind: "smtp", + }, + ), + ).rejects.toBeInstanceOf(NotifySinkCredentialInvalidError); + }); +}); diff --git a/packages/notify/test/deliver.test.ts b/packages/notify/test/deliver.test.ts new file mode 100644 index 000000000..173c35269 --- /dev/null +++ b/packages/notify/test/deliver.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; + +import { + createInMemoryNotifyDispatchStore, + createSinkRegistry, + deliverApprovalMail, + deliverMentionMail, + deliverNotification, + deliverRunFailureMail, + InvalidNotificationEventError, + NOTIFY_MAIL_SOURCE, + type MailboxDelivery, + type NotifyAddressing, + type NotifyDeliveryDeps, + type NotifyInboxItem, + type SinkDeliveryResult, +} from "../src/index"; + +const addressing: NotifyAddressing = { + inbox: (recipient) => `${recipient.principalId}@bench.invalid`, + from: (kind) => `${kind}@notify.invalid`, +}; + +function recordingMailbox(): { + mail: MailboxDelivery; + written: NotifyInboxItem[]; +} { + const written: NotifyInboxItem[] = []; + let next = 0; + const mail: MailboxDelivery = async (items, opts) => { + return items.map((item) => { + written.push(item); + next += 1; + const id = `mail-${next}`; + opts?.enqueue?.({ id, item }); + return { messageKey: `key-${id}`, id }; + }); + }; + return { mail, written }; +} + +function dedupingMailbox(): MailboxDelivery { + return async (items) => + items.map((item) => ({ messageKey: item.externalId, id: null })); +} + +function depsWith(mail: MailboxDelivery): NotifyDeliveryDeps { + return { + mail, + addressing, + dispatch: createInMemoryNotifyDispatchStore(), + sinks: createSinkRegistry(), + }; +} + +const approval = { + kind: "approval", + approvalId: "apr_1", + tenantId: "tnt_1", + runId: "run_1", + deploymentId: "dep_1", + toolName: "send_invoice", + toolArguments: { amount: 4200, to: "Acme" }, + recipients: [{ tenantId: "tnt_1", principalId: "prn_1" }], + createdAt: "2026-08-08T10:00:00.000Z", +} as const; + +describe("deliverNotification", () => { + test("writes one mail per recipient, keyed on the approval itself", async () => { + const { mail, written } = recordingMailbox(); + const deps = depsWith(mail); + const report = await deliverApprovalMail(deps, { + kind: "approval", + approvalId: approval.approvalId, + tenantId: approval.tenantId, + runId: approval.runId, + deploymentId: approval.deploymentId, + toolName: approval.toolName, + toolArguments: approval.toolArguments, + recipients: [ + { tenantId: "tnt_1", principalId: "prn_1" }, + { tenantId: "tnt_1", principalId: "prn_2" }, + ], + createdAt: approval.createdAt, + }); + + expect(report.deliveredMailboxRowIds).toEqual(["mail-1", "mail-2"]); + expect(written).toHaveLength(2); + expect(written[0]?.source).toBe(NOTIFY_MAIL_SOURCE); + expect(written[0]?.externalId).toBe("apr_1"); + expect(written[0]?.address).toBe("prn_1@bench.invalid"); + expect(written[1]?.address).toBe("prn_2@bench.invalid"); + expect(written[0]?.subject).toContain("send_invoice"); + expect(written[0]?.subject).not.toContain("apr_1"); + expect(written[0]?.refs).toContainEqual({ kind: "approval", id: "apr_1" }); + }); + + test("queues nothing when a recipient already had this mail", async () => { + const deps = depsWith(dedupingMailbox()); + const report = await deliverNotification(deps, approval); + expect(report.deliveredMailboxRowIds).toEqual([]); + expect(report.queuedDispatchCount).toBe(0); + }); + + test("refuses an event that does not parse instead of writing mail", async () => { + const { mail, written } = recordingMailbox(); + const deps = depsWith(mail); + await expect( + deliverNotification(deps, { + kind: "approval", + approvalId: "", + tenantId: "tnt_1", + runId: "run_1", + deploymentId: "dep_1", + toolName: "x", + toolArguments: {}, + recipients: [], + createdAt: "2026-08-08T10:00:00.000Z", + }), + ).rejects.toBeInstanceOf(InvalidNotificationEventError); + expect(written).toHaveLength(0); + }); + + test("queues one dispatch row per enabled sink and none for a disabled one", async () => { + const { mail } = recordingMailbox(); + const dispatch = createInMemoryNotifyDispatchStore(); + const sinks = createSinkRegistry(); + const delivered: SinkDeliveryResult = { status: "delivered" }; + sinks.register({ + name: "always", + isEnabledFor: async () => true, + deliver: async () => delivered, + }); + sinks.register({ + name: "never", + isEnabledFor: async () => false, + deliver: async () => delivered, + }); + + const report = await deliverNotification( + { mail, addressing, dispatch, sinks }, + approval, + ); + + expect(report.queuedDispatchCount).toBe(1); + const rows = await dispatch.listFor("mail-1"); + expect(rows.map((row) => row.sinkName)).toEqual(["always"]); + expect(rows[0]?.status).toBe("pending"); + expect(rows[0]?.id.startsWith("sig_")).toBe(true); + }); + + test("run failures and mentions read as plain sentences about named things", async () => { + const { mail, written } = recordingMailbox(); + const deps = depsWith(mail); + await deliverRunFailureMail(deps, { + kind: "run-failure", + tenantId: "tnt_1", + runId: "run_9", + deploymentId: "dep_9", + runLabel: "Nightly digest", + error: "upstream timed out", + recipients: [{ tenantId: "tnt_1", principalId: "prn_1" }], + createdAt: "2026-08-08T11:00:00.000Z", + }); + await deliverMentionMail(deps, { + kind: "mention", + tenantId: "tnt_1", + threadId: "thr_3", + threadLabel: "Launch plan", + mentionedBy: "Sawyer", + excerpt: "can you take this one?", + recipients: [{ tenantId: "tnt_1", principalId: "prn_1" }], + createdAt: "2026-08-08T12:00:00.000Z", + }); + + expect(written[0]?.subject).toBe("“Nightly digest” failed"); + expect(written[0]?.externalId).toBe("run_9:2026-08-08T11:00:00.000Z"); + expect(written[1]?.subject).toBe("Sawyer mentioned you in “Launch plan”"); + for (const item of written) { + expect(item.subject).not.toContain("_"); + } + }); +}); diff --git a/packages/notify/test/dispatcher.test.ts b/packages/notify/test/dispatcher.test.ts new file mode 100644 index 000000000..a9a1421a3 --- /dev/null +++ b/packages/notify/test/dispatcher.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; + +import { + createInMemoryNotifyDispatchStore, + createNotifyDispatcher, + createSinkRegistry, + DuplicateSinkNameError, + type NotificationEvent, + type NotificationSinkPlugin, + type NotifyDispatchLogger, + type SinkDeliveryResult, +} from "../src/index"; + +const event: NotificationEvent = { + kind: "approval", + approvalId: "apr_1", + tenantId: "tnt_1", + runId: "run_1", + deploymentId: "dep_1", + toolName: "send_invoice", + toolArguments: {}, + recipients: [{ tenantId: "tnt_1", principalId: "prn_1" }], + createdAt: "2026-08-08T10:00:00.000Z", +}; + +const silent: NotifyDispatchLogger = { + warn: () => undefined, + error: () => undefined, +}; + +function fakeSink( + name: string, + results: SinkDeliveryResult[], +): { plugin: NotificationSinkPlugin; calls: number[] } { + const calls: number[] = []; + let index = 0; + const plugin: NotificationSinkPlugin = { + name, + isEnabledFor: async () => true, + deliver: async () => { + calls.push(index); + const result = results[Math.min(index, results.length - 1)]; + index += 1; + return result ?? { status: "delivered" }; + }, + }; + return { plugin, calls }; +} + +function dispatcherOver( + sinks: ReturnType, + store: ReturnType, +) { + return createNotifyDispatcher({ + store, + sinks, + log: silent, + loadEvent: async () => event, + tickIntervalMs: 60_000, + batchSize: 25, + maxAttempts: 3, + retryBackoffMs: 1_000, + }); +} + +describe("createNotifyDispatcher", () => { + test("does nothing, and does not throw, with no sink registered", async () => { + const store = createInMemoryNotifyDispatchStore(); + const dispatcher = dispatcherOver(createSinkRegistry(), store); + expect(await dispatcher.runOnce(new Date())).toBe(0); + }); + + test("marks a delivered row delivered and stops retrying it", async () => { + const store = createInMemoryNotifyDispatchStore(); + const sinks = createSinkRegistry(); + const { plugin, calls } = fakeSink("test", [{ status: "delivered" }]); + sinks.register(plugin); + await store.enqueue([ + { + mailboxRowId: "mail-1", + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "test", + }, + ]); + const dispatcher = dispatcherOver(sinks, store); + + expect(await dispatcher.runOnce(new Date())).toBe(1); + const [row] = await store.listFor("mail-1"); + expect(row?.status).toBe("delivered"); + expect(row?.attempts).toBe(1); + expect(await dispatcher.runOnce(new Date())).toBe(0); + expect(calls).toHaveLength(1); + }); + + test("backs a retryable failure off, then gives up at the attempt ceiling", async () => { + const store = createInMemoryNotifyDispatchStore(); + const sinks = createSinkRegistry(); + const { plugin } = fakeSink("test", [ + { status: "failed", error: "rate limited", retryable: true }, + ]); + sinks.register(plugin); + await store.enqueue([ + { + mailboxRowId: "mail-1", + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "test", + }, + ]); + const dispatcher = dispatcherOver(sinks, store); + + const first = new Date("2026-08-08T10:00:00.000Z"); + await dispatcher.runOnce(first); + let [row] = await store.listFor("mail-1"); + expect(row?.status).toBe("failed"); + expect(row?.lastError).toBe("rate limited"); + expect(row?.nextAttemptAt.getTime()).toBe(first.getTime() + 1_000); + + // Nothing is due until the backoff elapses. + expect(await dispatcher.runOnce(first)).toBe(0); + + await dispatcher.runOnce(new Date(first.getTime() + 10_000)); + await dispatcher.runOnce(new Date(first.getTime() + 100_000)); + [row] = await store.listFor("mail-1"); + expect(row?.attempts).toBe(3); + expect(row?.status).toBe("dead"); + }); + + test("a non-retryable failure dies on its first attempt", async () => { + const store = createInMemoryNotifyDispatchStore(); + const sinks = createSinkRegistry(); + const { plugin } = fakeSink("test", [ + { status: "failed", error: "no such channel", retryable: false }, + ]); + sinks.register(plugin); + await store.enqueue([ + { + mailboxRowId: "mail-1", + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "test", + }, + ]); + await dispatcherOver(sinks, store).runOnce(new Date()); + const [row] = await store.listFor("mail-1"); + expect(row?.status).toBe("dead"); + expect(row?.attempts).toBe(1); + }); + + test("a throwing sink is treated as a retryable failure, never a crash", async () => { + const store = createInMemoryNotifyDispatchStore(); + const sinks = createSinkRegistry(); + sinks.register({ + name: "test", + isEnabledFor: async () => true, + deliver: async () => { + throw new Error("socket hang up"); + }, + }); + await store.enqueue([ + { + mailboxRowId: "mail-1", + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "test", + }, + ]); + await dispatcherOver(sinks, store).runOnce(new Date()); + const [row] = await store.listFor("mail-1"); + expect(row?.status).toBe("failed"); + expect(row?.lastError).toBe("socket hang up"); + }); + + test("rows left behind by an unregistered sink are closed out", async () => { + const store = createInMemoryNotifyDispatchStore(); + await store.enqueue([ + { + mailboxRowId: "mail-1", + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "removed", + }, + ]); + await dispatcherOver(createSinkRegistry(), store).runOnce(new Date()); + const [row] = await store.listFor("mail-1"); + expect(row?.status).toBe("dead"); + }); +}); + +describe("createSinkRegistry", () => { + test("refuses two sinks with the same name", () => { + const sinks = createSinkRegistry(); + const { plugin } = fakeSink("test", [{ status: "delivered" }]); + sinks.register(plugin); + expect(() => sinks.register(plugin)).toThrow(DuplicateSinkNameError); + }); + + test("lists only the sinks enabled for a principal", async () => { + const sinks = createSinkRegistry(); + sinks.register({ + name: "on", + isEnabledFor: async (scope) => scope.principalId === "prn_1", + deliver: async () => ({ status: "delivered" }), + }); + sinks.register({ + name: "off", + isEnabledFor: async () => false, + deliver: async () => ({ status: "delivered" }), + }); + const enabled = await sinks.listEnabledFor({ + tenantId: "tnt_1", + principalId: "prn_1", + }); + expect(enabled.map((sink) => sink.name)).toEqual(["on"]); + expect(sinks.list()).toHaveLength(2); + }); +}); diff --git a/packages/notify/test/migrations.test.ts b/packages/notify/test/migrations.test.ts new file mode 100644 index 000000000..c196ad9e6 --- /dev/null +++ b/packages/notify/test/migrations.test.ts @@ -0,0 +1,97 @@ +// DB-gated: runs against its own scratch database, never the developer's or +// the walking-skeleton suite's, mirroring `packages/schedules/test/migrations.test.ts`. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { applyNotifyMigrations } from "../src/migrations"; +import { createDrizzleNotifyDispatchStore } from "../src/store"; + +function scratchUrlFor(e2eUrl: string): string { + const url = new URL(e2eUrl); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_notify_migrations_test`; + return url.toString(); +} + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = databaseUrl === undefined ? describe.skip : describe; + +describeIfDb("applyNotifyMigrations", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); + + beforeAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + afterAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }); + + test("creates the dispatch table once and is a no-op on a re-run", async () => { + const first = await applyNotifyMigrations(scratchUrl); + expect(first.applied).toEqual(["0001_notify_dispatch"]); + const second = await applyNotifyMigrations(scratchUrl); + expect(second.applied).toEqual([]); + expect(second.alreadyApplied).toContain("0001_notify_dispatch"); + }); + + test("queues one row per sink, dedupes a redelivery, and settles it", async () => { + await applyNotifyMigrations(scratchUrl); + const client = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const store = createDrizzleNotifyDispatchStore(drizzle(client)); + const queued = { + mailboxRowId: "mail-1", + tenantId: "tnt_1", + principalId: "prn_1", + sinkName: "slack", + }; + await store.enqueue([queued]); + await store.enqueue([queued]); + const rows = await store.listFor("mail-1"); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + expect(rows[0]?.id.startsWith("sig_")).toBe(true); + + const due = await store.findDue(new Date(), 10); + expect(due.map((row) => row.id)).toEqual([rows[0]?.id ?? ""]); + + await store.settle({ + id: rows[0]?.id ?? "", + status: "delivered", + attempts: 1, + lastError: null, + nextAttemptAt: new Date(), + }); + expect(await store.findDue(new Date(), 10)).toEqual([]); + } finally { + await client.end(); + } + }); +}); diff --git a/packages/notify/tsconfig.json b/packages/notify/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/notify/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +} diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index 35d9516b0..4adfe4346 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -54,6 +54,11 @@ const ALLOWLIST: readonly { maxOccurrences: 1, tables: ["webhook_trigger"], }, + { + relPath: "packages/notify/src/schema.ts", + maxOccurrences: 1, + tables: ["notify_dispatch"], + }, ]; export async function scanFiles( diff --git a/scripts/checks/test/no-product-tenancy.test.ts b/scripts/checks/test/no-product-tenancy.test.ts index a0582edaa..5f752acb0 100644 --- a/scripts/checks/test/no-product-tenancy.test.ts +++ b/scripts/checks/test/no-product-tenancy.test.ts @@ -64,6 +64,10 @@ test("allowlisted product schema files pass at their max count", () => { relPath: "packages/webhook-triggers/src/schema.ts", contents: `export const webhookTrigger = pgTable("webhook_trigger", {});`, }, + { + relPath: "packages/notify/src/schema.ts", + contents: `export const notifyDispatch = pgTable("notify_dispatch", {});`, + }, ]); expect(report.violations).toEqual([]); expect( diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index efba4a27a..277b49de7 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -31,6 +31,7 @@ import { readdir } from "node:fs/promises"; import { applyChatMigrations } from "../packages/chat/src/migrations"; import { applyWebhookTriggersMigrations } from "../packages/webhook-triggers/src/migrations"; import { applyScheduleMigrations } from "../packages/schedules/src/migrations"; +import { applyNotifyMigrations } from "../packages/notify/src/migrations"; const repoRoot = path.resolve(import.meta.dir, ".."); const HUB_DIR = path.join(repoRoot, "apps", "hub"); @@ -48,6 +49,7 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { { name: "@corbits/chat", apply: applyChatMigrations }, { name: "@corbits/webhook-triggers", apply: applyWebhookTriggersMigrations }, { name: "@corbits/schedules", apply: applyScheduleMigrations }, + { name: "@corbits/notify", apply: applyNotifyMigrations }, ]; /**