@@ -297,6 +297,61 @@ async def train_detail(train_id: int) -> str:
297297 return "\n " .join (lines )
298298
299299
300+ @mcp .tool ()
301+ async def train_outputs (train_id : int , include_jobs : bool = False ) -> dict :
302+ """Return Grid output directories and merge coverage for one train.
303+
304+ One read-only metadata request. Directory locations are reported by Hyperloop;
305+ this does not assert which files still exist there or download Grid payloads.
306+ Include per-run job directories only when requested. Different merge entries
307+ can refer to the same directory; consumers must deduplicate before downloading.
308+ """
309+ if train_id <= 0 :
310+ raise ValueError ("train_id must be positive" )
311+ train = await _get ("trains/train.jsp" , {"train_id" : train_id })
312+ if isinstance (train , list ):
313+ if len (train ) != 1 :
314+ raise RuntimeError ("Expected a single train record" )
315+ train = train [0 ]
316+
317+ def runs (value ):
318+ if not value :
319+ return set ()
320+ if isinstance (value , str ):
321+ return {int (run .strip ()) for run in value .split ("," ) if run .strip ()}
322+ return {int (run ) for run in value }
323+
324+ merges = []
325+ for merge in train .get ("mergeResults" ) or []:
326+ intended = runs (merge .get ("total_merge" ))
327+ merged = runs (merge .get ("merged_runlist" ))
328+ merges .append ({
329+ "id" : merge .get ("id" ),
330+ "name" : merge .get ("full_name" ),
331+ "state" : merge .get ("merge_state" ),
332+ "directory" : merge .get ("outputdir" ),
333+ "merged_run_count" : len (merged ),
334+ "intended_run_count" : len (intended ),
335+ "missing_runs" : sorted (intended - merged ),
336+ "input_data_processed" : merge .get ("input_data_processed" ),
337+ "merge_completion" : merge .get ("merge_completion" ),
338+ })
339+ result = {
340+ "train_id" : train .get ("id" ),
341+ "dataset" : train .get ("dataset_name" ),
342+ "state" : train .get ("state" ),
343+ "package_tag" : train .get ("package_tag" ),
344+ "derived_data_type" : train .get ("derived_data_type" ),
345+ "merges" : merges ,
346+ }
347+ if include_jobs :
348+ result ["jobs" ] = [
349+ {key : job .get (key ) for key in ("id" , "run" , "merge_state" , "outputdir" )}
350+ for job in train .get ("jobResults" ) or []
351+ ]
352+ return result
353+
354+
300355@mcp .tool ()
301356async def wagon_stats (train_id : int ) -> str :
302357 """Get per-wagon CPU and memory breakdown for a train (ongoing or finished).
0 commit comments