From a4a2baef816e90e385818d22c81909c8076aafbd Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 2 Sep 2026 13:09:37 +0200 Subject: [PATCH] Prepare Brunch deployment artifact and infrastructure handoff Co-authored-by: Cursor Normalize deployment dependency lockfile Co-authored-by: Cursor Publish the Brunch image to GHCR Co-authored-by: Cursor --- apps/brunch-agent/README.md | 68 +++ apps/brunch-agent/docs/task-dependencies.json | 18 + apps/brunch-agent/package.json | 27 +- apps/brunch-agent/src/app.ts | 5 +- apps/brunch-agent/src/database-config.ts | 125 ++++++ apps/brunch-agent/src/db.ts | 33 +- .../src/deployment-smoke-validation.ts | 77 ++++ apps/brunch-agent/src/deployment-smoke.ts | 99 +++++ apps/brunch-agent/src/postgres.ts | 261 +++++++++++ apps/brunch-agent/src/rds-iam-probe.ts | 32 ++ apps/brunch-agent/src/telemetry-bootstrap.ts | 17 + apps/brunch-agent/src/telemetry.ts | 205 +++++++++ .../architecture/boundaries.integration.ts | 4 + apps/brunch-agent/test/build-artifact.test.ts | 15 +- apps/brunch-agent/test/container-smoke.ts | 320 ++++++++++++++ .../brunch-agent/test/database-config.test.ts | 109 +++++ .../test/deployment-smoke-validation.test.ts | 72 +++ .../test/integration/health.test.ts | 16 + apps/brunch-agent/test/postgres.test.ts | 254 +++++++++++ apps/brunch-agent/test/telemetry.test.ts | 110 +++++ apps/brunch-agent/turbo.json | 6 +- apps/brunch-agent/vitest.config.ts | 3 +- .../brunch-agent/vitest.integration.config.ts | 7 + .../mission-8-deployment-handoff.md | 101 +++++ turbo.json | 6 + yarn.lock | 417 +++++++++++------- 26 files changed, 2221 insertions(+), 186 deletions(-) create mode 100644 apps/brunch-agent/src/database-config.ts create mode 100644 apps/brunch-agent/src/deployment-smoke-validation.ts create mode 100644 apps/brunch-agent/src/deployment-smoke.ts create mode 100644 apps/brunch-agent/src/postgres.ts create mode 100644 apps/brunch-agent/src/rds-iam-probe.ts create mode 100644 apps/brunch-agent/src/telemetry-bootstrap.ts create mode 100644 apps/brunch-agent/src/telemetry.ts create mode 100644 apps/brunch-agent/test/container-smoke.ts create mode 100644 apps/brunch-agent/test/database-config.test.ts create mode 100644 apps/brunch-agent/test/deployment-smoke-validation.test.ts create mode 100644 apps/brunch-agent/test/integration/health.test.ts create mode 100644 apps/brunch-agent/test/postgres.test.ts create mode 100644 apps/brunch-agent/test/telemetry.test.ts create mode 100644 apps/brunch-agent/vitest.integration.config.ts create mode 100644 libs/@hashintel/brunch-agent/docs/evidence/implementations/mission-8-deployment-handoff.md diff --git a/apps/brunch-agent/README.md b/apps/brunch-agent/README.md index 7732125b788..4da1de04c58 100644 --- a/apps/brunch-agent/README.md +++ b/apps/brunch-agent/README.md @@ -39,6 +39,74 @@ running): yarn workspace @apps/brunch-agent transcript -- --principal --id ``` +## Production container + +Build from the repository root: + +```sh +yarn workspace @apps/brunch-agent build:docker +``` + +The image runs the generated `dist/server.mjs` under the repository-locked Node version as uid +`60000`. It listens on `PORT`, set to `3002` in the image, exposes the cheap liveness probe `GET /health`, +and requires Postgres plus an OTLP collector whenever `NODE_ENV=production`. Flue connects and +migrates its store before the server listens, so database configuration, connection, and migration +failures prevent readiness. `/health` reports process liveness only; it does not query Postgres or +Anthropic. + +Production database configuration uses dedicated fields: + +| Variable | Required when | Purpose | +| ----------------------------- | ----------------- | --------------------------------------------------- | +| `BRUNCH_POSTGRES_AUTH_MODE` | Always | `iam` or `password` | +| `BRUNCH_POSTGRES_HOST` | Always | Exact RDS endpoint used for TLS and IAM signing | +| `BRUNCH_POSTGRES_PORT` | Always | PostgreSQL port | +| `BRUNCH_POSTGRES_DATABASE` | Always | Flue database | +| `BRUNCH_POSTGRES_USER` | Always | PostgreSQL role | +| `BRUNCH_POSTGRES_TLS_CA_PATH` | Always | Path to the trusted RDS CA bundle | +| `BRUNCH_POSTGRES_AWS_REGION` | IAM | Region used by the RDS signer | +| `BRUNCH_POSTGRES_PASSWORD` | Password fallback | Runtime-injected database password | +| `HASH_OTLP_ENDPOINT` | Always | HASH OTLP/gRPC collector endpoint | +| `OTEL_SERVICE_NAME` | Optional | OTel service name; defaults to `Brunch Agent` | +| `OTEL_RESOURCE_ATTRIBUTES` | Optional | Standard deployment/resource correlation attributes | + +`DATABASE_URL`, `BRUNCH_DEV_DB_PATH`, and `BRUNCH_CHAT_DB_PATH` are rejected in production. +TLS verification is always enabled, and connection acquisition fails after 10 seconds rather than +waiting indefinitely. IAM mode uses the task credential chain and asks the RDS signer for a fresh +token whenever `pg` opens a physical connection. Run the real two-connection probe from the +selected task role and RDS network boundary: + +```sh +yarn workspace @apps/brunch-agent probe:rds-iam +``` + +The application exports content-free Flue traces, logs, and metrics: prompts, responses, tool +payloads, exception messages, and credentials are not recorded. The generated Flue shutdown +lifecycle drains active work, disposes its instrumentation, closes Postgres, and flushes the +application-owned OTel providers. Configure the ECS task with init handling and a stop timeout that +accommodates Flue's 60-second outer shutdown window. + +Only `/api/chat` should be reachable by the restricted diagnostic caller. The load balancer or +access boundary must not expose `/`, `/assets/*`, or `/agents/chat/:id`; caller-supplied principals, +CORS, and conversation hashes are not authentication. Desired count remains one until +same-conversation ownership across replicas is separately proven. + +The deployed chat path stores Flue conversations, submissions, compaction records, attachments, +claims, leases, and settlement state in Postgres. The separate Brunch capture store is not used by +that path and remains local-development machinery; enabling capture in a deployment requires a new +durability decision. + +For a restricted remote turn, provide `BRUNCH_SMOKE_BASE_URL`, +`BRUNCH_SMOKE_PRINCIPAL`, and a stable `BRUNCH_SMOKE_CONVERSATION_ID`. Reuse +that ID for the post-replacement history check and set +`BRUNCH_SMOKE_EXPECTED_TEXT` to text persisted by the turn; history mode fails +unless that text is present. + +```sh +yarn workspace @apps/brunch-agent smoke:deployment +BRUNCH_SMOKE_MODE=history yarn workspace @apps/brunch-agent smoke:deployment +``` + ## Voice dock A second input modality joins the same chat door. It is not a voice route and does not own diff --git a/apps/brunch-agent/docs/task-dependencies.json b/apps/brunch-agent/docs/task-dependencies.json index 2900aa293ba..572f11d4dc0 100644 --- a/apps/brunch-agent/docs/task-dependencies.json +++ b/apps/brunch-agent/docs/task-dependencies.json @@ -53,6 +53,24 @@ "@hashintel/brunch-agent-transport-aisdk#build", "@hashintel/petrinaut-core#build" ], + "start": [ + "@apps/brunch-agent#build" + ], + "start:healthcheck": [], + "start:test": [ + "@apps/brunch-agent#build" + ], + "start:test:healthcheck": [], + "test:docker": [ + "@apps/brunch-agent#build:docker" + ], + "test:integration": [ + "@hashintel/brunch-agent#build", + "@hashintel/brunch-agent-binding-flue#build", + "@hashintel/brunch-agent-plugin-sdcpn#build", + "@hashintel/brunch-agent-transport-aisdk#build", + "@hashintel/petrinaut-core#build" + ], "test:unit": [ "@apps/brunch-agent#build", "@hashintel/brunch-agent#build", diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index 144908fadbb..fb47e2d9c98 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -13,13 +13,23 @@ "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "petrinaut:dev": "vite dev --config petrinaut-local.vite.config.ts", + "probe:rds-iam": "node --experimental-strip-types src/rds-iam-probe.ts", "proof:manifest": "node --experimental-strip-types src/evaluations/persona/refresh-proof-manifest.ts", "runbook:headless": "vite build && node --experimental-strip-types src/evaluations/runbook/construction-run.ts", + "smoke:deployment": "node --experimental-strip-types src/deployment-smoke.ts", + "start": "PORT=3002 node dist/server.mjs", + "start:healthcheck": "wait-on --timeout 1200000 http-get://localhost:3002/health", + "start:test": "NODE_ENV=test PORT=3002 node dist/server.mjs", + "start:test:healthcheck": "wait-on --timeout 600000 http-get://localhost:3002/health", + "test:docker": "node --experimental-strip-types test/container-smoke.ts", + "test:integration": "vitest run --config vitest.integration.config.ts", "test:unit": "vitest run --config vitest.config.ts", "transcript": "node --experimental-strip-types src/diagnostics/transcript-cli.ts" }, "dependencies": { + "@aws-sdk/rds-signer": "3.1117.0", "@flue/opentelemetry": "2.0.3", + "@flue/postgres": "2.0.3", "@flue/react": "2.0.3", "@flue/runtime": "2.0.3", "@flue/sdk": "2.0.3", @@ -29,7 +39,20 @@ "@hashintel/brunch-agent-transport-aisdk": "workspace:*", "@hashintel/petrinaut-core": "workspace:*", "@opentelemetry/api": "1.9.1", + "@opentelemetry/api-logs": "0.220.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.220.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.220.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.220.0", + "@opentelemetry/instrumentation": "0.220.0", + "@opentelemetry/instrumentation-http": "0.220.0", + "@opentelemetry/instrumentation-undici": "0.28.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0", + "@opentelemetry/sdk-trace-node": "2.9.0", "hono": "4.13.2", + "pg": "8.23.0", "react": "19.2.6", "react-dom": "19.2.6", "valibot": "1.4.2" @@ -40,6 +63,7 @@ "@earendil-works/pi-tui": "0.84.3", "@flue/vite": "2.0.3", "@types/node": "22.18.13", + "@types/pg": "8.23.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260511.1", @@ -48,6 +72,7 @@ "oxlint-tsgolint": "0.22.1", "typebox": "1.3.7", "vite": "8.1.0", - "vitest": "4.1.10" + "vitest": "4.1.10", + "wait-on": "9.0.1" } } diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 715b56c2130..6a35669ac64 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -7,10 +7,9 @@ * path id. The Flue instance id is derived, not a bearer token. */ +import "./telemetry-bootstrap.ts"; import { readFile } from "node:fs/promises"; -import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry"; -import { instrument } from "@flue/runtime"; import { createAgentRouter } from "@flue/runtime/routing"; import { Hono } from "hono"; @@ -25,8 +24,6 @@ import { PETRINAUT_CHAT_ROUTE, } from "./http/routes.ts"; -instrument(createOpenTelemetryInstrumentation({ content: false })); - const app = new Hono(); const appTransport: typeof fetch = async (input, init) => app.fetch(input instanceof Request ? input : new Request(input, init)); diff --git a/apps/brunch-agent/src/database-config.ts b/apps/brunch-agent/src/database-config.ts new file mode 100644 index 00000000000..c178888bfbe --- /dev/null +++ b/apps/brunch-agent/src/database-config.ts @@ -0,0 +1,125 @@ +/** + * The deployed conversation-store contract. + * + * Production accepts only dedicated Postgres fields. Local development and + * hermetic tests keep the existing SQLite path, but production can never + * silently select it. + */ + +export const POSTGRES_ENV = { + authMode: "BRUNCH_POSTGRES_AUTH_MODE", + awsRegion: "BRUNCH_POSTGRES_AWS_REGION", + database: "BRUNCH_POSTGRES_DATABASE", + host: "BRUNCH_POSTGRES_HOST", + password: "BRUNCH_POSTGRES_PASSWORD", + port: "BRUNCH_POSTGRES_PORT", + tlsCaPath: "BRUNCH_POSTGRES_TLS_CA_PATH", + user: "BRUNCH_POSTGRES_USER", +} as const; + +export interface SqliteDatabaseConfig { + readonly kind: "sqlite"; +} + +export interface PostgresDatabaseConfig { + readonly kind: "postgres"; + readonly auth: + | { + readonly mode: "iam"; + readonly region: string; + } + | { + readonly mode: "password"; + readonly password: string; + }; + readonly database: string; + readonly host: string; + readonly port: number; + readonly tlsCaPath: string; + readonly user: string; +} + +export type DatabaseConfig = SqliteDatabaseConfig | PostgresDatabaseConfig; + +type Environment = Readonly>; + +const valueOf = (environment: Environment, name: string): string => { + const value = environment[name]?.trim(); + if (value === undefined || value.length === 0) { + throw new Error(`Production database configuration requires ${name}.`); + } + return value; +}; + +const absent = (environment: Environment, name: string): void => { + if (environment[name] !== undefined) { + throw new Error( + `Production database configuration does not accept ${name}.`, + ); + } +}; + +const portOf = (environment: Environment): number => { + const name = POSTGRES_ENV.port; + const source = valueOf(environment, name); + if (!/^\d+$/u.test(source)) { + throw new Error(`${name} must be an integer between 1 and 65535.`); + } + const port = Number(source); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error(`${name} must be an integer between 1 and 65535.`); + } + return port; +}; + +const rejectLegacyProductionInputs = (environment: Environment): void => { + absent(environment, "DATABASE_URL"); + absent(environment, "BRUNCH_DEV_DB_PATH"); + absent(environment, "BRUNCH_CHAT_DB_PATH"); +}; + +export function loadDatabaseConfig( + environment: Environment = process.env, +): DatabaseConfig { + if (environment.NODE_ENV !== "production") { + return { kind: "sqlite" }; + } + + rejectLegacyProductionInputs(environment); + + const authMode = valueOf(environment, POSTGRES_ENV.authMode); + const common = { + kind: "postgres" as const, + database: valueOf(environment, POSTGRES_ENV.database), + host: valueOf(environment, POSTGRES_ENV.host), + port: portOf(environment), + tlsCaPath: valueOf(environment, POSTGRES_ENV.tlsCaPath), + user: valueOf(environment, POSTGRES_ENV.user), + }; + + if (authMode === "iam") { + absent(environment, POSTGRES_ENV.password); + return { + ...common, + auth: { + mode: "iam", + region: valueOf(environment, POSTGRES_ENV.awsRegion), + }, + }; + } + + if (authMode === "password") { + absent(environment, POSTGRES_ENV.awsRegion); + return { + ...common, + auth: { + mode: "password", + password: valueOf(environment, POSTGRES_ENV.password), + }, + }; + } + + throw new Error( + `${POSTGRES_ENV.authMode} must be either "iam" or "password".`, + ); +} diff --git a/apps/brunch-agent/src/db.ts b/apps/brunch-agent/src/db.ts index 3750ec79bda..ad738a619eb 100644 --- a/apps/brunch-agent/src/db.ts +++ b/apps/brunch-agent/src/db.ts @@ -1,12 +1,35 @@ +import { postgres } from "@flue/postgres"; + +import { loadDatabaseConfig } from "./database-config.ts"; +import { conversationDbPath } from "./db-path.ts"; +import { createPostgresRunner } from "./postgres.ts"; +import { shutdownBrunchTelemetry } from "./telemetry-bootstrap.ts"; +import { recordOperationalFailure } from "./telemetry.ts"; + +import type { DatabaseConfig } from "./database-config.ts"; + /** * The substrate's conversation storage — host-authored because Flue requires * it of the consuming app. * - * Without this file conversations are process-memory and a restart loses them. + * Local development and hermetic tests retain SQLite. Production must provide + * the dedicated Postgres contract and cannot fall back to a task-local file. */ +let config: DatabaseConfig; +try { + config = loadDatabaseConfig(); +} catch (error) { + try { + await recordOperationalFailure("database_configuration", error); + } catch { + // The database configuration error remains the authoritative startup cause. + } + throw error; +} -import { sqlite } from "@flue/runtime/node"; - -import { conversationDbPath } from "./db-path.ts"; +const database = + config.kind === "postgres" + ? postgres(createPostgresRunner(config, shutdownBrunchTelemetry)) + : (await import("@flue/runtime/node")).sqlite(conversationDbPath()); -export default sqlite(conversationDbPath()); +export default database; diff --git a/apps/brunch-agent/src/deployment-smoke-validation.ts b/apps/brunch-agent/src/deployment-smoke-validation.ts new file mode 100644 index 00000000000..4cce19bf0a8 --- /dev/null +++ b/apps/brunch-agent/src/deployment-smoke-validation.ts @@ -0,0 +1,77 @@ +interface UiTextPart { + readonly type: "text"; + readonly text: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isUiTextPart = (value: unknown): value is UiTextPart => + isRecord(value) && value.type === "text" && typeof value.text === "string"; + +export const validatePersistedHistory = ( + value: unknown, + expectedText: string, +): number => { + if (!isRecord(value) || !Array.isArray(value.messages)) { + throw new Error("History response did not contain a messages array."); + } + + const found = value.messages.some( + (message) => + isRecord(message) && + Array.isArray(message.parts) && + message.parts.some( + (part: unknown) => + isUiTextPart(part) && part.text.includes(expectedText), + ), + ); + if (!found) { + throw new Error("History did not contain the expected persisted text."); + } + return value.messages.length; +}; + +export interface StreamValidationResult { + readonly bytes: number; + readonly chunks: number; +} + +export const validateUiMessageStream = async ( + body: ReadableStream, + onFirstChunk: () => void, +): Promise => { + const decoder = new TextDecoder(); + let bytes = 0; + let chunks = 0; + let encodedStream = ""; + + for await (const chunk of body) { + if (chunks === 0) onFirstChunk(); + chunks += 1; + bytes += chunk.byteLength; + encodedStream += decoder.decode(chunk, { stream: true }); + } + encodedStream += decoder.decode(); + + if (chunks === 0) { + throw new Error("Streamed turn completed without response chunks."); + } + + let finished = false; + for (const line of encodedStream.split(/\r?\n/u)) { + if (!line.startsWith("data: ")) continue; + const data = line.slice("data: ".length); + if (data === "[DONE]") continue; + const event = JSON.parse(data) as { readonly type?: unknown }; + if (event.type === "error" || event.type === "abort") { + throw new Error(`Streamed turn ended with ${event.type}.`); + } + if (event.type === "finish") finished = true; + } + + if (!finished) { + throw new Error("Streamed turn completed without a finish event."); + } + return { bytes, chunks }; +}; diff --git a/apps/brunch-agent/src/deployment-smoke.ts b/apps/brunch-agent/src/deployment-smoke.ts new file mode 100644 index 00000000000..f1b09a39319 --- /dev/null +++ b/apps/brunch-agent/src/deployment-smoke.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; + +import { + validatePersistedHistory, + validateUiMessageStream, +} from "./deployment-smoke-validation.ts"; + +const required = (name: string): string => { + const value = process.env[name]?.trim(); + if (value === undefined || value.length === 0) { + throw new Error(`Deployment smoke requires ${name}.`); + } + return value; +}; + +const baseUrl = required("BRUNCH_SMOKE_BASE_URL").replace(/\/$/u, ""); +const principal = required("BRUNCH_SMOKE_PRINCIPAL"); +const mode = process.env.BRUNCH_SMOKE_MODE ?? "turn"; +const conversationId = + mode === "history" + ? required("BRUNCH_SMOKE_CONVERSATION_ID") + : process.env.BRUNCH_SMOKE_CONVERSATION_ID?.trim() || randomUUID(); +const requestId = process.env.BRUNCH_SMOKE_REQUEST_ID?.trim() || randomUUID(); +const headers = new Headers({ + "content-type": "application/json", + "x-brunch-principal": principal, + "x-request-id": requestId, +}); +const bearerToken = process.env.BRUNCH_SMOKE_BEARER_TOKEN; +if (bearerToken) headers.set("authorization", `Bearer ${bearerToken}`); + +if (mode === "history") { + const response = await fetch( + `${baseUrl}/api/chat?id=${encodeURIComponent(conversationId)}`, + { headers }, + ); + if (!response.ok) { + throw new Error(`History request failed with HTTP ${response.status}.`); + } + const messages = validatePersistedHistory( + await response.json(), + required("BRUNCH_SMOKE_EXPECTED_TEXT"), + ); + process.stdout.write( + `${JSON.stringify({ + conversationId, + messages, + ok: true, + })}\n`, + ); +} else if (mode === "turn") { + const response = await fetch(`${baseUrl}/api/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + id: conversationId, + messages: [ + { + id: randomUUID(), + role: "user", + parts: [ + { + type: "text", + text: + process.env.BRUNCH_SMOKE_PROMPT ?? + "Activate the elicitation skill, call ping once, then briefly confirm the restricted deployment path.", + }, + ], + }, + ], + trigger: "submit-message", + }), + }); + if (!response.ok || response.body === null) { + throw new Error(`Streamed turn failed with HTTP ${response.status}.`); + } + + const { bytes, chunks } = await validateUiMessageStream(response.body, () => { + process.stdout.write( + `${JSON.stringify({ + conversationId, + event: "first-stream-chunk", + requestId, + })}\n`, + ); + }); + process.stdout.write( + `${JSON.stringify({ + bytes, + chunks, + conversationId, + event: "stream-complete", + ok: true, + requestId, + })}\n`, + ); +} else { + throw new Error('BRUNCH_SMOKE_MODE must be either "turn" or "history".'); +} diff --git a/apps/brunch-agent/src/postgres.ts b/apps/brunch-agent/src/postgres.ts new file mode 100644 index 00000000000..8303feb1fd0 --- /dev/null +++ b/apps/brunch-agent/src/postgres.ts @@ -0,0 +1,261 @@ +import { readFileSync } from "node:fs"; + +import { Signer } from "@aws-sdk/rds-signer"; +import { Pool } from "pg"; + +import { + type PostgresDatabaseConfig, + POSTGRES_ENV, +} from "./database-config.ts"; +import { recordOperationalFailure } from "./telemetry.ts"; + +import type { PostgresParameter, PostgresRunner } from "@flue/postgres"; +import type { PoolConfig } from "pg"; + +interface QueryClient { + query( + text: string, + values?: PostgresParameter[], + ): Promise<{ rows: Record[] }>; +} + +interface ReleasableQueryClient extends QueryClient { + release(): void; +} + +interface QueryPool extends QueryClient { + connect(): Promise; + end(): Promise; +} + +interface ConnectionOptions { + readonly onIamToken?: () => void; + readonly onPoolError?: (error: Error) => void; + readonly readTlsCa?: (path: string) => string; + readonly signerFactory?: (config: { + hostname: string; + port: number; + region: string; + username: string; + }) => Pick; +} + +export const POSTGRES_CONNECTION_TIMEOUT_MS = 10_000; + +const defaultSignerFactory: NonNullable = ( + config, +) => new Signer(config); + +const reportDatabaseFailure = async (error: unknown): Promise => { + try { + await recordOperationalFailure("database_operation", error); + } catch { + // Preserve the database failure as the authoritative operational cause. + } +}; + +export function createPostgresPoolConfig( + config: PostgresDatabaseConfig, + options: ConnectionOptions = {}, +): PoolConfig { + const readTlsCa = + options.readTlsCa ?? + ((path: string) => { + try { + return readFileSync(path, "utf8"); + } catch { + throw new Error(`Unable to read ${POSTGRES_ENV.tlsCaPath}.`); + } + }); + const common: PoolConfig = { + application_name: "brunch-agent", + connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS, + database: config.database, + host: config.host, + port: config.port, + ssl: { + ca: readTlsCa(config.tlsCaPath), + rejectUnauthorized: true, + }, + user: config.user, + }; + + if (config.auth.mode === "password") { + return { ...common, password: config.auth.password }; + } + + const signer = (options.signerFactory ?? defaultSignerFactory)({ + hostname: config.host, + port: config.port, + region: config.auth.region, + username: config.user, + }); + return { + ...common, + password: async () => { + const token = await signer.getAuthToken(); + options.onIamToken?.(); + return token; + }, + }; +} + +export const createPostgresPool = ( + config: PostgresDatabaseConfig, + options?: ConnectionOptions, +): Pool => { + const pool = new Pool(createPostgresPoolConfig(config, options)); + pool.on("error", (error) => { + options?.onPoolError?.(error); + if (options?.onPoolError === undefined) void reportDatabaseFailure(error); + }); + return pool; +}; + +export function createPostgresRunnerFromPool( + pool: QueryPool, + afterClose?: () => Promise, + reportFailure: (error: unknown) => Promise = reportDatabaseFailure, +): PostgresRunner { + const query = async ( + text: string, + params?: PostgresParameter[], + ): Promise[]> => { + try { + // SQL text comes only from Flue's trusted persistence adapter; request + // values remain separate parameters. + // nosemgrep: javascript.express.db.pg-express.pg-express + return (await pool.query(text, params)).rows; + } catch (error) { + await reportFailure(error); + throw error; + } + }; + + return { + query, + transaction: async ( + run: (transaction: { query: typeof query }) => Promise, + ): Promise => { + let client: ReleasableQueryClient | undefined; + try { + client = await pool.connect(); + const transactionClient = client; + const transactionQuery = async ( + text: string, + params?: PostgresParameter[], + ): Promise[]> => + (await transactionClient.query(text, params)).rows; + await transactionClient.query("BEGIN"); + const result = await run({ query: transactionQuery }); + await transactionClient.query("COMMIT"); + return result; + } catch (error) { + let failure: unknown = error; + if (client !== undefined) { + try { + await client.query("ROLLBACK"); + } catch (rollbackError) { + failure = new AggregateError( + [error, rollbackError], + "Postgres transaction and rollback both failed.", + ); + } + } + await reportFailure(failure); + throw failure; + } finally { + client?.release(); + } + }, + close: async () => { + const results = await Promise.allSettled([pool.end(), afterClose?.()]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason as unknown] : [], + ); + if (failures.length > 0) { + throw new AggregateError( + failures, + "Postgres or telemetry shutdown failed.", + ); + } + }, + }; +} + +export const createPostgresRunner = ( + config: PostgresDatabaseConfig, + afterClose?: () => Promise, +): PostgresRunner => + createPostgresRunnerFromPool(createPostgresPool(config), afterClose); + +export interface RdsIamProbeResult { + readonly distinctBackendConnections: boolean; + readonly tokenRequests: number; +} + +interface RdsIamProbeClient { + query(text: string): Promise<{ rows: T[] }>; + release(): void; +} + +interface RdsIamProbePool { + connect(): Promise; + end(): Promise; +} + +interface RdsIamProbeOptions { + readonly createPool?: ( + config: PostgresDatabaseConfig, + onIamToken: () => void, + ) => RdsIamProbePool; +} + +/** + * Verify that task credentials can open two independent TLS connections and + * that node-postgres requests a fresh IAM token for each physical connection. + */ +export async function probeRdsIam( + config: PostgresDatabaseConfig, + options: RdsIamProbeOptions = {}, +): Promise { + if (config.auth.mode !== "iam") { + throw new Error("The RDS IAM probe requires IAM authentication mode."); + } + + let tokenRequests = 0; + const onIamToken = () => { + tokenRequests += 1; + }; + const pool = + options.createPool?.(config, onIamToken) ?? + createPostgresPool(config, { onIamToken }); + const clients: RdsIamProbeClient[] = []; + try { + clients.push(await pool.connect()); + clients.push(await pool.connect()); + const results = await Promise.all( + clients.map((client) => + client.query<{ backendProcessId: number }>( + 'SELECT pg_backend_pid() AS "backendProcessId"', + ), + ), + ); + const backendProcessIds = results.map( + (result) => result.rows[0]?.backendProcessId, + ); + return { + distinctBackendConnections: + backendProcessIds.length === 2 && + backendProcessIds[0] !== undefined && + backendProcessIds[1] !== undefined && + backendProcessIds[0] !== backendProcessIds[1], + tokenRequests, + }; + } finally { + for (const client of clients) { + client.release(); + } + await pool.end(); + } +} diff --git a/apps/brunch-agent/src/rds-iam-probe.ts b/apps/brunch-agent/src/rds-iam-probe.ts new file mode 100644 index 00000000000..be3e910c6d6 --- /dev/null +++ b/apps/brunch-agent/src/rds-iam-probe.ts @@ -0,0 +1,32 @@ +/** + * Run inside the selected task role and RDS network boundary. + * + * The result contains connection counts only. Tokens, credentials, endpoint + * values, and database identifiers are never printed. + */ + +import { loadDatabaseConfig } from "./database-config.ts"; +import { probeRdsIam } from "./postgres.ts"; + +const config = loadDatabaseConfig(); +if (config.kind !== "postgres" || config.auth.mode !== "iam") { + throw new Error( + "Set NODE_ENV=production and configure IAM Postgres authentication before running the probe.", + ); +} + +const result = await probeRdsIam(config); +if (!result.distinctBackendConnections || result.tokenRequests < 2) { + throw new Error( + "RDS IAM probe did not observe two independent connections with fresh tokens.", + ); +} + +process.stdout.write( + `${JSON.stringify({ + ok: true, + connections: 2, + tokenRequests: result.tokenRequests, + tlsVerified: true, + })}\n`, +); diff --git a/apps/brunch-agent/src/telemetry-bootstrap.ts b/apps/brunch-agent/src/telemetry-bootstrap.ts new file mode 100644 index 00000000000..b4aedd90856 --- /dev/null +++ b/apps/brunch-agent/src/telemetry-bootstrap.ts @@ -0,0 +1,17 @@ +/** + * Side-effect entry imported before application and database dependencies. + * + * Keeping the install in one module ensures the generated Flue server owns one + * registration while the database entry can emit startup failures through the + * same provider. + */ + +import { installBrunchTelemetry } from "./telemetry.ts"; + +const disposeTelemetry = installBrunchTelemetry(); + +/** + * The Postgres adapter invokes this from its generated-lifecycle close hook. + * The disposer is idempotent if Flue also owns the registration directly. + */ +export const shutdownBrunchTelemetry = (): Promise => disposeTelemetry(); diff --git a/apps/brunch-agent/src/telemetry.ts b/apps/brunch-agent/src/telemetry.ts new file mode 100644 index 00000000000..c51b6c6c390 --- /dev/null +++ b/apps/brunch-agent/src/telemetry.ts @@ -0,0 +1,205 @@ +import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry"; +import { instrument } from "@flue/runtime"; +import { SpanStatusCode, trace } from "@opentelemetry/api"; +import { logs } from "@opentelemetry/api-logs"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-grpc"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; +import { + defaultResource, + envDetector, + resourceFromAttributes, +} from "@opentelemetry/resources"; +import { + BatchLogRecordProcessor, + LoggerProvider, +} from "@opentelemetry/sdk-logs"; +import { + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; + +import type { Attributes } from "@opentelemetry/api"; + +type Environment = Readonly>; + +export interface BrunchOpenTelemetrySetup { + readonly forceFlush: () => Promise; + readonly logger: ReturnType; + readonly meter: ReturnType; + readonly shutdown: () => Promise; + readonly tracer: ReturnType; +} + +let activeSetup: BrunchOpenTelemetrySetup | undefined; + +interface TelemetryDependencies { + readonly createFlueInstrumentation?: typeof createOpenTelemetryInstrumentation; + readonly registerHashOpenTelemetry?: (input: { + endpoint: string; + serviceName: string; + }) => BrunchOpenTelemetrySetup; +} + +const registerHashOpenTelemetry = ({ + endpoint, + serviceName, +}: { + endpoint: string; + serviceName: string; +}): BrunchOpenTelemetrySetup => { + const exporterOptions = { timeoutMillis: 5000, url: endpoint }; + const environmentAttributes = envDetector.detect().attributes ?? {}; + const resource = defaultResource() + .merge(resourceFromAttributes(environmentAttributes as Attributes)) + .merge(resourceFromAttributes({ "service.name": serviceName })); + const traceProvider = new NodeTracerProvider({ + resource, + spanProcessors: [ + new BatchSpanProcessor(new OTLPTraceExporter(exporterOptions)), + ], + }); + traceProvider.register(); + + const logProvider = new LoggerProvider({ + processors: [ + new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter(exporterOptions), + }), + ], + resource, + }); + logs.setGlobalLoggerProvider(logProvider); + + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(exporterOptions), + exportIntervalMillis: 30_000, + }), + ], + resource, + }); + const unregisterInstrumentations = registerInstrumentations({ + instrumentations: [new HttpInstrumentation(), new UndiciInstrumentation()], + meterProvider, + tracerProvider: traceProvider, + }); + + return { + forceFlush: async () => { + await Promise.all([ + traceProvider.forceFlush(), + logProvider.forceFlush(), + meterProvider.forceFlush(), + ]); + }, + logger: logProvider.getLogger("brunch-agent"), + meter: meterProvider.getMeter("brunch-agent"), + shutdown: async () => { + unregisterInstrumentations(); + const results = await Promise.allSettled([ + traceProvider.shutdown(), + logProvider.shutdown(), + meterProvider.shutdown(), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason as unknown] : [], + ); + if (failures.length > 0) { + throw new AggregateError( + failures, + "One or more Brunch OpenTelemetry providers failed to shut down.", + ); + } + }, + tracer: traceProvider.getTracer("brunch-agent"), + }; +}; + +/** + * Configure HASH exporters before Flue obtains its tracer and meter. + * + * The wrapper's asynchronous disposer lets the generated Flue server drain + * active work, end Flue spans, and then flush application-owned exporters. + */ +export function createBrunchTelemetryInstrumentation( + environment: Environment = process.env, + dependencies: TelemetryDependencies = {}, +) { + const endpoint = environment.HASH_OTLP_ENDPOINT?.trim(); + if ( + environment.NODE_ENV === "production" && + (endpoint === undefined || endpoint.trim().length === 0) + ) { + throw new Error("Production telemetry requires HASH_OTLP_ENDPOINT."); + } + + let setup: BrunchOpenTelemetrySetup | undefined; + if (endpoint) { + const register = + dependencies.registerHashOpenTelemetry ?? registerHashOpenTelemetry; + setup = register({ + endpoint, + serviceName: environment.OTEL_SERVICE_NAME?.trim() || "Brunch Agent", + }); + activeSetup = setup; + } + + const createFlue = + dependencies.createFlueInstrumentation ?? + createOpenTelemetryInstrumentation; + const flueInstrumentation = createFlue({ + content: false, + ...(setup + ? { + logger: setup.logger, + meter: setup.meter, + tracer: setup.tracer, + } + : {}), + }); + + return { + key: flueInstrumentation.key, + observe: flueInstrumentation.observe, + interceptor: flueInstrumentation.interceptor, + async dispose(): Promise { + flueInstrumentation.dispose(); + try { + await setup?.shutdown(); + } finally { + if (activeSetup === setup) activeSetup = undefined; + } + }, + }; +} + +export const installBrunchTelemetry = (): (() => Promise) => + instrument(createBrunchTelemetryInstrumentation()); + +const errorType = (error: unknown): string => + error instanceof Error ? error.constructor.name : typeof error; + +/** Export a content-free operational failure before startup or work aborts. */ +export async function recordOperationalFailure( + stage: "database_configuration" | "database_operation", + error: unknown, +): Promise { + const span = trace + .getTracer("brunch-agent") + .startSpan("brunch operational failure", { + attributes: { + "brunch.failure.stage": stage, + "error.type": errorType(error), + }, + }); + span.setStatus({ code: SpanStatusCode.ERROR }); + span.end(); + await activeSetup?.forceFlush(); +} diff --git a/apps/brunch-agent/test/architecture/boundaries.integration.ts b/apps/brunch-agent/test/architecture/boundaries.integration.ts index 85142865fb8..742035bfa6e 100644 --- a/apps/brunch-agent/test/architecture/boundaries.integration.ts +++ b/apps/brunch-agent/test/architecture/boundaries.integration.ts @@ -436,6 +436,10 @@ describe("the HASH smoke is runnable without a model key or a network (spec §12 "Defines the scripted pi-ai faux provider loaded only by the hermetic prospective-runner test — no provider key, no socket, and no network model call.", "apps/brunch-agent/test/runbook-headless.integration.ts": "Boots the built Flue ChatAgent with pi-ai's faux provider and a headless Petrinaut client to prove validated construct-only tool flow without a provider key, socket, or network model call.", + "apps/brunch-agent/test/telemetry.test.ts": + "Constructs Flue's content-free OpenTelemetry instrumentation with local spies to prove disposal and HASH exporter ordering; it registers no global instrumentation, opens no socket, and makes no provider call.", + "apps/brunch-agent/test/turn-timing.test.ts": + "Types recorded Flue observations and model requests so the condition-5 purpose splitter can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", }; test("no test file carries a live model credential", () => { diff --git a/apps/brunch-agent/test/build-artifact.test.ts b/apps/brunch-agent/test/build-artifact.test.ts index a11f909b31d..013ada40b22 100644 --- a/apps/brunch-agent/test/build-artifact.test.ts +++ b/apps/brunch-agent/test/build-artifact.test.ts @@ -88,7 +88,7 @@ describe("the emitted server bundle", () => { } }); - test("mounts the agent router and wires the conversation store", () => { + test("mounts the agent router, health check, and fail-closed production store", () => { // Without db.ts reaching the bundle, conversations are process-memory and a // restart loses them — a difference invisible until something restarts. // @@ -104,8 +104,17 @@ describe("the emitted server bundle", () => { expect(bundle).toContain( `chatAgentMount = \`/agents/\${CHAT_AGENT_ROUTE}\``, ); - expect(bundle).toContain("BRUNCH_DEV_DB_PATH"); // db.ts's env override - expect(bundle).toContain(".data-wipe-me"); // db.ts's default store path + expect(bundle).toContain("app.get(HEALTH_ROUTE, healthHandler);"); + expect(bundle).toContain("application/health+json"); + expect(bundle).toContain("BRUNCH_POSTGRES_AUTH_MODE"); + expect(bundle).toContain(`config.kind === "postgres"`); + expect(bundle).toContain( + `postgres(createPostgresRunner(config, shutdownBrunchTelemetry))`, + ); + expect(bundle).toContain("Production database configuration requires"); + // SQLite remains available to local/test execution only. + expect(bundle).toContain("BRUNCH_DEV_DB_PATH"); + expect(bundle).toContain(".data-wipe-me"); }); test("packages the authored skill without the retired filesystem loader", () => { diff --git a/apps/brunch-agent/test/container-smoke.ts b/apps/brunch-agent/test/container-smoke.ts new file mode 100644 index 00000000000..ffdc44ce065 --- /dev/null +++ b/apps/brunch-agent/test/container-smoke.ts @@ -0,0 +1,320 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const executeFile = promisify(execFile); +const suffix = randomUUID().slice(0, 8); +const applicationContainer = `brunch-agent-smoke-${suffix}`; +const collectorContainer = `brunch-otel-smoke-${suffix}`; +const databaseContainer = `brunch-postgres-smoke-${suffix}`; +const network = `brunch-agent-smoke-${suffix}`; +const temporaryDirectory = await mkdtemp( + join(tmpdir(), "brunch-container-smoke-"), +); +const certificate = join(temporaryDirectory, "server.crt"); +const privateKey = join(temporaryDirectory, "server.key"); +const collectorConfig = join(temporaryDirectory, "otel-collector.yaml"); + +const run = async ( + executable: string, + arguments_: readonly string[], +): Promise<{ stderr: string; stdout: string }> => + executeFile(executable, [...arguments_], { + maxBuffer: 10 * 1024 * 1024, + }); + +const removeContainer = async (name: string): Promise => { + try { + await run("docker", ["rm", "--force", name]); + } catch { + // A container that never started needs no cleanup. + } +}; + +const waitUntil = async ( + description: string, + check: () => Promise, +): Promise => { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop -- readiness probes are intentionally sequential. + if (await check()) return; + // eslint-disable-next-line no-await-in-loop -- polling must pause between attempts. + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for ${description}.`); +}; + +try { + await run("openssl", [ + "req", + "-new", + "-newkey", + "rsa:2048", + "-nodes", + "-x509", + "-days", + "1", + "-subj", + `/CN=${databaseContainer}`, + "-addext", + `subjectAltName=DNS:${databaseContainer}`, + "-keyout", + privateKey, + "-out", + certificate, + ]); + await writeFile( + collectorConfig, + ` +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 +exporters: + debug: + verbosity: basic +service: + pipelines: + logs: + receivers: [otlp] + exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] + traces: + receivers: [otlp] + exporters: [debug] +`.trimStart(), + ); + + await run("docker", ["network", "create", network]); + await run("docker", [ + "run", + "--detach", + "--name", + collectorContainer, + "--network", + network, + "--volume", + `${collectorConfig}:/etc/otelcol/config.yaml:ro`, + "otel/opentelemetry-collector:0.159.0", + "--config=/etc/otelcol/config.yaml", + ]); + await run("docker", [ + "run", + "--detach", + "--name", + databaseContainer, + "--network", + network, + "--env", + "POSTGRES_DB=brunch", + "--env", + "POSTGRES_PASSWORD=container-smoke-password", + "--volume", + `${temporaryDirectory}:/tls:ro`, + "--entrypoint", + "bash", + "postgres:17-bookworm", + "-euc", + [ + "cp /tls/server.crt /var/lib/postgresql/server.crt", + "cp /tls/server.key /var/lib/postgresql/server.key", + "chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key", + "chmod 600 /var/lib/postgresql/server.key", + "exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key", + ].join(" && "), + ]); + + await waitUntil("Postgres", async () => { + try { + await run("docker", [ + "exec", + databaseContainer, + "pg_isready", + "--host", + "127.0.0.1", + "--dbname", + "brunch", + "--username", + "postgres", + ]); + return true; + } catch { + return false; + } + }); + + await run("docker", [ + "run", + "--detach", + "--name", + applicationContainer, + "--network", + network, + "--env", + "NODE_ENV=production", + "--env", + "BRUNCH_POSTGRES_AUTH_MODE=password", + "--env", + `BRUNCH_POSTGRES_HOST=${databaseContainer}`, + "--env", + "BRUNCH_POSTGRES_PORT=5432", + "--env", + "BRUNCH_POSTGRES_DATABASE=brunch", + "--env", + "BRUNCH_POSTGRES_USER=postgres", + "--env", + "BRUNCH_POSTGRES_PASSWORD=container-smoke-password", + "--env", + "BRUNCH_POSTGRES_TLS_CA_PATH=/run/config/rds-ca.pem", + "--env", + `HASH_OTLP_ENDPOINT=http://${collectorContainer}:4317`, + "--volume", + `${certificate}:/run/config/rds-ca.pem:ro`, + "brunch-agent", + ]); + + try { + await waitUntil("Brunch health", async () => { + try { + await run("docker", [ + "exec", + applicationContainer, + "node", + "-e", + "const response = await fetch('http://127.0.0.1:3002/health'); if (!response.ok) process.exit(1)", + ]); + return true; + } catch { + return false; + } + }); + } catch (error) { + const { stderr, stdout } = await run("docker", [ + "logs", + applicationContainer, + ]); + throw new AggregateError( + [error], + `Brunch failed to become healthy:\n${stdout}\n${stderr}`, + ); + } + + const { stdout: userId } = await run("docker", [ + "exec", + applicationContainer, + "id", + "-u", + ]); + if (userId.trim() !== "60000") { + throw new Error(`Expected non-root uid 60000, received ${userId.trim()}.`); + } + + await run("docker", [ + "exec", + applicationContainer, + "node", + "-e", + [ + "const health = await fetch('http://127.0.0.1:3002/health')", + "if (health.headers.get('content-type') !== 'application/health+json') process.exit(1)", + "if (JSON.stringify(await health.json()) !== JSON.stringify({ status: 'pass' })) process.exit(1)", + "const root = await fetch('http://127.0.0.1:3002/')", + "if (!root.ok || !(await root.text()).includes('/assets/index.js')) process.exit(1)", + ].join(";"), + ]); + await run("docker", [ + "exec", + applicationContainer, + "node", + "-e", + [ + "const fs = await import('node:fs/promises')", + "const files = (await fs.readdir('dist')).filter((file) => file.endsWith('.mjs'))", + "const bundle = (await Promise.all(files.map((file) => fs.readFile('dist/' + file, 'utf8')))).join('\\n')", + "if (!bundle.includes('sdcpn-modelling')) process.exit(1)", + ].join(";"), + ]); + + const { stdout: repositoryChanges } = await run("docker", [ + "diff", + applicationContainer, + ]); + if ( + repositoryChanges + .split("\n") + .some((line) => line.trim().match(/^[ACD] \/repo(?:\/|$)/u)) + ) { + throw new Error(`Container wrote under /repo:\n${repositoryChanges}`); + } + + let refusalOutput = ""; + try { + await run("docker", [ + "run", + "--rm", + "--network", + network, + "--env", + "NODE_ENV=production", + "--env", + `HASH_OTLP_ENDPOINT=http://${collectorContainer}:4317`, + "brunch-agent", + ]); + throw new Error("Image started without required database configuration."); + } catch (error) { + refusalOutput = + error instanceof Error && "stderr" in error + ? String((error as Error & { stderr: unknown }).stderr) + : String(error); + } + if (!refusalOutput.includes("BRUNCH_POSTGRES_AUTH_MODE")) { + throw new Error( + `Missing database configuration did not fail clearly:\n${refusalOutput}`, + ); + } + + await run("docker", ["stop", "--time", "70", applicationContainer]); + const { stderr: applicationLogErrors, stdout: applicationLogs } = await run( + "docker", + ["logs", applicationContainer], + ); + if ( + `${applicationLogs}\n${applicationLogErrors}`.includes( + "[flue] Shutdown timed out", + ) + ) { + throw new Error("Generated Flue shutdown exceeded its 60-second window."); + } + const { stderr: collectorLogErrors, stdout: collectorLogs } = await run( + "docker", + ["logs", collectorContainer], + ); + const collectorOutput = `${collectorLogs}\n${collectorLogErrors}`; + if ( + !collectorOutput.includes('"otelcol.signal": "traces"') || + !collectorOutput.includes('"spans":') + ) { + throw new Error( + `Container smoke did not observe Brunch OTel export:\n${collectorOutput}`, + ); + } + + process.stdout.write("Brunch container smoke passed.\n"); +} finally { + await removeContainer(applicationContainer); + await removeContainer(databaseContainer); + await removeContainer(collectorContainer); + try { + await run("docker", ["network", "rm", network]); + } catch { + // The network may not have been created. + } + await rm(temporaryDirectory, { force: true, recursive: true }); +} diff --git a/apps/brunch-agent/test/database-config.test.ts b/apps/brunch-agent/test/database-config.test.ts new file mode 100644 index 00000000000..aa63baee78d --- /dev/null +++ b/apps/brunch-agent/test/database-config.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; + +import { loadDatabaseConfig, POSTGRES_ENV } from "../src/database-config.ts"; + +const productionEnvironment = { + NODE_ENV: "production", + [POSTGRES_ENV.authMode]: "iam", + [POSTGRES_ENV.awsRegion]: "eu-central-1", + [POSTGRES_ENV.database]: "brunch", + [POSTGRES_ENV.host]: "brunch.example.rds.amazonaws.com", + [POSTGRES_ENV.port]: "5432", + [POSTGRES_ENV.tlsCaPath]: "/run/config/rds-ca.pem", + [POSTGRES_ENV.user]: "brunch_agent", +} as const; + +describe("database configuration", () => { + test("keeps SQLite outside production", () => { + expect(loadDatabaseConfig({ NODE_ENV: "test" })).toEqual({ + kind: "sqlite", + }); + }); + + test("loads dedicated IAM fields in production", () => { + expect(loadDatabaseConfig(productionEnvironment)).toEqual({ + kind: "postgres", + auth: { mode: "iam", region: "eu-central-1" }, + database: "brunch", + host: "brunch.example.rds.amazonaws.com", + port: 5432, + tlsCaPath: "/run/config/rds-ca.pem", + user: "brunch_agent", + }); + }); + + test("trims values supplied through secret and config injection", () => { + expect( + loadDatabaseConfig({ + ...productionEnvironment, + [POSTGRES_ENV.awsRegion]: " eu-central-1\n", + [POSTGRES_ENV.host]: " brunch.example.rds.amazonaws.com\n", + [POSTGRES_ENV.port]: " 5432\n", + [POSTGRES_ENV.user]: " brunch_agent\n", + }), + ).toMatchObject({ + auth: { mode: "iam", region: "eu-central-1" }, + host: "brunch.example.rds.amazonaws.com", + port: 5432, + user: "brunch_agent", + }); + }); + + test("loads a runtime-injected password without accepting a region", () => { + const environment = { + ...productionEnvironment, + [POSTGRES_ENV.authMode]: "password", + [POSTGRES_ENV.awsRegion]: undefined, + [POSTGRES_ENV.password]: "secret-for-test", + }; + expect(loadDatabaseConfig(environment)).toMatchObject({ + kind: "postgres", + auth: { mode: "password", password: "secret-for-test" }, + }); + }); + + test.each([ + [POSTGRES_ENV.host, undefined], + [POSTGRES_ENV.database, ""], + [POSTGRES_ENV.port, "0"], + [POSTGRES_ENV.port, "5432.5"], + [POSTGRES_ENV.port, "65536"], + ])("rejects invalid required field %s", (name, value) => { + expect(() => + loadDatabaseConfig({ ...productionEnvironment, [name]: value }), + ).toThrow(name); + }); + + test.each(["DATABASE_URL", "BRUNCH_DEV_DB_PATH", "BRUNCH_CHAT_DB_PATH"])( + "rejects legacy production input %s", + (name) => { + expect(() => + loadDatabaseConfig({ + ...productionEnvironment, + [name]: "must-not-be-accepted", + }), + ).toThrow(name); + }, + ); + + test("rejects contradictory authentication inputs without exposing values", () => { + const password = "must-not-appear-in-the-error"; + expect(() => + loadDatabaseConfig({ + ...productionEnvironment, + [POSTGRES_ENV.password]: password, + }), + ).toThrow(POSTGRES_ENV.password); + + let errorMessage = ""; + try { + loadDatabaseConfig({ + ...productionEnvironment, + [POSTGRES_ENV.password]: password, + }); + } catch (error) { + errorMessage = String(error); + } + expect(errorMessage).not.toContain(password); + }); +}); diff --git a/apps/brunch-agent/test/deployment-smoke-validation.test.ts b/apps/brunch-agent/test/deployment-smoke-validation.test.ts new file mode 100644 index 00000000000..ff49b801c50 --- /dev/null +++ b/apps/brunch-agent/test/deployment-smoke-validation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + validatePersistedHistory, + validateUiMessageStream, +} from "../src/deployment-smoke-validation.ts"; + +const streamOf = (...values: string[]): ReadableStream => { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const value of values) controller.enqueue(encoder.encode(value)); + controller.close(); + }, + }); +}; + +describe("deployment history smoke validation", () => { + test("requires the expected persisted text", () => { + expect( + validatePersistedHistory( + { + messages: [ + { + parts: [{ type: "text", text: "persisted marker" }], + }, + ], + }, + "persisted marker", + ), + ).toBe(1); + + expect(() => + validatePersistedHistory({ messages: [] }, "persisted marker"), + ).toThrow("expected persisted text"); + }); +}); + +describe("deployment turn smoke validation", () => { + test("requires a successful terminal event", async () => { + const onFirstChunk = vi.fn<() => void>(); + await expect( + validateUiMessageStream( + streamOf( + 'data: {"type":"start"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\n', + "data: [DONE]\n\n", + ), + onFirstChunk, + ), + ).resolves.toMatchObject({ chunks: 3 }); + expect(onFirstChunk).toHaveBeenCalledOnce(); + }); + + test.each(["error", "abort"])("rejects a terminal %s event", async (type) => { + await expect( + validateUiMessageStream( + streamOf(`data: {"type":"${type}"}\n\n`, "data: [DONE]\n\n"), + () => undefined, + ), + ).rejects.toThrow(`ended with ${type}`); + }); + + test("rejects a stream without a finish event", async () => { + await expect( + validateUiMessageStream( + streamOf('data: {"type":"start"}\n\n', "data: [DONE]\n\n"), + () => undefined, + ), + ).rejects.toThrow("without a finish event"); + }); +}); diff --git a/apps/brunch-agent/test/integration/health.test.ts b/apps/brunch-agent/test/integration/health.test.ts new file mode 100644 index 00000000000..d1ac26d5c99 --- /dev/null +++ b/apps/brunch-agent/test/integration/health.test.ts @@ -0,0 +1,16 @@ +/** + * Runs against the server `start:test` brought up beforehand; nothing here + * boots the app. + */ + +import { expect, test } from "vitest"; + +const origin = "http://localhost:3002"; + +test("started server reports liveness on /health", async () => { + const response = await fetch(`${origin}/health`); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/health+json"); + await expect(response.json()).resolves.toEqual({ status: "pass" }); +}); diff --git a/apps/brunch-agent/test/postgres.test.ts b/apps/brunch-agent/test/postgres.test.ts new file mode 100644 index 00000000000..a1c5122d5f2 --- /dev/null +++ b/apps/brunch-agent/test/postgres.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + type PostgresDatabaseConfig, + POSTGRES_ENV, +} from "../src/database-config.ts"; +import { + createPostgresPool, + createPostgresPoolConfig, + createPostgresRunnerFromPool, + POSTGRES_CONNECTION_TIMEOUT_MS, + probeRdsIam, +} from "../src/postgres.ts"; + +interface TestQueryResult { + readonly rows: Record[]; +} + +const commonConfig = { + kind: "postgres", + database: "brunch", + host: "brunch.example.rds.amazonaws.com", + port: 5432, + tlsCaPath: "/run/config/rds-ca.pem", + user: "brunch_agent", +} as const; + +describe("Postgres connection configuration", () => { + test("generates a fresh IAM token for each password request", async () => { + const getAuthToken = vi + .fn<() => Promise>() + .mockResolvedValueOnce("token-one") + .mockResolvedValueOnce("token-two"); + const onIamToken = vi.fn<() => void>(); + const config: PostgresDatabaseConfig = { + ...commonConfig, + auth: { mode: "iam", region: "eu-central-1" }, + }; + + const poolConfig = createPostgresPoolConfig(config, { + onIamToken, + readTlsCa: () => "test-ca", + signerFactory: () => ({ getAuthToken }), + }); + expect(poolConfig).toMatchObject({ + application_name: "brunch-agent", + connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS, + database: "brunch", + host: commonConfig.host, + port: 5432, + ssl: { ca: "test-ca", rejectUnauthorized: true }, + user: "brunch_agent", + }); + expect(typeof poolConfig.password).toBe("function"); + + const password = poolConfig.password as () => Promise; + await expect(password()).resolves.toBe("token-one"); + await expect(password()).resolves.toBe("token-two"); + expect(getAuthToken).toHaveBeenCalledTimes(2); + expect(onIamToken).toHaveBeenCalledTimes(2); + }); + + test("uses the injected password without a signer", () => { + const signerFactory = + vi.fn< + (config: { + hostname: string; + port: number; + region: string; + username: string; + }) => { getAuthToken: () => Promise } + >(); + const config: PostgresDatabaseConfig = { + ...commonConfig, + auth: { mode: "password", password: "test-password" }, + }; + + const poolConfig = createPostgresPoolConfig(config, { + readTlsCa: () => "test-ca", + signerFactory, + }); + + expect(poolConfig.password).toBe("test-password"); + expect(signerFactory).not.toHaveBeenCalled(); + }); + + test("reports a missing CA by field name without exposing its path", () => { + const tlsCaPath = "/private/deployment/secret-ca-path.pem"; + let errorMessage = ""; + try { + createPostgresPoolConfig({ + ...commonConfig, + auth: { mode: "password", password: "test-password" }, + tlsCaPath, + }); + } catch (error) { + errorMessage = String(error); + } + + expect(errorMessage).toContain(POSTGRES_ENV.tlsCaPath); + expect(errorMessage).not.toContain(tlsCaPath); + }); + + test("handles errors emitted by idle pooled clients", async () => { + const onPoolError = vi.fn<(error: Error) => void>(); + const pool = createPostgresPool( + { + ...commonConfig, + auth: { mode: "password", password: "test-password" }, + }, + { onPoolError, readTlsCa: () => "test-ca" }, + ); + const failure = new Error("idle connection failed"); + + expect(() => pool.emit("error", failure, undefined as never)).not.toThrow(); + expect(onPoolError).toHaveBeenCalledWith(failure); + await pool.end(); + }); +}); + +describe("RDS IAM probe", () => { + test("releases a checked-out client when the next connection fails", async () => { + const connectionFailure = new Error("second connection failed"); + const release = vi.fn<() => void>(); + const firstClient = { + query: async (): Promise<{ rows: T[] }> => ({ rows: [] }), + release, + }; + const pool = { + connect: vi + .fn<() => Promise>() + .mockResolvedValueOnce(firstClient) + .mockRejectedValueOnce(connectionFailure), + end: vi.fn<() => Promise>(async () => undefined), + }; + + await expect( + probeRdsIam( + { + ...commonConfig, + auth: { mode: "iam", region: "eu-central-1" }, + }, + { createPool: () => pool }, + ), + ).rejects.toBe(connectionFailure); + + expect(release).toHaveBeenCalledOnce(); + expect(pool.end).toHaveBeenCalledOnce(); + }); +}); + +describe("Flue Postgres runner", () => { + test("reports a pool checkout failure", async () => { + const failure = new Error("connection failed"); + const reportFailure = vi.fn<(error: unknown) => Promise>( + async () => undefined, + ); + const pool = { + connect: vi.fn<() => Promise>(async () => { + throw failure; + }), + end: vi.fn<() => Promise>(async () => undefined), + query: vi.fn<() => Promise>(), + }; + const runner = createPostgresRunnerFromPool(pool, undefined, reportFailure); + + await expect(runner.transaction(async () => undefined)).rejects.toBe( + failure, + ); + expect(reportFailure).toHaveBeenCalledWith(failure); + }); + + test("pins a successful transaction to one checked-out client", async () => { + const release = vi.fn<() => void>(); + const client = { + query: vi.fn<(text: string) => Promise>( + async (text) => ({ + rows: text === "SELECT value" ? [{ value: 42 }] : [], + }), + ), + release, + }; + const pool = { + connect: vi.fn<() => Promise>(async () => client), + end: vi.fn<() => Promise>(async () => undefined), + query: vi.fn<() => Promise>(async () => ({ + rows: [{ outside: true }], + })), + }; + const runner = createPostgresRunnerFromPool(pool); + + await expect( + runner.transaction(async (transaction) => { + const rows = await transaction.query("SELECT value"); + return rows[0]?.value; + }), + ).resolves.toBe(42); + expect(client.query.mock.calls.map(([text]) => text)).toEqual([ + "BEGIN", + "SELECT value", + "COMMIT", + ]); + expect(pool.query).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); + + test("rolls back and releases the client after failure", async () => { + const release = vi.fn<() => void>(); + const client = { + query: vi.fn<(text: string) => Promise>(async () => ({ + rows: [], + })), + release, + }; + const pool = { + connect: vi.fn<() => Promise>(async () => client), + end: vi.fn<() => Promise>(async () => undefined), + query: vi.fn<(text: string) => Promise>(async () => ({ + rows: [], + })), + }; + const runner = createPostgresRunnerFromPool(pool); + const failure = new Error("transaction failed"); + + await expect( + runner.transaction(async () => { + throw failure; + }), + ).rejects.toBe(failure); + expect(client.query.mock.calls.map(([text]) => text)).toEqual([ + "BEGIN", + "ROLLBACK", + ]); + expect(release).toHaveBeenCalledOnce(); + }); + + test("closes Postgres and telemetry through the adapter lifecycle", async () => { + const closed: string[] = []; + const pool = { + connect: vi.fn<() => Promise>(), + end: vi.fn<() => Promise>(async () => { + closed.push("postgres"); + }), + query: vi.fn<(text: string) => Promise>(), + }; + const runner = createPostgresRunnerFromPool(pool, async () => { + closed.push("telemetry"); + }); + + await runner.close(); + + expect(closed).toEqual(["postgres", "telemetry"]); + }); +}); diff --git a/apps/brunch-agent/test/telemetry.test.ts b/apps/brunch-agent/test/telemetry.test.ts new file mode 100644 index 00000000000..76dab4c1483 --- /dev/null +++ b/apps/brunch-agent/test/telemetry.test.ts @@ -0,0 +1,110 @@ +import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry"; +import { metrics, trace } from "@opentelemetry/api"; +import { logs } from "@opentelemetry/api-logs"; +import { expect, test, vi } from "vitest"; + +import { + type BrunchOpenTelemetrySetup, + createBrunchTelemetryInstrumentation, +} from "../src/telemetry.ts"; + +test("production requires a HASH collector endpoint", () => { + expect(() => + createBrunchTelemetryInstrumentation({ NODE_ENV: "production" }), + ).toThrow("HASH_OTLP_ENDPOINT"); +}); + +test("keeps Flue content disabled and flushes exporters after Flue disposal", async () => { + const order: string[] = []; + const flueInstrumentation = createOpenTelemetryInstrumentation({ + content: false, + }); + const createFlueInstrumentation = vi.fn< + typeof createOpenTelemetryInstrumentation + >(() => ({ + ...flueInstrumentation, + dispose: () => { + order.push("flue"); + }, + })); + const setup = { + endpoint: "http://collector.test:4317", + forceFlush: async () => undefined, + logger: logs.getLogger("brunch-test"), + meter: metrics.getMeter("brunch-test"), + shutdown: async () => { + order.push("sdk"); + }, + tracer: trace.getTracer("brunch-test"), + } satisfies BrunchOpenTelemetrySetup & { endpoint: string }; + const registerHashOpenTelemetry = vi.fn< + (input: { + endpoint: string; + serviceName: string; + }) => BrunchOpenTelemetrySetup + >(() => setup); + + const instrumentation = createBrunchTelemetryInstrumentation( + { + HASH_OTLP_ENDPOINT: setup.endpoint, + NODE_ENV: "production", + OTEL_SERVICE_NAME: "Brunch Test", + }, + { + createFlueInstrumentation, + registerHashOpenTelemetry, + }, + ); + await instrumentation.dispose(); + + expect(createFlueInstrumentation).toHaveBeenCalledWith({ + content: false, + logger: setup.logger, + meter: setup.meter, + tracer: setup.tracer, + }); + expect(registerHashOpenTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: setup.endpoint, + serviceName: "Brunch Test", + }), + ); + expect(order).toEqual(["flue", "sdk"]); +}); + +test("trims collector configuration supplied through the environment", async () => { + const flueInstrumentation = createOpenTelemetryInstrumentation({ + content: false, + }); + const setup = { + forceFlush: async () => undefined, + logger: logs.getLogger("brunch-test"), + meter: metrics.getMeter("brunch-test"), + shutdown: async () => undefined, + tracer: trace.getTracer("brunch-test"), + } satisfies BrunchOpenTelemetrySetup; + const registerHashOpenTelemetry = vi.fn< + (input: { + endpoint: string; + serviceName: string; + }) => BrunchOpenTelemetrySetup + >(() => setup); + + const instrumentation = createBrunchTelemetryInstrumentation( + { + HASH_OTLP_ENDPOINT: " http://collector.test:4317\n", + NODE_ENV: "production", + OTEL_SERVICE_NAME: " Brunch Test\n", + }, + { + createFlueInstrumentation: () => flueInstrumentation, + registerHashOpenTelemetry, + }, + ); + await instrumentation.dispose(); + + expect(registerHashOpenTelemetry).toHaveBeenCalledWith({ + endpoint: "http://collector.test:4317", + serviceName: "Brunch Test", + }); +}); diff --git a/apps/brunch-agent/turbo.json b/apps/brunch-agent/turbo.json index 4d01ac0c4a7..06ca0f6e93d 100644 --- a/apps/brunch-agent/turbo.json +++ b/apps/brunch-agent/turbo.json @@ -11,9 +11,13 @@ "persistent": true, "passThroughEnv": [ "ANTHROPIC_API_KEY", + "BRUNCH_CHAT_MODEL", "BRUNCH_DEV_DB_PATH", "BRUNCH_PETRINAUT_ORIGINS", - "BRUNCH_TRANSPORT_AISDK_INSPECT" + "BRUNCH_TRANSPORT_AISDK_INSPECT", + "HASH_OTLP_ENDPOINT", + "OTEL_RESOURCE_ATTRIBUTES", + "OTEL_SERVICE_NAME" ] }, "petrinaut:dev": { diff --git a/apps/brunch-agent/vitest.config.ts b/apps/brunch-agent/vitest.config.ts index 8b5840acac7..522e94210e2 100644 --- a/apps/brunch-agent/vitest.config.ts +++ b/apps/brunch-agent/vitest.config.ts @@ -1,7 +1,8 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/**/*.test.ts"], + exclude: [...configDefaults.exclude, "test/integration/**"], }, }); diff --git a/apps/brunch-agent/vitest.integration.config.ts b/apps/brunch-agent/vitest.integration.config.ts new file mode 100644 index 00000000000..6af28444915 --- /dev/null +++ b/apps/brunch-agent/vitest.integration.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/integration/**/*.test.ts"], + }, +}); diff --git a/libs/@hashintel/brunch-agent/docs/evidence/implementations/mission-8-deployment-handoff.md b/libs/@hashintel/brunch-agent/docs/evidence/implementations/mission-8-deployment-handoff.md new file mode 100644 index 00000000000..33762c80b14 --- /dev/null +++ b/libs/@hashintel/brunch-agent/docs/evidence/implementations/mission-8-deployment-handoff.md @@ -0,0 +1,101 @@ +# Mission 8 deployment handoff + +Date: 2026-09-01 + +This report records the application-owned deployment work and local proof. It +does **not** claim that Brunch is deployed. The repository fixes CI publication +to `eu-central-1`, ECR account `469596578827`, and ECS deployment account +`054238437032`, but no Brunch ECR repository, RDS instance, ECS service, +restricted hostname, or HASH collector was confirmed. The AWS CLI was not +installed; an ephemeral CLI invocation found no configured profile or +credentials. + +## Application contract + +- Image: `brunch-agent` +- Current direct-main image ID: not revalidated; Docker execution was denied + before the build began +- Entrypoint: `node dist/server.mjs` +- Runtime identity: uid `60000`, gid `60000` +- Public container port: `3002` +- Restricted application route: `POST /api/chat` +- Liveness route: `GET /health` +- Ingress-denied routes: `/`, `/assets/*`, `/agents/chat/:id` +- Durable state: Flue-owned Postgres tables for conversation, submission, + recovery, and settlement state +- Non-durable and inactive in deployment: the separate JSON capture store +- Authentication preference: RDS IAM using task-role credentials and a fresh + token per physical connection +- Fallback: a runtime-injected Postgres password using the same host, port, + database, user, and verified TLS configuration +- Telemetry: OTLP/gRPC to `HASH_OTLP_ENDPOINT`; Flue content capture disabled +- Rollout policy: desired count one and stop-before-start until ownership + overlap is separately proven safe + +The exact environment fields and liveness/readiness semantics are documented +in `apps/brunch-agent/README.md`. + +## Observed local proof + +- `yarn workspace @apps/brunch-agent lint:tsc`: passed +- `yarn workspace @apps/brunch-agent lint:eslint`: passed with eight + pre-existing warnings and no errors +- `yarn workspace @apps/brunch-agent test:unit`: 14 files and 63 tests passed +- `yarn workspace @hashintel/brunch-agent test:unit`: 18 files passed, + 197 tests passed, and 1 test skipped +- `yarn workspace @apps/brunch-agent build`: passed +- `yarn workspace @apps/brunch-agent build:docker`: not run; Docker execution + was denied with `operation not permitted` +- Docker integration smoke: not run for the rewritten direct-main artifact + +The former-ancestry image and Docker integration smoke passed before this +branch was rewritten onto current `main`. Those results do not establish the +new artifact and are retained only as historical evidence. + +The former-ancestry container smoke observed: + +- startup and Flue migration against Postgres using the password fallback; +- refusal to start when production database configuration was absent; +- verified TLS; +- non-root execution; +- `/health` and packaged UI/agent resources; +- no writes under `/repo`; +- a graceful generated-server shutdown within the 60-second outer bound; and +- trace receipt by a disposable OTLP collector. + +Brunch is registered in the deploy service catalog for CI builds and +multi-architecture GHCR publication. Its catalog entry has `push: ["ghcr"]` +and an empty ECS target list. The workflow provisions neither an ECR repository +nor an ECS service, so ECR publication and deployment remain disabled until +infrastructure supplies and approves those targets. + +## Required infrastructure handoff + +The infrastructure owner must provide and record all of the following before +an ECS target is added: + +- confirmation that the repository-level publication/deployment accounts, + `eu-central-1`, ECR push role + `arn:aws:iam::469596578827:role/github-oidc-hash-cd-push`, and ECS deploy + role `arn:aws:iam::054238437032:role/github-oidc-hash-cd-deploy` are the + approved Brunch targets; +- ECR repository; +- ECS cluster, service, and task family; +- task role and execution role; +- RDS endpoint, port, database, user, schema policy, and CA mount; +- IAM policy and `rds_iam` role, or password secret reference and the reason + IAM was rejected; +- Anthropic secret reference; +- HASH collector endpoint and resource attributes; +- restricted hostname/access boundary; +- load-balancer health target and ingress route rules; +- measured streaming idle timeout, CPU/memory, health grace, drain/stop + timeout, and deployment percentages; and +- deployment and acceptance owner. + +After those resources exist, use one immutable image digest to execute the +Mission 8 remote proof matrix: two-connection IAM probe, real streamed +Anthropic/tool turn, in-place restart hydration, cross-host task replacement, +client abort, bounded provider and database failures, content/secret inspection, +graceful replacement, and rollback. Mission 8 remains open until those observed +facts and owner acceptance are recorded. diff --git a/turbo.json b/turbo.json index 04b0eacf707..3e3018df33d 100644 --- a/turbo.json +++ b/turbo.json @@ -74,6 +74,12 @@ "dependsOn": ["codegen", "^start:test:healthcheck"], "env": ["TEST_COVERAGE"] }, + // Smoke-tests the image `build:docker` produced. Not run in CI yet. + // TODO(SRE-1032): run this in the deploy workflow against the image it already builds. + "test:docker": { + "cache": false, + "dependsOn": ["build:docker"] + }, "test:codspeed": { "cache": false }, diff --git a/yarn.lock b/yarn.lock index b1e7b541220..e747efbf06c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -435,9 +435,11 @@ __metadata: resolution: "@apps/brunch-agent@workspace:apps/brunch-agent" dependencies: "@anthropic-ai/sdk": "npm:0.74.0" + "@aws-sdk/rds-signer": "npm:3.1117.0" "@earendil-works/pi-ai": "npm:0.83.0" "@earendil-works/pi-tui": "npm:0.84.3" "@flue/opentelemetry": "npm:2.0.3" + "@flue/postgres": "npm:2.0.3" "@flue/react": "npm:2.0.3" "@flue/runtime": "npm:2.0.3" "@flue/sdk": "npm:2.0.3" @@ -448,7 +450,20 @@ __metadata: "@hashintel/brunch-agent-transport-aisdk": "workspace:*" "@hashintel/petrinaut-core": "workspace:*" "@opentelemetry/api": "npm:1.9.1" + "@opentelemetry/api-logs": "npm:0.220.0" + "@opentelemetry/exporter-logs-otlp-grpc": "npm:0.220.0" + "@opentelemetry/exporter-metrics-otlp-grpc": "npm:0.220.0" + "@opentelemetry/exporter-trace-otlp-grpc": "npm:0.220.0" + "@opentelemetry/instrumentation": "npm:0.220.0" + "@opentelemetry/instrumentation-http": "npm:0.220.0" + "@opentelemetry/instrumentation-undici": "npm:0.28.0" + "@opentelemetry/resources": "npm:2.9.0" + "@opentelemetry/sdk-logs": "npm:0.220.0" + "@opentelemetry/sdk-metrics": "npm:2.9.0" + "@opentelemetry/sdk-trace-base": "npm:2.9.0" + "@opentelemetry/sdk-trace-node": "npm:2.9.0" "@types/node": "npm:22.18.13" + "@types/pg": "npm:8.23.1" "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" @@ -456,12 +471,14 @@ __metadata: hono: "npm:4.13.2" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1" + pg: "npm:8.23.0" react: "npm:19.2.6" react-dom: "npm:19.2.6" typebox: "npm:1.3.7" valibot: "npm:1.4.2" vite: "npm:8.1.0" vitest: "npm:4.1.10" + wait-on: "npm:9.0.1" languageName: unknown linkType: soft @@ -1760,19 +1777,19 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/core@npm:^3.973.7, @aws-sdk/core@npm:^3.974.11, @aws-sdk/core@npm:^3.974.14, @aws-sdk/core@npm:^3.977.7": - version: 3.977.7 - resolution: "@aws-sdk/core@npm:3.977.7" +"@aws-sdk/core@npm:^3.973.7, @aws-sdk/core@npm:^3.974.11, @aws-sdk/core@npm:^3.974.14, @aws-sdk/core@npm:^3.977.7, @aws-sdk/core@npm:^3.977.9": + version: 3.977.9 + resolution: "@aws-sdk/core@npm:3.977.9" dependencies: - "@aws-sdk/types": "npm:^3.974.3" - "@aws-sdk/xml-builder": "npm:^3.972.38" + "@aws-sdk/types": "npm:^3.974.5" + "@aws-sdk/xml-builder": "npm:^3.972.40" "@aws/lambda-invoke-store": "npm:^0.3.0" - "@smithy/core": "npm:^3.31.1" + "@smithy/core": "npm:^3.33.3" "@smithy/signature-v4": "npm:^5.6.12" - "@smithy/types": "npm:^4.16.1" + "@smithy/types": "npm:^4.17.2" bowser: "npm:^2.11.0" tslib: "npm:^2.6.2" - checksum: 10c0/305bc5d7bd61b33bbdbec7abc5dbf03fb64ea8e2b60fcda9e4ab22ce2fc09eea6db71769010ead7437e75d09c6407acedfe1f8a52e9b1ea97ba5c0ebd3e348e0 + checksum: 10c0/9118a8d05b7c27fe55fb5e793840193d1b311b6c0c2958e591bcada391a71783536ed9c8f4913cc9c4b7258dba650d625ed782669a12875348544c33f35aa8d6 languageName: node linkType: hard @@ -1786,79 +1803,79 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-cognito-identity@npm:^3.972.37": - version: 3.972.38 - resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.972.38" +"@aws-sdk/credential-provider-cognito-identity@npm:^3.972.37, @aws-sdk/credential-provider-cognito-identity@npm:^3.972.69": + version: 3.972.69 + resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.972.69" dependencies: - "@aws-sdk/nested-clients": "npm:^3.997.13" - "@aws-sdk/types": "npm:^3.973.9" - "@smithy/core": "npm:^3.24.5" - "@smithy/types": "npm:^4.14.2" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/6fc6c461c9fcde5fc34c29fb9aab09f7651e43c2f951f0b7e9d62ccbe15b9fe503ed32380ed6cda811ee31c6db31aed937ba30cc4e380e96eba9b5b870697f3e + checksum: 10c0/b6e1a120c5e2690619b031a04826c8b9d058ea827ddf016726fcf3e9b7d45e04677dce3478d9683553bc565b6b43dac93842893ebd1b44220f9959370896a2ad languageName: node linkType: hard -"@aws-sdk/credential-provider-env@npm:^3.972.40, @aws-sdk/credential-provider-env@npm:^3.972.5, @aws-sdk/credential-provider-env@npm:^3.972.68": - version: 3.972.68 - resolution: "@aws-sdk/credential-provider-env@npm:3.972.68" +"@aws-sdk/credential-provider-env@npm:^3.972.40, @aws-sdk/credential-provider-env@npm:^3.972.5, @aws-sdk/credential-provider-env@npm:^3.972.70": + version: 3.972.70 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.70" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/31b9d5fd71d00556ecf1bc5da793e8cb04f98d09ccb00c8b65a59b88a6a860a7737e1aeefcb5f7e62f322f9f3a7eba279b205c8623e912d936fe01a667ab724a + checksum: 10c0/f5d8f3f1021a83911f617dd6b36277486d0bdb174c107c4f7776a7e2c609a101aa03ada1cca9d97229ac79aea7797ec6392fe449029b87f7cd013360592bddf3 languageName: node linkType: hard -"@aws-sdk/credential-provider-http@npm:^3.972.42, @aws-sdk/credential-provider-http@npm:^3.972.7, @aws-sdk/credential-provider-http@npm:^3.972.70": - version: 3.972.70 - resolution: "@aws-sdk/credential-provider-http@npm:3.972.70" +"@aws-sdk/credential-provider-http@npm:^3.972.42, @aws-sdk/credential-provider-http@npm:^3.972.7, @aws-sdk/credential-provider-http@npm:^3.972.72": + version: 3.972.72 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.72" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/fetch-http-handler": "npm:^5.6.13" - "@smithy/node-http-handler": "npm:^4.9.13" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/9230d722c0307bbafd0b56a42c25708a25a0d087c9993c37f628fbae8e142120d73a34738b6b0bf15f57efdbcf664feb6ae773e2526b3729663ce54403393487 + checksum: 10c0/aaa67dc42ce00713d92f931c620e36cb199533e6f1f892a6be76553986c86977442924e76f3743732a7b65e6f6777271789a6edbc70d5a86e0a9b6b5a3ef25f3 languageName: node linkType: hard -"@aws-sdk/credential-provider-ini@npm:^3.972.44, @aws-sdk/credential-provider-ini@npm:^3.972.5, @aws-sdk/credential-provider-ini@npm:^3.973.13": - version: 3.973.13 - resolution: "@aws-sdk/credential-provider-ini@npm:3.973.13" +"@aws-sdk/credential-provider-ini@npm:^3.972.44, @aws-sdk/credential-provider-ini@npm:^3.972.5, @aws-sdk/credential-provider-ini@npm:^3.973.15": + version: 3.973.15 + resolution: "@aws-sdk/credential-provider-ini@npm:3.973.15" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/credential-provider-env": "npm:^3.972.68" - "@aws-sdk/credential-provider-http": "npm:^3.972.70" - "@aws-sdk/credential-provider-login": "npm:^3.972.75" - "@aws-sdk/credential-provider-process": "npm:^3.972.68" - "@aws-sdk/credential-provider-sso": "npm:^3.973.12" - "@aws-sdk/credential-provider-web-identity": "npm:^3.972.74" - "@aws-sdk/nested-clients": "npm:^3.997.42" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/credential-provider-env": "npm:^3.972.70" + "@aws-sdk/credential-provider-http": "npm:^3.972.72" + "@aws-sdk/credential-provider-login": "npm:^3.972.77" + "@aws-sdk/credential-provider-process": "npm:^3.972.70" + "@aws-sdk/credential-provider-sso": "npm:^3.973.14" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.76" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" "@smithy/credential-provider-imds": "npm:^4.4.16" - "@smithy/types": "npm:^4.16.1" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/0d7c76a3227f21e8c08af3db03e369651c9efb795b964707f243d69bdc1fb322b712a1be7e7d474ffaca25118eb00ce6eeb56a646850d72a14a8f7e6120cc02c + checksum: 10c0/1fe5f8c84cb9877a62084f9cbedebaed605ecc34891c44254d979ccd1282a5e164840d6dbb340773c311c028568f9ea642649bebf4b38cebb2d6d0039ad9de12 languageName: node linkType: hard -"@aws-sdk/credential-provider-login@npm:^3.972.44, @aws-sdk/credential-provider-login@npm:^3.972.75": - version: 3.972.75 - resolution: "@aws-sdk/credential-provider-login@npm:3.972.75" +"@aws-sdk/credential-provider-login@npm:^3.972.44, @aws-sdk/credential-provider-login@npm:^3.972.77": + version: 3.972.77 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.77" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/nested-clients": "npm:^3.997.42" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/ab05ddced3fe6bc9360b79950fb07ed01a06c103771c36686d3495135a18d55ebbc8b5fb56744b120e2cc2cc9e413615ce6e1b1c5186ca97b8eb68142162081e + checksum: 10c0/9be54fdf406325d462abdc0a2a182ba1ed39009fa18b60035705e6c3fae92dfe1afe5dba24a4fa3c8376f7cd2e1dad7a91152909f3b96dde0b215a88dfcd5330 languageName: node linkType: hard @@ -1882,68 +1899,68 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:^3.972.42, @aws-sdk/credential-provider-node@npm:^3.972.45, @aws-sdk/credential-provider-node@npm:^3.972.6": - version: 3.972.79 - resolution: "@aws-sdk/credential-provider-node@npm:3.972.79" +"@aws-sdk/credential-provider-node@npm:^3.972.42, @aws-sdk/credential-provider-node@npm:^3.972.45, @aws-sdk/credential-provider-node@npm:^3.972.6, @aws-sdk/credential-provider-node@npm:^3.972.81": + version: 3.972.81 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.81" dependencies: - "@aws-sdk/credential-provider-env": "npm:^3.972.68" - "@aws-sdk/credential-provider-http": "npm:^3.972.70" - "@aws-sdk/credential-provider-ini": "npm:^3.973.13" - "@aws-sdk/credential-provider-process": "npm:^3.972.68" - "@aws-sdk/credential-provider-sso": "npm:^3.973.12" - "@aws-sdk/credential-provider-web-identity": "npm:^3.972.74" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" + "@aws-sdk/credential-provider-env": "npm:^3.972.70" + "@aws-sdk/credential-provider-http": "npm:^3.972.72" + "@aws-sdk/credential-provider-ini": "npm:^3.973.15" + "@aws-sdk/credential-provider-process": "npm:^3.972.70" + "@aws-sdk/credential-provider-sso": "npm:^3.973.14" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.76" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" "@smithy/credential-provider-imds": "npm:^4.4.16" - "@smithy/types": "npm:^4.16.1" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/0c7b282b9b8a6774e0acc987e1fff59d10926afe336b0dfe0fa4af84d333b46df50f8679b2cce95d73b75b0ae593b44ad4896bc8d54e337a3b611666cf1ceb6a + checksum: 10c0/b0e60a641a4648dcae0e20b3f2fe3866bb1b5aeba64617146888b4208d94878a8421cd74bbf1f8b921a50cc1e8410fa707ca30af0349ec54be43ce5e8004a748 languageName: node linkType: hard -"@aws-sdk/credential-provider-process@npm:^3.972.40, @aws-sdk/credential-provider-process@npm:^3.972.5, @aws-sdk/credential-provider-process@npm:^3.972.68": - version: 3.972.68 - resolution: "@aws-sdk/credential-provider-process@npm:3.972.68" +"@aws-sdk/credential-provider-process@npm:^3.972.40, @aws-sdk/credential-provider-process@npm:^3.972.5, @aws-sdk/credential-provider-process@npm:^3.972.70": + version: 3.972.70 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.70" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/97a7061fdf997588ab8ea2feb4a5a268e3c94e841e3418faf683fade3bca1c627b6e30969666d7ca96207a37fe7178e704eb8adc720ac8eb391e8c1e85291f11 + checksum: 10c0/87ea5f575f611461a538312b6c502baf86ce33eef6974bb2f9e85bd32147cb8f6b518e80f5a5d8ef371fceb03d517f4d3b6c07ddeb7f15d5a2f56c6dcbf8e8ba languageName: node linkType: hard -"@aws-sdk/credential-provider-sso@npm:^3.972.44, @aws-sdk/credential-provider-sso@npm:^3.972.5, @aws-sdk/credential-provider-sso@npm:^3.973.12": - version: 3.973.12 - resolution: "@aws-sdk/credential-provider-sso@npm:3.973.12" +"@aws-sdk/credential-provider-sso@npm:^3.972.44, @aws-sdk/credential-provider-sso@npm:^3.972.5, @aws-sdk/credential-provider-sso@npm:^3.973.14": + version: 3.973.14 + resolution: "@aws-sdk/credential-provider-sso@npm:3.973.14" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/nested-clients": "npm:^3.997.42" - "@aws-sdk/token-providers": "npm:3.1108.0" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/token-providers": "npm:3.1116.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/061f0219ace75565abe79480f81920eb4bfe45e84b53901941c3b7d6c9aee7a67542e1058fbeb40139fd4887bb42cdf759ba1ebdb7b7cbb2fa9cd1e033e32871 + checksum: 10c0/12132e57c07277b4c115c835bb7a14b2a62e4f30a69998eca501a171d46be469439b3e7b33c970165362bd261b8689389dd06ba0a959657d97330f3e5172ec52 languageName: node linkType: hard -"@aws-sdk/credential-provider-web-identity@npm:^3.972.44, @aws-sdk/credential-provider-web-identity@npm:^3.972.5, @aws-sdk/credential-provider-web-identity@npm:^3.972.74": - version: 3.972.74 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.74" +"@aws-sdk/credential-provider-web-identity@npm:^3.972.44, @aws-sdk/credential-provider-web-identity@npm:^3.972.5, @aws-sdk/credential-provider-web-identity@npm:^3.972.76": + version: 3.972.76 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.76" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/nested-clients": "npm:^3.997.42" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/9c1da6479fdc329a7540420e0e5a1030f159f689150acb7d1d67ae9c91b99415b47f0abce859bc8987e9a7aee109e411e45f0e197b5e1d94c3c31379c8a426b2 + checksum: 10c0/5efcf6a4b10e49b5b3f9bf76b638ff0b0ce84d26126197f0d28cecbd01458f5d84a074a4cdc8817a7310a940a36a8ad164c1531eb031713bd1485d52bef8c877 languageName: node linkType: hard -"@aws-sdk/credential-providers@npm:3.1055.0, @aws-sdk/credential-providers@npm:^3.796.0": +"@aws-sdk/credential-providers@npm:3.1055.0": version: 3.1055.0 resolution: "@aws-sdk/credential-providers@npm:3.1055.0" dependencies: @@ -1968,6 +1985,30 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-providers@npm:3.1117.0, @aws-sdk/credential-providers@npm:^3.796.0": + version: 3.1117.0 + resolution: "@aws-sdk/credential-providers@npm:3.1117.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/credential-provider-cognito-identity": "npm:^3.972.69" + "@aws-sdk/credential-provider-env": "npm:^3.972.70" + "@aws-sdk/credential-provider-http": "npm:^3.972.72" + "@aws-sdk/credential-provider-ini": "npm:^3.973.15" + "@aws-sdk/credential-provider-login": "npm:^3.972.77" + "@aws-sdk/credential-provider-node": "npm:^3.972.81" + "@aws-sdk/credential-provider-process": "npm:^3.972.70" + "@aws-sdk/credential-provider-sso": "npm:^3.973.14" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.76" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/5f8d8a0bd0ee08fcddb9ec14d6f41c21ac2ab8105e8df8cd40917469d54304248cdca98e12fb7daa1343d47cccaf8e4e817f0e53145116e008cc35734587eb85 + languageName: node + linkType: hard + "@aws-sdk/eventstream-handler-node@npm:^3.972.16": version: 3.972.32 resolution: "@aws-sdk/eventstream-handler-node@npm:3.972.32" @@ -2151,19 +2192,33 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/nested-clients@npm:^3.997.12, @aws-sdk/nested-clients@npm:^3.997.13, @aws-sdk/nested-clients@npm:^3.997.42, @aws-sdk/nested-clients@npm:^3.997.9": - version: 3.997.42 - resolution: "@aws-sdk/nested-clients@npm:3.997.42" +"@aws-sdk/nested-clients@npm:^3.997.12, @aws-sdk/nested-clients@npm:^3.997.44, @aws-sdk/nested-clients@npm:^3.997.9": + version: 3.997.44 + resolution: "@aws-sdk/nested-clients@npm:3.997.44" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/signature-v4-multi-region": "npm:^3.996.44" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/fetch-http-handler": "npm:^5.6.13" - "@smithy/node-http-handler": "npm:^4.9.13" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.46" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10c0/947c2049a69a02399f600bd448f34bcaaf3a97b5bbb9b43e9ce932e84d4e9c3131687d330b9c48391da4365582890dd72bf4b8dfafe0693c041e48aa0f5c972d + languageName: node + linkType: hard + +"@aws-sdk/rds-signer@npm:3.1117.0": + version: 3.1117.0 + resolution: "@aws-sdk/rds-signer@npm:3.1117.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/credential-providers": "npm:3.1117.0" + "@smithy/core": "npm:^3.33.3" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/1405a73a86aa904fa503592fcc0793dc5b8ab994d1c9eda1b017ca26bd1a659fd87cd9af762c713466ebc8950ced06ee49521a5dea25a99edbe011720e1d86e3 + checksum: 10c0/6e0cd29df03791a94c829b242dcf50d01581583a74e8b9d9bc5cff75b341f3fba08346c914015acf4f1971d583231ea6a8d18aa8a27e7fd16a6622a22069f9d4 languageName: node linkType: hard @@ -2227,15 +2282,15 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/signature-v4-multi-region@npm:^3.996.44": - version: 3.996.44 - resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.44" +"@aws-sdk/signature-v4-multi-region@npm:^3.996.46": + version: 3.996.46 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.46" dependencies: - "@aws-sdk/types": "npm:^3.974.3" + "@aws-sdk/types": "npm:^3.974.5" "@smithy/signature-v4": "npm:^5.6.12" - "@smithy/types": "npm:^4.16.1" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/b9c970abad58f9f87dcb2577f9121c7c9015ea18b4101e4813ff92bb5c19524c8634f6507f3c273ad8694d5ef6f272d5bd0648993c16431fcb09dcc402dd7d72 + checksum: 10c0/069dfb7a95663cad2e0aec1d87df8a800abad33cb49dfbe9412dad2d63ab6350328c6165166b12d50e609d8dbe5a5536aa6b1101be4d91584fbca76b2fea4d00 languageName: node linkType: hard @@ -2253,27 +2308,27 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.1108.0": - version: 3.1108.0 - resolution: "@aws-sdk/token-providers@npm:3.1108.0" +"@aws-sdk/token-providers@npm:3.1116.0": + version: 3.1116.0 + resolution: "@aws-sdk/token-providers@npm:3.1116.0" dependencies: - "@aws-sdk/core": "npm:^3.977.7" - "@aws-sdk/nested-clients": "npm:^3.997.42" - "@aws-sdk/types": "npm:^3.974.3" - "@smithy/core": "npm:^3.31.1" - "@smithy/types": "npm:^4.16.1" + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/4593b1228d55cfc960cb22c6eecfe29032f4b1a01f0452f46ba22cdaa7bcde051f5749cfcc465ef682f659cc7a4f0d7c55ce254158b88eacf3ca1c3fa7946e7e + checksum: 10c0/e720401f6b6d5682984cf1927d3e4c86663b750086bac3b58827a3b9b8585c8b06fb93af82a03a40d5a8ca48c89c0f47c1ac48ea81822c52ddecc3e36ef7cf17 languageName: node linkType: hard -"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.1, @aws-sdk/types@npm:^3.973.8, @aws-sdk/types@npm:^3.973.9, @aws-sdk/types@npm:^3.974.3": - version: 3.974.3 - resolution: "@aws-sdk/types@npm:3.974.3" +"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.1, @aws-sdk/types@npm:^3.973.8, @aws-sdk/types@npm:^3.973.9, @aws-sdk/types@npm:^3.974.3, @aws-sdk/types@npm:^3.974.5": + version: 3.974.5 + resolution: "@aws-sdk/types@npm:3.974.5" dependencies: - "@smithy/types": "npm:^4.16.1" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/6850403d8d9358ea497a63eaa50129f1989a2acc959878bb473525f04238099ab85f9bb7877f9d03191f3397941cf841ce80aaf0f319c94273e41fedf51661d7 + checksum: 10c0/803aaaa1c0675dcb564803993f3c47d96302fad461af8af80e75afc40c72228ebf669593c18e44ca09fc5acf0d1bd25966261de07844d8f11ad82aa2650252d0 languageName: node linkType: hard @@ -2372,13 +2427,13 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/xml-builder@npm:^3.972.38": - version: 3.972.38 - resolution: "@aws-sdk/xml-builder@npm:3.972.38" +"@aws-sdk/xml-builder@npm:^3.972.40": + version: 3.972.40 + resolution: "@aws-sdk/xml-builder@npm:3.972.40" dependencies: - "@smithy/types": "npm:^4.16.1" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/8f4c500bea2e1060b2cf47aac4be87b80e9bdf5389d3d3aeaca66397ebc4b15cdc39a8d3f0d54df2c39597c3c2957edce4cca0d92a7a875aedcfdb094d6910ec + checksum: 10c0/5b06fa0466b5ddb0e33138dc16f6a30e11e52fc5ea71f3ed72af7b24621d29c9e8103df3eed9c4f14cef50f4d345dd9e7021242a8c155a67869ee4ce79982bb4 languageName: node linkType: hard @@ -6374,6 +6429,15 @@ __metadata: languageName: node linkType: hard +"@flue/postgres@npm:2.0.3": + version: 2.0.3 + resolution: "@flue/postgres@npm:2.0.3" + dependencies: + "@flue/runtime": "npm:2.0.3" + checksum: 10c0/201bd985ae3cbafb3a02494d375b469d9f7ed83b5154a457b2a2176e6c41a5cc7636e23d3f63eaf40fe6c3bf41fe8183fa52c0de244817c30d175bc47c43673c + languageName: node + linkType: hard + "@flue/react@npm:2.0.3": version: 2.0.3 resolution: "@flue/react@npm:2.0.3" @@ -15967,13 +16031,13 @@ __metadata: languageName: node linkType: hard -"@smithy/core@npm:^3.22.1, @smithy/core@npm:^3.24.2, @smithy/core@npm:^3.24.3, @smithy/core@npm:^3.24.5, @smithy/core@npm:^3.31.1, @smithy/core@npm:^3.32.0, @smithy/core@npm:^3.33.0": - version: 3.33.0 - resolution: "@smithy/core@npm:3.33.0" +"@smithy/core@npm:^3.22.1, @smithy/core@npm:^3.24.2, @smithy/core@npm:^3.24.3, @smithy/core@npm:^3.31.1, @smithy/core@npm:^3.32.0, @smithy/core@npm:^3.33.2, @smithy/core@npm:^3.33.3": + version: 3.33.3 + resolution: "@smithy/core@npm:3.33.3" dependencies: - "@smithy/types": "npm:^4.17.0" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/d640655fa8f0703cfff739c6fecf88a4c8116faca22197b27eaa47805c8e7360cdfc18ee8e7e02a355207f57afd8d398141f51a88f3a6c80e615c50deea1841d + checksum: 10c0/57c5c6c1834d84eddfff905932350973afa20e1006024b82d6599af4c8d1e75b243e06b93c998acc95946399f1342ddc298c6941e0030384e6541ceec4007376 languageName: node linkType: hard @@ -16090,14 +16154,14 @@ __metadata: languageName: node linkType: hard -"@smithy/fetch-http-handler@npm:^5.0.4, @smithy/fetch-http-handler@npm:^5.3.9, @smithy/fetch-http-handler@npm:^5.4.2, @smithy/fetch-http-handler@npm:^5.4.3, @smithy/fetch-http-handler@npm:^5.6.13": - version: 5.7.0 - resolution: "@smithy/fetch-http-handler@npm:5.7.0" +"@smithy/fetch-http-handler@npm:^5.0.4, @smithy/fetch-http-handler@npm:^5.3.9, @smithy/fetch-http-handler@npm:^5.4.2, @smithy/fetch-http-handler@npm:^5.4.3, @smithy/fetch-http-handler@npm:^5.6.13, @smithy/fetch-http-handler@npm:^5.7.2": + version: 5.7.2 + resolution: "@smithy/fetch-http-handler@npm:5.7.2" dependencies: - "@smithy/core": "npm:^3.32.0" - "@smithy/types": "npm:^4.17.0" + "@smithy/core": "npm:^3.33.2" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/2384d4f000855c8f1b097f959136820da1f133bf5c73883d2365c295776b0142f5bc3660721ee66e99d2c6a1ccce93158ee2c42f5983cf9f00d86ccab8c5dd0b + checksum: 10c0/9f4541cb743a5207f33d6abd135642a5310cc24f0ed5d46836bf3692901110216f64e09df977488a652762332156fe914b934c780f6a95ef8b1c7d3235c99c7f languageName: node linkType: hard @@ -16332,14 +16396,14 @@ __metadata: languageName: node linkType: hard -"@smithy/node-http-handler@npm:^4.4.9, @smithy/node-http-handler@npm:^4.7.2, @smithy/node-http-handler@npm:^4.7.3, @smithy/node-http-handler@npm:^4.9.13": - version: 4.11.0 - resolution: "@smithy/node-http-handler@npm:4.11.0" +"@smithy/node-http-handler@npm:^4.11.3, @smithy/node-http-handler@npm:^4.4.9, @smithy/node-http-handler@npm:^4.7.2, @smithy/node-http-handler@npm:^4.7.3": + version: 4.11.3 + resolution: "@smithy/node-http-handler@npm:4.11.3" dependencies: - "@smithy/core": "npm:^3.33.0" - "@smithy/types": "npm:^4.17.0" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10c0/3e496c49bb61f2948020da8f08cb2331d2678754ec2a11532dd80b90a061972d6fafc6f8908fece77b4e36c9750da6ad88c0fbff6ac8eddf0dc020bee02b0696 + checksum: 10c0/b1ec5956281d7ab5a5d1d4ca890b5d9f84f6540bb80c07e5b0eb31426bad27ff03ae56851e95c3fc8efb52ac9d432df86f9735e1be01db7ecdd1559a85683589 languageName: node linkType: hard @@ -16553,12 +16617,12 @@ __metadata: languageName: node linkType: hard -"@smithy/types@npm:^4.12.0, @smithy/types@npm:^4.14.1, @smithy/types@npm:^4.14.2, @smithy/types@npm:^4.16.1, @smithy/types@npm:^4.17.0, @smithy/types@npm:^4.8.0": - version: 4.17.0 - resolution: "@smithy/types@npm:4.17.0" +"@smithy/types@npm:^4.12.0, @smithy/types@npm:^4.14.1, @smithy/types@npm:^4.14.2, @smithy/types@npm:^4.16.1, @smithy/types@npm:^4.17.0, @smithy/types@npm:^4.17.2, @smithy/types@npm:^4.8.0": + version: 4.17.2 + resolution: "@smithy/types@npm:4.17.2" dependencies: tslib: "npm:^2.6.2" - checksum: 10c0/f985f116e02ad60168a4bcd97140e971ee0a83a083574a8800d3364c62c2445d1fa2314214f19d16446acda18bed9f0ee632cacdd897804c51339a5a1c9ce422 + checksum: 10c0/7a11f38e6dacdf2247c17bcd50a68ce6200a09dc6a7ecd8d563de94855a25c1da45c4d98f586e3c6fe94727ddbbde6d88ff212ac8eabf7d49779902d1a99a715 languageName: node linkType: hard @@ -19081,6 +19145,17 @@ __metadata: languageName: node linkType: hard +"@types/pg@npm:8.23.1": + version: 8.23.1 + resolution: "@types/pg@npm:8.23.1" + dependencies: + "@types/node": "npm:*" + pg-protocol: "npm:*" + pg-types: "npm:^2.2.0" + checksum: 10c0/0e39ff7dbe233e1e50b172d87fe84cca086a52e255adb104ef1d60619cded356c4414427ffbea58e70a5a985c3036603c8e544ca2937fc1cf4050185109973be + languageName: node + linkType: hard + "@types/pluralize@npm:0.0.33": version: 0.0.33 resolution: "@types/pluralize@npm:0.0.33" @@ -38242,10 +38317,10 @@ __metadata: languageName: node linkType: hard -"pg-connection-string@npm:^2.13.0": - version: 2.13.0 - resolution: "pg-connection-string@npm:2.13.0" - checksum: 10c0/870f83a8fca06d0340fc522653471d9c7081efbadf25c7f5801fcfb58104ef527138bb5d0546b21498ff4df75a742469622f657911a3b74034a1e94e59f34e31 +"pg-connection-string@npm:^2.14.0": + version: 2.14.0 + resolution: "pg-connection-string@npm:2.14.0" + checksum: 10c0/c26d85970f782de72b90aa45b4acfa34df90242fc08bc676e8827297164044dbfb40a2cf20b0646bd02662a6b14c5ed27f322db627e41e582f2b705bc41e8b47 languageName: node linkType: hard @@ -38265,14 +38340,14 @@ __metadata: languageName: node linkType: hard -"pg-protocol@npm:^1.14.0": - version: 1.14.0 - resolution: "pg-protocol@npm:1.14.0" - checksum: 10c0/dccb29b30f5cee8f2ca7dfd17da9eb957174f7a1a25e987e0bfc9fe7640f53dc9fd05c7f3635e7db0c5eefcd41716fffe625f3c1ea9789634d438851b9ce90ae +"pg-protocol@npm:*, pg-protocol@npm:^1.16.0": + version: 1.16.0 + resolution: "pg-protocol@npm:1.16.0" + checksum: 10c0/8ea4a8f4970aa0ca1e5bf9a482da0a41a043d7470fbaf7592736971146b09a2a4003d953c0091b72fc30bf7a7994a0b7fdf897bf50cd219787bd22806b3ffb04 languageName: node linkType: hard -"pg-types@npm:2.2.0": +"pg-types@npm:2.2.0, pg-types@npm:^2.2.0": version: 2.2.0 resolution: "pg-types@npm:2.2.0" dependencies: @@ -38285,14 +38360,14 @@ __metadata: languageName: node linkType: hard -"pg@npm:^8.18.0": - version: 8.21.0 - resolution: "pg@npm:8.21.0" +"pg@npm:8.23.0, pg@npm:^8.18.0": + version: 8.23.0 + resolution: "pg@npm:8.23.0" dependencies: pg-cloudflare: "npm:^1.4.0" - pg-connection-string: "npm:^2.13.0" + pg-connection-string: "npm:^2.14.0" pg-pool: "npm:^3.14.0" - pg-protocol: "npm:^1.14.0" + pg-protocol: "npm:^1.16.0" pg-types: "npm:2.2.0" pgpass: "npm:1.0.5" peerDependencies: @@ -38303,7 +38378,7 @@ __metadata: peerDependenciesMeta: pg-native: optional: true - checksum: 10c0/6b46ae867a3838bf3bb720ef5a3d877bd85de19d90c6f3422e772f56443fc04a4f5b1fa44c9e8544a0f44454971e653d98f4040096e92c378a5aa5a7b07fa0f1 + checksum: 10c0/11ce12a71b4239588c18f078531c378d5bcdbcb9ea892e080a6e71f91aaece2b4b8fd93ee5777f94210f9258ff56161f8ed1a45050c32d295aaca037bba12ee8 languageName: node linkType: hard