Skip to content

Commit 99c17a7

Browse files
bloveclaude
andcommitted
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>
1 parent 8de2961 commit 99c17a7

28 files changed

Lines changed: 917 additions & 13 deletions

.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

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+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MIT
2+
import { defineConfig, devices } from '@playwright/test';
3+
import { portsFor } from '../../../../../cockpit/ports.mjs';
4+
5+
const { angular: angularPort } = portsFor('cockpit-runtimes-mastra-angular');
6+
7+
8+
export default defineConfig({
9+
testDir: '.',
10+
testMatch: '**/*.spec.ts',
11+
fullyParallel: false,
12+
workers: 1,
13+
retries: process.env.CI ? 2 : 0,
14+
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
15+
use: {
16+
baseURL: `http://localhost:${angularPort}`,
17+
trace: 'retain-on-failure',
18+
},
19+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
20+
globalSetup: './global-setup-impl.ts',
21+
globalTeardown: require.resolve('../../../../../libs/e2e-harness/src/global-teardown'),
22+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ES2022",
5+
"moduleResolution": "Bundler",
6+
"esModuleInterop": true,
7+
"strict": true,
8+
"skipLibCheck": true,
9+
"noEmit": true,
10+
"types": [
11+
"node"
12+
],
13+
"baseUrl": "../../../../..",
14+
"paths": {
15+
"@threadplane-internal/e2e-harness": [
16+
"libs/e2e-harness/src/index.ts"
17+
],
18+
"@threadplane-internal/e2e-harness/global-teardown": [
19+
"libs/e2e-harness/src/global-teardown.ts"
20+
]
21+
},
22+
"allowJs": true
23+
},
24+
"include": [
25+
"**/*.ts"
26+
],
27+
"exclude": [
28+
"node_modules",
29+
"test-results",
30+
"playwright-report"
31+
]
32+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@threadplane/cockpit-runtimes-mastra-angular",
3+
"private": true,
4+
"version": "0.0.1",
5+
"peerDependencies": {
6+
"@threadplane/chat": "*",
7+
"@threadplane/ag-ui": "*"
8+
},
9+
"license": "MIT",
10+
"sideEffects": false
11+
}

0 commit comments

Comments
 (0)