/** * DES-MERGE-001 §5.4 / §6.1 (slice 3) — the `wicked.interactive.*` ↔ `/ws` relay. * * wicked-studio is absorbing wicked-interactive. The SPA already holds crew's `/ws` socket for * CoreEvent frames; interactive's own progress travels on the wicked-bus. This module bridges the * two so the skin needs exactly ONE socket — no second websocket, no new port. * * Two halves, one bus handle: * * 1. RELAY (bus → /ws). Tap `wicked.interactive.**` and rebroadcast each event as * `{ type: 'interactiveEvent', event: }` — ONE envelope type, the original * event nested whole, no field renaming, no project filter. Ordering is as received (the bus * hands a batch to the handler in order and `broadcast` is synchronous), and there is no * buffering beyond what the existing broadcast path already does. * * 2. EMIT (/ws client → bus). `POST /projects/:projectId/interactive-events` validates the body * against a SERVER-SIDE whitelist ({@link EMITTABLE_TYPES}) and puts the event on the bus with * interactive's own envelope conventions (domain `wicked-interactive`, producer `wi-crew`, * `ts`-stamped payload — draft-events.ts is the precedent), so interactive's own subscribers * see a UI-originated event exactly as they see a service-originated one. * * Deliberately DISTINCT from the `projectActivity` bridge in projects/events.ts, which reads the * same filter: that one is project-FILTERED and reshapes the event into an activity frame for a * project view. This one is unfiltered and verbatim, because the studio's document canvas must see * the events of a doc that was never filed under a project. Each is its own tap with its own * in-memory cursor, so neither seam can strand the other. * * Posture mirrors every other bus seam here: LOUD-non-fatal. A bus db that cannot open → the * factory logs once and returns null, the daemon boots without the relay, and the POST route * answers 503 instead of pretending the emit landed. */ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; /** The ONE envelope type this relay puts on `/ws`. Consumers that don't know it ignore it — the * CoreEvent contract is additive-safe (DES-STUDIO-001 §2.1). */ export declare const INTERACTIVE_EVENT_FRAME = "interactiveEvent"; /** * Server-side whitelist of the event types a `/ws` client may emit through * `POST /projects/:projectId/interactive-events`. A non-whitelisted type is a 400 that NAMES this * array — the UI never has to guess. Extend here; nothing else needs to change. */ export declare const EMITTABLE_TYPES: readonly string[]; /** The request body: a whitelisted type plus that type's own payload. Strict — an unknown key is * a 400, not a silently-dropped field. */ export declare const EmitInteractiveEventSchema: z.ZodObject<{ type: z.ZodString; payload: z.ZodOptional>; }, "strict", z.ZodTypeAny, { type: string; payload?: Record | undefined; }, { type: string; payload?: Record | undefined; }>; export interface InteractiveRelay { /** * Emit one interactive event onto the bus. Returns true on success (a key already on the bus is * success — the emit already happened); logs and returns false otherwise. Never rejects. */ emitInteractive(type: string, payload: Record, idempotencyKey: string): Promise; stop(): Promise; } export interface InteractiveRelayOptions { /** The bus db the daemon handed its engine (core/bus.ts); without one the relay does not arm. */ dbPath?: string; /** Poll cadence for the relay subscriber, ms (tests shorten it). */ pollIntervalMs?: number; log?: (msg: string) => void; /** Error-level logger for connection-fatal subscriber errors (the /diagnostics ring folds it); defaults to `log`. */ logError?: (msg: string) => void; /** * Called once per observed `wicked.interactive.doc.retired` with the retired document id * (crew#338). This is how a retirement that BYPASSED crew's governed delete route — a direct * bridge call, another tool — still drops the doc's handoff-ledger rows: `createServer` wires * the same idempotent four-ledger sweep the route runs, so at-least-once redelivery and the * route/relay overlap both collapse to "remove rows that may not exist". Best-effort by the * relay's nature (cursor starts at `latest`, no retries) — the governed route stays the * guaranteed path. A throw is caught and logged; it never takes the relay down. */ /** `projectId`: the retired doc's project, when the fact carries one (an Unfiled doc's does not). */ onDocRetired?: (documentId: string, projectId?: string) => void; } /** * Arm both halves. Returns `null` (logged) when the bus db cannot open — the caller degrades to a daemon whose `/ws` simply carries no interactive * frames, exactly as before this slice. */ export declare function startInteractiveWsRelay(opts?: InteractiveRelayOptions): Promise; /** Occurrence-unique, type-inclusive (DES-EXEC-001 rev0.2 #4a): two types sharing a key would * silently drop one on the bus's UNIQUE constraint, and every UI emission is a new fact. */ export declare function interactiveEmitKey(projectId: string, type: string): string; /** * `POST /api/v1/projects/:projectId/interactive-events` — the UI-emittable direction (§6.1). * Registered even when the relay is null, so the failure is an honest 503 rather than a 404 that * reads like "this daemon doesn't have the feature". */ export declare function registerInteractiveEventRoutes(app: FastifyInstance, relay: InteractiveRelay | null): void; //# sourceMappingURL=ws-relay.d.ts.map