diff --git a/.dockerignore b/.dockerignore index 61a8cd29fc9..53ee5a805b6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,8 @@ # Petrinaut bundles its user-facing docs into the build via `?raw` imports, # so they must be present in the Docker context (not just README.md). !libs/@hashintel/petrinaut/docs/*.md +# Brunch packages prompts and skills through `?raw` imports in their builds. +!libs/@hashintel/brunch-agent/packages/*/src/**/*.md ######################## ## Same as in .gitignore diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6fbd1cedfe0..375cfa7b0a4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -112,6 +112,14 @@ jobs: build_args: "API_ORIGIN=http://localhost:5001\nFRONTEND_URL=http://localhost:3000\n", ecs: [] }, + { + package: "@apps/brunch-agent", + service: "brunch-agent", + push: ["ecr", "ghcr"], + dockerfile: "apps/brunch-agent/docker/Dockerfile", + context: ".", + ecs: [] + }, { package: "@apps/hash-ai-worker-ts", service: "ai-worker-ts", diff --git a/apps/brunch-agent/docker/Dockerfile b/apps/brunch-agent/docker/Dockerfile new file mode 100644 index 00000000000..3dc4f5b9c01 --- /dev/null +++ b/apps/brunch-agent/docker/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1 + +# Build from the repository root: +# docker buildx build --file apps/brunch-agent/docker/Dockerfile . --load + +FROM debian:13.3-slim AS tools + +SHELL ["/bin/bash", "-euo", "pipefail", "-c"] + +ENV MISE_DATA_DIR="/mise" +ENV MISE_CACHE_DIR="/mise/cache" +ENV PATH="/mise/shims:$PATH" + +COPY .config/mise /etc/mise + +RUN --mount=type=secret,id=GITHUB_TOKEN,env=GITHUB_TOKEN \ + apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl && \ + /etc/mise/install.sh && \ + mise --version && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + eval "$(mise activate bash)" && \ + mise install --locked node npm:turbo yq + + +FROM tools AS pruner + +WORKDIR /repo + +COPY . . + +RUN mise trust && \ + turbo prune $(.github/scripts/prune-scopes.sh '@apps/brunch-agent') --docker + + +FROM tools AS builder + +WORKDIR /repo + +RUN corepack enable + +COPY --from=pruner /repo/out/json/ ./ +COPY --from=pruner /repo/out/yarn.lock ./yarn.lock +COPY --from=pruner /repo/out/full/.yarn ./.yarn +COPY --from=pruner /repo/out/full/turbo.json ./turbo.json + +RUN --mount=type=cache,target=/root/.yarn/berry/cache \ + yarn install --immutable + +COPY --from=pruner /repo/out/full/ ./ + +RUN turbo run build --filter '@apps/brunch-agent' --env-mode=loose && \ + yarn workspaces focus @apps/brunch-agent --production && \ + rm -rf apps/brunch-agent/test + + +FROM tools AS runner + +ENV NODE_ENV=production + +WORKDIR /repo/apps/brunch-agent + +COPY --from=builder /repo /repo + +RUN groupadd --system --gid 60000 hash && \ + useradd --system --uid 60000 --gid hash --create-home brunch && \ + install --directory --owner brunch --group hash \ + /repo/apps/brunch-agent/.data-wipe-me + +USER brunch:hash + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] + +CMD ["node", "dist/server.mjs"] diff --git a/apps/brunch-agent/docs/task-dependencies.json b/apps/brunch-agent/docs/task-dependencies.json index 5a701a790ae..2900aa293ba 100644 --- a/apps/brunch-agent/docs/task-dependencies.json +++ b/apps/brunch-agent/docs/task-dependencies.json @@ -15,6 +15,7 @@ "@hashintel/brunch-agent-transport-aisdk#build", "@hashintel/petrinaut-core#build" ], + "build:docker": [], "dev": [ "@hashintel/brunch-agent#build", "@hashintel/brunch-agent-binding-flue#build", diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index 9b158c52133..144908fadbb 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -7,6 +7,7 @@ "type": "module", "scripts": { "build": "vite build && vite build --config vite.client.config.ts", + "build:docker": "docker buildx build --tag brunch-agent --file docker/Dockerfile ../../ --load", "dev": "vite dev", "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 65b6d11ac79..715b56c2130 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -15,10 +15,15 @@ import { createAgentRouter } from "@flue/runtime/routing"; import { Hono } from "hono"; import { ChatAgent } from "./agents/chat-agent/agent.ts"; +import { healthHandler } from "./health.ts"; import { assetHandler } from "./http/assets.ts"; import { agentOwnershipGuard } from "./http/ownership.ts"; import { createPetrinautChatHandler } from "./http/petrinaut-chat.ts"; -import { CHAT_AGENT_ROUTE, PETRINAUT_CHAT_ROUTE } from "./http/routes.ts"; +import { + CHAT_AGENT_ROUTE, + HEALTH_ROUTE, + PETRINAUT_CHAT_ROUTE, +} from "./http/routes.ts"; instrument(createOpenTelemetryInstrumentation({ content: false })); @@ -35,6 +40,8 @@ app.on(["GET", "POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => petrinautChatHandler(c.req.raw), ); +app.get(HEALTH_ROUTE, healthHandler); + const uiRoot = new URL( // oxlint-disable-next-line typescript/no-unnecessary-condition -- import.meta.env is absent when Node executes this module directly. import.meta.env?.DEV === false ? "./client/" : "../", diff --git a/apps/brunch-agent/src/health.ts b/apps/brunch-agent/src/health.ts new file mode 100644 index 00000000000..12027892a69 --- /dev/null +++ b/apps/brunch-agent/src/health.ts @@ -0,0 +1,7 @@ +import type { Context } from "hono"; + +export const healthHandler = (context: Context): Response => + context.json({ status: "pass" }, 200, { + "cache-control": "no-store", + "content-type": "application/health+json", + }); diff --git a/apps/brunch-agent/src/http/routes.ts b/apps/brunch-agent/src/http/routes.ts index 5100c2cb669..008c73437ee 100644 --- a/apps/brunch-agent/src/http/routes.ts +++ b/apps/brunch-agent/src/http/routes.ts @@ -2,5 +2,8 @@ export const CHAT_AGENT_ROUTE = "chat"; +/** Cheap process-liveness probe; dependency readiness is established before listen. */ +export const HEALTH_ROUTE = "/health"; + /** Stock `DefaultChatTransport` endpoint used by Petrinaut's local panel. */ export const PETRINAUT_CHAT_ROUTE = "/api/chat"; diff --git a/apps/brunch-agent/test/health.test.ts b/apps/brunch-agent/test/health.test.ts new file mode 100644 index 00000000000..37c5c158750 --- /dev/null +++ b/apps/brunch-agent/test/health.test.ts @@ -0,0 +1,24 @@ +import { Hono } from "hono"; +import { expect, test, vi } from "vitest"; + +import { healthHandler } from "../src/health.ts"; + +test("health reports liveness without consulting dependencies", async () => { + const dependency = vi.fn<() => void>(); + const app = new Hono(); + app.get("/health", (context) => { + const response = healthHandler(context); + expect(dependency).not.toHaveBeenCalled(); + return response; + }); + + const response = await app.request("/health"); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toContain( + "application/health+json", + ); + await expect(response.json()).resolves.toEqual({ status: "pass" }); + expect(dependency).not.toHaveBeenCalled(); +});