From 9f629adb5b9b79d49f2f75ac72f7876608c85006 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 2 Sep 2026 13:09:37 +0200 Subject: [PATCH 1/6] 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 --- .dockerignore | 4 ++ .github/workflows/deploy.yml | 8 +++ apps/brunch-agent/docker/Dockerfile | 76 +++++++++++++++++++++++++++ apps/brunch-agent/src/health.ts | 7 +++ apps/brunch-agent/src/http/routes.ts | 3 ++ apps/brunch-agent/test/health.test.ts | 24 +++++++++ 6 files changed, 122 insertions(+) create mode 100644 apps/brunch-agent/docker/Dockerfile create mode 100644 apps/brunch-agent/src/health.ts create mode 100644 apps/brunch-agent/test/health.test.ts diff --git a/.dockerignore b/.dockerignore index 61a8cd29fc9..e70d6458db9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,10 @@ # 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 imports its core prompt and authored Flue skill through the build +# graph. Admit only those production Markdown inputs. +!libs/@hashintel/brunch-agent/packages/core/src/SYSTEM.md +!libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/skills/sdcpn-modelling/*.md ######################## ## Same as in .gitignore diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6fbd1cedfe0..4a7888c066e 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: ["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..5218c7f849a --- /dev/null +++ b/apps/brunch-agent/docker/Dockerfile @@ -0,0 +1,76 @@ +# 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 + +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) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + +CMD ["node", "dist/server.mjs"] 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(); +}); From 979aed4e2b805fc9c3a3802e45303fbc7e8629ce Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 2 Sep 2026 13:10:06 +0200 Subject: [PATCH 2/6] Wire container build and liveness entry points Co-authored-by: Cursor --- apps/brunch-agent/package.json | 1 + apps/brunch-agent/src/app.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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/" : "../", From 0ad3491196387b15f6cd0c0d0515c8a8548d5fdb Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 3 Sep 2026 10:21:08 +0200 Subject: [PATCH 3/6] Publish the Brunch image to ECR Co-authored-by: Cursor --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4a7888c066e..375cfa7b0a4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -115,7 +115,7 @@ jobs: { package: "@apps/brunch-agent", service: "brunch-agent", - push: ["ghcr"], + push: ["ecr", "ghcr"], dockerfile: "apps/brunch-agent/docker/Dockerfile", context: ".", ecs: [] From 440d73174909b5f124964714381a9f2e179fc4f9 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Thu, 3 Sep 2026 16:01:41 +0200 Subject: [PATCH 4/6] Document the Brunch container task dependency Co-authored-by: Cursor --- apps/brunch-agent/docs/task-dependencies.json | 1 + 1 file changed, 1 insertion(+) 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", From 72e2d831c442d9b7ce2bfdf9110d053a22df5968 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 4 Sep 2026 12:02:03 +0200 Subject: [PATCH 5/6] Include the Brunch skill in container builds Co-authored-by: Cursor --- .dockerignore | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index e70d6458db9..ea5d4af6116 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,10 +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 imports its core prompt and authored Flue skill through the build -# graph. Admit only those production Markdown inputs. -!libs/@hashintel/brunch-agent/packages/core/src/SYSTEM.md -!libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/skills/sdcpn-modelling/*.md +# Brunch imports its authored Flue skill through the application build graph. +!apps/brunch-agent/src/skills/sdcpn-modelling/*.md ######################## ## Same as in .gitignore From 9144fc880f8538b7e054d9438003667595f3ced5 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Fri, 4 Sep 2026 14:22:56 +0200 Subject: [PATCH 6/6] Fix Brunch container runtime inputs Co-authored-by: Cursor --- .dockerignore | 4 ++-- apps/brunch-agent/docker/Dockerfile | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index ea5d4af6116..53ee5a805b6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,8 +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 imports its authored Flue skill through the application build graph. -!apps/brunch-agent/src/skills/sdcpn-modelling/*.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/apps/brunch-agent/docker/Dockerfile b/apps/brunch-agent/docker/Dockerfile index 5218c7f849a..3dc4f5b9c01 100644 --- a/apps/brunch-agent/docker/Dockerfile +++ b/apps/brunch-agent/docker/Dockerfile @@ -64,13 +64,15 @@ 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 + 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) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + 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"]