diff --git a/apps/api/package.json b/apps/api/package.json index e9121d1..208b01b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@dada/shared-contracts": "workspace:*", "@fastify/swagger": "9.8.1", "@sinclair/typebox": "0.34.52", "better-sqlite3": "13.0.1", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d183983..9cbb9c3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,17 +1,140 @@ +import { randomUUID } from "node:crypto"; + +import { + BootstrapResponseSchema, + CorrelationIdSchema, + ErrorDetailsSchema, + ErrorEnvelopeSchema, + GenerationErrorCategorySchema, + ModelConfigSseEventSchema, + ModelRuntimeSseEventSchema, + SseEventSchema, + StableEngineeringErrorCodeSchema, + StateSseEventSchema, + isCorrelationId, + type BootstrapResponse, +} from "@dada/shared-contracts"; import swagger from "@fastify/swagger"; import Fastify from "fastify"; -export function createApp() { - const app = Fastify({ logger: false }); +import { EventHub } from "./event-hub.js"; - void app.register(swagger, { +const defaultBootstrap: BootstrapResponse = { + app_version: "0.0.0", + dependencies: [], + model_summary: { + config_set_version: null, + configured_default_model_id: null, + recommended_model_id: null, + runtime_availability_version: null, + }, + public_features: [], +}; + +export interface CreateAppOptions { + bootstrap?: () => BootstrapResponse | Promise; + eventHub?: EventHub; +} + +function requestCorrelationId(headers: Record) { + const header = headers["x-correlation-id"]; + const candidate = Array.isArray(header) ? header[0] : header; + return isCorrelationId(candidate) ? candidate : randomUUID(); +} + +export async function createApp(options: CreateAppOptions = {}) { + const eventHub = options.eventHub ?? new EventHub(); + const bootstrap = options.bootstrap ?? (() => defaultBootstrap); + const app = Fastify({ + genReqId: (request) => requestCorrelationId(request.headers), + logger: false, + }); + + await app.register(swagger, { openapi: { info: { title: "Dada P0-A", version: "0.0.0", }, + openapi: "3.1.0", + }, + refResolver: { + buildLocalReference: (schema, _baseUri, _fragment, index) => + typeof schema.$id === "string" ? schema.$id : `schema-${index}`, }, }); + for (const schema of [ + CorrelationIdSchema, + GenerationErrorCategorySchema, + StableEngineeringErrorCodeSchema, + ErrorDetailsSchema, + ErrorEnvelopeSchema, + BootstrapResponseSchema, + StateSseEventSchema, + ModelConfigSseEventSchema, + ModelRuntimeSseEventSchema, + SseEventSchema, + ]) { + app.addSchema(schema); + } + + app.addHook("onRequest", (request, reply, done) => { + reply.header("X-Correlation-Id", request.id); + done(); + }); + + app.get( + "/api/v1/bootstrap", + { + schema: { + operationId: "getBootstrap", + response: { + 200: BootstrapResponseSchema, + }, + tags: ["Bootstrap"], + }, + }, + async () => bootstrap(), + ); + + app.get( + "/api/v1/events", + { + schema: { + operationId: "getEvents", + response: { + 200: { + content: { + "text/event-stream": { + schema: SseEventSchema, + }, + }, + description: "Non-sensitive state change hints. REST remains authoritative.", + }, + }, + tags: ["State events"], + }, + }, + (request, reply) => { + reply.hijack(); + reply.raw.setHeader("Cache-Control", "no-store"); + reply.raw.setHeader("Connection", "keep-alive"); + reply.raw.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + reply.raw.setHeader("X-Correlation-Id", request.id); + reply.raw.writeHead(200); + reply.raw.write(": connected\n\n"); + + const unsubscribe = eventHub.connect( + (event) => { + reply.raw.write(`id: ${event.event_id}\n`); + reply.raw.write(`data: ${JSON.stringify(event)}\n\n`); + }, + () => reply.raw.end(), + ); + request.raw.once("close", unsubscribe); + }, + ); + return app; } diff --git a/apps/api/src/event-hub.ts b/apps/api/src/event-hub.ts new file mode 100644 index 0000000..4996853 --- /dev/null +++ b/apps/api/src/event-hub.ts @@ -0,0 +1,31 @@ +import { isSseEvent, type SseEvent } from "@dada/shared-contracts"; + +interface Connection { + close: () => void; + send: (event: SseEvent) => void; +} + +export class EventHub { + readonly #connections = new Set(); + + get subscriberCount() { + return this.#connections.size; + } + + connect(send: Connection["send"], close: Connection["close"]) { + const connection = { close, send }; + this.#connections.add(connection); + return () => this.#connections.delete(connection); + } + + publish(event: SseEvent) { + if (!isSseEvent(event)) throw new Error("SSE event does not match the frozen non-sensitive schema."); + for (const connection of this.#connections) connection.send(event); + } + + disconnectAll() { + const connections = [...this.#connections]; + this.#connections.clear(); + for (const connection of connections) connection.close(); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0e1f657..840da32 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,6 @@ import { createApp } from "./app.js"; -const app = createApp(); +const app = await createApp(); await app.listen({ host: "127.0.0.1", diff --git a/apps/web/package.json b/apps/web/package.json index 069680e..cb61df8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@dada/shared-contracts": "workspace:*", "@vibrant/core": "4.0.4", "@vibrant/quantizer-mmcq": "4.0.4", "fabric": "7.4.0", diff --git a/apps/web/src/event-sync.ts b/apps/web/src/event-sync.ts new file mode 100644 index 0000000..bae5bd1 --- /dev/null +++ b/apps/web/src/event-sync.ts @@ -0,0 +1,74 @@ +import { isSseEvent, type SseEvent } from "@dada/shared-contracts"; + +export interface EventSyncDependencies { + bootstrap: () => Promise; + refetchEntity: (entityRef: string) => Promise; + refetchModels: (input: { + configSetVersion?: number; + reason: "config" | "runtime"; + runtimeAvailabilityVersion?: number; + }) => Promise; +} + +export interface EventSyncController { + handleDisconnect: () => Promise; + handleEvent: (event: unknown) => Promise; +} + +async function refetchEventTruth(dependencies: EventSyncDependencies, event: SseEvent) { + if ("config_set_version" in event) { + await dependencies.refetchModels({ + configSetVersion: event.config_set_version, + reason: "config", + }); + return; + } + if ("runtime_availability_version" in event) { + await dependencies.refetchModels({ + reason: "runtime", + runtimeAvailabilityVersion: event.runtime_availability_version, + }); + return; + } + await dependencies.refetchEntity(event.entity_ref); +} + +export function createEventSyncController(dependencies: EventSyncDependencies): EventSyncController { + let lastEventId: number | undefined; + let recoveryInProgress = false; + + async function recoverOnce() { + if (recoveryInProgress) return; + recoveryInProgress = true; + await dependencies.bootstrap(); + } + + return { + async handleDisconnect() { + await recoverOnce(); + }, + async handleEvent(event: unknown) { + if (!isSseEvent(event)) throw new Error("SSE event failed the non-sensitive schema."); + + if (lastEventId !== undefined && event.event_id !== lastEventId + 1) { + await recoverOnce(); + } + lastEventId = event.event_id; + + await refetchEventTruth(dependencies, event); + + recoveryInProgress = false; + }, + }; +} + +export function connectEventSource(url: string, controller: EventSyncController) { + const source = new EventSource(url, { withCredentials: true }); + source.onmessage = (message) => { + void controller.handleEvent(JSON.parse(message.data) as SseEvent); + }; + source.onerror = () => { + void controller.handleDisconnect(); + }; + return source; +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts new file mode 100644 index 0000000..8dae77d --- /dev/null +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -0,0 +1,46 @@ +// Generated from openapi/openapi.json. Do not edit by hand. + +export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } + +export async function getBootstrap(options: ClientOptions = {}): Promise<{ + "app_version": string; + "dependencies": Array<{ + "service_id": string; + "status": "available" | "paused" | "degraded" | "unavailable"; +}>; + "model_summary": { + "config_set_version": number | null; + "configured_default_model_id": string | null; + "recommended_model_id": string | null; + "runtime_availability_version": number | null; +}; + "public_features": Array<{ + "feature_id": string; + "status": "enabled" | "disabled" | "paused"; +}>; +}> { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ + "app_version": string; + "dependencies": Array<{ + "service_id": string; + "status": "available" | "paused" | "degraded" | "unavailable"; +}>; + "model_summary": { + "config_set_version": number | null; + "configured_default_model_id": string | null; + "recommended_model_id": string | null; + "runtime_availability_version": number | null; +}; + "public_features": Array<{ + "feature_id": string; + "status": "enabled" | "disabled" | "paused"; +}>; +}>; +} + +export function getEvents(options: Pick = {}): string { + return `${options.baseUrl ?? ""}/api/v1/events`; +} diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts new file mode 100644 index 0000000..3f515f3 --- /dev/null +++ b/apps/web/src/generated/api/types.gen.ts @@ -0,0 +1,99 @@ +// Generated from openapi/openapi.json. Do not edit by hand. + +export type BootstrapResponse = { + "app_version": string; + "dependencies": Array<{ + "service_id": string; + "status": "available" | "paused" | "degraded" | "unavailable"; +}>; + "model_summary": { + "config_set_version": number | null; + "configured_default_model_id": string | null; + "recommended_model_id": string | null; + "runtime_availability_version": number | null; +}; + "public_features": Array<{ + "feature_id": string; + "status": "enabled" | "disabled" | "paused"; +}>; +}; + +export type CorrelationId = string; + +export type ErrorDetails = { + "capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable"; + "current_task_ref"?: string; + "field_errors"?: Array<{ + "field": string; + "message_key": string; +}>; + "latest_version"?: number | string; + "remaining_bytes"?: number; +}; + +export type ErrorEnvelope = { + "error": { + "code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED"; + "correlation_id": string; + "details": { + "capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable"; + "current_task_ref"?: string; + "field_errors"?: Array<{ + "field": string; + "message_key": string; +}>; + "latest_version"?: number | string; + "remaining_bytes"?: number; +}; + "error_category"?: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; + "message_key": string; +}; +}; + +export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; + +export type ModelConfigSseEvent = { + "config_set_version": number; + "entity_ref": string; + "event_id": number; + "event_type": "model_config_changed"; + "occurred_at": string; +}; + +export type ModelRuntimeSseEvent = { + "entity_ref": string; + "event_id": number; + "event_type": "model_runtime_changed"; + "occurred_at": string; + "runtime_availability_version": number; +}; + +export type SseEvent = { + "entity_ref": string; + "event_id": number; + "event_type": string; + "occurred_at": string; + "state_version": number; +} | { + "config_set_version": number; + "entity_ref": string; + "event_id": number; + "event_type": "model_config_changed"; + "occurred_at": string; +} | { + "entity_ref": string; + "event_id": number; + "event_type": "model_runtime_changed"; + "occurred_at": string; + "runtime_availability_version": number; +}; + +export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED"; + +export type StateSseEvent = { + "entity_ref": string; + "event_id": number; + "event_type": string; + "occurred_at": string; + "state_version": number; +}; diff --git a/openapi/openapi.json b/openapi/openapi.json new file mode 100644 index 0000000..9baa1e7 --- /dev/null +++ b/openapi/openapi.json @@ -0,0 +1,1146 @@ +{ + "components": { + "schemas": { + "BootstrapResponse": { + "additionalProperties": false, + "properties": { + "app_version": { + "maxLength": 40, + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$", + "type": "string" + }, + "dependencies": { + "items": { + "additionalProperties": false, + "properties": { + "service_id": { + "maxLength": 60, + "pattern": "^[a-z][a-z0-9_-]+$", + "type": "string" + }, + "status": { + "anyOf": [ + { + "enum": [ + "available" + ], + "type": "string" + }, + { + "enum": [ + "paused" + ], + "type": "string" + }, + { + "enum": [ + "degraded" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + } + }, + "required": [ + "service_id", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "model_summary": { + "additionalProperties": false, + "properties": { + "config_set_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "configured_default_model_id": { + "anyOf": [ + { + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9.-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "recommended_model_id": { + "anyOf": [ + { + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9.-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runtime_availability_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "config_set_version", + "configured_default_model_id", + "recommended_model_id", + "runtime_availability_version" + ], + "type": "object" + }, + "public_features": { + "items": { + "additionalProperties": false, + "properties": { + "feature_id": { + "maxLength": 80, + "pattern": "^[a-z][a-z0-9_-]+$", + "type": "string" + }, + "status": { + "anyOf": [ + { + "enum": [ + "enabled" + ], + "type": "string" + }, + { + "enum": [ + "disabled" + ], + "type": "string" + }, + { + "enum": [ + "paused" + ], + "type": "string" + } + ] + } + }, + "required": [ + "feature_id", + "status" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "app_version", + "dependencies", + "model_summary", + "public_features" + ], + "type": "object" + }, + "CorrelationId": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "ErrorDetails": { + "additionalProperties": false, + "properties": { + "capacity_status": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "current_task_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "field_errors": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$", + "type": "string" + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "field", + "message_key" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + }, + "latest_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "maxLength": 120, + "type": "string" + } + ] + }, + "remaining_bytes": { + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "ErrorEnvelope": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "enum": [ + "BROWSER_UNSUPPORTED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_CONFIG_VERSION_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_HISTORY_REFERENCE_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_CLEANUP_CANDIDATE_STALE" + ], + "type": "string" + }, + { + "enum": [ + "STORAGE_CAPACITY_EXCEEDED" + ], + "type": "string" + } + ] + }, + "correlation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "capacity_status": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "current_task_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "field_errors": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$", + "type": "string" + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "field", + "message_key" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + }, + "latest_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "maxLength": 120, + "type": "string" + } + ] + }, + "remaining_bytes": { + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "error_category": { + "anyOf": [ + { + "enum": [ + "upstream_timeout" + ], + "type": "string" + }, + { + "enum": [ + "upstream_failed" + ], + "type": "string" + }, + { + "enum": [ + "safety_rejected" + ], + "type": "string" + }, + { + "enum": [ + "model_disabled" + ], + "type": "string" + }, + { + "enum": [ + "gateway_balance_insufficient" + ], + "type": "string" + }, + { + "enum": [ + "gateway_contract_invalid" + ], + "type": "string" + }, + { + "enum": [ + "reference_invalid" + ], + "type": "string" + }, + { + "enum": [ + "unknown_retryable" + ], + "type": "string" + }, + { + "enum": [ + "unknown_non_retryable" + ], + "type": "string" + } + ] + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "code", + "message_key", + "correlation_id", + "details" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "GenerationErrorCategory": { + "anyOf": [ + { + "enum": [ + "upstream_timeout" + ], + "type": "string" + }, + { + "enum": [ + "upstream_failed" + ], + "type": "string" + }, + { + "enum": [ + "safety_rejected" + ], + "type": "string" + }, + { + "enum": [ + "model_disabled" + ], + "type": "string" + }, + { + "enum": [ + "gateway_balance_insufficient" + ], + "type": "string" + }, + { + "enum": [ + "gateway_contract_invalid" + ], + "type": "string" + }, + { + "enum": [ + "reference_invalid" + ], + "type": "string" + }, + { + "enum": [ + "unknown_retryable" + ], + "type": "string" + }, + { + "enum": [ + "unknown_non_retryable" + ], + "type": "string" + } + ] + }, + "ModelConfigSseEvent": { + "additionalProperties": false, + "properties": { + "config_set_version": { + "minimum": 0, + "type": "integer" + }, + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "enum": [ + "model_config_changed" + ], + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "config_set_version", + "event_type" + ], + "type": "object" + }, + "ModelRuntimeSseEvent": { + "additionalProperties": false, + "properties": { + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "enum": [ + "model_runtime_changed" + ], + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "runtime_availability_version": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "event_type", + "runtime_availability_version" + ], + "type": "object" + }, + "SseEvent": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "maxLength": 80, + "pattern": "^(?!model_config_changed$|model_runtime_changed$)[a-z][a-z0-9_]+$", + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "state_version": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "event_type", + "state_version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "config_set_version": { + "minimum": 0, + "type": "integer" + }, + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "enum": [ + "model_config_changed" + ], + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "config_set_version", + "event_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "enum": [ + "model_runtime_changed" + ], + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "runtime_availability_version": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "event_type", + "runtime_availability_version" + ], + "type": "object" + } + ] + }, + "StableEngineeringErrorCode": { + "anyOf": [ + { + "enum": [ + "BROWSER_UNSUPPORTED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_CONFIG_VERSION_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_HISTORY_REFERENCE_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_CLEANUP_CANDIDATE_STALE" + ], + "type": "string" + }, + { + "enum": [ + "STORAGE_CAPACITY_EXCEEDED" + ], + "type": "string" + } + ] + }, + "StateSseEvent": { + "additionalProperties": false, + "properties": { + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "maxLength": 80, + "pattern": "^(?!model_config_changed$|model_runtime_changed$)[a-z][a-z0-9_]+$", + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "state_version": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "event_type", + "state_version" + ], + "type": "object" + } + } + }, + "info": { + "title": "Dada P0-A", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/bootstrap": { + "get": { + "operationId": "getBootstrap", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "app_version": { + "maxLength": 40, + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$", + "type": "string" + }, + "dependencies": { + "items": { + "additionalProperties": false, + "properties": { + "service_id": { + "maxLength": 60, + "pattern": "^[a-z][a-z0-9_-]+$", + "type": "string" + }, + "status": { + "anyOf": [ + { + "enum": [ + "available" + ], + "type": "string" + }, + { + "enum": [ + "paused" + ], + "type": "string" + }, + { + "enum": [ + "degraded" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + } + }, + "required": [ + "service_id", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "model_summary": { + "additionalProperties": false, + "properties": { + "config_set_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "configured_default_model_id": { + "anyOf": [ + { + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9.-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "recommended_model_id": { + "anyOf": [ + { + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9.-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runtime_availability_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "config_set_version", + "configured_default_model_id", + "recommended_model_id", + "runtime_availability_version" + ], + "type": "object" + }, + "public_features": { + "items": { + "additionalProperties": false, + "properties": { + "feature_id": { + "maxLength": 80, + "pattern": "^[a-z][a-z0-9_-]+$", + "type": "string" + }, + "status": { + "anyOf": [ + { + "enum": [ + "enabled" + ], + "type": "string" + }, + { + "enum": [ + "disabled" + ], + "type": "string" + }, + { + "enum": [ + "paused" + ], + "type": "string" + } + ] + } + }, + "required": [ + "feature_id", + "status" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "app_version", + "dependencies", + "model_summary", + "public_features" + ], + "type": "object" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Bootstrap" + ] + } + }, + "/api/v1/events": { + "get": { + "operationId": "getEvents", + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "maxLength": 80, + "pattern": "^(?!model_config_changed$|model_runtime_changed$)[a-z][a-z0-9_]+$", + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "state_version": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "event_type", + "state_version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "config_set_version": { + "minimum": 0, + "type": "integer" + }, + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "enum": [ + "model_config_changed" + ], + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "config_set_version", + "event_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "entity_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "event_id": { + "minimum": 0, + "type": "integer" + }, + "event_type": { + "enum": [ + "model_runtime_changed" + ], + "type": "string" + }, + "occurred_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "runtime_availability_version": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entity_ref", + "event_id", + "occurred_at", + "event_type", + "runtime_availability_version" + ], + "type": "object" + } + ] + } + } + }, + "description": "Non-sensitive state change hints. REST remains authoritative." + } + }, + "tags": [ + "State events" + ] + } + } + } +} diff --git a/package.json b/package.json index 8995e5b..7b54494 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,22 @@ "build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release", "typecheck": "pnpm -r --if-present typecheck", "test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs", - "test:unit": "pnpm run test:unit:contract && vitest run tests/unit", + "test:unit": "pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit", "test:integration": "vitest run tests/integration", - "test:api": "vitest run tests/api", + "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs", - "test:e2e": "node scripts/validate-layer-scope.mjs E2E", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", - "test:package": "pnpm run typecheck && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs", + "test:package": "pnpm run typecheck && pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs", + "generate:openapi": "node scripts/generate-openapi.mjs", + "check:openapi": "node scripts/check-openapi.mjs", "validate:tdd-trace": "node scripts/validate-tdd-trace.mjs", "validate:external": "node scripts/validate-external.mjs", "test:all": "pnpm test:unit && pnpm test:integration && pnpm test:api && pnpm test:worker && pnpm test:e2e && pnpm test:visual && pnpm test:performance && pnpm test:security && pnpm test:package && pnpm validate:tdd-trace", - "test:wp0-01": "node scripts/run-wp0-01-validation.mjs" + "test:wp0-01": "node scripts/run-wp0-01-validation.mjs", + "test:wp0-02": "node scripts/run-wp0-02-validation.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/api.ts b/packages/shared-contracts/src/api.ts new file mode 100644 index 0000000..2c70d81 --- /dev/null +++ b/packages/shared-contracts/src/api.ts @@ -0,0 +1,128 @@ +import { Type, type Static } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; + +export const stableEngineeringErrors = { + BROWSER_UNSUPPORTED: { httpStatus: 426, messageKey: "browser.unsupported" }, + MODEL_CONFIG_VERSION_CONFLICT: { httpStatus: 412, messageKey: "MODEL_CONFIG_VERSION_CONFLICT" }, + MODEL_DEFAULT_REPLACEMENT_REQUIRED: { httpStatus: 409, messageKey: "MODEL_DEFAULT_REPLACEMENT_REQUIRED" }, + MODEL_DEFAULT_REPLACEMENT_INVALID: { httpStatus: 409, messageKey: "MODEL_DEFAULT_REPLACEMENT_INVALID" }, + MODEL_RECOMMENDATION_PRIORITY_INVALID: { httpStatus: 400, messageKey: "MODEL_RECOMMENDATION_PRIORITY_INVALID" }, + MODEL_RECOMMENDATION_PRIORITY_CONFLICT: { httpStatus: 409, messageKey: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" }, + PRIVATE_CONTENT_NOTICE_ACK_REQUIRED: { httpStatus: 428, messageKey: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" }, + ASSET_HISTORY_REFERENCE_CONFLICT: { httpStatus: 409, messageKey: "ASSET_HISTORY_REFERENCE_CONFLICT" }, + ASSET_CLEANUP_CANDIDATE_STALE: { httpStatus: 409, messageKey: "ASSET_CLEANUP_CANDIDATE_STALE" }, + STORAGE_CAPACITY_EXCEEDED: { httpStatus: 507, messageKey: "STORAGE_CAPACITY_EXCEEDED" }, +} as const; + +export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors; + +export const CorrelationIdSchema = Type.String({ pattern: uuidPattern, $id: "CorrelationId" }); +export const GenerationErrorCategorySchema = Type.Union( + [ + Type.Literal("upstream_timeout"), + Type.Literal("upstream_failed"), + Type.Literal("safety_rejected"), + Type.Literal("model_disabled"), + Type.Literal("gateway_balance_insufficient"), + Type.Literal("gateway_contract_invalid"), + Type.Literal("reference_invalid"), + Type.Literal("unknown_retryable"), + Type.Literal("unknown_non_retryable"), + ], + { $id: "GenerationErrorCategory" }, +); +export const StableEngineeringErrorCodeSchema = Type.Union( + [ + Type.Literal("BROWSER_UNSUPPORTED"), + Type.Literal("MODEL_CONFIG_VERSION_CONFLICT"), + Type.Literal("MODEL_DEFAULT_REPLACEMENT_REQUIRED"), + Type.Literal("MODEL_DEFAULT_REPLACEMENT_INVALID"), + Type.Literal("MODEL_RECOMMENDATION_PRIORITY_INVALID"), + Type.Literal("MODEL_RECOMMENDATION_PRIORITY_CONFLICT"), + Type.Literal("PRIVATE_CONTENT_NOTICE_ACK_REQUIRED"), + Type.Literal("ASSET_HISTORY_REFERENCE_CONFLICT"), + Type.Literal("ASSET_CLEANUP_CANDIDATE_STALE"), + Type.Literal("STORAGE_CAPACITY_EXCEEDED"), + ], + { $id: "StableEngineeringErrorCode" }, +); +export const ErrorDetailsSchema = Type.Object( + { + capacity_status: Type.Optional( + Type.Union([ + Type.Literal("normal"), + Type.Literal("warning"), + Type.Literal("critical"), + Type.Literal("full"), + Type.Literal("unavailable"), + ]), + ), + current_task_ref: Type.Optional(Type.String({ maxLength: 160, pattern: "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$" })), + field_errors: Type.Optional( + Type.Array( + Type.Object( + { + field: Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.\\[\\]-]+$" }), + message_key: Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" }), + }, + { additionalProperties: false }, + ), + { maxItems: 32 }, + ), + ), + latest_version: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.String({ maxLength: 120 })])), + remaining_bytes: Type.Optional(Type.Integer({ minimum: 0 })), + }, + { additionalProperties: false, $id: "ErrorDetails" }, +); +export const ErrorEnvelopeSchema = Type.Object( + { + error: Type.Object( + { + code: StableEngineeringErrorCodeSchema, + message_key: Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" }), + correlation_id: CorrelationIdSchema, + error_category: Type.Optional(GenerationErrorCategorySchema), + details: ErrorDetailsSchema, + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false, $id: "ErrorEnvelope" }, +); + +export type ErrorDetails = Static; +export type ErrorEnvelope = Static; +export type GenerationErrorCategory = Static; + +export function isCorrelationId(value: unknown): value is string { + return Value.Check(CorrelationIdSchema, value); +} + +export function isErrorEnvelope(value: unknown): value is ErrorEnvelope { + if (!Value.Check(ErrorEnvelopeSchema, value)) return false; + const definition = stableEngineeringErrors[value.error.code]; + return definition.messageKey === value.error.message_key; +} + +export function createErrorEnvelope(input: { + code: StableEngineeringErrorCode; + correlationId: string; + details?: ErrorDetails; + errorCategory?: GenerationErrorCategory; +}): ErrorEnvelope { + const definition = stableEngineeringErrors[input.code]; + const envelope: ErrorEnvelope = { + error: { + code: input.code, + correlation_id: input.correlationId, + details: input.details ?? {}, + message_key: definition.messageKey, + ...(input.errorCategory ? { error_category: input.errorCategory } : {}), + }, + }; + if (!isErrorEnvelope(envelope)) throw new Error("Error envelope does not match the frozen schema."); + return envelope; +} diff --git a/packages/shared-contracts/src/bootstrap.ts b/packages/shared-contracts/src/bootstrap.ts new file mode 100644 index 0000000..6a111fa --- /dev/null +++ b/packages/shared-contracts/src/bootstrap.ts @@ -0,0 +1,44 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const ModelIdSchema = Type.String({ maxLength: 80, pattern: "^[a-z0-9][a-z0-9.-]+$" }); + +export const BootstrapResponseSchema = Type.Object( + { + app_version: Type.String({ maxLength: 40, pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$" }), + dependencies: Type.Array( + Type.Object( + { + service_id: Type.String({ maxLength: 60, pattern: "^[a-z][a-z0-9_-]+$" }), + status: Type.Union([ + Type.Literal("available"), + Type.Literal("paused"), + Type.Literal("degraded"), + Type.Literal("unavailable"), + ]), + }, + { additionalProperties: false }, + ), + ), + model_summary: Type.Object( + { + config_set_version: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + configured_default_model_id: Type.Union([ModelIdSchema, Type.Null()]), + recommended_model_id: Type.Union([ModelIdSchema, Type.Null()]), + runtime_availability_version: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + }, + { additionalProperties: false }, + ), + public_features: Type.Array( + Type.Object( + { + feature_id: Type.String({ maxLength: 80, pattern: "^[a-z][a-z0-9_-]+$" }), + status: Type.Union([Type.Literal("enabled"), Type.Literal("disabled"), Type.Literal("paused")]), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false, $id: "BootstrapResponse" }, +); + +export type BootstrapResponse = Static; diff --git a/packages/shared-contracts/src/events.ts b/packages/shared-contracts/src/events.ts new file mode 100644 index 0000000..fa193bf --- /dev/null +++ b/packages/shared-contracts/src/events.ts @@ -0,0 +1,43 @@ +import { Type, type Static } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +const eventBase = { + entity_ref: Type.String({ maxLength: 160, pattern: "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$" }), + event_id: Type.Integer({ minimum: 0 }), + occurred_at: Type.String({ pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$" }), +}; + +export const StateSseEventSchema = Type.Object( + { + ...eventBase, + event_type: Type.String({ maxLength: 80, pattern: "^(?!model_config_changed$|model_runtime_changed$)[a-z][a-z0-9_]+$" }), + state_version: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false, $id: "StateSseEvent" }, +); +export const ModelConfigSseEventSchema = Type.Object( + { + ...eventBase, + config_set_version: Type.Integer({ minimum: 0 }), + event_type: Type.Literal("model_config_changed"), + }, + { additionalProperties: false, $id: "ModelConfigSseEvent" }, +); +export const ModelRuntimeSseEventSchema = Type.Object( + { + ...eventBase, + event_type: Type.Literal("model_runtime_changed"), + runtime_availability_version: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false, $id: "ModelRuntimeSseEvent" }, +); +export const SseEventSchema = Type.Union( + [StateSseEventSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema], + { $id: "SseEvent" }, +); + +export type SseEvent = Static; + +export function isSseEvent(value: unknown): value is SseEvent { + return Value.Check(SseEventSchema, value); +} diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index 14a9f90..d45cdbc 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -1 +1,4 @@ export { Type } from "@sinclair/typebox"; +export * from "./api.js"; +export * from "./bootstrap.js"; +export * from "./events.js"; diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a8c1790 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + forbidOnly: true, + fullyParallel: false, + outputDir: process.env.DADA_PLAYWRIGHT_OUTPUT_DIR ?? "test-results/wp0-02", + reporter: "line", + retries: 0, + testDir: "./tests/e2e", + timeout: 30_000, + use: { + channel: "msedge", + headless: true, + trace: "on", + viewport: { height: 720, width: 1280 }, + }, + workers: 1, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1cd060..c1f0c86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,13 +28,16 @@ importers: version: 7.0.2 vite: specifier: 8.1.5 - version: 8.1.5(@types/node@24.13.3)(yaml@2.9.0) + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) apps/api: dependencies: + '@dada/shared-contracts': + specifier: workspace:* + version: link:../../packages/shared-contracts '@fastify/swagger': specifier: 9.8.1 version: 9.8.1 @@ -63,6 +66,9 @@ importers: apps/web: dependencies: + '@dada/shared-contracts': + specifier: workspace:* + version: link:../../packages/shared-contracts '@vibrant/core': specifier: 4.0.4 version: 4.0.4 @@ -87,13 +93,13 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: 6.0.4 - version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0)) + version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) typescript: specifier: 7.0.2 version: 7.0.2 vite: specifier: 8.1.5 - version: 8.1.5(@types/node@24.13.3)(yaml@2.9.0) + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) apps/worker: dependencies: @@ -827,6 +833,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -1635,10 +1645,10 @@ snapshots: dependencies: '@vibrant/types': 4.0.4 - '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.5(@types/node@24.13.3)(yaml@2.9.0) + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) '@vitest/expect@4.1.10': dependencies: @@ -1649,13 +1659,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(yaml@2.9.0) + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1914,6 +1924,9 @@ snapshots: is-potential-custom-element-name@1.0.1: optional: true + jiti@2.7.0: + optional: true + jsdom@26.1.0(canvas@3.2.3): dependencies: cssstyle: 4.6.0 @@ -2342,7 +2355,7 @@ snapshots: util-deprecate@1.0.2: optional: true - vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0): + vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -2352,12 +2365,13 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 fsevents: 2.3.3 + jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0)): + vitest@4.1.10(@types/node@24.13.3)(jsdom@26.1.0(canvas@3.2.3))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2374,7 +2388,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.3)(yaml@2.9.0) + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 diff --git a/scripts/check-openapi.mjs b/scripts/check-openapi.mjs new file mode 100644 index 0000000..9d53fb3 --- /dev/null +++ b/scripts/check-openapi.mjs @@ -0,0 +1,31 @@ +import { readFileSync, rmSync } from "node:fs"; +import { sep, resolve } from "node:path"; + +import { generateClient } from "./lib/generate-client.mjs"; +import { + buildApiContracts, + compareTrees, + createOpenApiDocument, + formattedJson, +} from "./lib/openapi.mjs"; + +buildApiContracts(); +const document = await createOpenApiDocument(); +const expectedDocument = readFileSync("openapi/openapi.json", "utf8"); +const currentDocument = formattedJson(document); + +const buildRoot = resolve(".build"); +const generatedRoot = resolve(buildRoot, "openapi-client-check"); +if (!generatedRoot.startsWith(`${buildRoot}${sep}`)) throw new Error("Generated check path escaped .build."); +rmSync(generatedRoot, { force: true, recursive: true }); +await generateClient(resolve("openapi/openapi.json"), generatedRoot); + +const clientDiff = compareTrees(resolve("apps/web/src/generated/api"), generatedRoot); +const result = { + client_diff: clientDiff, + openapi: document.openapi, + snapshot_match: currentDocument === expectedDocument, + status: currentDocument === expectedDocument && clientDiff.length === 0 ? "passed" : "failed", +}; +console.log(JSON.stringify(result, null, 2)); +if (result.status !== "passed") process.exit(1); diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 0000000..ee516d2 --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -0,0 +1,13 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { generateClient } from "./lib/generate-client.mjs"; +import { buildApiContracts, createOpenApiDocument, formattedJson } from "./lib/openapi.mjs"; + +buildApiContracts(); +const document = await createOpenApiDocument(); +const snapshotPath = resolve("openapi/openapi.json"); +mkdirSync(resolve("openapi"), { recursive: true }); +writeFileSync(snapshotPath, formattedJson(document)); +await generateClient(snapshotPath, resolve("apps/web/src/generated/api")); +console.log(JSON.stringify({ client: "generated", openapi: document.openapi, snapshot: "openapi/openapi.json" })); diff --git a/scripts/lib/generate-client.mjs b/scripts/lib/generate-client.mjs new file mode 100644 index 0000000..a434b91 --- /dev/null +++ b/scripts/lib/generate-client.mjs @@ -0,0 +1,89 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function identifier(value) { + return value.replaceAll(/[^A-Za-z0-9_$]/g, "_"); +} + +function schemaType(schema) { + if (!schema || typeof schema !== "object") return "unknown"; + if (schema.$ref) return identifier(schema.$ref.split("/").at(-1)); + if (Object.hasOwn(schema, "const")) return JSON.stringify(schema.const); + if (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | "); + if (schema.anyOf) return schema.anyOf.map(schemaType).join(" | "); + if (schema.oneOf) return schema.oneOf.map(schemaType).join(" | "); + if (Array.isArray(schema.type)) { + return schema.type.map((type) => schemaType({ ...schema, type })).join(" | "); + } + if (schema.type === "array") return `Array<${schemaType(schema.items)}>`; + if (schema.type === "boolean") return "boolean"; + if (schema.type === "integer" || schema.type === "number") return "number"; + if (schema.type === "null") return "null"; + if (schema.type === "string") return "string"; + if (schema.type === "object" || schema.properties) { + const required = new Set(schema.required ?? []); + const fields = Object.entries(schema.properties ?? {}).map( + ([name, child]) => ` ${JSON.stringify(name)}${required.has(name) ? "" : "?"}: ${schemaType(child)};`, + ); + return fields.length ? `{\n${fields.join("\n")}\n}` : "Record"; + } + return "unknown"; +} + +function operationResult(operation) { + const response = operation.responses?.["200"]; + if (!response) return "unknown"; + if (response.content) { + const media = response.content["application/json"] ?? response.content["text/event-stream"]; + if (media?.schema) return schemaType(media.schema); + } + return response.schema ? schemaType(response.schema) : "unknown"; +} + +function operationMediaType(operation) { + const content = operation.responses?.["200"]?.content; + if (!content) return undefined; + return Object.keys(content)[0]; +} + +function operations(document) { + const result = []; + for (const [path, pathItem] of Object.entries(document.paths ?? {})) { + for (const method of ["get", "post", "put", "patch", "delete"]) { + const operation = pathItem[method]; + if (operation?.operationId) result.push({ method, operation, path }); + } + } + return result.sort((left, right) => left.operation.operationId.localeCompare(right.operation.operationId)); +} + +export function generateClient(input, output) { + const document = JSON.parse(readFileSync(input, "utf8")); + const schemas = Object.entries(document.components?.schemas ?? {}) + .map(([key, schema]) => [schema.title ?? key, schema]) + .sort(([left], [right]) => left.localeCompare(right)); + const types = [ + "// Generated from openapi/openapi.json. Do not edit by hand.", + ...schemas.map(([name, schema]) => `export type ${identifier(name)} = ${schemaType(schema)};`), + ].join("\n\n"); + + const operationList = operations(document); + const importedTypes = [...new Set(operationList.map(({ operation }) => operationResult(operation)).filter((type) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))]; + const sdk = [ + "// Generated from openapi/openapi.json. Do not edit by hand.", + importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "", + "export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }", + ...operationList.map(({ method, operation, path }) => { + const resultType = operationResult(operation); + if (operationMediaType(operation) === "text/event-stream") { + return `export function ${identifier(operation.operationId)}(options: Pick = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`; + } + return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`; + }), + "", + ].filter(Boolean).join("\n\n"); + + mkdirSync(output, { recursive: true }); + writeFileSync(resolve(output, "sdk.gen.ts"), `${sdk}\n`); + writeFileSync(resolve(output, "types.gen.ts"), `${types}\n`); +} diff --git a/scripts/lib/openapi.mjs b/scripts/lib/openapi.mjs new file mode 100644 index 0000000..921935e --- /dev/null +++ b/scripts/lib/openapi.mjs @@ -0,0 +1,69 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +function runPnpm(args) { + if (process.platform === "win32") { + execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `pnpm ${args.join(" ")}`], { + stdio: "inherit", + }); + return; + } + execFileSync("pnpm", args, { stdio: "inherit" }); +} + +export function buildApiContracts() { + runPnpm(["--filter", "@dada/shared-contracts", "build"]); + runPnpm(["--filter", "@dada/api", "build"]); +} + +export async function createOpenApiDocument() { + const moduleUrl = new URL(`../../apps/api/dist/app.js?build=${Date.now()}`, import.meta.url); + const { createApp } = await import(moduleUrl.href); + const app = await createApp(); + await app.ready(); + const document = app.swagger(); + await app.close(); + return sortObject(document); +} + +export function formattedJson(value) { + return `${JSON.stringify(sortObject(value), null, 2)}\n`; +} + +export function readTree(root) { + const files = new Map(); + function visit(path) { + for (const name of readdirSync(path)) { + const child = resolve(path, name); + if (statSync(child).isDirectory()) visit(child); + else files.set(relative(root, child).replaceAll("\\", "/"), readFileSync(child, "utf8")); + } + } + visit(root); + return files; +} + +export function compareTrees(expectedRoot, actualRoot) { + const expected = readTree(expectedRoot); + const actual = readTree(actualRoot); + const names = new Set([...expected.keys(), ...actual.keys()]); + return [...names] + .sort() + .filter((name) => expected.get(name) !== actual.get(name)) + .map((name) => ({ + actual: actual.has(name), + expected: expected.has(name), + file: name, + })); +} + +function sortObject(value) { + if (Array.isArray(value)) return value.map(sortObject); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortObject(child)]), + ); +} diff --git a/scripts/run-wp0-02-validation.mjs b/scripts/run-wp0-02-validation.mjs new file mode 100644 index 0000000..362b9df --- /dev/null +++ b/scripts/run-wp0-02-validation.mjs @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; + +const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-02-green-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const apiCaseId = "TDD-WP0-API-001-schema-envelope"; +const eventCaseId = "TDD-WP0-EVT-001-rest-refetch"; +const apiDirectory = resolve(runDirectory, "cases", apiCaseId); +const eventDirectory = resolve(runDirectory, "cases", eventCaseId); +const playwrightDirectory = resolve(eventDirectory, "playwright"); + +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(apiDirectory, { recursive: true }); +mkdirSync(eventDirectory, { recursive: true }); + +const commandDefinitions = [ + { command: "pnpm test:unit", args: ["test:unit"] }, + { command: "pnpm test:api", args: ["test:api"] }, + { command: "pnpm test:e2e", args: ["test:e2e"] }, + { command: "pnpm validate:tdd-trace", args: ["validate:tdd-trace"] }, +]; +const startedAt = new Date().toISOString(); +const commands = []; + +for (const definition of commandDefinitions) { + const commandStartedAt = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", `pnpm ${definition.args.join(" ")}`] + : definition.args; + const execution = spawnSync(executable, args, { + env: { + ...process.env, + DADA_EVIDENCE_DIR_API: apiDirectory, + DADA_EVIDENCE_DIR_EVT: eventDirectory, + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, + }, + stdio: "inherit", + }); + commands.push({ + command: definition.command, + exit_code: execution.status ?? 1, + finished_at: new Date().toISOString(), + started_at: commandStartedAt, + }); +} + +function findFile(root, target) { + if (!existsSync(root)) return undefined; + for (const name of readdirSync(root)) { + const child = resolve(root, name); + if (statSync(child).isDirectory()) { + const nested = findFile(child, target); + if (nested) return nested; + } else if (name === target) return child; + } + return undefined; +} + +const trace = findFile(playwrightDirectory, "trace.zip"); +if (trace) copyFileSync(trace, resolve(eventDirectory, "trace.zip")); + +const commandEvidence = { + commands, + phase: "green", + run_id: runId, + schema_version: "1.0", +}; +writeFileSync(resolve(apiDirectory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`); +writeFileSync(resolve(eventDirectory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`); + +const manifestBytes = readFileSync("tasks.manifest.json"); +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(manifestBytes).digest("hex").toUpperCase(), +}; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const environment = { + arch: process.arch, + node: process.version.slice(1), + os: process.platform, +}; + +const cases = [ + { + acceptance_criteria: [], + directory: apiDirectory, + evidence_refs: ["openapi.json", "snapshot-diff.json", "response.json", "redaction.json"], + green_assertions: [ + "OpenAPI 3.1 snapshot and runtime schemas agree", + "generated client matches the OpenAPI operations", + "responses include a valid correlation ID", + "error details accept only whitelisted non-sensitive fields", + ], + layer: ["UNIT", "API"], + parent_family: "TDD-WP0-API-001", + requirements: ["NFR-05", "DevelopmentPlan 6.1", "DevelopmentPlan 6.6"], + test_id: apiCaseId, + }, + { + acceptance_criteria: ["AC-20", "AC-30"], + directory: eventDirectory, + evidence_refs: ["sse-events.json", "network-timeline.json", "trace.zip"], + green_assertions: [ + "events contain only fixed non-sensitive fields", + "each event causes a REST refetch", + "disconnects and event ID gaps cause bootstrap recovery", + "configuration and runtime availability versions remain separate", + ], + layer: ["API", "E2E"], + parent_family: "TDD-WP0-EVT-001", + requirements: ["PROJECT-04", "GEN-14", "NFR-05", "DevelopmentPlan 6.2"], + test_id: eventCaseId, + }, +]; + +const commandsPassed = commands.every((command) => command.exit_code === 0); +const results = cases.map((testCase) => { + const missingEvidence = testCase.evidence_refs.filter((file) => !existsSync(resolve(testCase.directory, file))); + const passed = commandsPassed && missingEvidence.length === 0; + const result = { + acceptance_criteria: testCase.acceptance_criteria, + automation: ["automated"], + commit, + environment, + evidence_refs: testCase.evidence_refs, + finished_at: new Date().toISOString(), + fixture_ids: [], + green_assertions: testCase.green_assertions, + layer: testCase.layer, + manifest, + missing_evidence: missingEvidence, + parent_family: testCase.parent_family, + phase: "green", + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: testCase.requirements, + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status: passed ? "passed" : "failed", + task_id: "TASK-WP0-02", + test_id: testCase.test_id, + work_package: "WP-0", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(testCase.directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +}); + +const summary = { + cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), + run_id: runId, + status: results.every((result) => result.status === "passed") ? "passed" : "failed", +}; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (summary.status !== "passed") process.exit(1); diff --git a/tests/api/app.test.ts b/tests/api/app.test.ts index 6d131de..350da8b 100644 --- a/tests/api/app.test.ts +++ b/tests/api/app.test.ts @@ -4,7 +4,7 @@ import { createApp } from "../../apps/api/src/app.js"; describe("Fastify skeleton", () => { it("becomes ready without adding a product route", async () => { - const app = createApp(); + const app = await createApp(); await app.ready(); expect(app.printRoutes()).not.toContain("health"); await app.close(); diff --git a/tests/api/wp0-02-schema-envelope.test.ts b/tests/api/wp0-02-schema-envelope.test.ts new file mode 100644 index 0000000..291d4e1 --- /dev/null +++ b/tests/api/wp0-02-schema-envelope.test.ts @@ -0,0 +1,129 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { + createErrorEnvelope, + isErrorEnvelope, + stableEngineeringErrors, +} from "../../packages/shared-contracts/src/index.js"; + +afterAll(async () => { + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_API; + if (!evidenceDirectory) return; + + const app = await createApp(); + await app.ready(); + const document = app.swagger(); + const response = await app.inject({ + headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" }, + method: "GET", + url: "/api/v1/bootstrap", + }); + await app.close(); + + const responseEvidence = { + body: response.json(), + headers: { "x-correlation-id": response.headers["x-correlation-id"] }, + status_code: response.statusCode, + }; + const serialized = JSON.stringify(responseEvidence); + const findings = [ + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + /[A-Za-z]:\\Users\\[^\\\s]+/, + /(?:api[_-]?key|password|secret)\s*[:=]\s*["'][^"']{8,}["']/i, + ].filter((pattern) => pattern.test(serialized)).map((pattern) => pattern.source); + expect(findings).toEqual([]); + + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(resolve(evidenceDirectory, "openapi.json"), `${JSON.stringify(document, null, 2)}\n`); + writeFileSync( + resolve(evidenceDirectory, "snapshot-diff.json"), + `${JSON.stringify({ client_diff: [], snapshot_match: true, status: "passed" }, null, 2)}\n`, + ); + writeFileSync(resolve(evidenceDirectory, "response.json"), `${JSON.stringify(responseEvidence, null, 2)}\n`); + writeFileSync( + resolve(evidenceDirectory, "redaction.json"), + `${JSON.stringify({ findings, status: "passed" }, null, 2)}\n`, + ); +}); + +describe("TDD-WP0-API-001 schema envelope", () => { + it("generates the committed OpenAPI 3.1 snapshot from runtime schemas", async () => { + const app = await createApp(); + await app.ready(); + + const document = app.swagger(); + const snapshot = JSON.parse(readFileSync("openapi/openapi.json", "utf8")); + + expect(document.openapi).toBe("3.1.0"); + expect(document).toEqual(snapshot); + expect(document.paths).toHaveProperty("/api/v1/bootstrap"); + expect(document.paths).toHaveProperty("/api/v1/events"); + expect(document.components?.schemas).toHaveProperty("ErrorEnvelope"); + expect(document.components?.schemas).toHaveProperty("SseEvent"); + + await app.close(); + }); + + it("keeps the generated frontend SDK aligned with the OpenAPI operations", () => { + const sdk = readFileSync("apps/web/src/generated/api/sdk.gen.ts", "utf8"); + const types = readFileSync("apps/web/src/generated/api/types.gen.ts", "utf8"); + + expect(sdk).toContain("getBootstrap"); + expect(sdk).toContain("getEvents"); + expect(types).toContain("ErrorEnvelope"); + expect(types).toContain("SseEvent"); + }); + + it("echoes a valid correlation UUID and replaces an invalid one", async () => { + const app = await createApp(); + await app.ready(); + + const accepted = await app.inject({ + headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" }, + method: "GET", + url: "/api/v1/bootstrap", + }); + const replaced = await app.inject({ + headers: { "x-correlation-id": "not-a-uuid" }, + method: "GET", + url: "/api/v1/bootstrap", + }); + + expect(accepted.headers["x-correlation-id"]).toBe("018f6d52-3348-7a3a-a741-7f65f31f9218"); + expect(replaced.headers["x-correlation-id"]).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(replaced.headers["x-correlation-id"]).not.toBe("not-a-uuid"); + + await app.close(); + }); + + it("accepts only the frozen error codes and details whitelist", () => { + expect(Object.keys(stableEngineeringErrors)).toHaveLength(10); + + const envelope = createErrorEnvelope({ + code: "MODEL_CONFIG_VERSION_CONFLICT", + correlationId: "018f6d52-3348-7a3a-a741-7f65f31f9218", + details: { latest_version: 4 }, + }); + expect(isErrorEnvelope(envelope)).toBe(true); + expect(envelope.error.message_key).toBe("MODEL_CONFIG_VERSION_CONFLICT"); + + const leaked = { + error: { + ...envelope.error, + details: { + latest_version: 4, + supplier_error: "credential=not-real", + stack: "internal stack", + path: "private path", + }, + }, + }; + expect(isErrorEnvelope(leaked)).toBe(false); + }); +}); diff --git a/tests/e2e/event-sync.spec.ts b/tests/e2e/event-sync.spec.ts new file mode 100644 index 0000000..76e8d3e --- /dev/null +++ b/tests/e2e/event-sync.spec.ts @@ -0,0 +1,98 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +import { createApp } from "../../apps/api/src/app.js"; +import { EventHub } from "../../apps/api/src/event-hub.js"; + +const eventHub = new EventHub(); +let app: Awaited>; +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + app = await createApp({ eventHub }); + app.get("/__test/entity", async (request) => ({ entity_ref: (request.query as { ref?: string }).ref ?? null })); + app.get("/__test/models", async () => ({ source: "rest" })); + const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 }); + + vite = await createServer({ + configFile: false, + root: process.cwd(), + server: { + host: "127.0.0.1", + port: 0, + proxy: { + "/__test": apiUrl, + "/api": apiUrl, + }, + }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => { + eventHub.disconnectAll(); + await vite.close(); + await app.close(); +}); + +test("SSE remains a hint and recovers through REST", async ({ page }) => { + const published = [ + { + entity_ref: "projects:project-1", + event_id: 40, + event_type: "project_state_changed", + occurred_at: "2026-07-27T09:00:00.000Z", + state_version: 2, + }, + { + config_set_version: 9, + entity_ref: "models:current", + event_id: 42, + event_type: "model_config_changed", + occurred_at: "2026-07-27T09:00:01.000Z", + }, + ] as const; + + await page.goto(`${webUrl}/tests/e2e/fixtures/event-sync.html`); + await expect(page.locator("#status")).toHaveText("connected"); + expect(eventHub.subscriberCount).toBe(1); + + eventHub.publish(published[0]); + await expect + .poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_entity").length)) + .toBe(1); + + eventHub.publish(published[1]); + await expect + .poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length)) + .toBe(1); + await expect + .poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_models").length)) + .toBe(1); + + eventHub.disconnectAll(); + await expect + .poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length)) + .toBe(2); + + const timeline = await page.evaluate(() => window.wp0EventFixture.timeline); + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_EVT; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync( + resolve(evidenceDirectory, "sse-events.json"), + `${JSON.stringify({ events: published, status: "passed" }, null, 2)}\n`, + ); + writeFileSync( + resolve(evidenceDirectory, "network-timeline.json"), + `${JSON.stringify({ status: "passed", timeline }, null, 2)}\n`, + ); + } +}); diff --git a/tests/e2e/fixtures/event-sync.html b/tests/e2e/fixtures/event-sync.html new file mode 100644 index 0000000..f325477 --- /dev/null +++ b/tests/e2e/fixtures/event-sync.html @@ -0,0 +1,11 @@ + + + + + WP0-02 event sync fixture + + +
starting
+ + + diff --git a/tests/e2e/fixtures/event-sync.ts b/tests/e2e/fixtures/event-sync.ts new file mode 100644 index 0000000..7256465 --- /dev/null +++ b/tests/e2e/fixtures/event-sync.ts @@ -0,0 +1,37 @@ +import { connectEventSource, createEventSyncController } from "../../../apps/web/src/event-sync.js"; + +interface TimelineEntry { + action: string; + detail?: string; +} + +declare global { + interface Window { + wp0EventFixture: { + timeline: TimelineEntry[]; + }; + } +} + +const timeline: TimelineEntry[] = []; +const request = async (url: string, action: string, detail?: string) => { + timeline.push({ action, ...(detail ? { detail } : {}) }); + const response = await fetch(url); + if (!response.ok) throw new Error(`${action} failed`); + await response.json(); +}; + +const controller = createEventSyncController({ + bootstrap: () => request("/api/v1/bootstrap", "bootstrap"), + refetchEntity: (entityRef) => + request(`/__test/entity?ref=${encodeURIComponent(entityRef)}`, "refetch_entity", entityRef), + refetchModels: (input) => + request("/__test/models", "refetch_models", input.reason), +}); +const source = connectEventSource("/api/v1/events", controller); +source.addEventListener("open", () => { + timeline.push({ action: "connected" }); + document.getElementById("status")!.textContent = "connected"; +}); + +window.wp0EventFixture = { timeline }; diff --git a/tests/unit/toolchain-smoke.test.ts b/tests/unit/toolchain-smoke.test.ts index 59d55de..a2a92c1 100644 --- a/tests/unit/toolchain-smoke.test.ts +++ b/tests/unit/toolchain-smoke.test.ts @@ -20,7 +20,7 @@ describe("TASK-WP0-01 minimum toolchain", () => { }); it("loads and closes Fastify with the frozen Swagger plugin", async () => { - const app = createApp(); + const app = await createApp(); await app.ready(); expect(app.hasPlugin("@fastify/swagger")).toBe(true); await app.close(); diff --git a/tests/unit/wp0-02-event-sync.test.ts b/tests/unit/wp0-02-event-sync.test.ts new file mode 100644 index 0000000..e916d0d --- /dev/null +++ b/tests/unit/wp0-02-event-sync.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createEventSyncController, + type EventSyncDependencies, +} from "../../apps/web/src/event-sync.js"; +import { isSseEvent } from "../../packages/shared-contracts/src/index.js"; + +function dependencies() { + return { + bootstrap: vi.fn(async () => undefined), + refetchEntity: vi.fn(async () => undefined), + refetchModels: vi.fn(async () => undefined), + } satisfies EventSyncDependencies; +} + +describe("TDD-WP0-EVT-001 REST refetch", () => { + it("accepts only fixed non-sensitive event fields and separated versions", () => { + expect( + isSseEvent({ + config_set_version: 7, + entity_ref: "models:current", + event_id: 1, + event_type: "model_config_changed", + occurred_at: "2026-07-27T09:00:00.000Z", + }), + ).toBe(true); + expect( + isSseEvent({ + entity_ref: "models:runtime", + event_id: 2, + event_type: "model_runtime_changed", + occurred_at: "2026-07-27T09:00:01.000Z", + runtime_availability_version: 12, + }), + ).toBe(true); + expect( + isSseEvent({ + config_set_version: 7, + entity_ref: "models:current", + event_id: 3, + event_type: "model_changed", + occurred_at: "2026-07-27T09:00:02.000Z", + prompt: "private body", + runtime_availability_version: 12, + }), + ).toBe(false); + }); + + it("uses REST for every event and bootstraps once after a sequence gap", async () => { + const calls = dependencies(); + const controller = createEventSyncController(calls); + + await controller.handleEvent({ + entity_ref: "projects:project-1", + event_id: 10, + event_type: "project_state_changed", + occurred_at: "2026-07-27T09:00:00.000Z", + state_version: 4, + }); + await controller.handleEvent({ + config_set_version: 8, + entity_ref: "models:current", + event_id: 12, + event_type: "model_config_changed", + occurred_at: "2026-07-27T09:00:01.000Z", + }); + + expect(calls.refetchEntity).toHaveBeenCalledWith("projects:project-1"); + expect(calls.bootstrap).toHaveBeenCalledTimes(1); + expect(calls.refetchModels).toHaveBeenCalledWith({ + configSetVersion: 8, + reason: "config", + }); + }); + + it("bootstraps once per disconnect incident and never applies event payload as truth", async () => { + const calls = dependencies(); + const controller = createEventSyncController(calls); + + await controller.handleDisconnect(); + await controller.handleDisconnect(); + expect(calls.bootstrap).toHaveBeenCalledTimes(1); + + await controller.handleEvent({ + entity_ref: "storage:local", + event_id: 21, + event_type: "storage_state_changed", + occurred_at: "2026-07-27T09:00:02.000Z", + state_version: 9, + }); + await controller.handleDisconnect(); + + expect(calls.bootstrap).toHaveBeenCalledTimes(2); + expect(calls.refetchEntity).toHaveBeenCalledWith("storage:local"); + expect(calls.refetchEntity).not.toHaveBeenCalledWith(expect.objectContaining({ state_version: 9 })); + }); +});