Skip to content

Commit 6dbedef

Browse files
bloveclaude
andcommitted
feat(deployments): ag-ui-mastra — hand-written Node AG-UI hosting service for Mastra (Lane B)
Upstream @ag-ui/mastra ships no plain AG-UI HTTP endpoint, only the in-process MastraAgent bridge. This self-contained service (own package.json + lockfile, deps NOT in the root workspace) is that endpoint: POST /agent/<topic> -> MastraAgent.run(input) -> SSE frames. - /ok unauthenticated; everything else requires X-Internal-Token === AG_UI_INTERNAL_TOKEN with a clean 401 JSON (ag-ui-dev middleware contract); refuses to boot without the env var. - Observable errors map to RUN_ERROR frames, never a dropped socket. - LibSQL file storage (AG_UI_MASTRA_DB_PATH) for memory + suspended-run snapshots — suspend/resume requires persistent storage (spike finding); on Railway this must ride a mounted volume. - The mastra topic's agent lives here (agents.mjs): streaming chat, a backend tool, working-memory shared state (STATE_SNAPSHOT + real STATE_DELTA), and a suspend/approval tool whose resume round-trips via forwardedProps.command.interruptEvent{toolCallId,runId}. - npm test: transcript-shape tests asserting each surface's SSE event grammar against the measured 2026-08-31 spike captures, plus an interrupt->resume round trip through the real @ag-ui/client 0.0.59. - deploy-ag-ui-mastra.yml mirrors the ag-ui-dev boot gate (#899): npm ci, node --check, npm test, boot + curl /ok BEFORE railway up --detach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 53425c6 commit 6dbedef

13 files changed

Lines changed: 9329 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: Deploy AG-UI Mastra Railway
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'deployments/ag-ui-mastra/**'
8+
- '.github/workflows/deploy-ag-ui-mastra.yml'
9+
workflow_dispatch:
10+
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: false
14+
15+
permissions:
16+
contents: read
17+
18+
env:
19+
DO_NOT_TRACK: '1'
20+
21+
jobs:
22+
deploy:
23+
name: Deploy ag-ui-mastra to Railway
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
27+
28+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
29+
with:
30+
node-version: 22
31+
cache: npm
32+
cache-dependency-path: deployments/ag-ui-mastra/package-lock.json
33+
34+
# The service is self-contained: its own package.json + lockfile,
35+
# independent of the root workspace.
36+
- name: Install service dependencies
37+
working-directory: deployments/ag-ui-mastra
38+
run: npm ci --no-audit --no-fund
39+
40+
- name: Syntax-check entry modules
41+
working-directory: deployments/ag-ui-mastra
42+
run: |
43+
node --check server.mjs
44+
node --check agents.mjs
45+
46+
- name: Transcript-shape tests (real @ag-ui/client, scripted model)
47+
working-directory: deployments/ag-ui-mastra
48+
run: npm test
49+
50+
- name: Boot gate — the service must start and answer /ok
51+
# `railway up --detach` reports success at upload time, so a build or
52+
# boot failure on Railway is invisible to CI and the last good image
53+
# keeps serving (this hid a broken ag-ui-dev deploy for 2.5 months —
54+
# see .github/workflows/deploy-ag-ui.yml). Booting the exact code
55+
# with the exact pinned deps reproduces the container's start here
56+
# and fails the workflow instead.
57+
working-directory: deployments/ag-ui-mastra
58+
run: |
59+
AG_UI_INTERNAL_TOKEN=bootgate OPENAI_API_KEY=sk-bootgate PORT=8321 \
60+
node server.mjs &
61+
SERVER_PID=$!
62+
for i in $(seq 1 30); do
63+
if curl -fsS http://127.0.0.1:8321/ok >/dev/null 2>&1; then
64+
echo "boot ok: /ok answered"
65+
kill "$SERVER_PID"
66+
exit 0
67+
fi
68+
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
69+
echo "::error::service exited before /ok answered"
70+
exit 1
71+
fi
72+
sleep 1
73+
done
74+
echo "::error::service did not answer /ok within 30s"
75+
kill "$SERVER_PID" || true
76+
exit 1
77+
78+
- name: Install Railway CLI
79+
run: npm install -g @railway/cli@4.68.0
80+
81+
- name: Deploy
82+
working-directory: deployments/ag-ui-mastra
83+
run: railway up --service ag-ui-mastra --detach
84+
env:
85+
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
data
3+
test
4+
.env
5+
*.db
6+
*.db-shm
7+
*.db-wal
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# ag-ui-mastra service configuration. Placeholder values only — never commit
2+
# real tokens or keys.
3+
4+
# Shared secret. Every route except GET /ok requires the request header
5+
# X-Internal-Token to equal this value (injected by the Vercel proxy in
6+
# production). The service refuses to boot without it.
7+
AG_UI_INTERNAL_TOKEN=choose-a-random-internal-token
8+
9+
# Model access for the Mastra agent (model router: openai/gpt-4o-mini).
10+
OPENAI_API_KEY=sk-your-openai-api-key
11+
12+
# HTTP port (Railway injects PORT automatically).
13+
PORT=8321
14+
15+
# LibSQL file path for Mastra memory + suspended-run snapshots.
16+
# MUST live on persistent storage: resume loads the suspended snapshot from
17+
# here, so on Railway this must point into a mounted volume (e.g. /data).
18+
# Defaults to ./data/mastra.db next to server.mjs when unset.
19+
AG_UI_MASTRA_DB_PATH=/data/mastra.db
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
data/
3+
*.db
4+
*.db-shm
5+
*.db-wal
6+
.env
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# ag-ui-mastra: Node hosting service for the Mastra AG-UI runtime (Lane B).
2+
# Mirrors the ag-ui-dev deployment pattern: slim runtime image, /ok
3+
# healthcheck via railway.json, watchdog entrypoint.
4+
FROM node:22-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS builder
5+
WORKDIR /build
6+
COPY package.json package-lock.json ./
7+
RUN npm ci --omit=dev --no-audit --no-fund
8+
9+
FROM node:22-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5
10+
WORKDIR /app
11+
12+
# curl is needed by entrypoint.sh's watchdog.
13+
RUN apt-get update && apt-get install -y --no-install-recommends curl \
14+
&& rm -rf /var/lib/apt/lists/*
15+
16+
COPY --from=builder /build/node_modules ./node_modules
17+
COPY . .
18+
RUN chmod +x entrypoint.sh
19+
20+
ENV NODE_ENV=production
21+
EXPOSE 8321
22+
CMD ["./entrypoint.sh"]

deployments/ag-ui-mastra/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# ag-ui-mastra
2+
3+
Hand-written Node hosting service for the **Mastra** AG-UI runtime — Lane B of
4+
the runtime-portability matrix (`docs/superpowers/plans/2026-08-31-runtime-portability-matrix.md`).
5+
6+
Upstream `@ag-ui/mastra` ships **no** plain AG-UI HTTP endpoint (only the
7+
in-process `MastraAgent` bridge and a CopilotKit runtime mount). This service
8+
is that missing endpoint: `POST /agent/<topic>``MastraAgent.run(input)`
9+
one SSE `data:` frame per AG-UI event. It is deliberately hand-written, not
10+
generated: the Python generator (`scripts/generate-ag-ui-deployment-config.ts`)
11+
targets one aggregated FastAPI process, and Mastra is a different language and
12+
hosting lane.
13+
14+
Unlike the Python lane (where each topic's backend lives in
15+
`cockpit/<product>/<topic>/python` and is staged into `deployments/ag-ui-dev`),
16+
the `mastra` topic's backend lives HERE (`agents.mjs`) — the registry entry
17+
`rt-mastra` has no `pythonDir` on purpose.
18+
19+
## Contract (mirrors deployments/ag-ui-dev/server.py)
20+
21+
- `GET /ok` — unauthenticated health check.
22+
- Every other route requires `X-Internal-Token: $AG_UI_INTERNAL_TOKEN`,
23+
else `401 {"detail":"unauthorized"}`. The service refuses to boot without
24+
the env var.
25+
- `POST /agent/mastra` — AG-UI run endpoint (RunAgentInput JSON in, SSE out).
26+
- An Observable error is mapped to a `RUN_ERROR` frame, never a dropped socket.
27+
28+
## Storage (required for suspend/resume)
29+
30+
Mastra persists memory and **suspended-run snapshots** to LibSQL file storage
31+
(`AG_UI_MASTRA_DB_PATH`, default `./data/mastra.db`). Resume loads the
32+
suspended snapshot back, so this path must be persistent:
33+
34+
- **Railway: mount a volume** (e.g. at `/data`) and set
35+
`AG_UI_MASTRA_DB_PATH=/data/mastra.db`. Without the volume, every redeploy
36+
or restart orphans pending interrupts.
37+
38+
## Local development (serves cockpit/runtimes/mastra)
39+
40+
```bash
41+
cd deployments/ag-ui-mastra
42+
npm ci
43+
AG_UI_INTERNAL_TOKEN=dev-local-token \
44+
OPENAI_API_KEY=sk-... \
45+
PORT=5332 node server.mjs
46+
```
47+
48+
Then in another terminal: `npx nx run cockpit-runtimes-mastra-angular:serve:cockpit --port 4332`
49+
(or `npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-mastra`,
50+
which starts the Angular side; this Node service must be started manually as
51+
above — the serve script only auto-starts Python backends). The example's
52+
`proxy.conf.mjs` forwards `/agent``http://localhost:5332/agent/mastra` and
53+
injects `X-Internal-Token: dev-local-token` (override via the
54+
`AG_UI_INTERNAL_TOKEN` env var when serving with a different token).
55+
56+
Do NOT `source` the repo root `.env` — export only what you need. (A stray
57+
`AG_UI_INTERNAL_TOKEN` mismatch between service and proxy manifests as bogus
58+
401s that look like an OpenAI auth problem.)
59+
60+
## Tests
61+
62+
`npm test` runs transcript-shape tests: every surface's SSE event grammar is
63+
asserted against the measured 2026-08-31 spike captures, and the
64+
interrupt→resume round trip is driven through the real `@ag-ui/client` 0.0.59
65+
(`devDependencies`) — the same client the Angular adapter wraps. The model is
66+
a scripted OpenAI responses-API mock; no network, no key.
67+
68+
## Deployment
69+
70+
`.github/workflows/deploy-ag-ui-mastra.yml` deploys to the Railway service
71+
`ag-ui-mastra` on pushes to main that touch this directory. It mirrors the
72+
ag-ui-dev boot gate: `npm ci` + `node --check` + a real boot (dummy token,
73+
`curl /ok`) + `npm test` BEFORE `railway up --detach`, because `--detach`
74+
reports success at upload time and would otherwise hide build/boot failures.
75+
76+
Railway service requirements (one-time setup):
77+
- service name `ag-ui-mastra` in the same project as `ag-ui-dev`
78+
- env vars: `AG_UI_INTERNAL_TOKEN` (same value the Vercel examples project
79+
uses), `OPENAI_API_KEY`, `AG_UI_MASTRA_DB_PATH=/data/mastra.db`
80+
- a volume mounted at `/data`
81+
- a public domain (its URL becomes `AG_UI_MASTRA_URL` on the Vercel examples
82+
project, read by `scripts/ag-ui-proxy.ts`)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// SPDX-License-Identifier: MIT
2+
// Mastra agent definitions for the `mastra` runtime-portability topic
3+
// (cockpit/runtimes/mastra/). This service IS that topic's backend — the
4+
// registry entry has no pythonDir, so unlike the Python lane there is no
5+
// per-example module staged into a generated deployment; the agent lives
6+
// here, next to the HTTP shim that serves it.
7+
//
8+
// The agent exercises every green cell of the measured matrix on one route:
9+
// - streaming chat (TEXT_MESSAGE_CHUNK)
10+
// - one backend tool (`check_conditions` → TOOL_CALL_* + TOOL_CALL_RESULT)
11+
// - shared state via working memory (STATE_SNAPSHOT + STATE_DELTA — Mastra
12+
// emits real JSON-Patch deltas, measured in the spike's 04a capture)
13+
// - suspend/resume human-in-the-loop (`reserve_campsite` → CUSTOM
14+
// on_interrupt + RUN_FINISHED outcome interrupt; resume arrives as
15+
// forwardedProps.command.interruptEvent{toolCallId,runId})
16+
import { Agent } from '@mastra/core/agent';
17+
import { Mastra } from '@mastra/core/mastra';
18+
import { createTool } from '@mastra/core/tools';
19+
import { Memory } from '@mastra/memory';
20+
import { LibSQLStore } from '@mastra/libsql';
21+
import { z } from 'zod';
22+
23+
/** Model string resolved by Mastra's model router. It honors the standard
24+
* OPENAI_API_KEY and OPENAI_BASE_URL env vars, which is what lets the
25+
* aimock e2e harness intercept calls without any code fork. */
26+
const MODEL = 'openai/gpt-4o-mini';
27+
28+
const NIGHTLY_RATE_USD = 45;
29+
30+
/** Deterministic backend tool — no external calls, stable for fixtures. */
31+
const checkConditionsTool = createTool({
32+
id: 'check_conditions',
33+
description: 'Check current trail and weather conditions for a location.',
34+
inputSchema: z.object({ location: z.string().describe('Park or trailhead name') }),
35+
outputSchema: z.object({
36+
location: z.string(),
37+
forecast: z.string(),
38+
high_c: z.number(),
39+
low_c: z.number(),
40+
}),
41+
execute: async (inputData) => ({
42+
location: inputData.location,
43+
forecast: 'Clear skies, light afternoon breeze',
44+
high_c: 18,
45+
low_c: 4,
46+
}),
47+
});
48+
49+
/**
50+
* Human-in-the-loop tool. First call suspends the run (persisted to LibSQL —
51+
* suspend/resume REQUIRES persistent storage, a spike finding); the frontend
52+
* shows an approval card and resumes with `{ approved: boolean }`.
53+
*/
54+
const reserveCampsiteTool = createTool({
55+
id: 'reserve_campsite',
56+
description:
57+
'Reserve a campsite. Requires explicit user approval: the tool pauses the run and shows the user a confirmation card before booking.',
58+
inputSchema: z.object({
59+
site: z.string().describe('Campsite name'),
60+
nights: z.number().int().min(1).describe('Number of nights'),
61+
}),
62+
suspendSchema: z.object({
63+
site: z.string(),
64+
nights: z.number(),
65+
total_usd: z.number(),
66+
}),
67+
resumeSchema: z.object({
68+
approved: z.boolean().optional(),
69+
}),
70+
execute: async (inputData, context) => {
71+
const { resumeData, suspend } = context?.agent ?? {};
72+
if (!resumeData) {
73+
return suspend?.({
74+
site: inputData.site,
75+
nights: inputData.nights,
76+
total_usd: inputData.nights * NIGHTLY_RATE_USD,
77+
});
78+
}
79+
if (resumeData.approved) {
80+
return `Reserved ${inputData.site} for ${inputData.nights} night(s) — total $${
81+
inputData.nights * NIGHTLY_RATE_USD
82+
}. Confirmation TP-${String(inputData.nights).padStart(2, '0')}88.`;
83+
}
84+
return `Reservation for ${inputData.site} was declined by the user. Nothing was booked.`;
85+
},
86+
});
87+
88+
/**
89+
* Build the Mastra instance for this service.
90+
*
91+
* @param {string} dbUrl LibSQL url (`file:/path/to/mastra.db`). File-backed
92+
* storage is required: Mastra persists suspended-run snapshots there, and
93+
* resume loads them back — an in-memory store would break resume across
94+
* HTTP requests (and across restarts on Railway, hence the volume).
95+
*/
96+
export function createMastra(dbUrl) {
97+
const store = (id) => new LibSQLStore({ id, url: dbUrl });
98+
99+
const tripAgent = new Agent({
100+
id: 'mastra',
101+
name: 'mastra',
102+
instructions: `You are a terse camping trip planner.
103+
The packing list in working memory is the user's shared state: whenever the user adds, removes, or changes items (or starts a list), update working memory to match. 'items' is an array of {name, qty}. Never mention memory or the list mechanics.
104+
For questions about weather or trail conditions you MUST call check_conditions.
105+
When the user asks to reserve or book a campsite you MUST call reserve_campsite; after it resumes, confirm the outcome.
106+
Always answer in one short sentence.`,
107+
model: MODEL,
108+
tools: {
109+
check_conditions: checkConditionsTool,
110+
reserve_campsite: reserveCampsiteTool,
111+
},
112+
memory: new Memory({
113+
storage: store('mastra-topic-memory'),
114+
options: {
115+
workingMemory: {
116+
enabled: true,
117+
schema: z.object({
118+
packing_list: z.object({
119+
title: z.string().describe('Packing list title'),
120+
items: z
121+
.array(z.object({ name: z.string(), qty: z.number() }))
122+
.describe('All items on the list'),
123+
}),
124+
}),
125+
},
126+
},
127+
}),
128+
});
129+
130+
return new Mastra({
131+
agents: { mastra: tripAgent },
132+
storage: store('mastra-instance'),
133+
});
134+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
# Starts the Node service and runs a watchdog that polls /ok every 30s after
3+
# a 60s startup grace. Three consecutive failures kill the server so
4+
# Railway's restart-policy can recover. Same pattern as ag-ui-dev.
5+
set -euo pipefail
6+
7+
PORT="${PORT:-8321}"
8+
node server.mjs &
9+
NODE_PID=$!
10+
11+
sleep 60 # startup grace
12+
STRIKES=0
13+
while kill -0 "${NODE_PID}" 2>/dev/null; do
14+
sleep 30
15+
if curl -fsS "http://127.0.0.1:${PORT}/ok" >/dev/null; then
16+
STRIKES=0
17+
else
18+
STRIKES=$((STRIKES + 1))
19+
echo "watchdog: strike ${STRIKES}/3" >&2
20+
if [ "${STRIKES}" -ge 3 ]; then
21+
echo "watchdog: 3 strikes, killing node (pid ${NODE_PID})" >&2
22+
kill "${NODE_PID}" || true
23+
exit 1
24+
fi
25+
fi
26+
done
27+
wait "${NODE_PID}"

0 commit comments

Comments
 (0)