Skip to content

Commit d23c259

Browse files
bloveclaude
andauthored
feat: Mastra runtime Lane B — ag-ui-mastra Node service, proxy map, rt-mastra example, deploy workflow (#903)
* 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> * feat(examples-proxy): topic→upstream map (mastra → AG_UI_MASTRA_URL) + /runtimes proxy routes - scripts/ag-ui-proxy.ts: upstreamBaseFor(topic) — every existing topic keeps routing to the default Railway FastAPI upstream unchanged; the mastra topic routes to AG_UI_MASTRA_URL (clear per-topic 500 when unset). parseProxyPath now also accepts the /runtimes/<topic>/agent path shape. - scripts/assemble-examples.ts: add the /runtimes proxy route (same catch-all function dest) and include 'runtimes' in the SPA filesystem fallback routes — the runtimes product examples were copied into the deploy dir but unreachable in production (404 on /runtimes/microsoft-agent-framework/ before this). - scripts/ag-ui-proxy.spec.ts: pins the contract — all 9 existing topics hit the default upstream with the injected internal token, origin allowlist and 404 shapes unchanged, mastra mapping + misconfig path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(cockpit): rt-mastra runtime example — first cap with a Node-hosted backend cockpit/runtimes/mastra/: duplicated standalone Angular example bound through @threadplane/ag-ui, demonstrating streaming chat, the check_conditions backend tool, Mastra working memory as shared state (STATE_SNAPSHOT + real STATE_DELTA), and the suspend/approval -> command.interruptEvent resume flow. No subagents surface (measured red: Mastra reserves ACTIVITY_* for background tasks). aimock e2e intercepts the Mastra model router via OPENAI_BASE_URL (responses API). Registry: rt-mastra has NO pythonDir — its backend is deployments/ag-ui-mastra. Consumers taught to tolerate that: - capability-registry: framework 'mastra' (Node lane) + rt-mastra entry (ports 4332/5332) - generate-ag-ui-deployment-config: PythonHostedFramework excludes 'mastra'; a mastra cap with a pythonDir now throws. Regeneration stays byte-identical (verified: git-clean after regenerate). - cockpit-matrix.mjs emits python:'' for caps without a python sibling; ci.yml guards the uv/venv steps on it. - cockpit-ports.spec skips caps with no python/ dir at all (still fails a python/ dir missing project.json). - cockpit-e2e-wiring.spec accepts backendCwd alongside langgraphCwd/pythonCwd. - e2e global setup is hand-rolled (spawns node server.mjs against aimock, npm ci on demand, fresh LibSQL per run) and registers state for the shared teardown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 53425c6 commit d23c259

44 files changed

Lines changed: 10433 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,18 @@ jobs:
458458
with:
459459
python-version: '3.12'
460460
- run: npm ci
461+
# matrix.cap.python is '' for caps whose backend is not Python (e.g.
462+
# cockpit-runtimes-mastra-angular — its backend is the
463+
# deployments/ag-ui-mastra Node service, installed by the example's
464+
# own e2e global setup). Skip the uv/venv steps for those.
461465
- name: Cache cap python venv
466+
if: matrix.cap.python != ''
462467
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
463468
with:
464469
path: ${{ matrix.cap.python }}/.venv
465470
key: uv-venv-${{ runner.os }}-py3.12-${{ matrix.cap.python }}-${{ hashFiles(format('{0}/uv.lock', matrix.cap.python)) }}
466471
- name: uv sync per-cap python
472+
if: matrix.cap.python != ''
467473
working-directory: ${{ matrix.cap.python }}
468474
run: uv sync
469475
- name: Cache Playwright browsers
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 }}

apps/cockpit/cockpit-e2e-wiring.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,14 @@ function activeCockpitE2eWiring(): E2eWiring[] {
8383
const globalSetupPath = join(projectRoot, 'e2e/global-setup-impl.ts');
8484
const globalSetup = readFileSync(globalSetupPath, 'utf8');
8585
// langgraph-shaped global-setup uses `langgraphCwd`; ag-ui-shaped
86-
// global-setup (createAgUiGlobalSetup) uses `pythonCwd`. Both name
87-
// the python project's cwd — accept either.
86+
// global-setup (createAgUiGlobalSetup) uses `pythonCwd`; a Node-hosted
87+
// backend (rt-mastra's hand-rolled setup spawning
88+
// deployments/ag-ui-mastra) uses `backendCwd`. All name the backend
89+
// project's cwd — accept any.
8890
const langgraphCwd =
8991
parseStringProperty(globalSetup, 'langgraphCwd') ??
90-
parseStringProperty(globalSetup, 'pythonCwd');
92+
parseStringProperty(globalSetup, 'pythonCwd') ??
93+
parseStringProperty(globalSetup, 'backendCwd');
9194

9295
// Post-port-registry migration: ports are imported from
9396
// cockpit/ports.mjs rather than living as literals in

apps/cockpit/scripts/capability-registry.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@
99
* per-topic module contract (`src/graph.py` exposing `graph` for LangGraph
1010
* vs `src/agent.py` exposing `agent` for Microsoft Agent Framework), and
1111
* the FastAPI mount call. Omitted means 'langgraph'.
12+
*
13+
* 'mastra' is the TypeScript hosting lane (Lane B): its backend is the
14+
* hand-written Node service deployments/ag-ui-mastra, NOT the aggregated
15+
* Python deployment — a 'mastra' capability therefore has no pythonDir and
16+
* the Python deployment generator never stages it.
1217
*/
13-
export type CapabilityFramework = 'langgraph' | 'microsoft-agent-framework' | 'aws-strands';
18+
export type CapabilityFramework = 'langgraph' | 'microsoft-agent-framework' | 'aws-strands' | 'mastra';
1419

1520
export interface Capability {
1621
id: string;
@@ -80,6 +85,10 @@ export const capabilities: readonly Capability[] = [
8085
// like the ag-ui caps, but the backend is genuinely non-LangGraph)
8186
{ id: 'rt-maf', product: 'runtimes', topic: 'microsoft-agent-framework', angularProject: 'cockpit-runtimes-microsoft-agent-framework-angular', port: 4330, pythonPort: 5330, pythonDir: 'cockpit/runtimes/microsoft-agent-framework/python', framework: 'microsoft-agent-framework' },
8287
{ id: 'rt-strands', product: 'runtimes', topic: 'aws-strands', angularProject: 'cockpit-runtimes-aws-strands-angular', port: 4331, pythonPort: 5331, pythonDir: 'cockpit/runtimes/aws-strands/python', framework: 'aws-strands' },
88+
// No pythonDir: the Mastra topic's backend is the hand-written Node
89+
// service deployments/ag-ui-mastra (start it locally on pythonPort — here
90+
// meaning "backend port" — for dev/e2e; see that service's README).
91+
{ id: 'rt-mastra', product: 'runtimes', topic: 'mastra', angularProject: 'cockpit-runtimes-mastra-angular', port: 4332, pythonPort: 5332, framework: 'mastra' },
8392
] as const;
8493

8594
export function findCapability(id: string): Capability | undefined {

cockpit/ports.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export const PORTS = Object.freeze({
3434
'cockpit-ag-ui-subagents-angular': { angular: 4326, langgraph: 5326 },
3535
'cockpit-runtimes-microsoft-agent-framework-angular': { angular: 4330, langgraph: 5330 },
3636
'cockpit-runtimes-aws-strands-angular': { angular: 4331, langgraph: 5331 },
37+
// rt-mastra: 'langgraph' here means backend port (the ag-ui convention);
38+
// the backend is the deployments/ag-ui-mastra Node service, not Python.
39+
'cockpit-runtimes-mastra-angular': { angular: 4332, langgraph: 5332 },
3740
'cockpit-chat-a2ui-angular': { angular: 4511, langgraph: 5511 },
3841
'cockpit-chat-debug-angular': { angular: 4509, langgraph: 5509 },
3942
'cockpit-chat-generative-ui-angular': { angular: 4508, langgraph: 5508 },
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": { "userMessage": "packing list", "hasToolResult": true },
5+
"response": {
6+
"content": "Your Yosemite Weekend packing list is ready."
7+
}
8+
},
9+
{
10+
"match": { "userMessage": "conditions", "hasToolResult": true },
11+
"response": {
12+
"content": "Clear skies at Yosemite Valley with a high of 18°C."
13+
}
14+
},
15+
{
16+
"match": { "userMessage": "reserve", "hasToolResult": true },
17+
"response": {
18+
"content": "North Pines is reserved for 2 nights — confirmation TP-0288."
19+
}
20+
},
21+
{
22+
"match": { "userMessage": "packing list" },
23+
"response": {
24+
"toolCalls": [
25+
{
26+
"name": "updateWorkingMemory",
27+
"arguments": {
28+
"memory": {
29+
"packing_list": {
30+
"title": "Yosemite Weekend",
31+
"items": [
32+
{ "name": "tent", "qty": 1 },
33+
{ "name": "sleeping bag", "qty": 2 }
34+
]
35+
}
36+
}
37+
}
38+
}
39+
]
40+
}
41+
},
42+
{
43+
"match": { "userMessage": "conditions" },
44+
"response": {
45+
"toolCalls": [
46+
{
47+
"name": "check_conditions",
48+
"arguments": { "location": "Yosemite Valley" }
49+
}
50+
]
51+
}
52+
},
53+
{
54+
"match": { "userMessage": "reserve" },
55+
"response": {
56+
"toolCalls": [
57+
{
58+
"name": "reserve_campsite",
59+
"arguments": { "site": "North Pines", "nights": 2 }
60+
}
61+
]
62+
}
63+
}
64+
]
65+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// SPDX-License-Identifier: MIT
2+
// Hand-rolled global setup for the ONLY cockpit cap whose backend is not
3+
// Python: the rt-mastra topic is served by the deployments/ag-ui-mastra
4+
// Node service. This mirrors createAgUiGlobalSetup (libs/e2e-harness) —
5+
// aimock + backend + Angular dev server, state registered in
6+
// __AIMOCK_HARNESS_STATE__ so the shared global-teardown cleans up — but
7+
// spawns `node server.mjs` instead of `uv run uvicorn`, with the model
8+
// redirected to aimock via OPENAI_BASE_URL (Mastra's model router honors it,
9+
// and calls the OpenAI responses API, which aimock speaks).
10+
import { execSync, spawn, type ChildProcess } from 'node:child_process';
11+
import { mkdtempSync, existsSync } from 'node:fs';
12+
import { tmpdir } from 'node:os';
13+
import { join, resolve } from 'node:path';
14+
import { setTimeout as delay } from 'node:timers/promises';
15+
import { portsFor } from '../../../../../cockpit/ports.mjs';
16+
import { startAimock, type AimockHandle } from '@threadplane-internal/e2e-harness';
17+
18+
const ports = portsFor('cockpit-runtimes-mastra-angular');
19+
const angularProject = 'cockpit-runtimes-mastra-angular';
20+
// Parsed by apps/cockpit/cockpit-e2e-wiring.spec.ts (accepts backendCwd for
21+
// Node-hosted backends alongside langgraphCwd/pythonCwd — keep the
22+
// `backendCwd: '<path>'` literal shape).
23+
const wiring = { backendCwd: 'deployments/ag-ui-mastra' };
24+
const backendCwd = wiring.backendCwd;
25+
const backendPort = ports.langgraph; // "backend port" by the ag-ui convention
26+
const angularPort = ports.angular;
27+
const INTERNAL_TOKEN = 'dev-local-token'; // proxy.conf.mjs injects the same default
28+
29+
interface SharedState {
30+
aimock: AimockHandle;
31+
backend?: ChildProcess;
32+
backendPort?: number;
33+
angular: ChildProcess;
34+
angularPort: number;
35+
}
36+
37+
declare global {
38+
// eslint-disable-next-line no-var
39+
var __AIMOCK_HARNESS_STATE__: Map<string, SharedState> | undefined;
40+
}
41+
42+
async function waitForPort(url: string, timeoutMs: number, label: string): Promise<void> {
43+
const start = Date.now();
44+
while (Date.now() - start < timeoutMs) {
45+
try {
46+
const res = await fetch(url);
47+
if (res.ok || res.status === 404) return;
48+
} catch {
49+
// not up yet
50+
}
51+
await delay(500);
52+
}
53+
throw new Error(`[${label}] not ready at ${url} within ${timeoutMs}ms`);
54+
}
55+
56+
export default async function globalSetup(): Promise<void> {
57+
const root = resolve(__dirname, '../../../../..');
58+
const fixturesDir = resolve(__dirname, 'fixtures');
59+
const serviceDir = resolve(root, backendCwd);
60+
61+
const aimock = await startAimock({ mode: 'replay', fixturePath: fixturesDir });
62+
console.log(`[mastra-harness] aimock listening at ${aimock.baseUrl}`);
63+
64+
// The service is self-contained (own package.json + lockfile, deps NOT in
65+
// the root workspace). Install once per checkout; CI runners start clean.
66+
if (!existsSync(join(serviceDir, 'node_modules'))) {
67+
console.log('[mastra-harness] npm ci in deployments/ag-ui-mastra');
68+
execSync('npm ci --no-audit --no-fund', { cwd: serviceDir, stdio: 'inherit' });
69+
}
70+
71+
// Fresh LibSQL file per run: suspended-run snapshots and memory from a
72+
// previous e2e run must not leak into this one.
73+
const dbDir = mkdtempSync(join(tmpdir(), 'ag-ui-mastra-e2e-'));
74+
75+
const backend = spawn('node', ['server.mjs'], {
76+
cwd: serviceDir,
77+
env: {
78+
...process.env,
79+
PORT: String(backendPort),
80+
AG_UI_INTERNAL_TOKEN: INTERNAL_TOKEN,
81+
OPENAI_API_KEY: 'test-not-used',
82+
OPENAI_BASE_URL: aimock.baseUrl,
83+
AG_UI_MASTRA_DB_PATH: join(dbDir, 'mastra.db'),
84+
},
85+
stdio: 'pipe',
86+
// Own process group so the shared teardown can kill the whole tree.
87+
detached: true,
88+
});
89+
backend.stdout?.on('data', (b) => process.stdout.write(`[ag-ui-mastra] ${b}`));
90+
backend.stderr?.on('data', (b) => process.stderr.write(`[ag-ui-mastra] ${b}`));
91+
92+
await waitForPort(`http://localhost:${backendPort}/ok`, 90_000, 'ag-ui-mastra');
93+
console.log(`[mastra-harness] backend ready on :${backendPort}`);
94+
95+
const angular = spawn(
96+
'npx',
97+
['nx', 'serve', angularProject, '--port', String(angularPort)],
98+
{
99+
cwd: root,
100+
env: { ...process.env, AG_UI_INTERNAL_TOKEN: INTERNAL_TOKEN },
101+
stdio: 'pipe',
102+
detached: true,
103+
},
104+
);
105+
angular.stdout?.on('data', (b) => process.stdout.write(`[angular] ${b}`));
106+
angular.stderr?.on('data', (b) => process.stderr.write(`[angular] ${b}`));
107+
108+
await waitForPort(`http://localhost:${angularPort}/`, 120_000, 'angular');
109+
console.log(`[mastra-harness] angular ready on :${angularPort}`);
110+
111+
if (!globalThis.__AIMOCK_HARNESS_STATE__) {
112+
globalThis.__AIMOCK_HARNESS_STATE__ = new Map();
113+
}
114+
globalThis.__AIMOCK_HARNESS_STATE__.set(angularProject, {
115+
aimock,
116+
backend,
117+
backendPort,
118+
angular,
119+
angularPort,
120+
});
121+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: MIT
2+
import { test, expect } from '@playwright/test';
3+
4+
// First cockpit e2e whose backend is neither LangGraph nor Python: the
5+
// rt-mastra topic runs against the deployments/ag-ui-mastra Node service
6+
// (Mastra agents behind the hand-written AG-UI SSE endpoint). Interrupts
7+
// surface as CUSTOM on_interrupt (Mastra-shaped payload with toolCallId +
8+
// runId) plus the protocol-standard RUN_FINISHED outcome, and resume rides
9+
// forwardedProps.command.interruptEvent — the adapter path shipped in
10+
// #888/#889/#891, proven live here for the first time.
11+
test.describe('cockpit runtimes/mastra: camping trip planner', () => {
12+
test('backend tool result streams into the reply', async ({ page }) => {
13+
await page.goto('/');
14+
await page.getByText('Check trail conditions').click();
15+
await expect(page.getByText(/Clear skies at Yosemite Valley/i)).toBeVisible({ timeout: 30_000 });
16+
});
17+
18+
test('working memory streams into the shared-state panel', async ({ page }) => {
19+
await page.goto('/');
20+
await page.getByText('Start a packing list').click();
21+
const panel = page.getByTestId('packing-state');
22+
await expect(panel).toContainText('Yosemite Weekend', { timeout: 30_000 });
23+
await expect(panel).toContainText('tent');
24+
await expect(panel).toContainText('sleeping bag');
25+
await expect(page.getByText(/packing list is ready/i)).toBeVisible({ timeout: 30_000 });
26+
});
27+
28+
test('suspended reserve_campsite shows the approval card', async ({ page }) => {
29+
await page.goto('/');
30+
await page.getByText('Reserve the campsite').click();
31+
const dialog = page.locator('dialog.chat-approval-card');
32+
await expect(dialog).toBeVisible({ timeout: 30_000 });
33+
await expect(dialog).toContainText('Reservation approval required');
34+
await expect(dialog).toContainText('North Pines');
35+
await expect(dialog).toContainText('$90.00');
36+
});
37+
38+
test('Approve resumes the suspended run and the reservation completes', async ({ page }) => {
39+
await page.goto('/');
40+
await page.getByText('Reserve the campsite').click();
41+
const dialog = page.locator('dialog.chat-approval-card');
42+
await expect(dialog).toBeVisible({ timeout: 30_000 });
43+
await dialog.getByRole('button', { name: 'Approve' }).click();
44+
await expect(page.getByText(/reserved for 2 nights/i)).toBeVisible({ timeout: 30_000 });
45+
});
46+
});

0 commit comments

Comments
 (0)