Skip to content
68 changes: 68 additions & 0 deletions apps/brunch-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,74 @@ running):
yarn workspace @apps/brunch-agent transcript -- --principal <key> --id <conversationId>
```

## 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
Expand Down
18 changes: 18 additions & 0 deletions apps/brunch-agent/docs/task-dependencies.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion apps/brunch-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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"
}
}
5 changes: 1 addition & 4 deletions apps/brunch-agent/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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));
Expand Down
125 changes: 125 additions & 0 deletions apps/brunch-agent/src/database-config.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>;

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;
};
Comment thread
cursor[bot] marked this conversation as resolved.

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".`,
);
}
33 changes: 28 additions & 5 deletions apps/brunch-agent/src/db.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading