From e9cc86b86973a926c165a25a0ece25f8e7c823f9 Mon Sep 17 00:00:00 2001 From: Giulio Eulisse <10544+ktf@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:48:48 +0200 Subject: [PATCH 1/3] DPL MCP: improve ability to do trend plots --- .gitignore | 3 + .../hyperloop_server.cpython-314.pyc | Bin 17267 -> 0 bytes .../hyperloop-server/hyperloop_server.py | 326 ++++++++++++++++++ 3 files changed, 329 insertions(+) delete mode 100644 Framework/Core/scripts/hyperloop-server/__pycache__/hyperloop_server.cpython-314.pyc diff --git a/.gitignore b/.gitignore index d58d1e151800b..70dc0c49567c4 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ bazel-* DataFormats/Detectors/CTP/include/DataFormatsCTP/Scalers.h dpl-config.json O2.code-workspace + +# Python bytecode +*.pyc diff --git a/Framework/Core/scripts/hyperloop-server/__pycache__/hyperloop_server.cpython-314.pyc b/Framework/Core/scripts/hyperloop-server/__pycache__/hyperloop_server.cpython-314.pyc deleted file mode 100644 index b69ae691c2064c4975f397c3e900dcc1375596d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17267 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 any: return r.json() +ALIMON = f"{PROXY}/alimonitor" +ALIMON_TOKEN = os.environ.get("HYPERLOOP_ALIMON_TOKEN", "jalien-secret") + + +async def _get_workdir_json(train_id: int, fname: str): + """Fetch a file from a test's train-workdir (alimonitor route).""" + b = f"{train_id // 10000:04d}" + n = f"{train_id:08d}" + url = f"{ALIMON}/train-workdir/tests/{b}/{n}/{fname}" + hdrs = {"Authorization": f"Bearer {ALIMON_TOKEN}", "Accept-Encoding": "identity"} + async with httpx.AsyncClient(timeout=180) as client: + r = await client.get(url, headers=hdrs) + r.raise_for_status() + return r.json() + + +def _tag_date(tag: str | None) -> str | None: + """Extract the YYYYMMDD date from a package tag like '…daily-20260604-0400-1'.""" + m = re.search(r"(?:daily|nightly|epn)-?(\d{8})", (tag or "").lower()) + return m.group(1) if m else None + + +def _series_max(v) -> float | None: + """Max value of a {timestamp,value} time series (or a scalar). Use for + cumulative metrics like processed_size (max = final total).""" + if isinstance(v, list) and v: + try: + return max(float(x["value"]) for x in v) + except Exception: + return None + try: + return float(v) + except Exception: + return None + + +def _series_sum(v) -> float: + """Sum of a {timestamp,value} time series. `cpuUsedAbsolute` is per-interval + CPU microseconds (O2 Monitoring ProcessMonitor: Δ getrusage utime+stime per + sample), so the sum is the *total* CPU time of the run (µs; /1e6 = CPU-s).""" + if isinstance(v, list): + try: + return sum(float(x["value"]) for x in v) + except Exception: + return 0.0 + return 0.0 + + def _fmt_bytes(n: float | None) -> str: if n is None: return "n/a" @@ -419,6 +470,281 @@ async def find_wagons_by_config(analysis_id: int, param: str, return "\n".join(lines) +@mcp.tool() +async def wagon_status(analysis_id: int, wagon_name: str, + dataset: str = "") -> str: + """Monitor a wagon's latest test run(s) in an analysis, one row per dataset. + + A wagon is tested once per dataset, so the dataset matters: this resolves + every wagon in `analysis_id` whose name contains `wagon_name` + (case-insensitive substring), finds its most recent test train per dataset, + and reports state, job progress (done/total), error rate and package. Pass + `dataset` to restrict to datasets whose name contains that substring. + + Use to track the progress of a specific wagon, e.g. + wagon_status(50446, "PIDTPCServiceTests") + wagon_status(50446, "PIDTPCServiceTests", "PbPb") + For full per-run metrics (CPU/mem/throughput) follow up with train_detail on + the reported train ID. + """ + wagons = await _get("analysis/wagons-by-analyses.jsp", + {"analysis_ids": analysis_id}) + if not isinstance(wagons, dict) or not wagons: + return f"No wagons found for analysis {analysis_id}." + needle = wagon_name.lower() + matched = {wid: w for wid, w in wagons.items() + if needle in str(w.get("name", "")).lower()} + if not matched: + return (f"No wagon in analysis {analysis_id} matches '{wagon_name}'. " + f"Use analysis_wagons({analysis_id}) to list them.") + + # wagon_id -> {test_train_id}: each association is one (wagon, dataset) test. + assoc = await _get("analysis/wagondataset-by-analyses.jsp", + {"analysis_ids": analysis_id}) + tids_by_wagon: dict = {} + for a in (assoc or []): + tid = a.get("test_train_id") + if tid: + tids_by_wagon.setdefault(str(a.get("wagon_id")), set()).add(tid) + + out = [] + for wid, w in sorted(matched.items(), + key=lambda kv: str(kv[1].get("name", "")).lower()): + out.append(f"Wagon {wid}: {w.get('name')}") + trains = [] + for tid in sorted(tids_by_wagon.get(str(wid), ()), reverse=True): + try: + t = await _get("trains/train.jsp", {"train_id": tid}) + trains.append(t[0] if isinstance(t, list) else t) + except Exception: + pass + if dataset: + d = dataset.lower() + trains = [t for t in trains + if d in str(t.get("dataset_name", "")).lower()] + # Keep only the latest test (highest train id) per dataset. + latest: dict = {} + for t in trains: + ds = t.get("dataset_name", "?") + if t.get("id", 0) > latest.get(ds, {}).get("id", -1): + latest[ds] = t + if not latest: + out.append(" (no test runs" + + (f" matching dataset '{dataset}'" if dataset else "") + + ")\n") + continue + rows = sorted(latest.values(), key=lambda t: t.get("id", 0), reverse=True) + out.append(_format_train_table(rows) + "\n") + return "\n".join(out).rstrip() + + +@mcp.tool() +async def analysis_trains(analysis_id: int, days: int = 14, + daily_only: bool = True, dataset: str = "") -> str: + """Recent test-train history for an analysis — the source for trend analysis. + + Each daily release re-tests an analysis's wagons, producing a test train. + This lists those trains (id, date, state, dataset, wagons), most recent + first, filtered to the last `days` (by package date); `daily_only` keeps + only daily builds and `dataset` filters by substring. Feed the train IDs to + `test_metrics` to build a per-release time series (CPU / PSS / throughput). + """ + raw = await _get("analysis/trains-by-analyses.jsp", {"analysis_ids": analysis_id}) + c = raw[0] if isinstance(raw, list) and raw else raw + trains = c.get("trains", []) if isinstance(c, dict) else [] + cutoff = (datetime.date.today() - datetime.timedelta(days=days)).strftime("%Y%m%d") + rows = [] + for t in trains: + d = _tag_date(t.get("package_tag")) + if not d or d < cutoff: + continue + if daily_only and "daily" not in (t.get("package_tag") or "").lower(): + continue + if dataset and dataset.lower() not in (t.get("dataset_name") or "").lower(): + continue + rows.append(t) + if not rows: + return f"No matching test trains in analysis {analysis_id} (last {days}d)." + rows.sort(key=lambda t: (_tag_date(t.get("package_tag")) or "", t.get("id", 0)), + reverse=True) + lines = [f"{len(rows)} test trains in analysis {analysis_id} (last {days}d" + + (", daily" if daily_only else "") + "):\n", + f"{'date':>8} {'train':>7} {'state':<10} {'dataset':<26} wagons"] + lines.append("-" * 100) + for t in rows: + lines.append(f"{_tag_date(t.get('package_tag')):>8} {t.get('id'):>7} " + f"{str(t.get('state'))[:10]:<10} " + f"{str(t.get('dataset_name'))[:26]:<26} " + f"{(t.get('wagons_names') or '')[:42]}") + return "\n".join(lines) + + +@mcp.tool() +async def test_metrics(train_id: int, per_device: bool = False) -> str: + """Resource metrics for one test train (from performanceMetrics_processed.json). + + Aggregates per-device CPU (`cpuUsedAbsolute`) and peak PSS, plus the input + actually processed. With `per_device=True`, lists the heaviest devices — the + hot spots. Call across the train IDs from `analysis_trains` to build a trend + (these tests are time-limited, so CPU/PSS move with optimizations while raw + throughput is often I/O-bound and flat). + """ + try: + d = await _get_workdir_json(train_id, "performanceMetrics_processed.json") + except Exception as e: + return f"No performance metrics for test {train_id} ({e})." + devs = [] + tot_cpu = tot_pss = tot_instr = 0.0 + proc = None + for name, m in d.items(): + if not isinstance(m, dict): + continue + cpu = _series_sum(m.get("cpuUsedAbsolute")) + instr = _series_sum(m.get("cpuInstructions")) + pss = (m.get("proportionalSetSize_summary") or {}).get("max", 0.0) + tot_cpu += cpu + tot_instr += instr + tot_pss += pss + if "processed_size" in m: + proc = _series_max(m["processed_size"]) or proc + if name.startswith("o2-") and (cpu or pss): + devs.append((name, cpu, instr, pss, m.get("wagon_id"))) + lines = [f"Test {train_id}: total cpuAbs={tot_cpu:,.0f}" + + (f" instr={tot_instr:,.0f}" if tot_instr else "") + + f" PSS(sum dev max)={_fmt_bytes(tot_pss)}" + + (f" processed={_fmt_bytes(proc)}" if proc else "")] + if per_device: + devs.sort(key=lambda x: -x[1]) + lines.append(f"\n{'device':<46} {'cpuAbs':>13} {'instr':>15} {'PSS':>10} {'wagon':>7}") + for name, cpu, instr, pss, wid in devs[:18]: + lines.append(f"{name[:46]:<46} {cpu:>13,.0f} {instr:>15,.0f} " + f"{_fmt_bytes(pss):>10} {str(wid or ''):>7}") + return "\n".join(lines) + + +async def _daily_tests(analysis_ids: list[int], days: int) -> dict: + """train_id -> (date, dataset) for daily test trains across analyses, last `days`.""" + cutoff = (datetime.date.today() - datetime.timedelta(days=days)).strftime("%Y%m%d") + seen: dict = {} + for aid in analysis_ids: + try: + raw = await _get("analysis/trains-by-analyses.jsp", {"analysis_ids": aid}) + except Exception: + continue + c = raw[0] if isinstance(raw, list) and raw else raw + for t in (c.get("trains", []) if isinstance(c, dict) else []): + d = _tag_date(t.get("package_tag")) + if not d or d < cutoff or t.get("state") != "done": + continue + if "daily" not in (t.get("package_tag") or "").lower(): + continue + seen[t["id"]] = (d, t.get("dataset_name")) + return seen + + +@mcp.tool() +async def wagon_trend(device: str = "", analysis_ids: str = "21674,50446,50462,50570", + days: int = 14, metric: str = "throughput") -> str: + """Optimization-progress trend across recent **daily** test trains (normalized). + + Per (dataset, day), builds a series of the chosen metric and normalizes each + dataset to its first day (1.00 = start), so you read the relative change as + fixes land. Daily-only — eulisse-local / non-daily builds excluded. + + metric: + 'instructions_per_gb' device retired instructions (`cpuInstructions`) / input + GB, for devices matching `device`. The cleanest efficiency + metric: unlike CPU-time it is invariant to CPU frequency, core + contention and the test's wall-clock cap, so a real ↓ is the + optimization landing. Falls back to cpu_per_gb on tests run + before the instruction counter shipped (no `cpuInstructions`). + 'cpu_per_gb' device cpuUsedAbsolute / input GB, for devices matching + `device`. Efficiency: ↓ means the optimization landed + (normalizes out per-run work variation; far cleaner than raw cpu). + 'throughput' input_size / wall_time (MB/s). The honest "did it get faster" + measure — raw CPU can RISE when a faster upstream stage stops + starving a downstream one. `device` is ignored. + 'cpu' raw device cpuUsedAbsolute (noisy — scales with work done). + 'pss' peak proportionalSetSize for matching devices. + + Pool analyses (default: integration/nightly/MC test analyses) so cross-cutting + hot spots get many datasets. + + Examples: + wagon_trend(metric="throughput") + wagon_trend("tracks-extra-v002-converter", metric="cpu_per_gb") + """ + aids = [int(x) for x in str(analysis_ids).replace(" ", "").split(",") if x] + tests = await _daily_tests(aids, days) + if not tests: + return f"No daily test trains for analyses {aids} in the last {days}d." + key = device.lower() + series: dict = collections.defaultdict(dict) # dataset -> {date: value} + matched: set = set() + need_train = metric in ("throughput", "cpu_per_gb", "instructions_per_gb") + for tid, (d, ds) in tests.items(): + ins = wall = None + if need_train: + try: + tj = await _get("trains/train.jsp", {"train_id": tid}) + tj = tj[0] if isinstance(tj, list) else tj + ins, wall = tj.get("input_size"), tj.get("wall_time") + except Exception: + continue + if metric == "throughput": + if ins and wall: + series[ds][d] = max(series[ds].get(d, 0.0), ins / wall / 1e6) + continue + if metric in ("cpu_per_gb", "instructions_per_gb") and not ins: + continue + try: + pj = await _get_workdir_json(tid, "performanceMetrics_processed.json") + except Exception: + continue + val = 0.0 + hit = False + for name, m in pj.items(): + if not isinstance(m, dict) or (key and key not in name.lower()): + continue + hit = True + matched.add(name) + if metric == "pss": + val += (m.get("proportionalSetSize_summary") or {}).get("max", 0.0) + elif metric == "instructions_per_gb": + # Prefer retired instructions; fall back to CPU-µs when the test + # predates the instruction counter (so old + new days stay comparable). + instr = _series_sum(m.get("cpuInstructions")) + val += instr if instr else _series_sum(m.get("cpuUsedAbsolute")) + else: + val += _series_sum(m.get("cpuUsedAbsolute")) + if hit: + if metric in ("cpu_per_gb", "instructions_per_gb"): + val = val / (ins / 1e9) + series[ds][d] = max(series[ds].get(d, 0.0), val) + if metric != "throughput" and not matched: + return (f"No device matching '{device}' in the daily tests of {aids}. " + f"Try test_metrics(, per_device=True) to see device names.") + units = {"throughput": "MB/s", "cpu_per_gb": "CPU/GB", "instructions_per_gb": "instr/GB", + "cpu": "cpuAbs", "pss": "PSS"} + out = [f"Trend [{units.get(metric, metric)}, normalized to first day] " + + (f"device '{device}' " if device else "") + + f"analyses {aids}, last {days}d"] + if matched: + out.append(f"matched devices: {', '.join(sorted(matched))}") + out.append("") + for ds, ser in sorted(series.items(), key=lambda kv: -len(kv[1])): + if len(ser) < 2: + continue + xs = sorted(ser) + base = ser[xs[0]] + if not base: + continue + pts = " ".join(f"{x[4:6]}/{x[6:8]}={ser[x] / base:.2f}" for x in xs) + chg = 100 * (ser[xs[-1]] / base - 1) + out.append(f"{ds:24} ({len(xs):2} pts) first→last {chg:+5.0f}% {pts}") + return "\n".join(out) + + def main(): import argparse global PROXY, TOKEN, API From 867706341dd4f548a7f12a9edf86be695b3e6fa5 Mon Sep 17 00:00:00 2001 From: Giulio Eulisse <10544+ktf@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:48:48 +0200 Subject: [PATCH 2/3] git: ignore local settings for claude code --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 70dc0c49567c4..3142e97cd74b5 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ O2.code-workspace # Python bytecode *.pyc + +# Claude code +.claude/settings.local.json From eaf33119a65b7f6506b16d4e1f9f29e86515eace Mon Sep 17 00:00:00 2001 From: Giulio Eulisse <10544+ktf@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:13:32 +0200 Subject: [PATCH 3/3] DPL MCP: add ability to create new wagons --- .../hyperloop-server/hyperloop_server.py | 368 +++++++++++++++++- 1 file changed, 367 insertions(+), 1 deletion(-) diff --git a/Framework/Core/scripts/hyperloop-server/hyperloop_server.py b/Framework/Core/scripts/hyperloop-server/hyperloop_server.py index 081a633c63498..66d66918ff020 100644 --- a/Framework/Core/scripts/hyperloop-server/hyperloop_server.py +++ b/Framework/Core/scripts/hyperloop-server/hyperloop_server.py @@ -47,6 +47,16 @@ TOKEN = os.environ.get("HYPERLOOP_TOKEN", "foo-baz") API = f"{PROXY}/alihyperloop-data" +# --- Write guardrails --------------------------------------------------------- +# Wagon-creating tools are HARD-LOCKED to this one analysis. The destination is a +# baked-in constant, never a caller argument, so the tools physically cannot touch +# any other analysis. +ALLOWED_ANALYSIS = 50446 # "O2 Development" +# Every created wagon is prefixed with this, so test wagons are easy to spot/clean. +WAGON_PREFIX = "Test" +# Writes are inert unless the server is explicitly started with this enabled. +ALLOW_WRITE = os.environ.get("HYPERLOOP_ALLOW_WRITE", "").strip().lower() in ("1", "true", "yes", "on") + def _headers() -> dict[str, str]: return {"Authorization": f"Bearer {TOKEN}"} @@ -61,6 +71,17 @@ async def _get(path: str, params: dict | None = None) -> any: return r.json() +async def _get_text(path: str, params: dict | None = None) -> str: + """GET a JSP endpoint and return the raw response text. Used for mutating + endpoints (e.g. clone-wagon) that don't always return JSON.""" + hdrs = _headers() + hdrs["Accept-Encoding"] = "identity" + async with httpx.AsyncClient(timeout=60) as client: + r = await client.get(f"{API}/{path}", params=params, headers=hdrs) + r.raise_for_status() + return r.text + + ALIMON = f"{PROXY}/alimonitor" ALIMON_TOKEN = os.environ.get("HYPERLOOP_ALIMON_TOKEN", "jalien-secret") @@ -405,6 +426,47 @@ async def wagon_config(wagon_id: int, device: str = "") -> str: return "\n".join(lines).rstrip() +@mcp.tool() +async def wagon_detail(wagon_id: int) -> str: + """Show a wagon's identity and dependency chain (read-only, any analysis). + + Reports name, owning analysis, workflow name, derived-data limits and the + dependency wagons with their resolved names and owning analyses — the + information needed to understand how a train composed from this wagon is + put together (e.g. which producer provides which workflow). Follow up with + wagon_detail on a dependency id to walk the chain. + """ + w = await _get("analysis/wagon/wagon.jsp", + {"wagon_id": int(wagon_id), "referenceTime": 0}) + if not isinstance(w, dict) or w.get("id") is None: + return f"No wagon {wagon_id} (or not accessible)." + lines = [f"Wagon {w.get('id')} '{w.get('name')}'", + f" analysis: {w.get('analysis_id')} ({w.get('analysis_name')})", + f" workflow: {w.get('work_flow_name')}", + f" max_df_size: {w.get('max_df_size')} " + f"max_derived_file_size: {w.get('max_derived_file_size')} " + f"slim_ready: {w.get('slim_ready')}", + f" last change: {w.get('changed_by')}"] + # Resolve dependency ids to names/analyses via the parallel existing_* arrays. + dep_info = {} + ex_ids = str(w.get("existing_dependencies") or "").split(",") + ex_names = str(w.get("existing_dependencies_name") or "").split(",") + ex_ana = str(w.get("existing_dependencies_analysis_name") or "").split(",") + for i, d in enumerate(ex_ids): + if d: + dep_info[d] = (ex_names[i] if i < len(ex_names) else "?", + ex_ana[i] if i < len(ex_ana) else "?") + deps = [d for d in str(w.get("dependencies") or "").split(",") if d] + if not deps: + lines.append(" dependencies: (none)") + else: + lines.append(f" dependencies ({len(deps)}):") + for d in deps: + name, ana = dep_info.get(d, ("?", "?")) + lines.append(f" {d:>8} {name} [{ana}]") + return "\n".join(lines) + + @mcp.tool() async def find_wagons_by_config(analysis_id: int, param: str, value: str | None = None) -> str: @@ -745,18 +807,322 @@ async def wagon_trend(device: str = "", analysis_ids: str = "21674,50446,50462,5 return "\n".join(out) +@mcp.tool() +async def clone_wagon(src_wagon_id: int, name: str) -> str: + """Clone an existing wagon into the O2 Development analysis (50446). + + WRITE operation — it creates a new wagon. It is HARD-LOCKED to analysis 50446 + ("O2 Development"): the destination is baked in, there is no analysis + argument, so it physically cannot create or modify wagons anywhere else. + Inert unless the server was started with HYPERLOOP_ALLOW_WRITE=1. + + `src_wagon_id` may come from any analysis (e.g. a pre-configured creator or + builder you found with analysis_wagons / find_wagons_by_config). The new + wagon's name is always prefixed with 'Test' so created wagons are easy to + spot and clean up; you may pass `name` with or without the prefix. + + Returns the server response and a read-back confirmation. Inspect the result + with analysis_wagons(50446). + """ + if not ALLOW_WRITE: + return ("Refused: writes are disabled. Start the MCP server with " + "HYPERLOOP_ALLOW_WRITE=1 to enable wagon creation (locked to " + f"analysis {ALLOWED_ANALYSIS}).") + name = (name or "").strip() + if not name: + return "Refused: a non-empty wagon name is required." + if not name.startswith(WAGON_PREFIX): + name = f"{WAGON_PREFIX}{name}" + # Hard guardrail: destination analysis is the baked-in constant, never a caller arg. + params = {"wagon_id": int(src_wagon_id), "name": name, + "to_analysis_id": ALLOWED_ANALYSIS} + try: + resp = await _get_text("analysis/clone-wagon.jsp", params) + except Exception as e: + return f"Clone of wagon {src_wagon_id} failed ({e})." + # Read-back guardrail: confirm the new wagon really landed in 50446. + landed = False + try: + back = await _get("analysis/wagons-by-analyses.jsp", + {"analysis_ids": ALLOWED_ANALYSIS}) + landed = name in json.dumps(back) + except Exception: + pass + status = ("confirmed in analysis {}".format(ALLOWED_ANALYSIS) if landed + else "NOT confirmed — check analysis_wagons({})".format(ALLOWED_ANALYSIS)) + return (f"Cloned wagon {src_wagon_id} -> '{name}' into analysis " + f"{ALLOWED_ANALYSIS} ({status}).\nServer response: {resp.strip()[:400]}") + + +async def _post_form(path: str, data: dict) -> str: + """POST application/x-www-form-urlencoded to a JSP endpoint; return raw text.""" + hdrs = _headers() + hdrs["Accept-Encoding"] = "identity" + async with httpx.AsyncClient(timeout=60) as client: + r = await client.post(f"{API}/{path}", data=data, headers=hdrs) + r.raise_for_status() + return r.text + + +async def _wagon_in_allowed(wagon_id: int) -> bool: + """True iff `wagon_id` belongs to the one writable analysis (50446). The + by-id write tools refuse anything that isn't in this set, so they cannot + touch a wagon in another analysis.""" + try: + data = await _get("analysis/wagons-by-analyses.jsp", + {"analysis_ids": ALLOWED_ANALYSIS}) + except Exception: + return False + ids: set = set() + + def collect(o): + if isinstance(o, dict): + if "id" in o and o.get("analysis_id") == ALLOWED_ANALYSIS: + try: + ids.add(int(o["id"])) + except (TypeError, ValueError): + pass + for v in o.values(): + collect(v) + elif isinstance(o, list): + for v in o: + collect(v) + + collect(data) + return int(wagon_id) in ids + + +@mcp.tool() +async def set_wagon_config(wagon_id: int, params: dict) -> str: + """Set configuration parameters on a wagon in O2 Development (50446). + + WRITE operation. Refuses unless the target wagon belongs to analysis 50446, + so it cannot modify wagons elsewhere. Inert unless the server was started + with HYPERLOOP_ALLOW_WRITE=1 (or --allow-write). + + `params` maps parameter name -> new value, e.g. + {"createDplus": 1, "processNoPvRefitWithDCAFitterNCentFT0M": 1, "do3prong": 1} + If a name is shared by several tasks, disambiguate with "task_name.param". + Booleans/ints are sent as Hyperloop stores them ("1"/"0"); arrays pass through. + + It reads the wagon's current config (recovering each param's subwagon/id/type/ + kind), applies the new values, and writes them back in one POST. Verify with + wagon_config(wagon_id). + """ + if not ALLOW_WRITE: + return ("Refused: writes are disabled. Start the server with " + f"HYPERLOOP_ALLOW_WRITE=1 (locked to analysis {ALLOWED_ANALYSIS}).") + if not await _wagon_in_allowed(wagon_id): + return (f"Refused: wagon {wagon_id} is not in analysis {ALLOWED_ANALYSIS} " + "(or could not be verified). Writes are restricted to that analysis.") + if not isinstance(params, dict) or not params: + return "Refused: `params` must be a non-empty {name: value} mapping." + try: + conf = await _get("analysis/wagon/get-subwagons-configuration.jsp", + {"lists": "subwagons_configuration", + "wagon_id": int(wagon_id), "referenceTime": 0}) + except Exception as e: + return f"Could not read current config of wagon {wagon_id} ({e})." + entries = conf.get("subwagons_conf", []) if isinstance(conf, dict) else [] + if not entries: + return f"No configuration entries returned for wagon {wagon_id}." + by_key: dict = {} + by_name: dict = {} + subwagon_tasks: dict = {} + for e in entries: + tn, nm, sid = e.get("task_name"), e.get("name"), e.get("subwagon_id") + by_key[(tn, nm)] = e + by_name.setdefault(nm, []).append(e) + subwagon_tasks.setdefault(sid, set()).add(tn) + resolved: list = [] + errors: list = [] + for key, val in params.items(): + task = None + nm = key + if "." in key: + cand_task, cand_name = key.split(".", 1) + if any(cand_task == t for (t, _) in by_key): + task, nm = cand_task, cand_name + matches = ([by_key[(task, nm)]] if (task and (task, nm) in by_key) + else by_name.get(nm, [])) + if not matches: + errors.append(f"'{key}': no such parameter") + elif len(matches) > 1: + tasks = sorted({m.get("task_name") for m in matches}) + errors.append(f"'{key}': ambiguous across tasks {tasks}; use 'task.param'") + else: + resolved.append((matches[0], val)) + if errors: + return "Refused (nothing written):\n " + "\n ".join(errors) + + def coerce(entry, val): + ev = entry.get("value") + if isinstance(val, bool): + return "1" if val else "0" + if isinstance(ev, str) and isinstance(val, (int, float)): + return str(val) + return val + + subs: dict = {} + for e, val in resolved: + sid = e["subwagon_id"] + sval = coerce(e, val) + blk = subs.setdefault(sid, {"task": {}, "configuration": {}, "id": str(sid)}) + for t in subwagon_tasks.get(sid, set()): + blk["task"].setdefault(t, {"configuration": {}}) + nm, tn = e["name"], e["task_name"] + blk["task"][tn]["configuration"][nm] = { + "id": e.get("id"), "name": nm, "value": sval, "help": e.get("help"), + "labels_rows": e.get("labels_rows"), "labels_cols": e.get("labels_cols"), + "type": e.get("type"), "kind": e.get("kind"), "conf": e.get("conf"), + } + blk["configuration"][nm] = { + "task_name": tn, "value": sval, "type": e.get("type"), + "labels_rows": e.get("labels_rows"), "labels_cols": e.get("labels_cols"), + "kind": e.get("kind"), "help": e.get("help"), "id": e.get("id"), + } + payload = {str(sid): blk for sid, blk in subs.items()} + try: + resp = await _post_form("analysis/wagon/update-subwagon-configuration.jsp", + {"subwagons": json.dumps(payload)}) + except Exception as e: + return f"Config update of wagon {wagon_id} failed ({e})." + changed = ", ".join(f"{e['task_name']}.{e['name']}={coerce(e, v)}" for e, v in resolved) + return (f"Updated wagon {wagon_id} in analysis {ALLOWED_ANALYSIS}: {changed}.\n" + f"Server response: {resp.strip()[:300]}") + + +@mcp.tool() +async def set_wagon_dependencies(wagon_id: int, dependency_wagon_ids: list) -> str: + """Set the dependency wagons of a wagon in O2 Development (50446). + + WRITE operation. Refuses unless the target wagon is in analysis 50446, so it + cannot modify wagons elsewhere. Inert unless the server was started with + HYPERLOOP_ALLOW_WRITE=1 (or --allow-write). + + `dependency_wagon_ids` REPLACES the wagon's full dependency set (mirroring the + UI). Pass the complete producer chain, e.g. [564, 3443, 9998]; pass [] to + clear. The wagon's other fields (name, workflow, max sizes, slim flag) are + read first and preserved, so only the dependency list changes. Verify with + analysis_wagons(50446). + """ + if not ALLOW_WRITE: + return ("Refused: writes are disabled. Start the server with " + f"HYPERLOOP_ALLOW_WRITE=1 (locked to analysis {ALLOWED_ANALYSIS}).") + try: + w = await _get("analysis/wagon/wagon.jsp", + {"wagon_id": int(wagon_id), "referenceTime": 0}) + except Exception as e: + return f"Could not read wagon {wagon_id} ({e})." + if not isinstance(w, dict) or w.get("analysis_id") != ALLOWED_ANALYSIS: + return (f"Refused: wagon {wagon_id} is not in analysis {ALLOWED_ANALYSIS} " + "(or could not be verified). Writes are restricted to that analysis.") + try: + deps = ",".join(str(int(x)) for x in (dependency_wagon_ids or [])) + except (TypeError, ValueError): + return "Refused: dependency_wagon_ids must be a list of integer wagon ids." + # Read-modify-write: preserve every other wagon field, change only dependencies. + params = { + "id": int(wagon_id), + "name": w.get("name", ""), + "work_flow_name": w.get("work_flow_name", ""), + "dependencies": deps, + "max_df_size": w.get("max_df_size", 100000000), + "max_derived_file_size": w.get("max_derived_file_size", 0), + "slim_ready": "true" if w.get("slim_ready") else "false", + } + try: + resp = await _get_text("analysis/wagon/update-wagon.jsp", params) + except Exception as e: + return f"Dependency update of wagon {wagon_id} failed ({e})." + now = "" + try: + w2 = await _get("analysis/wagon/wagon.jsp", + {"wagon_id": int(wagon_id), "referenceTime": 0}) + now = w2.get("dependencies", "") if isinstance(w2, dict) else "" + except Exception: + pass + return (f"Set dependencies of wagon {wagon_id} ('{w.get('name')}') to " + f"[{deps or '(none)'}] in analysis {ALLOWED_ANALYSIS}. " + f"Now: [{now or '(none)'}].\nServer response: {resp.strip()[:200]}") + + +async def _resolve_dataset_id(dataset: str): + """Resolve a dataset NAME (or numeric id) to its numeric id. + Returns (id, None) on success or (None, error_message).""" + s = str(dataset).strip() + if s.isdigit(): + return int(s), None + try: + lst = await _get("dataset/list-dataset.jsp", {"lists": "dataset-list"}) + except Exception as e: + return None, f"could not fetch the dataset list ({e})" + items = lst if isinstance(lst, list) else [] + matches = [it for it in items if isinstance(it, dict) and it.get("name") == s] + if not matches: + return None, f"no dataset named '{s}' found" + if len(matches) > 1: + return None, f"'{s}' is ambiguous: ids {[m.get('id') for m in matches]}" + return int(matches[0]["id"]), None + + +@mcp.tool() +async def subscribe_dataset(dataset: str) -> str: + """Subscribe (enable) a dataset to the O2 Development analysis (50446). + + WRITE operation. HARD-LOCKED to analysis 50446 — there is no analysis + argument, so it can only ever subscribe a dataset to O2 Development. Inert + unless the server was started with HYPERLOOP_ALLOW_WRITE=1 (or --allow-write). + + `dataset` is the dataset NAME (e.g. "LHC26ac_pass1_Thin_small") or its numeric + id; names are resolved via the dataset list. Returns a read-back confirmation + that 50446 now appears among the dataset's subscribed analyses. + """ + if not ALLOW_WRITE: + return ("Refused: writes are disabled. Start the server with " + f"HYPERLOOP_ALLOW_WRITE=1 (locked to analysis {ALLOWED_ANALYSIS}).") + dsid, err = await _resolve_dataset_id(dataset) + if dsid is None: + return f"Refused: {err}." + try: + resp = await _get_text("analysis/enable-dataset.jsp", + {"dataset_id": dsid, "analysis_id": ALLOWED_ANALYSIS}) + except Exception as e: + return f"Subscribing dataset '{dataset}' (id {dsid}) failed ({e})." + # Read-back: confirm analysis 50446 is now among the dataset's analyses. + subscribed = False + try: + lst = await _get("dataset/list-dataset.jsp", + {"lists": "dataset-analysis", "dataset_id": dsid}) + if isinstance(lst, list): + subscribed = any(isinstance(a, dict) and a.get("id") == ALLOWED_ANALYSIS + for a in lst) + except Exception: + pass + status = (f"confirmed subscribed to analysis {ALLOWED_ANALYSIS}" if subscribed + else "NOT confirmed — check the dataset's analyses") + return (f"Subscribed dataset '{dataset}' (id {dsid}) to analysis " + f"{ALLOWED_ANALYSIS} ({status}).\nServer response: {resp.strip()[:200]}") + + def main(): import argparse - global PROXY, TOKEN, API + global PROXY, TOKEN, API, ALLOW_WRITE parser = argparse.ArgumentParser(description="AliHyperloop MCP server") parser.add_argument("--proxy", default=PROXY, help="Proxy base URL") parser.add_argument("--token", default=TOKEN, help="Bearer token") + parser.add_argument("--allow-write", action="store_true", + help=("Enable the wagon-write tools (clone/configure), " + f"hard-locked to analysis {ALLOWED_ANALYSIS}. " + "Off by default; HYPERLOOP_ALLOW_WRITE=1 also enables it.")) args = parser.parse_args() PROXY = args.proxy TOKEN = args.token API = f"{PROXY}/alihyperloop-data" + if args.allow_write: + ALLOW_WRITE = True mcp.run(transport="stdio")