Skip to content

Commit 5e47d76

Browse files
committed
fix: protocol for dashboard
1 parent 82fdfef commit 5e47d76

5 files changed

Lines changed: 268 additions & 77 deletions

File tree

ai-server/src/mastra/dashboard-dsl/compile-dashboard.ts

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,23 @@ type Component = Record<string, unknown> & {
2727
component: string;
2828
};
2929

30+
/**
31+
* Record of a single data-fetch step the compiler ran while assembling
32+
* the dashboard. The route surfaces these as synthetic AG-UI tool-call
33+
* events so the user keeps the same "tool calls" visibility they had
34+
* before the DSL refactor (where every call originated from the LLM).
35+
*/
36+
export interface DataStep {
37+
name: string;
38+
args: unknown;
39+
result?: unknown;
40+
}
41+
3042
export interface CompiledDashboard {
3143
surfaceId: string;
3244
structural: A2uiMessage[];
3345
dataModel: A2uiMessage[];
46+
dataSteps: DataStep[];
3447
}
3548

3649
interface DashboardData {
@@ -56,12 +69,14 @@ export async function compileDashboard(
5669
spec: DashboardSpec,
5770
options: { surfaceId?: string } = {},
5871
): Promise<CompiledDashboard> {
59-
const data = await fetchAllDashboardData(spec);
60-
return assembleDashboard(spec, data, options.surfaceId);
72+
const dataSteps: DataStep[] = [];
73+
const data = await fetchAllDashboardData(spec, dataSteps);
74+
return assembleDashboard(spec, data, options.surfaceId, dataSteps);
6175
}
6276

6377
async function fetchAllDashboardData(
6478
spec: DashboardSpec,
79+
dataSteps: DataStep[],
6580
): Promise<DashboardData> {
6681
const routes = new Set<string>();
6782
let needsBookedFlights = false;
@@ -88,6 +103,19 @@ async function fetchAllDashboardData(
88103
}
89104

90105
const routeList = [...routes];
106+
const flightStepIndices: number[] = [];
107+
for (const key of routeList) {
108+
const [from, to] = key.split('|');
109+
flightStepIndices.push(
110+
dataSteps.push({ name: 'searchFlights', args: { from, to } }) - 1,
111+
);
112+
}
113+
let findBookedStepIdx: number | null = null;
114+
if (needsBookedFlights) {
115+
findBookedStepIdx =
116+
dataSteps.push({ name: 'findBookedFlights', args: {} }) - 1;
117+
}
118+
91119
const [bookedFlights, ...flightLists] = await Promise.all([
92120
needsBookedFlights ? getBookedFlights() : Promise.resolve([]),
93121
...routeList.map((key) => {
@@ -96,6 +124,13 @@ async function fetchAllDashboardData(
96124
}),
97125
]);
98126

127+
flightStepIndices.forEach((stepIdx, i) => {
128+
dataSteps[stepIdx].result = { count: flightLists[i]?.length ?? 0 };
129+
});
130+
if (findBookedStepIdx !== null) {
131+
dataSteps[findBookedStepIdx].result = { count: bookedFlights.length };
132+
}
133+
99134
const flightsByRoute = new Map<string, FlightRecord[]>();
100135
routeList.forEach((key, idx) => {
101136
flightsByRoute.set(key, flightLists[idx] ?? []);
@@ -108,6 +143,7 @@ function assembleDashboard(
108143
spec: DashboardSpec,
109144
data: DashboardData,
110145
givenSurfaceId: string | undefined,
146+
dataSteps: DataStep[],
111147
): CompiledDashboard {
112148
const surfaceId = givenSurfaceId ?? `dash-${randomUUID()}`;
113149

@@ -116,7 +152,7 @@ function assembleDashboard(
116152
const rootChildren: string[] = [];
117153

118154
spec.tiles.forEach((tile, idx) => {
119-
const result = buildTile(idx, tile, data, surfaceId);
155+
const result = buildTile(idx, tile, data, surfaceId, dataSteps);
120156
rootChildren.push(...result.rootChildren);
121157
allComponents.push(...result.components);
122158
allDataOps.push(...result.dataOps);
@@ -140,36 +176,37 @@ function assembleDashboard(
140176
} as unknown as A2uiMessage,
141177
];
142178

143-
return { surfaceId, structural, dataModel: allDataOps };
179+
return { surfaceId, structural, dataModel: allDataOps, dataSteps };
144180
}
145181

146182
function buildTile(
147183
idx: number,
148184
tile: DashboardTile,
149185
data: DashboardData,
150186
surfaceId: string,
187+
dataSteps: DataStep[],
151188
): TileBuildResult {
152189
switch (tile.type) {
153190
case 'flightsTable':
154191
return buildFlightsTable(idx, tile, data, surfaceId, false);
155192
case 'delayedFlightsTable':
156193
return buildFlightsTable(idx, tile, data, surfaceId, true);
157194
case 'delayShareChart':
158-
return buildDelayShareChart(idx, tile, data, surfaceId);
195+
return buildDelayShareChart(idx, tile, data, surfaceId, dataSteps);
159196
case 'delaysPerDayChart':
160-
return buildDelaysPerDayChart(idx, tile, data, surfaceId);
197+
return buildDelaysPerDayChart(idx, tile, data, surfaceId, dataSteps);
161198
case 'boardingPasses':
162199
return buildBoardingPasses(idx, tile, data, surfaceId);
163200
case 'bookedFlightsList':
164-
return buildBookedFlightsList(idx, data, surfaceId);
201+
return buildBookedFlightsList(idx, data, surfaceId, dataSteps);
165202
case 'flightSearch':
166203
return buildFlightSearch(idx, tile, surfaceId);
167204
case 'rentalCars':
168-
return buildRentalCars(idx, tile, data, surfaceId);
205+
return buildRentalCars(idx, tile, data, surfaceId, dataSteps);
169206
case 'hotels':
170-
return buildHotels(idx, tile, data, surfaceId);
207+
return buildHotels(idx, tile, data, surfaceId, dataSteps);
171208
case 'weatherList':
172-
return buildWeatherList(idx, data, surfaceId);
209+
return buildWeatherList(idx, data, surfaceId, dataSteps);
173210
}
174211
}
175212

@@ -294,6 +331,7 @@ function buildDelayShareChart(
294331
tile: Extract<DashboardTile, { type: 'delayShareChart' }>,
295332
data: DashboardData,
296333
surfaceId: string,
334+
dataSteps: DataStep[],
297335
): TileBuildResult {
298336
const flights = data.flightsByRoute.get(routeKey(tile.from, tile.to)) ?? [];
299337
let onTime = 0;
@@ -302,12 +340,23 @@ function buildDelayShareChart(
302340
if (f.delay > 0) delayed += 1;
303341
else onTime += 1;
304342
}
343+
const chartType = tile.chartType ?? 'pie';
305344
const url = buildAndCacheChartUrl({
306-
type: tile.chartType ?? 'pie',
345+
type: chartType,
307346
title: `On-time vs. delayed (${tile.from}${tile.to})`,
308347
labels: ['On time', 'Delayed'],
309348
datasets: [{ label: 'Flights', data: [onTime, delayed] }],
310349
});
350+
dataSteps.push({
351+
name: 'renderFlightChart',
352+
args: {
353+
from: tile.from,
354+
to: tile.to,
355+
type: 'delayShare',
356+
chartType,
357+
},
358+
result: { onTime, delayed, total: onTime + delayed, url },
359+
});
311360
return chartTile(
312361
idx,
313362
surfaceId,
@@ -321,6 +370,7 @@ function buildDelaysPerDayChart(
321370
tile: Extract<DashboardTile, { type: 'delaysPerDayChart' }>,
322371
data: DashboardData,
323372
surfaceId: string,
373+
dataSteps: DataStep[],
324374
): TileBuildResult {
325375
const flights = data.flightsByRoute.get(routeKey(tile.from, tile.to)) ?? [];
326376
const buckets = new Map<string, { onTime: number; delayed: number }>();
@@ -343,6 +393,16 @@ function buildDelaysPerDayChart(
343393
{ label: 'Delayed', data: sortedDays.map(([, v]) => v.delayed) },
344394
],
345395
});
396+
dataSteps.push({
397+
name: 'renderFlightChart',
398+
args: {
399+
from: tile.from,
400+
to: tile.to,
401+
type: 'delaysPerDay',
402+
chartType: 'bar',
403+
},
404+
result: { days: sortedDays.length, url },
405+
});
346406
return chartTile(
347407
idx,
348408
surfaceId,
@@ -426,6 +486,7 @@ function buildBookedFlightsList(
426486
idx: number,
427487
data: DashboardData,
428488
surfaceId: string,
489+
dataSteps: DataStep[],
429490
): TileBuildResult {
430491
const flights = data.bookedFlights;
431492
const cardId = tileId(idx, 'card');
@@ -511,6 +572,11 @@ function buildBookedFlightsList(
511572
);
512573

513574
const w = weatherForecast(flight.to, flight.date);
575+
dataSteps.push({
576+
name: 'weatherForecast',
577+
args: { city: flight.to, date: flight.date.slice(0, 10) },
578+
result: { condition: w.condition, temperatureC: w.temperatureC },
579+
});
514580
const meta = `${flight.date.slice(0, 10)} · ${weatherIconFor(w.condition)} ${w.condition}${w.temperatureC} °C · ${
515581
flight.delay > 0 ? `Delayed by ${flight.delay} min` : 'On time'
516582
}`;
@@ -610,9 +676,15 @@ function buildRentalCars(
610676
tile: Extract<DashboardTile, { type: 'rentalCars' }>,
611677
data: DashboardData,
612678
surfaceId: string,
679+
dataSteps: DataStep[],
613680
): TileBuildResult {
614681
const city = tile.city ?? data.bookedFlights[0]?.to ?? FALLBACK_CITY;
615682
const result = searchRentalCars(city);
683+
dataSteps.push({
684+
name: 'searchRentalCars',
685+
args: { city },
686+
result: { count: result.cars.length },
687+
});
616688
return imageRowList({
617689
idx,
618690
surfaceId,
@@ -630,9 +702,15 @@ function buildHotels(
630702
tile: Extract<DashboardTile, { type: 'hotels' }>,
631703
data: DashboardData,
632704
surfaceId: string,
705+
dataSteps: DataStep[],
633706
): TileBuildResult {
634707
const city = tile.city ?? data.bookedFlights[0]?.to ?? FALLBACK_CITY;
635708
const result = searchHotels(city);
709+
dataSteps.push({
710+
name: 'searchHotels',
711+
args: { city },
712+
result: { count: result.hotels.length },
713+
});
636714
return imageRowList({
637715
idx,
638716
surfaceId,
@@ -649,6 +727,7 @@ function buildWeatherList(
649727
idx: number,
650728
data: DashboardData,
651729
surfaceId: string,
730+
dataSteps: DataStep[],
652731
): TileBuildResult {
653732
const flights = data.bookedFlights;
654733
const cardId = tileId(idx, 'card');
@@ -694,6 +773,11 @@ function buildWeatherList(
694773
variant: 'body',
695774
});
696775
const w = weatherForecast(flight.to, flight.date);
776+
dataSteps.push({
777+
name: 'weatherForecast',
778+
args: { city: flight.to, date: flight.date.slice(0, 10) },
779+
result: { condition: w.condition, temperatureC: w.temperatureC },
780+
});
697781
const line = `${flight.to} · ${flight.date.slice(0, 10)} · ${weatherIconFor(w.condition)} ${w.condition}${w.temperatureC} °C`;
698782
dataOps.push(dataOp(surfaceId, path, line));
699783
});

ai-server/src/mastra/dashboard-dsl/spec-channel.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,52 @@
1+
import type { DataStep } from './compile-dashboard.js';
12
import type { DashboardSpec } from './dashboard-spec.js';
23

3-
// Module-scoped relay used to ferry the parsed `DashboardSpec` from
4-
// `renderDashboardTool.execute` (called by Mastra during a streamed run)
5-
// to the route handler that owns the cache. Mastra's tool result event
6-
// only carries the surface id + A2UI messages, so we cannot read the
7-
// spec back via the AG-UI snapshot pipeline. A short-lived in-memory
8-
// map keyed by surface id is the smallest viable side channel.
4+
// Module-scoped relay used to ferry the parsed `DashboardSpec` and the
5+
// list of compiler data steps from `renderDashboardTool.execute` to the
6+
// route handler. Mastra's tool result event only carries the surface id
7+
// + A2UI messages, so we cannot read these back via the AG-UI snapshot
8+
// pipeline. A short-lived in-memory map keyed by surface id is the
9+
// smallest viable side channel.
910
//
10-
// Entries are removed by `consumeRecordedSpec` after the route reads
11-
// them, with a cleanup timer for safety in case the route fails before
12-
// reading.
11+
// Entries are removed by the consume helpers after the route reads
12+
// them; the cleanup timer is a safety net for runs that fail before
13+
// reaching the consume call.
1314

1415
const TTL_MS = 60_000;
1516

16-
const recordedSpecs = new Map<string, DashboardSpec>();
17+
interface RecordedRun {
18+
spec: DashboardSpec;
19+
dataSteps: readonly DataStep[];
20+
}
21+
22+
const recordedRuns = new Map<string, RecordedRun>();
1723
const expirationTimers = new Map<string, NodeJS.Timeout>();
1824

19-
export function recordDashboardSpec(
25+
export function recordDashboardRun(
2026
surfaceId: string,
2127
spec: DashboardSpec,
28+
dataSteps: readonly DataStep[],
2229
): void {
23-
recordedSpecs.set(surfaceId, spec);
30+
recordedRuns.set(surfaceId, { spec, dataSteps });
2431
clearExpiration(surfaceId);
2532
expirationTimers.set(
2633
surfaceId,
2734
setTimeout(() => {
28-
recordedSpecs.delete(surfaceId);
35+
recordedRuns.delete(surfaceId);
2936
expirationTimers.delete(surfaceId);
3037
}, TTL_MS),
3138
);
3239
}
3340

34-
export function consumeRecordedSpec(
35-
surfaceId: string,
36-
): DashboardSpec | undefined {
37-
const spec = recordedSpecs.get(surfaceId);
38-
recordedSpecs.delete(surfaceId);
41+
export function peekRecordedRun(surfaceId: string): RecordedRun | undefined {
42+
return recordedRuns.get(surfaceId);
43+
}
44+
45+
export function consumeRecordedRun(surfaceId: string): RecordedRun | undefined {
46+
const run = recordedRuns.get(surfaceId);
47+
recordedRuns.delete(surfaceId);
3948
clearExpiration(surfaceId);
40-
return spec;
49+
return run;
4150
}
4251

4352
function clearExpiration(surfaceId: string): void {

ai-server/src/mastra/routes/ag-ui-stream.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ export interface CreateAgUiEventStreamOptions {
1717
* full operation list.
1818
*/
1919
transformA2uiOperations?: (operations: unknown[]) => unknown[];
20+
/**
21+
* Hook fired right before an `a2ui-surface` `ACTIVITY_SNAPSHOT` is
22+
* forwarded. Returned events are written to the SSE stream first, in
23+
* the order returned, then the snapshot itself. Used by the dashboard
24+
* route to surface the compiler's data fetches as synthetic AG-UI
25+
* tool-call events so the user retains the same "tool calls" insight
26+
* they had before the DSL refactor (when each fetch was an individual
27+
* LLM tool call).
28+
*/
29+
injectBeforeA2uiSurface?: (
30+
operations: readonly unknown[],
31+
) => readonly BaseEvent[];
2032
}
2133

2234
export type ParseRunAgentInputResult =
@@ -80,6 +92,12 @@ export async function streamAgentEvents(
8092
event,
8193
options.transformA2uiOperations,
8294
);
95+
const ops = readA2uiSurfaceOperations(transformed);
96+
if (ops && options.injectBeforeA2uiSurface) {
97+
for (const injected of options.injectBeforeA2uiSurface(ops)) {
98+
enqueueEvent(injected);
99+
}
100+
}
83101
tryCaptureA2uiSurface(transformed, options.onA2uiSurface);
84102
enqueueEvent(transformed);
85103
},

0 commit comments

Comments
 (0)