From f003080e2dc002215d59a464846fa3fac10be81f Mon Sep 17 00:00:00 2001 From: theAfish Date: Thu, 6 Aug 2026 18:37:04 +0800 Subject: [PATCH 01/14] fix: not rendering poscar --- tests/test_structure_file_detection.py | 24 +++++++++++++++++++ web/main.py | 7 +++--- web/structure_formats.py | 16 +++++++++++++ .../src/features/session/fileTree.js | 11 ++++++--- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 tests/test_structure_file_detection.py create mode 100644 web/structure_formats.py diff --git a/tests/test_structure_file_detection.py b/tests/test_structure_file_detection.py new file mode 100644 index 00000000..3f38da33 --- /dev/null +++ b/tests/test_structure_file_detection.py @@ -0,0 +1,24 @@ +"""Regression tests for recognizing conventional VASP structure filenames.""" + +import sys +from pathlib import Path + + +WEB_DIR = Path(__file__).resolve().parents[1] / "web" +if str(WEB_DIR) not in sys.path: + sys.path.insert(0, str(WEB_DIR)) + +from structure_formats import is_vasp_structure_filename + + +def test_recognizes_poscar_and_contcar_variants() -> None: + assert is_vasp_structure_filename("POSCAR") + assert is_vasp_structure_filename("POSCAR_water_layer2") + assert is_vasp_structure_filename("POSCAR-2_water6") + assert is_vasp_structure_filename("CONTCAR.relaxed") + + +def test_does_not_misidentify_unrelated_filenames() -> None: + assert not is_vasp_structure_filename("my_POSCAR_backup") + assert not is_vasp_structure_filename("POSCARwater") + assert not is_vasp_structure_filename("POSCARwater.txt") diff --git a/web/main.py b/web/main.py index 743c81f2..f0b1e549 100644 --- a/web/main.py +++ b/web/main.py @@ -70,6 +70,8 @@ import users_db # noqa: E402 +from structure_formats import is_vasp_structure_filename # noqa: E402 + from matcreator.workspace import get_session_workdir, get_workspace_root, workspace_skills_dir # noqa: E402 from matcreator.agents.cancellation import ( # noqa: E402 request_cancellation, @@ -1623,8 +1625,7 @@ def _load_json_field(raw_value: str | None, fallback): def _ase_read_structure(path: Path): from ase.io import read as ase_read - name = path.name.lower() - if name in {"poscar", "contcar"} or path.suffix.lower() == ".vasp": + if is_vasp_structure_filename(path.name) or path.suffix.lower() == ".vasp": return ase_read(str(path), format="vasp") return ase_read(str(path)) @@ -3669,7 +3670,7 @@ async def list_modeling_structure_files( for path in sorted(root.rglob("*")): if not path.is_file(): continue - if path.suffix.lower() not in structure_suffixes and path.name.lower() not in {"poscar", "contcar"}: + if path.suffix.lower() not in structure_suffixes and not is_vasp_structure_filename(path.name): continue files.append({ "name": path.name, diff --git a/web/structure_formats.py b/web/structure_formats.py new file mode 100644 index 00000000..ff4124c6 --- /dev/null +++ b/web/structure_formats.py @@ -0,0 +1,16 @@ +"""Filename-based structure format detection shared by web endpoints.""" + +from __future__ import annotations + +import re + + +def is_vasp_structure_filename(filename: str) -> bool: + """Return whether *filename* is a conventional POSCAR/CONTCAR variant. + + VASP workflows commonly preserve the original POSCAR/CONTCAR prefix when + creating variants, for example ``POSCAR_water_layer2`` or + ``POSCAR-2_water6``. ASE cannot infer the VASP format from those names, + so recognize a prefix followed by the usual filename delimiters. + """ + return bool(re.fullmatch(r"(?:poscar|contcar)(?:[_.-].*)?", filename, re.IGNORECASE)) diff --git a/web/vite-frontend/src/features/session/fileTree.js b/web/vite-frontend/src/features/session/fileTree.js index 6ae7046f..dae98ed5 100644 --- a/web/vite-frontend/src/features/session/fileTree.js +++ b/web/vite-frontend/src/features/session/fileTree.js @@ -1,12 +1,17 @@ const STRUCTURE_EXTENSIONS = new Set([".cif", ".xyz", ".extxyz", ".vasp"]); -const STRUCTURE_NAMES = new Set(["poscar", "contcar"]); const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg"]); +function isVaspStructureFilename(name) { + // VASP workflows often generate variants such as POSCAR_water_layer2 or + // POSCAR-2_water6. These have no informative extension, but are still POSCARs. + return /^(?:poscar|contcar)(?:[_.-].*)?$/i.test(name); +} + export function classifyPath(path) { const name = path.split("/").pop(); const dotIndex = name.lastIndexOf("."); const extension = dotIndex >= 0 ? name.slice(dotIndex).toLowerCase() : ""; - if (STRUCTURE_EXTENSIONS.has(extension) || STRUCTURE_NAMES.has(name.toLowerCase())) return "structure"; + if (STRUCTURE_EXTENSIONS.has(extension) || isVaspStructureFilename(name)) return "structure"; if (IMAGE_EXTENSIONS.has(extension)) return "image"; return "artifact"; } @@ -131,4 +136,4 @@ export function createSessionFileTree({ getSessionId, pathToApiUrl, openStructur } return { render }; -} \ No newline at end of file +} From b22eb3b15f4cc6c8833b41d3e2527c7ea091f71f Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 12:49:02 +0800 Subject: [PATCH 02/14] fix: roadmap disappearing bug --- tests/test_web_session_access.py | 23 + web/main.py | 7 +- web/vite-frontend/package-lock.json | 558 ++++++++++-------- .../src/features/graphs/ExecutionPlanView.js | 47 +- 4 files changed, 375 insertions(+), 260 deletions(-) diff --git a/tests/test_web_session_access.py b/tests/test_web_session_access.py index 9268908c..0d9cf6de 100644 --- a/tests/test_web_session_access.py +++ b/tests/test_web_session_access.py @@ -358,3 +358,26 @@ def test_local_mode_reads_session_detail_regardless_of_requested_user(monkeypatc assert payload["userId"] == "legacy-display-name" assert payload["state"] == {"answer": 42} assert payload["events"] == [{"event": "persisted"}] + + +def test_execution_graph_endpoint_reads_atomic_graph_snapshot(monkeypatch, tmp_path): + web_main = _load_web_main(monkeypatch) + db_path = tmp_path / "session.db" + _create_session_db(db_path, web_main.APP_NAME) + graph = { + "graph_id": "plan-2", + "nodes": {"step-1": {"label": "Prepare structure", "status": "pending"}}, + "edges": [], + } + with sqlite3.connect(db_path) as conn: + conn.execute( + "UPDATE sessions SET state = ? WHERE app_name = ? AND id = ?", + (json.dumps({"execution_graph": [graph]}), web_main.APP_NAME, "session-1"), + ) + conn.commit() + monkeypatch.setattr(web_main, "SESSION_DB_PATH", db_path) + monkeypatch.setattr(web_main, "_MATCREATOR_MODE", "local") + + response = asyncio.run(web_main.get_execution_graph("session-1")) + + assert json.loads(response.body) == graph diff --git a/web/main.py b/web/main.py index f0b1e549..4fc8488a 100644 --- a/web/main.py +++ b/web/main.py @@ -81,6 +81,7 @@ request_step_cancellation, ) from matcreator.agents.graph_logger import AgentGraphLogger # noqa: E402 +from matcreator.agents.execution_graph_state import decode_execution_graph # noqa: E402 from matcreator.agents.session_log import build_session_log_export # noqa: E402 from matcreator.skill import ( # noqa: E402 ALL_SKILLS, @@ -2797,10 +2798,8 @@ def _load_execution_graph(session_id: str) -> dict: if row is None: continue state = _load_json_field(row["state"], {}) - raw = state.get("execution_graph") - if isinstance(raw, str): - raw = _load_json_field(raw, None) - if not isinstance(raw, dict): + raw = decode_execution_graph(state.get("execution_graph")) + if raw is None: return {"nodes": {}, "edges": []} return raw except sqlite3.Error: diff --git a/web/vite-frontend/package-lock.json b/web/vite-frontend/package-lock.json index f7e6c9b9..1807849a 100644 --- a/web/vite-frontend/package-lock.json +++ b/web/vite-frontend/package-lock.json @@ -43,13 +43,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -99,12 +99,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -137,17 +137,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -155,9 +155,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1143,6 +1143,15 @@ } } }, + "node_modules/@mui/material/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@mui/private-theming": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", @@ -1243,6 +1252,15 @@ } } }, + "node_modules/@mui/system/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@mui/types": { "version": "7.2.24", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", @@ -1287,6 +1305,35 @@ } } }, + "node_modules/@mui/utils/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1308,9 +1355,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -1322,9 +1369,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -1336,9 +1383,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -1350,9 +1397,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -1364,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -1378,9 +1425,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -1392,13 +1439,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1406,13 +1456,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1420,13 +1473,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1434,13 +1490,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1448,13 +1507,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1462,13 +1524,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1476,13 +1541,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1490,13 +1558,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1504,13 +1575,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1518,13 +1592,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1532,13 +1609,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1546,13 +1626,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1560,13 +1643,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1574,9 +1660,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -1588,9 +1674,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -1602,9 +1688,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -1616,9 +1702,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -1630,9 +1716,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -1644,9 +1730,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -1658,9 +1744,9 @@ ] }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz", + "integrity": "sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1707,9 +1793,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/hammerjs": { @@ -1741,9 +1827,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", "peer": true, "dependencies": { @@ -1793,9 +1879,9 @@ ] }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1814,9 +1900,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "license": "MIT", "engines": { "node": ">=12" @@ -1922,15 +2008,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/call-bind": { @@ -2113,9 +2199,9 @@ } }, "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2662,9 +2748,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", "license": "MIT" }, "node_modules/dom-helpers": { @@ -2678,9 +2764,9 @@ } }, "node_modules/dpdm": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.2.0.tgz", - "integrity": "sha512-Vq862fZ9UE66rlr2VcMhU8ZstTH3ItqmniLSCtAeg6T2AYeB2oD3Z6lGjiFDjyUvxLbfLyBBNWagCLMehpmo5g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.3.0.tgz", + "integrity": "sha512-2ZrP5B3MHHo7mXWgNxntU5DhJIvXNP4hcMBdCJsuxEWenKMdf4h6XouuKMf3Sj2SAUNBy/ajcWAVIyiOZly6rw==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -2827,9 +2913,9 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", - "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", + "integrity": "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2897,9 +2983,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -3131,9 +3217,9 @@ } }, "node_modules/indigo-ketcher": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/indigo-ketcher/-/indigo-ketcher-1.45.0.tgz", - "integrity": "sha512-ilq2cZtB237g3lVC5ry1evk6TMmNxfLYevLxPO07aelTeKPCf9Tu1wnijiMsaOiGytAFJ2mU11wkroe1iHYfSg==", + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/indigo-ketcher/-/indigo-ketcher-1.45.1.tgz", + "integrity": "sha512-A2+v9SNRuJWeMyLc+IhU/1o/G1CvQ/DWVWonZ+owwuZyhwVb8nfaYsg2Gc0UjB1hNEpo832wd0OgC/8kbDWMjw==", "license": "Apache-2.0" }, "node_modules/inherits": { @@ -3409,9 +3495,9 @@ } }, "node_modules/ketcher-core": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ketcher-core/-/ketcher-core-3.17.0.tgz", - "integrity": "sha512-xONVbx3xfjHLwACYh8JaZudiOYIVXhVxf3pSc5pZ3Die7yN9TTRAFMcK5Hxu3ziNrn4OdhSyUZGnS8sJKAimVA==", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/ketcher-core/-/ketcher-core-3.17.2.tgz", + "integrity": "sha512-jCh/oqlQEdTJ3++SyFGQ/a/IKXVQ4S29lOqOTvlItvlduUqpaYtnXZgYR74baGGuVgR5A61iqxCJpHT3eOfHVA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.10", @@ -3430,9 +3516,9 @@ } }, "node_modules/ketcher-react": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ketcher-react/-/ketcher-react-3.17.0.tgz", - "integrity": "sha512-cGcJZvnzRyUJk+6EjROIkp/mIfbDmmY30ghKsox3P+jd1aWVatXGeyUceUC8maZ5TpLwkGFGLwCR07l34rTdLA==", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/ketcher-react/-/ketcher-react-3.17.2.tgz", + "integrity": "sha512-sU/LjxAcxBaO1r7SaAL3NNd2gvdCog5v+4I8TNAmAy47f+TOwt6zx8VDvU0s7ajJ6dpTpIu8m2tR1hSKIh13yw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.10", @@ -3483,36 +3569,14 @@ "react-dom": "^18.2.0 || ^19.0.0" } }, - "node_modules/ketcher-react/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ketcher-react/node_modules/miew-react": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/miew-react/-/miew-react-0.11.0.tgz", - "integrity": "sha512-IdJYzrbkpotS4Hx0JYg7KyOcpwyTdGRS1hnc504Y//Xvdl24Ft6uahuf9mbCEI2PNVPDARLc+hqzMTbpDWD88A==", - "license": "MIT", - "dependencies": { - "miew": "^0.11.0" - }, - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "node_modules/ketcher-standalone": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ketcher-standalone/-/ketcher-standalone-3.17.0.tgz", - "integrity": "sha512-MBpMC90UintTKuRrCsaQYs1WwDlX2tIm6+Cr6gohvWay0x/o5YFP0o5nZaYZNqQAvyI+TxHSwBqpPm+SIFsrqw==", + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/ketcher-standalone/-/ketcher-standalone-3.17.2.tgz", + "integrity": "sha512-n5o+p1hyjVh2rHTXSXUsa1rehUp3ZrD3mds9TnKmechVdPe44TOtcGgKI7+ZuXGukTd76TE/CiMlbCjpPtlpfA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.10", - "indigo-ketcher": "1.45.0", + "indigo-ketcher": "1.45.1", "ketcher-core": "*" }, "engines": { @@ -3914,6 +3978,19 @@ "three": "0.153.0" } }, + "node_modules/miew-react": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/miew-react/-/miew-react-0.11.0.tgz", + "integrity": "sha512-IdJYzrbkpotS4Hx0JYg7KyOcpwyTdGRS1hnc504Y//Xvdl24Ft6uahuf9mbCEI2PNVPDARLc+hqzMTbpDWD88A==", + "license": "MIT", + "dependencies": { + "miew": "^0.11.0" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, "node_modules/miew/node_modules/three": { "version": "0.153.0", "resolved": "https://registry.npmjs.org/three/-/three-0.153.0.tgz", @@ -3933,12 +4010,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -3963,9 +4040,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -4179,9 +4256,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", - "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -4199,7 +4276,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4295,15 +4372,6 @@ "react-dom": ">=16" } }, - "node_modules/react-contexify/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react-device-detect": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz", @@ -4372,9 +4440,9 @@ } }, "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "license": "MIT" }, "node_modules/react-lifecycles-compat": { @@ -4440,15 +4508,6 @@ "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-virtualized/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4580,13 +4639,13 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -4596,31 +4655,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -4778,9 +4838,9 @@ } }, "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "version": "5.56.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", + "integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -4804,6 +4864,15 @@ "node": ">=18" } }, + "node_modules/svelte/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/svgpath": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", @@ -5182,15 +5251,15 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" }, @@ -5207,27 +5276,10 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yjs": { - "version": "13.6.31", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", - "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "version": "13.6.32", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz", + "integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==", "license": "MIT", "peer": true, "dependencies": { @@ -5243,9 +5295,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "license": "MIT", "engines": { "node": ">=18" diff --git a/web/vite-frontend/src/features/graphs/ExecutionPlanView.js b/web/vite-frontend/src/features/graphs/ExecutionPlanView.js index 2cf9d196..00a1a92e 100644 --- a/web/vite-frontend/src/features/graphs/ExecutionPlanView.js +++ b/web/vite-frontend/src/features/graphs/ExecutionPlanView.js @@ -21,6 +21,10 @@ const PLAN_GRAPH_DEFAULT_LAYOUT = { edgeMinimization: true, }; +const PLAN_GRAPH_MIN_SCALE = 0.15; +const PLAN_GRAPH_MAX_SCALE = 4; +const PLAN_GRAPH_WHEEL_ZOOM_FACTOR = 0.001; + export class ExecutionPlanView { constructor(containerId, options = {}) { this._container = document.getElementById(containerId); @@ -69,7 +73,9 @@ export class ExecutionPlanView { tooltipDelay: 200, dragNodes: true, dragView: true, - zoomView: true, + // Wheel zoom is handled below so high-resolution wheel events scale + // proportionally instead of becoming fixed, repeated zoom steps. + zoomView: false, }, }; this._network = new Network( @@ -78,6 +84,41 @@ export class ExecutionPlanView { options ); this._network.on("beforeDrawing", (ctx) => this._drawCanvasGrid(ctx)); + this._container.addEventListener("wheel", (event) => this._handleWheelZoom(event), { passive: false }); + } + + _handleWheelZoom(event) { + if (!this._network || event.deltaY === 0) return; + + // vis-network applies a fixed zoom step to every wheel event. High- + // resolution mice and touchpads can emit dozens of tiny events for a + // single scroll, causing runaway zoom. Scale by the actual wheel distance + // instead, and cap a malformed event to one normal wheel notch. + event.preventDefault(); + const delta = clamp(event.deltaY, -100, 100); + const oldScale = this._network.getScale(); + const newScale = clamp( + oldScale * Math.exp(-delta * PLAN_GRAPH_WHEEL_ZOOM_FACTOR), + PLAN_GRAPH_MIN_SCALE, + PLAN_GRAPH_MAX_SCALE, + ); + if (newScale === oldScale) return; + + const bounds = this._container.getBoundingClientRect(); + const pointer = this._network.DOMtoCanvas({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + const position = this._network.getViewPosition(); + const scaleRatio = oldScale / newScale; + this._network.moveTo({ + position: { + x: pointer.x - (pointer.x - position.x) * scaleRatio, + y: pointer.y - (pointer.y - position.y) * scaleRatio, + }, + scale: newScale, + animation: false, + }); } _computeLevels(nodeIds, rawEdges) { @@ -665,13 +706,13 @@ export class ExecutionPlanView { zoomIn() { if (!this._network) return; - const scale = this._network.getScale() * 1.3; + const scale = clamp(this._network.getScale() * 1.3, PLAN_GRAPH_MIN_SCALE, PLAN_GRAPH_MAX_SCALE); this._network.moveTo({ scale, animation: { duration: 200, easingFunction: "easeInOutQuad" } }); } zoomOut() { if (!this._network) return; - const scale = this._network.getScale() / 1.3; + const scale = clamp(this._network.getScale() / 1.3, PLAN_GRAPH_MIN_SCALE, PLAN_GRAPH_MAX_SCALE); this._network.moveTo({ scale, animation: { duration: 200, easingFunction: "easeInOutQuad" } }); } From 9daa0ca6e988a44173d94e629b36ad7131c9f296 Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 14:10:43 +0800 Subject: [PATCH 03/14] feat: one agent, one node --- src/matcreator/agents/graph_logger.py | 5 +++ src/matcreator/agents/orchestrator/agent.py | 38 +++++++++++++++-- web/vite-frontend/index.html | 4 ++ .../src/features/graphs/AgentGraphView.js | 16 +++++-- web/vite-frontend/src/main.js | 4 ++ web/vite-frontend/src/styles/chat.css | 42 +++++++++++++++++++ 6 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/matcreator/agents/graph_logger.py b/src/matcreator/agents/graph_logger.py index 8e5c83a5..1cbe5d54 100644 --- a/src/matcreator/agents/graph_logger.py +++ b/src/matcreator/agents/graph_logger.py @@ -130,6 +130,11 @@ def count_nodes_of_type(self, node_type: NodeType) -> int: graph = self._read() return sum(1 for n in graph["nodes"].values() if n["type"] == node_type) + def nodes_of_type(self, node_type: NodeType) -> list[dict]: + """Return graph nodes of a type for lifecycle-aware node reuse.""" + graph = self._read() + return [node for node in graph["nodes"].values() if node.get("type") == node_type] + def log_node_input(self, node_id: str, input_data: dict) -> None: """Store the structured input that was passed to the sub-agent.""" with self._lock: diff --git a/src/matcreator/agents/orchestrator/agent.py b/src/matcreator/agents/orchestrator/agent.py index e896af00..155961cc 100644 --- a/src/matcreator/agents/orchestrator/agent.py +++ b/src/matcreator/agents/orchestrator/agent.py @@ -43,6 +43,7 @@ _DEFAULT_MEMORIZATION_FREQUENCY = 1 _DEFAULT_REVIEW_FREQUENCY = 10 +_PLANNING_NODE_STATE_KEY = "_graph_planning_node_id" # --------------------------------------------------------------------------- @@ -78,6 +79,36 @@ def _is_graph_complete(state: dict) -> bool: return bool(nodes) and all(n.get("status") == "success" for n in nodes.values()) +def _get_planning_node_id(state: dict, graph: AgentGraphLogger) -> str: + """Return the graph node representing the session's current planner. + + The orchestrator keeps one planning-agent instance for the session. A + planning invocation is therefore activity on that agent, not evidence that + a new agent was created. Persisting its graph id in session state keeps + execution/replanning loops attached to one node. A future code path that + deliberately replaces the planning agent can clear this state key before + creating its replacement node. + """ + node_id = state.get(_PLANNING_NODE_STATE_KEY) + if isinstance(node_id, str) and node_id: + return node_id + + # Preserve the original planner node when resuming a graph written before + # this state key existed, rather than adding another planning node. + existing_planners = [ + node for node in graph.nodes_of_type("planning") + if isinstance(node.get("id"), str) + ] + if existing_planners: + existing_planners.sort(key=lambda node: (node.get("start_time") or "", node["id"])) + node_id = existing_planners[0]["id"] + else: + node_id = "planning_0" + + state[_PLANNING_NODE_STATE_KEY] = node_id + return node_id + + # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- @@ -125,14 +156,13 @@ async def _run_async_impl( logger.warning("[orchestrator] recovered execution state: %s", recovered) loop_idx = graph.count_nodes_of_type("execution") + planning_id = _get_planning_node_id(state, graph) while True: # ── Planning phase (always runs first) ─────────────────────────── state["execution_approved"] = False - has_execution = loop_idx > 0 - planning_id = f"planning_{loop_idx}" if has_execution else "planning_0" logger.info("[orchestrator] entering planning phase") - graph.log_node_start(planning_id, "planning", f"Planning {loop_idx + 1}", "orchestrator") + graph.log_node_start(planning_id, "planning", "Planning", "orchestrator") # Approval is a hard handoff boundary. Yield the successful tool # response first so clients can persist/render it, then close the # planner stream before it can start another model/tool round. @@ -171,7 +201,7 @@ async def _run_async_impl( ) exec_id = f"execution_{loop_idx}" - graph.log_node_start(exec_id, "execution", f"Execution {loop_idx + 1}", "orchestrator") + graph.log_node_start(exec_id, "execution", f"Execution {loop_idx + 1}", planning_id) state["_graph_exec_node_id"] = exec_id async for event in self.execution_agent.run_async(ctx): diff --git a/web/vite-frontend/index.html b/web/vite-frontend/index.html index 6f3dd2ba..b1d81c2b 100644 --- a/web/vite-frontend/index.html +++ b/web/vite-frontend/index.html @@ -184,6 +184,10 @@

Agent Graph

+
diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index 520049c9..1629855e 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -488,10 +488,20 @@ export class AgentGraphView { }); }); - // Phase nodes are logged as orchestrator children because the orchestrator - // invokes them. For display, group each execution/testing phase beneath - // the planning invocation whose context produced it. + // Newer graph records persist the actual planning parent. Older sessions + // logged every phase under the orchestrator, so retain temporal grouping as + // a backwards-compatible fallback. childPhaseNodes.forEach((node) => { + const persistedParent = nodeMap[node.parent_id]; + if (persistedParent?.type === "planning") { + displayEdges.push({ + id: `phase__${persistedParent.id}__${node.id}`, + from: persistedParent.id, + to: node.id, + }); + return; + } + const nodeStart = node.start_time ? new Date(node.start_time).getTime() : Infinity; let parentPlanning = null; for (const planning of planningNodes) { diff --git a/web/vite-frontend/src/main.js b/web/vite-frontend/src/main.js index d25fabc1..5c49cb54 100644 --- a/web/vite-frontend/src/main.js +++ b/web/vite-frontend/src/main.js @@ -67,7 +67,9 @@ const state = { const chatArea = document.getElementById("chat-area"); const textInput = document.getElementById("text-input"); +const inputArea = document.querySelector(".input-area"); const inputContainer = document.querySelector(".input-container"); +const agentRunningIndicator = document.getElementById("agent-running-indicator"); const sendBtn = document.getElementById("send-btn"); const fileUploadBtn = document.getElementById("file-upload-btn"); const fileUploadInput = document.getElementById("file-upload-input"); @@ -1068,6 +1070,8 @@ function releaseSessionRequest(request) { function updateSendButtonState() { const running = Boolean(activeSessionRequest()); + inputArea?.classList.toggle("is-agent-running", running); + if (agentRunningIndicator) agentRunningIndicator.setAttribute("aria-hidden", String(!running)); if (!sendBtn) return; sendBtn.textContent = running ? "■" : "➜"; sendBtn.title = running ? "Stop" : "Send"; diff --git a/web/vite-frontend/src/styles/chat.css b/web/vite-frontend/src/styles/chat.css index 9b1ce890..260b77ad 100644 --- a/web/vite-frontend/src/styles/chat.css +++ b/web/vite-frontend/src/styles/chat.css @@ -1330,6 +1330,48 @@ body[data-theme="dark"] .input-container[data-agent-mode="bench"] { transform: translateY(2px); } +.agent-running-indicator { + display: none; + align-items: center; + gap: 8px; + margin: 0 4px 6px; + color: var(--accent); + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.input-area.is-agent-running .agent-running-indicator { + display: flex; +} + +.agent-running-dots { + display: inline-flex; + align-items: center; + gap: 3px; + height: 12px; +} + +.agent-running-dots i { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + animation: agent-running-pulse 1.2s ease-in-out infinite; +} + +.agent-running-dots i:nth-child(2) { animation-delay: 0.15s; } +.agent-running-dots i:nth-child(3) { animation-delay: 0.3s; } + +@keyframes agent-running-pulse { + 0%, 60%, 100% { opacity: 0.28; transform: scale(0.7); } + 30% { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .agent-running-dots i { animation: none; opacity: 0.8; transform: none; } +} + .plan-approval-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); From 61c4c90f992039ab301ad3f79e3977064186c588 Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 15:26:35 +0800 Subject: [PATCH 04/14] feat: waiting info --- src/matcreator/agents/graph_logger.py | 34 +++- tests/test_graph_logger.py | 42 ++++ web/main.py | 27 +++ web/vite-frontend/index.html | 2 +- .../src/components/OrbitalAgentIndicator.css | 62 ++++++ .../src/components/OrbitalAgentIndicator.jsx | 183 ++++++++++++++++++ .../components/mountOrbitalAgentIndicator.js | 17 ++ .../src/features/graphs/AgentGraphView.js | 40 +++- web/vite-frontend/src/main.js | 4 + web/vite-frontend/src/styles/chat.css | 51 ++--- 10 files changed, 434 insertions(+), 28 deletions(-) create mode 100644 tests/test_graph_logger.py create mode 100644 web/vite-frontend/src/components/OrbitalAgentIndicator.css create mode 100644 web/vite-frontend/src/components/OrbitalAgentIndicator.jsx create mode 100644 web/vite-frontend/src/components/mountOrbitalAgentIndicator.js diff --git a/src/matcreator/agents/graph_logger.py b/src/matcreator/agents/graph_logger.py index 1cbe5d54..05e72ee6 100644 --- a/src/matcreator/agents/graph_logger.py +++ b/src/matcreator/agents/graph_logger.py @@ -166,13 +166,31 @@ def log_state_delta(self, node_id: str, delta: dict) -> None: self._write(graph) def log_conversation_event(self, node_id: str, entry: dict) -> None: - """Append one conversation turn to the node's conversation list.""" + """Append one conversation turn, coalescing adjacent streamed text. + + LLM providers may send either a token delta or the entire response so + far. Keeping every chunk made the graph noisy and meant the frontend + could not present a single live response for a running node. + """ with self._lock: graph = self._read() node = graph["nodes"].get(node_id) if node is None: return - node.setdefault("conversation", []).append(entry) + conversation = node.setdefault("conversation", []) + previous = conversation[-1] if conversation else None + if ( + previous + and entry.get("type") in {"text", "thought"} + and previous.get("type") == entry.get("type") + and previous.get("author") == entry.get("author") + and isinstance(previous.get("content"), str) + and isinstance(entry.get("content"), str) + ): + previous["content"] = _merge_streamed_text(previous["content"], entry["content"]) + previous["timestamp"] = entry.get("timestamp", previous.get("timestamp")) + else: + conversation.append(entry) self._write(graph) def mark_running_nodes_cancelled( @@ -256,3 +274,15 @@ def _read(self) -> dict: def _write(self, graph: dict) -> None: graph["updated_at"] = _now() self._path.write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _merge_streamed_text(current: str, incoming: str) -> str: + """Merge a streamed delta or cumulative snapshot without duplicated text.""" + if not incoming or current.endswith(incoming): + return current + if incoming.startswith(current): + return incoming + for overlap in range(min(len(current), len(incoming)), 0, -1): + if current.endswith(incoming[:overlap]): + return current + incoming[overlap:] + return current + incoming diff --git a/tests/test_graph_logger.py b/tests/test_graph_logger.py new file mode 100644 index 00000000..64cd1b29 --- /dev/null +++ b/tests/test_graph_logger.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json + +from matcreator.agents import graph_logger + + +def test_consecutive_streamed_text_is_coalesced(tmp_path, monkeypatch) -> None: + """A running node exposes one growing response instead of token cards.""" + monkeypatch.setattr(graph_logger, "ADK_DIR", tmp_path) + logger = graph_logger.AgentGraphLogger("streaming-session") + logger.log_node_start("step-one", "step", "Step one") + + logger.log_conversation_event("step-one", { + "timestamp": "first", + "author": "step_executor", + "type": "text", + "content": "Calculating lattice", + }) + # Cumulative snapshots and deltas are both emitted by supported providers. + logger.log_conversation_event("step-one", { + "timestamp": "second", + "author": "step_executor", + "type": "text", + "content": "Calculating lattice parameters", + }) + logger.log_conversation_event("step-one", { + "timestamp": "third", + "author": "step_executor", + "type": "text", + "content": " and checking convergence.", + }) + + graph_path = tmp_path / "agent_graphs" / "streaming-session.json" + graph = json.loads(graph_path.read_text(encoding="utf-8")) + conversation = graph["nodes"]["step-one"]["conversation"] + assert conversation == [{ + "timestamp": "third", + "author": "step_executor", + "type": "text", + "content": "Calculating lattice parameters and checking convergence.", + }] diff --git a/web/main.py b/web/main.py index 4fc8488a..bdd57064 100644 --- a/web/main.py +++ b/web/main.py @@ -9,6 +9,8 @@ --------- GET /api/agent-graph/{session_id} Returns the JSON graph file for the session, or an empty graph if not found. +GET /api/agent-graph/{session_id}/events + Streams graph snapshots whenever an agent node emits a new event. GET /api/workspace/files?path= Serves any file from the workspace root (absolute or relative path). Returns 403 if the path escapes the workspace root. @@ -3319,6 +3321,31 @@ async def get_agent_graph(session_id: str) -> JSONResponse: return JSONResponse(data) +@app.get("/api/agent-graph/{session_id}/events") +async def stream_agent_graph(session_id: str, request: Request) -> StreamingResponse: + """Push graph updates so concurrent node output appears without polling.""" + async def stream(): + last_updated_at = object() + while not await request.is_disconnected(): + data = _load_agent_graph_data(session_id) + if not data: + data = {"session_id": session_id, "nodes": {}, "edges": [], "updated_at": None} + updated_at = data.get("updated_at") + if updated_at != last_updated_at: + yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + last_updated_at = updated_at + # The logger writes synchronously for every model/tool event. A + # short server-side wait keeps the browser connection quiet while + # making independently running nodes feel genuinely concurrent. + await asyncio.sleep(0.2) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @app.post("/api/workspace/cli") async def run_workspace_cli(body: WorkspaceCliBody) -> JSONResponse: command = body.command.strip() diff --git a/web/vite-frontend/index.html b/web/vite-frontend/index.html index b1d81c2b..5adfe15b 100644 --- a/web/vite-frontend/index.html +++ b/web/vite-frontend/index.html @@ -185,7 +185,7 @@

Agent Graph

diff --git a/web/vite-frontend/src/components/OrbitalAgentIndicator.css b/web/vite-frontend/src/components/OrbitalAgentIndicator.css new file mode 100644 index 00000000..ef26b27b --- /dev/null +++ b/web/vite-frontend/src/components/OrbitalAgentIndicator.css @@ -0,0 +1,62 @@ +.orbital-agent-indicator { + display: block; + overflow: visible; +} + +.orbital-agent-indicator__motion { + transform-box: fill-box; + transform-origin: 50% 50%; + animation: orbital-breathe 5.6s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} + +.orbital-agent-indicator__glow { + animation: orbital-glow 3.8s ease-in-out infinite; +} + +.orbital-agent-indicator--searching .orbital-agent-indicator__motion { + animation: orbital-search 3.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} + +.orbital-agent-indicator--computing .orbital-agent-indicator__motion { + animation-duration: 2.8s; +} + +.orbital-agent-indicator--done .orbital-agent-indicator__motion { + animation: orbital-done 1.4s cubic-bezier(0.22, 1, 0.36, 1) 1 both; +} + +.orbital-agent-indicator--done .orbital-agent-indicator__glow { + animation: orbital-done-glow 1.4s ease-out 1 both; +} + +@keyframes orbital-breathe { + 0%, 100% { opacity: 0.78; transform: scale(0.94); } + 50% { opacity: 1; transform: scale(1.04); } +} + +@keyframes orbital-glow { + 0%, 100% { opacity: 0.38; } + 50% { opacity: 0.9; } +} + +@keyframes orbital-search { + 0%, 100% { opacity: 0.8; transform: rotate(-5deg) scale(0.95); } + 50% { opacity: 1; transform: rotate(5deg) scale(1.05); } +} + +@keyframes orbital-done { + 0%, 100% { opacity: 0.82; transform: scale(1); } + 35% { opacity: 1; transform: scale(1.14); } +} + +@keyframes orbital-done-glow { + 0%, 100% { opacity: 0.38; } + 35% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .orbital-agent-indicator__motion, + .orbital-agent-indicator__glow { + animation: none; + } +} diff --git a/web/vite-frontend/src/components/OrbitalAgentIndicator.jsx b/web/vite-frontend/src/components/OrbitalAgentIndicator.jsx new file mode 100644 index 00000000..8af587fa --- /dev/null +++ b/web/vite-frontend/src/components/OrbitalAgentIndicator.jsx @@ -0,0 +1,183 @@ +import React, { useEffect, useId, useRef, useState } from "react"; +import "./OrbitalAgentIndicator.css"; + +// Every orbital uses eight cubic Bezier segments in the same order. Keeping the +// path commands compatible lets the browser interpolate `d` smoothly in SMIL, +// without a JavaScript animation loop or a path-morphing dependency. +const ORBITALS = { + // A rounded, isotropic s orbital: no nodal waist or directional lobe. + s: "M 50 10 C 61 10 71 16 78 25 C 85 34 88 42 88 50 C 88 58 85 66 78 75 C 71 84 61 90 50 90 C 39 90 29 84 22 75 C 15 66 12 58 12 50 C 12 42 15 34 22 25 C 29 16 39 10 50 10 Z", + // px: an exact two-lobed figure-eight. The first four Beziers trace the + // right lobe and the next four trace its horizontal mirror; both meet only + // at the central node (50, 50), making the nodal waist unmistakable. + p: "M 50 50 C 50 36 57 20 70 20 C 83 20 90 34 90 50 C 90 66 83 80 70 80 C 57 80 50 64 50 50 C 50 64 43 80 30 80 C 17 80 10 66 10 50 C 10 34 17 20 30 20 C 43 20 50 36 50 50 Z", + // Four broad, equal d-orbital leaves. Each pair of curves leaves and returns + // to the central node, so all four lobes remain full at small icon sizes. + d: "M 50 50 C 35 42 20 22 50 8 C 80 22 65 42 50 50 C 58 35 78 20 92 50 C 78 80 58 65 50 50 C 65 58 80 78 50 92 C 20 78 35 58 50 50 C 42 65 22 80 8 50 C 22 20 42 35 50 50 Z", +}; + +const ACTIVE_STATES = new Set(["thinking", "searching", "computing"]); +const REST_MIN_MS = 2400; +const REST_MAX_MS = 4000; +const TRANSITION_MS = 560; + +// The paths above intentionally share one `M`, eight `C`, and `Z` commands. +// We therefore interpolate their corresponding Bezier coordinates directly, +// producing a genuine intermediate SVG geometry on every transition frame. +function pathNumbers(path) { + return path.match(/-?\d*\.?\d+/g).map(Number); +} + +const PATH_POINTS = Object.fromEntries( + Object.entries(ORBITALS).map(([name, path]) => [name, pathNumbers(path)]), +); + +function serializePath(points) { + let result = `M ${points[0].toFixed(2)} ${points[1].toFixed(2)}`; + for (let index = 2; index < points.length; index += 6) { + result += ` C ${points.slice(index, index + 6).map((value) => value.toFixed(2)).join(" ")}`; + } + return `${result} Z`; +} + +function interpolatePath(from, to, progress) { + const fromPoints = PATH_POINTS[from]; + const toPoints = PATH_POINTS[to]; + return serializePath(fromPoints.map((value, index) => value + (toPoints[index] - value) * progress)); +} + +function excitationEase(progress) { + // Smooth acceleration/deceleration plus a restrained overshoot before settle. + const shifted = progress - 1; + return 1 + 2.1 * shifted ** 3 + 1.1 * shifted ** 2; +} + +function chooseNextOrbital(current) { + const choices = Object.keys(ORBITALS).filter((orbital) => orbital !== current); + return choices[Math.floor(Math.random() * choices.length)]; +} + +const THEME_COLORS = { + cool: "#7dd3fc", + violet: "#c4b5fd", + emerald: "#6ee7b7", +}; + +/** + * A compact, SVG-only agent-status animation inspired by atomic orbitals. + * + * Active states rest on an orbital and periodically select a different s/p/d + * target. The only per-frame work is a 560 ms coordinate interpolation during + * an excitation; resting phases remain entirely CSS-driven. `color` accepts + * any CSS color; `theme` provides compact default palettes. + */ +export function OrbitalAgentIndicator({ + state = "idle", + size = 32, + color, + theme = "cool", + className = "", + title = "MatCreator status", +}) { + const id = useId().replaceAll(":", ""); + const safeState = ["idle", "thinking", "searching", "computing", "done"].includes(state) ? state : "idle"; + // Always begin at the s orbital. The active cycle subsequently chooses among + // all three states rather than imposing a fixed mode-specific sequence. + const [orbital, setOrbital] = useState("s"); + const [nextOrbital, setNextOrbital] = useState(null); + const [displayPath, setDisplayPath] = useState(ORBITALS.s); + const isInitialDwell = useRef(true); + const isTransitioning = nextOrbital !== null; + + // Rest on an orbital, then choose a different s/p/d target at random. + useEffect(() => { + if (!ACTIVE_STATES.has(safeState) || isTransitioning) return undefined; + // Ensure the first visible active state is a clearly recognizable s cloud. + const delay = isInitialDwell.current + ? REST_MAX_MS + : REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS); + const timer = window.setTimeout(() => { + isInitialDwell.current = false; + setNextOrbital(chooseNextOrbital(orbital)); + }, delay); + return () => window.clearTimeout(timer); + }, [safeState, orbital, nextOrbital, isTransitioning]); + + useEffect(() => { + if (!nextOrbital) return undefined; + const start = performance.now(); + let frameId; + const animate = (now) => { + const progress = Math.min((now - start) / TRANSITION_MS, 1); + setDisplayPath(interpolatePath(orbital, nextOrbital, excitationEase(progress))); + if (progress < 1) frameId = requestAnimationFrame(animate); + else { + setDisplayPath(ORBITALS[nextOrbital]); + setOrbital(nextOrbital); + setNextOrbital(null); + } + }; + frameId = requestAnimationFrame(animate); + return () => cancelAnimationFrame(frameId); + }, [orbital, nextOrbital]); + + const orbitalColor = color || THEME_COLORS[theme] || THEME_COLORS.cool; + const gradientId = `orbital-gradient-${id}`; + const glowId = `orbital-glow-${id}`; + + return ( + + {title} + + + + + + + + + + + + + + + {isTransitioning && ( + + )} + + + + + + + + + + ); +} + +export default OrbitalAgentIndicator; diff --git a/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js new file mode 100644 index 00000000..6739f665 --- /dev/null +++ b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js @@ -0,0 +1,17 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import OrbitalAgentIndicator from "./OrbitalAgentIndicator.jsx"; + +// The main chat is vanilla JS. This narrow bridge mounts the reusable React +// indicator without making the rest of the composer depend on React. +export function mountOrbitalAgentIndicator(target) { + if (!target) return null; + const root = createRoot(target); + root.render(React.createElement(OrbitalAgentIndicator, { + state: "computing", + size: 18, + color: "var(--accent)", + title: "MatCreator is working", + })); + return root; +} diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index 1629855e..a54bc995 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -46,6 +46,7 @@ export class AgentGraphView { this._edges = new DataSet([]); this._network = null; this._pollInterval = null; + this._eventStream = null; this._didInitialFit = false; this._pendingFit = true; this._animationFrame = null; @@ -651,10 +652,26 @@ export class AgentGraphView { startPolling(sessionId) { this._currentSessionId = sessionId; this._poll(sessionId); - this._pollInterval = setInterval(() => this._poll(sessionId), 2000); + this._eventStream?.close(); + this._eventStream = new EventSource(`/api/agent-graph/${encodeURIComponent(sessionId)}/events`); + this._eventStream.onmessage = (event) => { + try { + if (sessionId !== this._currentSessionId) return; + this.update(JSON.parse(event.data)); + } catch (_) { + // Ignore a malformed snapshot; EventSource will deliver the next one. + } + }; + // Keep a low-frequency fallback for deployments running an older web + // backend that does not yet expose the graph event endpoint. + this._eventStream.onerror = () => { + if (!this._pollInterval) this._pollInterval = setInterval(() => this._poll(sessionId), 2000); + }; } stopPolling() { + this._eventStream?.close(); + this._eventStream = null; if (this._pollInterval) { clearInterval(this._pollInterval); this._pollInterval = null; @@ -1252,6 +1269,18 @@ export class StepExecutionFeed { body.appendChild(p); } + const liveResponse = this._latestStreamedResponse(node); + if (node.status === "running" && liveResponse) { + const response = document.createElement("div"); + response.className = "step-feed-live-response"; + const label = document.createElement("span"); + label.textContent = "Live response"; + const content = document.createElement("span"); + content.textContent = liveResponse; + response.append(label, content); + body.appendChild(response); + } + if (node.input && Object.keys(node.input).length) { body.appendChild(this._wireNested(node.id, "input", this._renderStepInput(node.input))); } @@ -1344,6 +1373,15 @@ export class StepExecutionFeed { details.appendChild(body); } + + _latestStreamedResponse(node) { + const conversation = node.conversation || []; + for (let index = conversation.length - 1; index >= 0; index -= 1) { + const event = conversation[index]; + if (["text", "thought"].includes(event.type) && event.content) return String(event.content); + } + return ""; + } } // --------------------------------------------------------------------------- diff --git a/web/vite-frontend/src/main.js b/web/vite-frontend/src/main.js index 5c49cb54..255938c6 100644 --- a/web/vite-frontend/src/main.js +++ b/web/vite-frontend/src/main.js @@ -10,6 +10,7 @@ import { AgentGraphView, StepExecutionFeed } from "./features/graphs/AgentGraphV import { ExecutionPlanView } from "./features/graphs/ExecutionPlanView.js"; import { createSkillGraphController } from "./features/skills/SkillGraphController.js"; import { createSettingsController } from "./features/settings/SettingsController.js"; +import { mountOrbitalAgentIndicator } from "./components/mountOrbitalAgentIndicator.js"; import "./styles/index.css"; // --------------------------------------------------------------------------- @@ -70,6 +71,7 @@ const textInput = document.getElementById("text-input"); const inputArea = document.querySelector(".input-area"); const inputContainer = document.querySelector(".input-container"); const agentRunningIndicator = document.getElementById("agent-running-indicator"); +const agentRunningOrbital = document.getElementById("agent-running-orbital"); const sendBtn = document.getElementById("send-btn"); const fileUploadBtn = document.getElementById("file-upload-btn"); const fileUploadInput = document.getElementById("file-upload-input"); @@ -1078,6 +1080,8 @@ function updateSendButtonState() { sendBtn.classList.toggle("is-stopping", running); } +mountOrbitalAgentIndicator(agentRunningOrbital); + function storeSessionSelection(sessionId, owner) { localStorage.setItem(SESSION_ID_KEY, sessionId); localStorage.setItem(SESSION_OWNER_KEY, owner); diff --git a/web/vite-frontend/src/styles/chat.css b/web/vite-frontend/src/styles/chat.css index 260b77ad..321ea64f 100644 --- a/web/vite-frontend/src/styles/chat.css +++ b/web/vite-frontend/src/styles/chat.css @@ -903,6 +903,29 @@ background: rgba(255, 255, 255, 0.03); } +.step-feed-live-response { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 10px; + border-left: 2px solid #fbbf24; + border-radius: 0 8px 8px 0; + background: rgba(251, 191, 36, 0.08); + color: var(--text); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.step-feed-live-response > span:first-child { + color: #fbbf24; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .step-feed-section { display: flex; flex-direction: column; @@ -1345,31 +1368,11 @@ body[data-theme="dark"] .input-container[data-agent-mode="bench"] { display: flex; } -.agent-running-dots { +.agent-running-orbital { display: inline-flex; - align-items: center; - gap: 3px; - height: 12px; -} - -.agent-running-dots i { - width: 5px; - height: 5px; - border-radius: 50%; - background: currentColor; - animation: agent-running-pulse 1.2s ease-in-out infinite; -} - -.agent-running-dots i:nth-child(2) { animation-delay: 0.15s; } -.agent-running-dots i:nth-child(3) { animation-delay: 0.3s; } - -@keyframes agent-running-pulse { - 0%, 60%, 100% { opacity: 0.28; transform: scale(0.7); } - 30% { opacity: 1; transform: scale(1); } -} - -@media (prefers-reduced-motion: reduce) { - .agent-running-dots i { animation: none; opacity: 0.8; transform: none; } + width: 18px; + height: 18px; + flex: 0 0 18px; } .plan-approval-actions { From 37eb07846e1f573732321bef6448db55dc286cfe Mon Sep 17 00:00:00 2001 From: Fillianore <37468338+Fillianore@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:34:37 +0800 Subject: [PATCH 05/14] feat(skills): add DPA-4c distillation workflow Add concept-level and deepmd-skill support for distilling a DPA-4c student model from a fine-tuned DPA-4 teacher: - mlff concept SKILL: rewrite the distillation procedure into explicit Phase A (teacher NPT MD) -> Phase B (teacher inference labeling) -> Phase C (student training + two-level evaluation), with strict phase ordering gates and seed/training-set provenance checks. - deepmd SKILL + supported models reference: document DPA-4c CLI usage (--pt-expt, never --finetune due to OOM for sel=[999999]), the --skip-neighbor-stat requirement, and the recommended Bohrium image (dpa4-mlip-340e01f9) on a 5090 GPU. - deepmd_prepare.py: fix epoch-key alias handling so exactly one epoch key remains in the training dict (avoids dargs strict-schema failure). - references/dpa4c_distill_input.json: verified input template from a completed 1M-step DPA-4c distillation run. --- .../machine-learning-force-field/SKILL.md | 133 ++++++++++-- src/matcreator/skills/deepmd/SKILL.md | 51 +++++ .../references/dpa4c_distill_input.json | 190 ++++++++++++++++++ .../references/supported_deepmd_models.md | 26 +++ .../skills/deepmd/scripts/deepmd_prepare.py | 21 +- 5 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 src/matcreator/skills/deepmd/references/dpa4c_distill_input.json diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md b/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md index c4a33d1d..4522c5e0 100644 --- a/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md +++ b/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md @@ -212,18 +212,127 @@ The smaller MLFF model is called the **student model**, and the larger MLFF mode MLFF must be appropriately distilled before applying the MLFF model to a large-scale simulation (> 100 K atoms, > 1 ns). -The distillation workflow is largely the same as the fine-tuning workflow, except that: -- The labels can be generated by the teacher model instead of DFT. -- The student model is trained from scratch, as the result, - the training set should be much larger than the fine-tuning case - (usually 10 times the fine-tuning case is recommended). -- As notified before in fine-tuning, the resulting student model also has to be evaluated. - The evaluation must first be performed comparing the student model's prediction on the test set - against the teacher model's label, then comparing the student model's prediction against the - **ground truth** (DFT). Since distillation is often preceded by fine-tuning, you can reuse the - DFT-labeled test set from the corresponding fine-tuning workflow. If that is not available, then - consider choosing an appropriate subset of the distillation testing set and re-compute them with DFT - (subset number of frames times number of atoms in each frame should not exceed 10000 total atoms). +The distillation workflow mirrors the fine-tuning workflow phase by phase, except that the labels are +generated by the teacher model instead of DFT, and the student model is trained from scratch on a much +larger dataset. The concept-level procedure below is the direct counterpart of the fine-tuning procedure above. + +## Recommended Procedure — Generate a force field via distillation + +> **Strict phase ordering — Phase A → Phase B → Phase C.** +> These three phases must be executed **in order, with no phase skipped, no phase +> reordered, and no phase omitted because it "seems unnecessary" for the structure at +> hand.** The only allowed variation is tuning *parameters within each phase* +> (temperature, pressure, frame count, training steps, etc.); the phase *sequence and +> presence* are fixed. +> +> **If you believe a phase can be skipped or shortened for your particular structure, +> that belief is precisely the error this rule exists to prevent.** Skipping Phase A +> (teacher MD) or Phase B (proper labeling) and jumping straight to Phase C (student +> training) produces a student that reproduces the teacher on MD-sampled frames but +> degrades by an order of magnitude on DFT-relaxed structures — the classic +> distillation failure mode. +### Phase Zero — Gate: Is a valid teacher model available? + +A valid **teacher model** must satisfy BOTH conditions: + +1. It is a **fine-tuned** model on the target system, i.e., it has already gone through the + fine-tuning procedure above on DFT-labeled data of that system. +2. It is a **single-task** model (dedicated to the target system). + +- **If no valid teacher exists** (the user only has a pretrained model, or only a multi-task model): + do NOT proceed with distillation — a pretrained model must **NEVER** be used as a teacher. + Return to the fine-tuning workflow above first, produce a fine-tuned single-task model for the + target system, and only then come back to distillation. +- **Student model:** the student is restricted to **DPA-4c**. + +> All DPA-4c-specific CLI commands, scripts and environment setup are owned by the `deepmd` skill. +> Load the `deepmd` skill when executing any phase below; this concept file deliberately does not +> repeat concrete commands. + +### Phase A — Teacher MD exploration (candidate structure generation) + +Generate candidate structures using the **fine-tuned teacher model** as the MD calculator, +**reusing the fine-tuning Phase A sampling rules**: + +- Same structure preparation and cell-size rules (~50 atoms per structure; supercell replication at + most once and never for large systems). +- Explore configuration space via **NPT-ensemble MD** with the same parameter conventions as + fine-tuning (NPT ensemble is mandatory; same temperature/pressure/step-size/saving-interval rules). +- **Entropy-based structure selection is MANDATORY** after MD sampling and before labeling, + exactly as in fine-tuning Phase A. + +The only systematic difference is the sampling scale: because the student is trained from scratch, +distillation needs roughly **20 times** the number of frames used in fine-tuning. + +> Output frames recommendation (distillation): +> - **20000** for simple systems (bulk crystals, random alloys, simple compounds) +> - **40000** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) +> - **100000** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) + +> **Seed ≠ training set.** The POSCAR / cif structure you start with is a **seed** for +> MD exploration, not a training frame. You MUST first run teacher **NPT-ensemble MD** +> on the seed (Phase A) to generate diverse configurations, then label those +> configurations with the teacher (Phase B), and only then train the student on the +> labeled MD-sampled frames (Phase C). **Directly labeling the static seed structure +> (or a handful of manually-built structures) and training on it is a typical +> distillation failure and is strictly forbidden.** +> +> Recall the three non-negotiable Phase A rules that the seed-based workflow depends on: +> **(1) NPT ensemble** — mandatory for strain diversity; never switch to NVT/NVE without +> explicit user approval. **(2) Entropy-based structure selection** — mandatory after +> MD sampling and before labeling. **(3) ~100× frame scale** — distillation needs +> roughly 100× the frames of fine-tuning (≥ 20000 for simple systems). + +### Phase B — Teacher inference labeling (replaces DFT) + +Label the entropy-selected structures by **teacher model inference** (single-point energy, force and +virial predictions) instead of DFT: + +- The teacher's predictions fully replace DFT single-point calculations in this phase; no DFT jobs + are launched. +- The teacher is guaranteed to be a fine-tuned **single-task** model by the Phase Zero gate, so there + is **no model head selection** involved — run inference with the teacher model as-is. +- MD/inference skill choice follows the same rules as fine-tuning (`ase` first, `lammps` as fallback). + +### Phase C — Student training from scratch & Evaluation + +> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**, as in fine-tuning. + +> **GATE — verify training-set provenance and size before proceeding to student +> training.** Before step 1 below, confirm ALL of the following: +> 1. The training frames were **produced by Phase A → Phase B** (teacher NPT MD +> sampling + entropy selection + teacher inference labeling), **not** statically +> labeled from the seed structure. +> 2. The total labeled frame count is **≥ ~20000** (simple systems) / **≥ ~40000** +> (complex systems) / **≥ ~100000** (very complex systems), consistent with the +> Phase A output recommendation above. +> 3. The frames span a **diverse configuration space** (different cell shapes, strains, +> and atomic positions from the NPT trajectory), not a single relaxed geometry. +> +> **If any check fails, STOP and return to Phase A** — regenerate/expand the MD +> trajectory and re-label. Do not proceed to student training on insufficient or +> non-MD-sourced data. + +1. Prepare the teacher-labeled data in a fresh workdir. The data preparation and **domain split must + follow exactly the same rules as fine-tuning Phase C**: train vs test split ratio is **4:1** for + all teacher-labeled frames. + +2. Train the DPA-4c student model **from scratch** (no pretrained initialization). Because the student + starts from scratch, training is much longer than fine-tuning: use about **1,000,000 (1M) training + steps**. For the concrete DPA-4c training commands and environment, load and follow the `deepmd` skill. + +3. **Evaluate on two levels (both are required):** + - **Level 1 — student vs teacher:** compare the student's predictions against the teacher's labels + on the held-out test set (the 1/5 test split from step 1). This checks that the student faithfully + reproduces the teacher. + - **Level 2 — student vs DFT ground truth:** compare the student's predictions against DFT. + **Prefer reusing the DFT-labeled test set from the corresponding fine-tuning workflow.** If that + is not available, choose an appropriate subset of the distillation testing set and re-compute it + with DFT (subset number of frames times number of atoms in each frame should not exceed 10000 + total atoms). + +4. Distillation is a **single-round** procedure: train the student once and evaluate. Do NOT iterate + (no repeated teacher-relabeling rounds) within this workflow. --- diff --git a/src/matcreator/skills/deepmd/SKILL.md b/src/matcreator/skills/deepmd/SKILL.md index 41c879ef..fe7bb462 100644 --- a/src/matcreator/skills/deepmd/SKILL.md +++ b/src/matcreator/skills/deepmd/SKILL.md @@ -136,6 +136,57 @@ dp show model-branch dp show descriptor ``` +--- + +# DPA-4c distillation (student model training) + +Distilling a student model from a fine-tuned DPA-4 teacher is the only scenario where +training a model is advised (see the description of this skill). The student architecture +for distillation is **DPA-4c** (`descriptor.type = "dpa4c"`), whose CLI usage differs +from regular fine-tuning. + +> **Prerequisite gate — Phase A and Phase B must be completed first.** +> Before using any command in this section, verify that: +> 1. **Phase A (teacher NPT MD exploration)** has been completed — the teacher model +> was run as an NPT-ensemble MD calculator on the seed structure to generate a +> diverse set of candidate frames (~2000+ frames for simple systems, more for +> complex ones). The seed POSCAR/cif is **not** a training frame. +> 2. **Phase B (teacher inference labeling)** has been completed — the MD-sampled +> frames were labeled by single-point teacher inference (energy, forces, virial). +> 3. The resulting labeled dataset has a sufficient frame count and is sourced +> exclusively from the A→B pipeline. +> +> **Training directly on the seed structure (or any statically-built structures) +> is forbidden** and will produce a student that reproduces the teacher on +> MD-sampled frames but degrades by an order of magnitude on DFT-relaxed structures. +> If Phase A/B have not been done, return to the `machine-learning-force-field` skill +> and complete them before proceeding. + +> **For DPA-4c, ALWAYS use `--pt-expt` in the `dp` CLI. NEVER use `--finetune`:** +> the bias-adjustment dense forward pass of `--finetune` runs out of memory (OOM) +> for the DPA-4c selection `sel=[999999]`. + +Training command (run inside the workdir): + +```bash +dp --pt-expt train input.json --init-model --skip-neighbor-stat +``` + +- ``: the DPA-4c pretrained checkpoint used to initialize the student + (historically `dpa4c_pretrain_rmse_epoch.pt`). +- `--skip-neighbor-stat`: must be appended for DPA-4c. + +**Verified input template:** [references/dpa4c_distill_input.json](references/dpa4c_distill_input.json) +is the input.json actually used in a historical, completed DPA-4c distillation run +(1,000,000 steps, `Training finished`). This template is written **exclusively for +DPA-4c — do NOT use it for any other architecture** (dpa2, dpa3, dpa4/SeZM, se_atten_v2, ...). + +**Bohrium submission (bohr skill):** the recommended image for DPA-4c distillation is +`registry.dp.tech/dptech/dpa-calculator:dpa4-mlip-340e01f9` on a **5090** GPU machine. + +**Historical reference values** (SrO/TiO2 slab distillation, single 5090 GPU): +~2400 training frames / ~600 test frames, `numb_steps = 1,000,000`, wall time ~3.4 h. + --- # DeePMD-kit python interface (ASE calculator) diff --git a/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json b/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json new file mode 100644 index 00000000..1163cade --- /dev/null +++ b/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json @@ -0,0 +1,190 @@ +{ + "model": { + "type_map": [ + "H", + "He", + "Li", + "Be", + "B", + "C", + "N", + "O", + "F", + "Ne", + "Na", + "Mg", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe", + "Cs", + "Ba", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn", + "Fr", + "Ra", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + "Md", + "No", + "Lr", + "Rf", + "Db", + "Sg", + "Bh", + "Hs", + "Mt", + "Ds", + "Rg", + "Cn", + "Nh", + "Fl", + "Mc", + "Lv", + "Ts", + "Og" + ], + "descriptor": { + "type": "dpa4c", + "rcut": 6.0, + "channels": 64, + "lmax": 2, + "radial_modes": 0, + "use_amp": false, + "precision": "float32", + "seed": 42 + }, + "fitting_net": { + "neuron": [ + 256, + 256, + 256 + ], + "resnet_dt": false, + "activation_function": "silu", + "precision": "float32", + "seed": 42 + } + }, + "learning_rate": { + "type": "cosine", + "start_lr": 0.003, + "stop_lr": 1e-06, + "warmup_ratio": 0.003, + "warmup_start_factor": 0.2 + }, + "loss": { + "type": "ener", + "loss_func": "mae", + "f_use_norm": true, + "start_pref_e": 20, + "limit_pref_e": 20, + "start_pref_f": 20, + "limit_pref_f": 20, + "start_pref_v": 5, + "limit_pref_v": 5 + }, + "optimizer": { + "type": "HybridMuon", + "weight_decay": 0.001 + }, + "training": { + "stat_file": "stat.hdf5", + "training_data": { + "systems": "./sto_slab_train_distillation_npy", + "batch_size": "filter:92" + }, + "validation_data": { + "systems": "./sto_slab_test_distillation_npy", + "batch_size": 1, + "numb_batch": 1 + }, + "numb_steps": 1000000, + "enable_compile": true, + "gradient_max_norm": 5, + "save_freq": 2000, + "max_ckpt_keep": 5, + "disp_file": "lcurve.out", + "disp_freq": 1000, + "disp_training": true, + "time_training": true, + "seed": 42 + }, + "_comment": "1 GPU; initialized from ./dpa4c_pretrain_rmse_epoch.pt via CLI flag: dp --pt-expt train input.json --init-model ./dpa4c_pretrain_rmse_epoch.pt --skip-neighbor-stat (--finetune is NOT usable: its bias-adjustment dense forward OOMs for dpa4c sel=[999999])" +} diff --git a/src/matcreator/skills/deepmd/references/supported_deepmd_models.md b/src/matcreator/skills/deepmd/references/supported_deepmd_models.md index d3542ab9..1e68a72c 100644 --- a/src/matcreator/skills/deepmd/references/supported_deepmd_models.md +++ b/src/matcreator/skills/deepmd/references/supported_deepmd_models.md @@ -135,3 +135,29 @@ DPA-2 and DPA-3 models are supported but not strongly recommended in any scenari 4. Never use any non-nvidia GPUs for now as they are poorly supported by deepmd-kit. 5. Also, do not use nvidia GPUs older than V100 as they no longer support the triton AOT induction route of modern pytorch, which is compulsory for deepmd-kit>=3.2.0. + +## DPA-4c distillation student (dpa4c descriptor) + +DPA-4c is the student architecture for distillation from a fine-tuned DPA-4 teacher. +Its CLI usage differs from the fine-tuning flow documented elsewhere in this skill: + +**CLI — always `--pt-expt`, never `--finetune`:** + +```bash +dp --pt-expt train input.json --init-model --skip-neighbor-stat +``` + +- `--finetune` is **prohibited** for DPA-4c: its bias-adjustment dense forward pass + runs out of memory (OOM) for the DPA-4c selection `sel=[999999]`. +- `` is the DPA-4c pretrained checkpoint (historically + `dpa4c_pretrain_rmse_epoch.pt`); `--skip-neighbor-stat` must be appended. + +**Verified input template:** [dpa4c_distill_input.json](dpa4c_distill_input.json) (in this +`references/` directory) is the input.json actually used in a historical, completed +DPA-4c distillation run. It is **exclusive to DPA-4c — do NOT use it for any other +architecture** (dpa2, dpa3, dpa4/SeZM, se_atten_v2, ...). + +**[For bohrium submission] Image and machine:** recommended image +`registry.dp.tech/dptech/dpa-calculator:dpa4-mlip-340e01f9` on a **5090** GPU machine. +This image is specific to DPA-4c distillation; all other DPA models keep the images +given in the previous section. \ No newline at end of file diff --git a/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py b/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py index c9594c7f..ae5b8654 100644 --- a/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py +++ b/src/matcreator/skills/deepmd/scripts/deepmd_prepare.py @@ -801,10 +801,19 @@ def cmd_prepare_finetune(args) -> None: logger.info("Sub-seed %s=%d", seed_path, seed_value) # Set number of epochs. Supported since deepmd 3.2.0. Estimating number of steps no longer meaningful. - cfg["training"]["num_epoch"] = args.epochs + # NOTE: dargs alias conversion only converts the FIRST alias found, so the training + # dict must contain exactly one epoch key, otherwise the leftover alias trips the + # strict schema check in `dp train`. + if args.epochs is not None: + for _k in ("num_epochs", "numb_epoch", "numb_epochs", "num_epoch"): + cfg["training"].pop(_k, None) + cfg["training"]["numb_epoch"] = args.epochs + else: + # Keep the template default (e.g. num_epochs for DPA-4); just drop stray nulls. + cfg["training"].pop("num_epoch", None) logger.info( - "Epochs=%d, n_train=%d", - args.epochs, + "Epochs=%s, n_train=%d", + cfg["training"].get("numb_epoch", cfg["training"].get("num_epochs")), len(train_atoms), ) @@ -987,12 +996,6 @@ def _add_finetune_argparse(p: argparse.ArgumentParser) -> None: required=True, metavar="NUMTRAIN", ) - p.add_argument( - "--seed", - help="Random seed (default: None)", - type=int, - default=None - ) p.add_argument( "--model_type", help="Model type (default: None)", From e9a6dd33ff5dd3c3a477bf323238e5470fd99e08 Mon Sep 17 00:00:00 2001 From: syf <591288770@qq.com> Date: Thu, 13 Aug 2026 20:02:06 +0800 Subject: [PATCH 06/14] fix(web): chat scroll and stop status handling --- tests/test_web_concurrent_sessions.py | 124 +++++- .../src/features/chat/messageStream.js | 84 +++- .../src/features/chat/rendering.js | 391 ++++++++++++++++-- .../src/features/chat/timeline.js | 27 +- .../src/features/graphs/AgentGraphView.js | 99 ++--- .../src/features/session/runtime.js | 79 +++- .../src/features/ui/disclosureState.js | 83 ++++ web/vite-frontend/src/main.js | 101 +++-- web/vite-frontend/src/styles/chat.css | 34 +- 9 files changed, 850 insertions(+), 172 deletions(-) create mode 100644 web/vite-frontend/src/features/ui/disclosureState.js diff --git a/tests/test_web_concurrent_sessions.py b/tests/test_web_concurrent_sessions.py index c0ecf95a..16aa14b0 100644 --- a/tests/test_web_concurrent_sessions.py +++ b/tests/test_web_concurrent_sessions.py @@ -134,7 +134,129 @@ def test_stop_request_identifies_the_active_session_owner() -> None: assert "new URLSearchParams({ user_id: request.owner || state.userId })" in stop_message assert "cancel?${query}" in stop_message - assert "pollCancellationConfirmed(request.sessionId, request.owner)" in stop_message + assert "pollCancellationConfirmed(request)" in stop_message + + +def test_stop_feedback_uses_managed_run_status_and_survives_session_refresh() -> None: + content = _message_stream_js() + + assert 'content = request.stopStatus === "stopped" ? "✓ Execution stopped." : "Still executing…"' in content + assert 'fetch(`/api/runs/${encodeURIComponent(request.runId)}`)' in content + assert '["completed", "failed", "cancelled"].includes(run.status)' in content + assert "request.stopStatus = \"stopped\";\n releaseSessionRequest(request);" in content + assert "await reloadSessionSnapshot();\n // A session snapshot replaces the chat DOM." in content + assert "renderStopStatus(request);" in content + assert "cancellation_requested" not in content + + finally_block = content[content.index("} finally {"):] + assert 'if (request.stopStatus !== "waiting") releaseSessionRequest(request);' in finally_block + + +def test_stop_and_plan_refreshes_preserve_open_node_dialogs() -> None: + streams = _message_stream_js() + runtime = _runtime_js() + disclosures = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "ui" / "disclosureState.js").read_text(encoding="utf-8") + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + + assert "sessionRuntime.markSessionRendered(state.sessionId, owner);" in streams + assert "if (preserveDisclosures) stepExecutionFeed.captureDisclosureState();" in runtime + assert "openState.set(details.dataset.disclosureKey, details.open);" in disclosures + assert "captureDisclosureState()" in graph + assert '? details.open || node.status === "running"' in graph + + +def test_bottom_attachment_and_node_toggle_use_one_scroll_policy() -> None: + rendering = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "chat" / "rendering.js").read_text(encoding="utf-8") + disclosures = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "ui" / "disclosureState.js").read_text(encoding="utf-8") + main = _main_js() + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + + assert "const BOTTOM_ATTACH_THRESHOLD = 80;" in rendering + assert "currentScrollTop < lastScrollTop - 0.5" in rendering + assert "if (preserveUserPosition && userDetached) return;" in rendering + assert "if (event.deltaY < 0) detachBottomFollow();" in rendering + assert "else if (event.deltaY > 0 && isChatNearBottom()) enterBottomFollow();" in rendering + assert "viewportModeVersion !== transaction.viewportModeVersion" in rendering + assert "position.viewportModeVersion !== viewportModeVersion" in rendering + assert "|| !userDetached) return;" in rendering + assert "if (!userScrollActive && isChatNearBottom()) bottomPinned = true;" not in rendering + assert "if (detachBottom && !followBottom) detachBottomFollow();" in rendering + assert "if (followBottom) return { followBottom: true, userScrollIntent, viewportModeVersion };" in rendering + assert "if (snapshot.followBottom)" in rendering + assert "captureScrollPosition?.(details," in disclosures + assert 'block.dataset.readingAnchor = `${key}:block:${index}`;' in disclosures + assert "absolute: true" not in disclosures + assert "const readingPosition = wasBottomPinned ? null : captureScrollPosition();" in rendering + assert "restoreScrollPosition(transaction.readingPosition);" in rendering + assert "absolute = false" not in rendering + assert "function updatePreservingReadingPosition(update)" in rendering + assert "updatePreservingReadingPosition(() => {" in main + assert "this._updatePreservingReadingPosition(() => {" in graph + assert "const shouldStick = isChatBottomPinned();" not in main + assert "const shouldStick = this._isChatBottomPinned();" not in graph + + +def test_all_chat_disclosures_share_the_reading_position_controller() -> None: + main = _main_js() + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + runtime = _runtime_js() + + render_timeline = main[main.index("function renderTimeline("):main.index("function addAgentTimelineMessage(")] + assert 'details.className = "timeline-thought";' in render_timeline + assert 'details.className = "timeline-function-call";' in render_timeline + assert 'details.className = "timeline-function-response";' in render_timeline + assert render_timeline.count("wireTimelineDetails(details,") == 3 + assert "updatePreservingReadingPosition(() => {" in render_timeline + + render_card = graph[graph.index("_createCard(node)"):graph.index("// ---------------------------------------------------------------------------\n// Execution Plan Graph")] + assert 'this._disclosures.wire(details, `step:${node.id}:card`' in render_card + assert 'this._wireNested(node.id, "input"' in render_card + assert 'this._wireNested(node.id, "section:conversation"' in render_card + assert 'this._wireNested(node.id, "section:toolcalls"' in render_card + assert "this._wireNested(node.id, key, this._renderStepConversationEvent(evt))" in render_card + assert "this._wireNested(node.id, key, this._renderStepToolCall(tc))" in render_card + + assert "beginScrollTransaction();" in runtime + assert "endScrollTransaction();" in runtime + assert "endScrollTransaction({ revealBottom: awaitingPlanApproval });" not in runtime + + +def test_image_and_all_timeline_updates_preserve_node_disclosures() -> None: + main = _main_js() + rendering = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "chat" / "rendering.js").read_text(encoding="utf-8") + render_timeline = main[main.index("function renderTimeline("):main.index("function addAgentTimelineMessage(")] + create_image = main[main.index("function createTimelineImage("):main.index("function isExecutorLauncherTool(")] + + assert "disclosures.capture(chatArea);" in render_timeline + assert "container.innerHTML = \"\";" in render_timeline + assert render_timeline.index("disclosures.capture(chatArea);") < render_timeline.index('container.innerHTML = "";') + assert "pendingRestoreSnapshot.userScrollIntent !== snapshot.userScrollIntent" in rendering + assert "[data-reading-anchor]" in rendering + assert 'anchorKeyType: anchorEl?.dataset.readingAnchor ? "reading"' in rendering + assert "function protectAsyncContentLayout(root)" in rendering + assert 'img:not([data-layout-protected])' in rendering + assert "protectAsyncContentLayout(body);" in render_timeline + assert "protectAsyncContentLayout(div);" in render_timeline + assert create_image.count("updatePreservingReadingPosition(() => {") == 2 + assert "prepareAsyncReadingPositionUpdate" not in create_image + + +def test_plain_text_blocks_keep_history_order_and_stable_identity() -> None: + timeline = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "chat" / "timeline.js").read_text(encoding="utf-8") + streams = _message_stream_js() + runtime = _runtime_js() + main = _main_js() + + text_upsert = timeline[timeline.index("export function upsertTimelineText"):timeline.index("function timelineEventKey")] + assert 'const last = timeline[timeline.length - 1];' in text_upsert + assert 'if (last?.type === "text")' in text_upsert + assert 'timelineId: nextTimelineItemId(timeline, "text")' in text_upsert + assert "timeline.splice" not in text_upsert + assert 'upsertTimelineText(timeline, part.text);' in streams + assert 'upsertTimelineText(timeline, part.text);' in runtime + assert 'let accumulatedText = "";' not in streams + assert 'let accumulatedText = "";' not in runtime + assert '${item.timelineId || "text:legacy"}:content' in main def test_step_cancellation_identifies_the_active_session_owner() -> None: diff --git a/web/vite-frontend/src/features/chat/messageStream.js b/web/vite-frontend/src/features/chat/messageStream.js index 8fbd55b7..52806fd2 100644 --- a/web/vite-frontend/src/features/chat/messageStream.js +++ b/web/vite-frontend/src/features/chat/messageStream.js @@ -1,6 +1,4 @@ import { - compactRepeatedPrefixSnapshots, - mergeReplayedText, upsertTimelineEvent, upsertTimelineText, upsertTimelineThought, @@ -17,23 +15,54 @@ export function createMessageStreamController(deps) { generateSessionSummary, refreshSessionFiles, sessionRuntime, } = deps; - function pollCancellationConfirmed(sessionId, owner, attempts = 0) { - if (attempts >= 20) { - addMessage("agent", "⚠️ Stop requested but execution may still be running in the background."); + function renderStopStatus(request) { + if (!request.stopStatus || sessionRequestKey(request.sessionId, request.owner) !== sessionRequestKey()) return; + const content = request.stopStatus === "stopped" ? "✓ Execution stopped." : "Still executing…"; + if (!request.stopStatusMessage?.isConnected) { + request.stopStatusMessage = addMessage("agent", content); return; } - setTimeout(async () => { - try { - const query = new URLSearchParams({ user_id: owner || state.userId }); - const response = await fetch(`/api/sessions/${sessionId}/cancel?${query}`); - const result = await response.json(); - if (!result.cancellation_requested) { - addMessage("agent", "✓ Execution stopped."); - return; + const inner = request.stopStatusMessage.querySelector(".markdown-content"); + if (inner) inner.textContent = content; + } + + async function pollCancellationConfirmed(request, attempts = 0) { + await new Promise((resolve) => setTimeout(resolve, attempts ? Math.min(1000 + attempts * 100, 3000) : 0)); + try { + let run = null; + if (request.runId) { + const response = await fetch(`/api/runs/${encodeURIComponent(request.runId)}`); + if (response.ok) run = await response.json(); + } else { + const query = new URLSearchParams({ user_id: request.owner || state.userId, session_id: request.sessionId }); + const response = await fetch(`/api/runs/active?${query}`); + if (response.ok) run = (await response.json()).run; + } + // A stop can be clicked while POST /api/runs is still returning. Give + // active-run discovery a short grace period before treating "not found" + // as terminal, so a just-created run cannot slip past the stop lock. + if (!run && !request.runId && attempts < 3) { + request.stopStatus = "waiting"; + renderStopStatus(request); + void pollCancellationConfirmed(request, attempts + 1); + return; + } + if (!run || ["completed", "failed", "cancelled"].includes(run.status)) { + request.stopStatus = "stopped"; + releaseSessionRequest(request); + if (sessionRequestKey(request.sessionId, request.owner) === sessionRequestKey()) { + await sessionRuntime.loadSession(request.sessionId, request.owner); } - } catch (_) { /* Ignore transient network errors. */ } - pollCancellationConfirmed(sessionId, owner, attempts + 1); - }, 2000); + renderStopStatus(request); + return; + } + request.stopStatus = "waiting"; + renderStopStatus(request); + } catch (_) { + request.stopStatus = "waiting"; + renderStopStatus(request); + } + void pollCancellationConfirmed(request, attempts + 1); } function stop() { @@ -42,8 +71,10 @@ export function createMessageStreamController(deps) { sessionRuntime.suppressPlanApproval(request.sessionId); const query = new URLSearchParams({ user_id: request.owner || state.userId }); fetch(`/api/sessions/${request.sessionId}/cancel?${query}`, { method: "POST" }).catch(() => {}); + request.stopStatus = "waiting"; + renderStopStatus(request); request.controller.abort(); - pollCancellationConfirmed(request.sessionId, request.owner); + void pollCancellationConfirmed(request); } async function send(message) { @@ -86,6 +117,10 @@ export function createMessageStreamController(deps) { agentGraph.startPolling(state.sessionId); planGraph.startPolling(state.sessionId, { autoOpenOnNewGraph: true, autoOpenBaselineKey: previousPlanGraphKey }); const owner = state.activeSessionUserId || state.userId; + // The optimistic user message and live assistant shell already represent + // this session. Mark it before the first persisted snapshot arrives so + // stop/plan-completion refreshes preserve the visible disclosure state. + sessionRuntime.markSessionRendered(state.sessionId, owner); const request = { key: sessionRequestKey(state.sessionId, owner), sessionId: state.sessionId, owner, backendUserId: activeSessionBackendUserId(), controller: new AbortController(), lastSequence: 0, runId: null, @@ -93,7 +128,6 @@ export function createMessageStreamController(deps) { state.activeRequests.set(request.key, request); updateSendButtonState(); - let accumulatedText = ""; let lineBuffer = ""; let summaryTriggered = false; let validatedPlanThisTurn = false; @@ -116,8 +150,7 @@ export function createMessageStreamController(deps) { if ((response.name === "confirm_plan_and_start_execution" || response.name === "resume_execution") && response.response?.status === "ok") executionApprovedThisTurn = true; } else if (part.text) { - accumulatedText = mergeReplayedText(accumulatedText, part.text); - upsertTimelineText(timeline, compactRepeatedPrefixSnapshots(accumulatedText)); + upsertTimelineText(timeline, part.text); if (!summaryTriggered && !state.summaryGeneratedFor.has(request.sessionId) && !state.sessionSummaries[request.sessionId]) { summaryTriggered = true; generateSessionSummary(request.sessionId, request.owner); @@ -188,9 +221,12 @@ export function createMessageStreamController(deps) { } if (lineBuffer.trim().startsWith("data: ")) handleAdkData(lineBuffer.trim().slice(6)); } catch (error) { - addMessage("agent", error?.name === "AbortError" ? "Stopping execution…" : `Backend error: ${error}`, undefined, liveTurn); + if (error?.name !== "AbortError") addMessage("agent", `Backend error: ${error}`, undefined, liveTurn); } finally { - releaseSessionRequest(request); + // A cancelled browser subscription can finish before the managed run + // does. Keep the composer locked until cancellation polling observes a + // terminal run, otherwise a new send would clear the cancellation flag. + if (request.stopStatus !== "waiting") releaseSessionRequest(request); await agentGraph._poll(request.sessionId); agentGraph.stopPolling(); await planGraph._poll(request.sessionId); @@ -198,6 +234,10 @@ export function createMessageStreamController(deps) { await refreshSessionFiles(request.sessionId, request.owner); stepExecutionFeed.finishLiveTurn(); await reloadSessionSnapshot(); + // A session snapshot replaces the chat DOM. Restore the stop indicator + // from the request state so cancellation feedback is never lost during + // the final refresh. + renderStopStatus(request); // Do not depend on session DB timing for the prompt. The live ADK // response is authoritative; the persisted snapshot remains a fallback // for page refreshes and reconnects. diff --git a/web/vite-frontend/src/features/chat/rendering.js b/web/vite-frontend/src/features/chat/rendering.js index 1b6d9440..1ac8676b 100644 --- a/web/vite-frontend/src/features/chat/rendering.js +++ b/web/vite-frontend/src/features/chat/rendering.js @@ -8,18 +8,147 @@ const AGENT_AVATAR_SVG = ` `; +// Native scrolling commonly stops a few fractional pixels short of the +// mathematical maximum (and trackpads can finish between scroll events). +// Treat the bottom composer-sized reading zone as attached to the bottom. +const BOTTOM_ATTACH_THRESHOLD = 80; -export function createChatRenderer({ chatArea }) { +export function createChatRenderer({ chatArea, bottomOverlay = null }) { let asciiWidth = 0; let cjkWidth = 0; let userScrollIntent = 0; let pendingScrollFrame = null; + let pendingRestoreFrame = null; + let pendingRestoreSnapshot = null; + let pendingBottomRequest = null; + let bottomReserve = 0; + let observedBottomDialog = null; + // User intent is the source of truth. Geometry can change underneath the + // viewport while content streams, so "near bottom" must not double as the + // follow-mode state. + let userDetached = false; + let viewportModeVersion = 0; + let pointerScrollActive = false; + let lastUserScrollIntentAt = 0; + let lastScrollTop = chatArea.scrollTop; + let scrollTransaction = null; - ["pointerdown", "touchstart", "wheel"].forEach((eventName) => { - chatArea.addEventListener(eventName, () => { + function cancelScheduledPlacement() { + if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingScrollFrame = null; + pendingRestoreFrame = null; + pendingBottomRequest = null; + pendingRestoreSnapshot = null; + } + + function enterBottomFollow() { + if (!userDetached) return; + userDetached = false; + viewportModeVersion += 1; + // A detached-mode restore may have been queued by a content update after + // the user's wheel/pointer event but before its resulting scroll event. + // Reaching the bottom supersedes that anchor unconditionally. + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingRestoreFrame = null; + pendingRestoreSnapshot = null; + } + + const bottomDialogObserver = new ResizeObserver(() => { + // Dialog growth (streaming text, details, images) changes where the + // bottom is, but must never change the size of the bottom spacer. + if (isChatBottomPinned()) scrollToBottom({ preserveUserPosition: true }); + }); + + function getBottomDialog() { + return [...chatArea.children].reverse().find((element) => ( + element.matches?.(".message:not(.is-pending)") && !element.classList.contains("hidden") + )) || null; + } + + function observeBottomDialog() { + const dialog = getBottomDialog(); + if (dialog === observedBottomDialog) return dialog; + if (observedBottomDialog) bottomDialogObserver.unobserve(observedBottomDialog); + observedBottomDialog = dialog; + if (observedBottomDialog) bottomDialogObserver.observe(observedBottomDialog); + return dialog; + } + + function syncBottomReserve({ followBottom = true } = {}) { + const overlayHeight = bottomOverlay?.getBoundingClientRect().height || 0; + observeBottomDialog(); + // The input area is absolutely positioned 16px above the panel bottom. + // The message's own bottom margin provides the visual gap. + // Dialog height is deliberately excluded: adding it creates a large + // elastic blank area and destabilizes anchors while content streams. + const reserve = Math.ceil((overlayHeight || 98) + 16); + if (reserve === bottomReserve) return; + const shouldStick = followBottom && isChatBottomPinned(); + const readingPosition = shouldStick ? null : captureScrollPosition(); + bottomReserve = reserve; + chatArea.style.setProperty("--chat-bottom-reserve", `${reserve}px`); + if (shouldStick) scrollToBottom({ preserveUserPosition: true }); + else restoreScrollPosition(readingPosition); + } + if (bottomOverlay) { + new ResizeObserver(syncBottomReserve).observe(bottomOverlay); + syncBottomReserve(); + } + new MutationObserver(() => { + observeBottomDialog(); + syncBottomReserve(); + }).observe(chatArea, { childList: true, attributes: true, attributeFilter: ["class"], subtree: true }); + + ["pointerdown", "touchstart", "wheel", "keydown"].forEach((eventName) => { + chatArea.addEventListener(eventName, (event) => { userScrollIntent += 1; - }, { passive: true }); + // Any real interaction owns the viewport. It invalidates every queued + // write captured before this gesture; a disclosure may immediately + // capture a fresh snapshot later in the same event dispatch. + cancelScheduledPlacement(); + // Pointer presses inside a disclosure are content interactions, not + // scrollbar drags. Treat only a press targeting the scroll container + // itself as a possible scrollbar gesture; otherwise our own anchor + // restore could accidentally re-enable bottom follow before pointerup. + if (eventName === "pointerdown") { + pointerScrollActive = event.target === chatArea; + } + if (eventName === "touchstart") { + lastUserScrollIntentAt = performance.now(); + } + if (eventName === "wheel") { + lastUserScrollIntentAt = performance.now(); + if (event.deltaY < 0) detachBottomFollow(); + else if (event.deltaY > 0 && isChatNearBottom()) enterBottomFollow(); + } + if (eventName === "keydown" && ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { + lastUserScrollIntentAt = performance.now(); + if (["ArrowUp", "PageUp", "Home"].includes(event.key) || (event.key === " " && event.shiftKey)) { + detachBottomFollow(); + } else if (event.key === "End" || isChatNearBottom()) { + enterBottomFollow(); + } + } + }, { passive: true, capture: true }); }); + window.addEventListener("pointerup", () => { pointerScrollActive = false; }, { passive: true }); + window.addEventListener("pointercancel", () => { pointerScrollActive = false; }, { passive: true }); + chatArea.addEventListener("scroll", () => { + // Layout growth and programmatic placement also emit `scroll`. Only an + // explicit, recent user gesture is allowed to attach/detach bottom mode. + const currentScrollTop = chatArea.scrollTop; + if (pointerScrollActive || performance.now() - lastUserScrollIntentAt < 1200) { + // Upward intent always detaches, even while still inside the generous + // bottom zone; downward/neutral motion attaches upon entering it. This + // avoids trapping small wheel steps while retaining reliable attach for + // scrollbar dragging, momentum and fractional scroll positions. + if (currentScrollTop < lastScrollTop - 0.5) detachBottomFollow(); + else if (isChatNearBottom()) enterBottomFollow(); + else userDetached = true; + } + lastScrollTop = currentScrollTop; + }, { passive: true }); function renderMarkdown(text) { if (!text) return ""; @@ -29,7 +158,11 @@ export function createChatRenderer({ chatArea }) { return BOX_RE.test(decoded) ? `
${decoded}
` : match; }; html = html.replace(/
([\s\S]*?)<\/code><\/pre>/gi, wrapAsciiArt);
-    return html.replace(/

([\s\S]*?)<\/p>/gi, wrapAsciiArt); + html = html.replace(/

([\s\S]*?)<\/p>/gi, wrapAsciiArt); + return html.replace( + /([\s\S]*?)<\/table>/gi, + '
$1

', + ); } function unescapeText(text) { @@ -90,10 +223,20 @@ export function createChatRenderer({ chatArea }) { pre.className = "json-block"; pre.dataset.raw = unescapeText(content).replace(/^\{\s*/, "").replace(/\s*\}$/, ""); applyWrapMarkers(pre); - new ResizeObserver(() => applyWrapMarkers(pre)).observe(pre); + new ResizeObserver(() => updatePreservingReadingPosition(() => applyWrapMarkers(pre))).observe(pre); return pre; } + function markReadingAnchors(root, prefix) { + if (!root || !prefix) return; + const blocks = root.matches?.("p, pre, blockquote, li, table, h1, h2, h3, h4, h5, h6") + ? [root] + : [...root.querySelectorAll("p, pre, blockquote, li, table, h1, h2, h3, h4, h5, h6")]; + blocks.forEach((block, index) => { + block.dataset.readingAnchor = `${prefix}:block:${index}`; + }); + } + function getUserAvatar() { return localStorage.getItem("user-avatar-url") || null; } function applyUserAvatarToEl(element) { const url = getUserAvatar(); @@ -116,44 +259,222 @@ export function createChatRenderer({ chatArea }) { return element; } function scrollToBottom({ preserveUserPosition = false } = {}) { - const userIntentAtRequest = userScrollIntent; - if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + // Calling this function is the single transition into bottom-follow mode. + // Activate synchronously so updates arriving before the next animation + // frame also follow the bottom. A subsequent user scroll/disclosure + // interaction atomically cancels the queued placement. + // Passive render/resize requests may continue following an attached + // viewport, but they must never reattach a reader who has scrolled away. + if (preserveUserPosition && userDetached) return; + enterBottomFollow(); + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingRestoreFrame = null; + pendingRestoreSnapshot = null; + if (!pendingBottomRequest) { + pendingBottomRequest = { preserveUserPosition, userIntent: userScrollIntent }; + } else if (!preserveUserPosition) { + // An explicit placement (for example, the user's own message) wins over + // passive streaming requests that should yield to user scroll input. + pendingBottomRequest.preserveUserPosition = false; + pendingBottomRequest.userIntent = userScrollIntent; + } + if (scrollTransaction) { + scrollTransaction.bottomRequested = true; + scrollTransaction.preserveUserPosition &&= preserveUserPosition; + return; + } + if (pendingScrollFrame !== null) return; - //
blocks finish contributing their full height after layout. - // Waiting two frames ensures the bottom target includes every collapsed - // label and its surrounding layout. + // MutationObserver, ResizeObserver and the renderer can all request a + // placement for the same DOM update. Coalesce them into one layout-frame + // write so no intermediate scroll position is painted. pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = null; - if (preserveUserPosition && userScrollIntent !== userIntentAtRequest) return; - chatArea.scrollTop = chatArea.scrollHeight; - }); + pendingScrollFrame = null; + const request = pendingBottomRequest; + pendingBottomRequest = null; + if (!request) return; + if (request.preserveUserPosition && userScrollIntent !== request.userIntent) return; + syncBottomReserve({ followBottom: false }); + const target = Math.max(0, chatArea.scrollHeight - chatArea.clientHeight); + if (Math.abs(chatArea.scrollTop - target) > 0.5) { + chatArea.scrollTop = target; + lastScrollTop = target; + } + enterBottomFollow(); }); } - function isChatNearBottom() { return chatArea.scrollHeight - chatArea.scrollTop - chatArea.clientHeight < 80; } - function captureScrollPosition() { - if (isChatNearBottom()) return null; - const chatTop = chatArea.getBoundingClientRect().top; - const anchorEl = [...chatArea.children].find((element) => element.getBoundingClientRect().bottom > chatTop); + function isChatNearBottom() { + return chatArea.scrollHeight - chatArea.scrollTop - chatArea.clientHeight <= BOTTOM_ATTACH_THRESHOLD; + } + function isChatBottomPinned() { + // Geometry alone cannot reattach bottom mode: content growth can put a + // detached reader inside the threshold without any downward gesture. + // Reattachment happens only in the user-driven scroll handler above or + // through an explicit scrollToBottom request. + return !userDetached; + } + function detachBottomFollow() { + if (!userDetached) viewportModeVersion += 1; + userDetached = true; + cancelScheduledPlacement(); + } + function beginScrollTransaction() { + if (scrollTransaction) { + scrollTransaction.depth += 1; + return; + } + const wasBottomPinned = isChatBottomPinned(); + // Full session snapshots use the same stable disclosure/Node anchor as + // incremental Thinking/IN/OUT and executor updates. An absolute scrollTop + // drifts whenever content above the reader changes height during rebuild. + const readingPosition = wasBottomPinned ? null : captureScrollPosition(); + if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + if (pendingRestoreFrame !== null) cancelAnimationFrame(pendingRestoreFrame); + pendingScrollFrame = null; + pendingRestoreFrame = null; + pendingBottomRequest = null; + pendingRestoreSnapshot = null; + scrollTransaction = { + depth: 1, + wasBottomPinned, + readingPosition, + userIntent: userScrollIntent, + viewportModeVersion, + bottomRequested: false, + preserveUserPosition: true, + }; + } + function endScrollTransaction() { + if (!scrollTransaction) return; + scrollTransaction.depth -= 1; + if (scrollTransaction.depth > 0) return; + const transaction = scrollTransaction; + scrollTransaction = null; + if (userScrollIntent !== transaction.userIntent || viewportModeVersion !== transaction.viewportModeVersion) return; + if (transaction.wasBottomPinned && transaction.bottomRequested) { + enterBottomFollow(); + scrollToBottom({ preserveUserPosition: transaction.preserveUserPosition }); + return; + } + userDetached = true; + restoreScrollPosition(transaction.readingPosition); + } + function captureScrollPosition(preferredAnchor = null, { + force = false, + detachBottom = false, + } = {}) { + const followBottom = isChatBottomPinned(); + // Callers decide whether a render should follow the bottom by consulting + // the same reconciled state. A forced disclosure snapshot records bottom + // mode explicitly instead of turning it into a stale absolute scrollTop. + if (!force && followBottom) return null; + // Detaching must be atomic. A resize/stream update may already have + // queued a bottom placement before the user clicks a disclosure. Leaving + // that request alive makes it race the reading-position restore, which is + // especially visible in polling Node/Input/Conversations cards. + if (detachBottom && !followBottom) detachBottomFollow(); + if (followBottom) return { followBottom: true, userScrollIntent, viewportModeVersion }; + const chatRect = chatArea.getBoundingClientRect(); + let anchorEl = null; + if (preferredAnchor?.isConnected) { + anchorEl = preferredAnchor; + } else { + // Node cards rebuild their inner DOM as polling data arrives. Prefer a + // visible, stable disclosure/node key near the viewport edge so the + // equivalent element can be found after that rebuild. Falling back to + // the outer message is sufficient for ordinary timeline updates. + const stableCandidates = [...chatArea.querySelectorAll("[data-reading-anchor], [data-disclosure-key], [data-step-node-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.bottom > chatRect.top && rect.top < chatRect.bottom; + }); + anchorEl = stableCandidates + .filter((element) => element.getBoundingClientRect().top >= chatRect.top) + .sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top)[0] + || stableCandidates + .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top)[0] + || [...chatArea.children].find((element) => element.getBoundingClientRect().bottom > chatRect.top); + } return { scrollTop: chatArea.scrollTop, anchorEl, - anchorOffset: anchorEl ? anchorEl.getBoundingClientRect().top - chatTop : 0, + anchorKey: anchorEl?.dataset.readingAnchor || anchorEl?.dataset.disclosureKey || anchorEl?.dataset.stepNodeId || null, + anchorKeyType: anchorEl?.dataset.readingAnchor ? "reading" : anchorEl?.dataset.disclosureKey ? "disclosure" : anchorEl?.dataset.stepNodeId ? "step" : null, + anchorOffset: anchorEl ? anchorEl.getBoundingClientRect().top - chatRect.top : 0, userScrollIntent, + viewportModeVersion, }; } + function resolveSnapshotAnchor(position) { + if (position.anchorEl?.isConnected) return position.anchorEl; + if (!position.anchorKey) return null; + const selectors = { + reading: ["readingAnchor", "reading-anchor"], + disclosure: ["disclosureKey", "disclosure-key"], + step: ["stepNodeId", "step-node-id"], + }; + const [attribute, selector] = selectors[position.anchorKeyType] || []; + if (!attribute) return null; + return [...chatArea.querySelectorAll(`[data-${selector}]`)] + .find((element) => element.dataset[attribute] === position.anchorKey) || null; + } function restoreScrollPosition(snapshot) { if (!snapshot) return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (snapshot.userScrollIntent !== userScrollIntent || isChatNearBottom()) return; - if (snapshot.anchorEl?.isConnected) { - const currentOffset = snapshot.anchorEl.getBoundingClientRect().top - chatArea.getBoundingClientRect().top; - chatArea.scrollTop += currentOffset - snapshot.anchorOffset; - } else { - chatArea.scrollTop = snapshot.scrollTop; - } - }); + if (scrollTransaction) return; + if (snapshot.followBottom) { + if (snapshot.userScrollIntent === userScrollIntent && snapshot.viewportModeVersion === viewportModeVersion) { + scrollToBottom({ preserveUserPosition: true }); + } + return; + } + if (pendingScrollFrame !== null) cancelAnimationFrame(pendingScrollFrame); + pendingScrollFrame = null; + pendingBottomRequest = null; + // All mutations before the next paint form one visual transaction. Keep + // its earliest snapshot; replacing it with a later image/observer snapshot + // would preserve an already-drifted intermediate layout. + if (!pendingRestoreSnapshot + || pendingRestoreSnapshot.userScrollIntent !== snapshot.userScrollIntent + || pendingRestoreSnapshot.viewportModeVersion !== snapshot.viewportModeVersion) { + pendingRestoreSnapshot = snapshot; + } + if (pendingRestoreFrame !== null) return; + pendingRestoreFrame = requestAnimationFrame(() => { + pendingRestoreFrame = null; + const position = pendingRestoreSnapshot; + pendingRestoreSnapshot = null; + if (!position + || position.userScrollIntent !== userScrollIntent + || position.viewportModeVersion !== viewportModeVersion + || !userDetached) return; + const anchorEl = resolveSnapshotAnchor(position); + if (anchorEl) { + const currentOffset = anchorEl.getBoundingClientRect().top - chatArea.getBoundingClientRect().top; + chatArea.scrollTop += currentOffset - position.anchorOffset; + } else { + chatArea.scrollTop = position.scrollTop; + } + lastScrollTop = chatArea.scrollTop; + }); + } + function updatePreservingReadingPosition(update) { + const followBottom = isChatBottomPinned(); + const readingPosition = followBottom ? null : captureScrollPosition(); + const result = update(); + if (followBottom) scrollToBottom({ preserveUserPosition: true }); + else restoreScrollPosition(readingPosition); + return result; + } + function protectAsyncContentLayout(root) { + root?.querySelectorAll?.("img:not([data-layout-protected])").forEach((img) => { + img.dataset.layoutProtected = "true"; + if (img.complete) return; + img.hidden = true; + const reveal = () => { + updatePreservingReadingPosition(() => { img.hidden = false; }); + }; + img.addEventListener("load", reveal, { once: true }); + img.addEventListener("error", reveal, { once: true }); }); } function appendLiveTurnChild(container, child) { @@ -162,7 +483,7 @@ export function createChatRenderer({ chatArea }) { return firstStepCard ? container.insertBefore(child, firstStepCard) : container.appendChild(child); } function addMessage(role, content, msgIndex, container = chatArea) { - const shouldStick = role === "user" || isChatNearBottom(); + const shouldStick = role === "user" || isChatBottomPinned(); const message = document.createElement("div"); message.className = `message ${role}-message`; if (msgIndex !== undefined) message.dataset.msgIndex = String(msgIndex); @@ -172,6 +493,8 @@ export function createChatRenderer({ chatArea }) { const inner = document.createElement("div"); inner.className = "markdown-content"; inner.innerHTML = renderMarkdown(content || ""); + markReadingAnchors(inner, `message:${msgIndex ?? "live"}`); + protectAsyncContentLayout(inner); bubble.append(inner); message.append(bubble); appendLiveTurnChild(container, message); @@ -179,5 +502,5 @@ export function createChatRenderer({ chatArea }) { return message; } - return { addMessage, appendLiveTurnChild, applyUserAvatarToEl, captureScrollPosition, createAgentAvatarEl, createJsonBlock, isChatNearBottom, renderMarkdown, restoreScrollPosition, scrollToBottom, setUserAvatar }; + return { addMessage, appendLiveTurnChild, applyUserAvatarToEl, beginScrollTransaction, captureScrollPosition, createAgentAvatarEl, createJsonBlock, endScrollTransaction, isChatBottomPinned, markReadingAnchors, protectAsyncContentLayout, renderMarkdown, restoreScrollPosition, scrollToBottom, setUserAvatar, updatePreservingReadingPosition }; } diff --git a/web/vite-frontend/src/features/chat/timeline.js b/web/vite-frontend/src/features/chat/timeline.js index cadf0d48..ddd0982d 100644 --- a/web/vite-frontend/src/features/chat/timeline.js +++ b/web/vite-frontend/src/features/chat/timeline.js @@ -32,6 +32,12 @@ export function compactRepeatedPrefixSnapshots(text) { return compacted; } +function nextTimelineItemId(timeline, prefix) { + const nextId = timeline._nextItemId || 0; + timeline._nextItemId = nextId + 1; + return `${prefix}:${nextId}`; +} + export function upsertTimelineThought(timeline, text) { if (!text) return; const compacted = compactRepeatedPrefixSnapshots(text); @@ -40,14 +46,21 @@ export function upsertTimelineThought(timeline, text) { last.text = compactRepeatedPrefixSnapshots(mergeReplayedText(last.text || "", compacted)); return; } - timeline.push({ type: "thought", text: compacted }); + timeline.push({ type: "thought", timelineId: nextTimelineItemId(timeline, "thought"), text: compacted }); } export function upsertTimelineText(timeline, text) { - for (let index = timeline.length - 1; index >= 0; index--) { - if (timeline[index].type === "text") timeline.splice(index, 1); + if (!text) return; + const compacted = compactRepeatedPrefixSnapshots(text); + const last = timeline[timeline.length - 1]; + if (last?.type === "text") { + // Only the currently streaming, contiguous text block is mutable. Text + // before a Thinking/IN/OUT item is historical content and must retain its + // position and DOM identity when a later text block arrives. + last.text = compactRepeatedPrefixSnapshots(mergeReplayedText(last.text || "", compacted)); + return; } - if (text) timeline.push({ type: "text", text }); + timeline.push({ type: "text", timelineId: nextTimelineItemId(timeline, "text"), text: compacted }); } function timelineEventKey(event) { @@ -64,11 +77,15 @@ export function upsertTimelineEvent(timeline, event) { (item.type === "function_call" || item.type === "function_response") && timelineEventKey(item) === eventKey ) { + // Keep the UI identity when an incoming event refreshes an existing + // call/response. Its expanded state must survive the refresh. + event.timelineId = item.timelineId; timeline[index] = event; return; } } const last = timeline[timeline.length - 1]; if (last && JSON.stringify(last) === JSON.stringify(event)) return; + event.timelineId ||= nextTimelineItemId(timeline, event.type); timeline.push(event); -} \ No newline at end of file +} diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index 520049c9..c6821e40 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -1,4 +1,5 @@ import { Network, DataSet } from "vis-network/standalone"; +import { createDisclosureController } from "../ui/disclosureState.js"; const NODE_COLORS = { orchestrator: { core: "124, 58, 237", edge: "196, 181, 253", font: "#f5f3ff" }, @@ -67,22 +68,15 @@ export class AgentGraphView { this._detailConversation = document.getElementById("detail-conversation"); this._nodeData = {}; this._activeDetailNodeId = null; - this._init(); - } - - _captureOpenToolCallKeys() { - const openKeys = new Set(); - this._detailToolcalls?.querySelectorAll("details[data-toolcall-key][open]")?.forEach((el) => { - openKeys.add(el.getAttribute("data-toolcall-key")); - }); - return openKeys; - } - - _restoreOpenToolCallKeys(openKeys) { - if (!openKeys?.size) return; - this._detailToolcalls?.querySelectorAll("details[data-toolcall-key]")?.forEach((el) => { - el.open = openKeys.has(el.getAttribute("data-toolcall-key")); + this._detailDisclosures = createDisclosureController({ + captureScrollPosition: () => ({ scrollTop: this._detailEl.scrollTop }), + restoreScrollPosition: (position) => { + requestAnimationFrame(() => requestAnimationFrame(() => { + if (position) this._detailEl.scrollTop = position.scrollTop; + })); + }, }); + this._init(); } _init() { @@ -672,6 +666,7 @@ export class AgentGraphView { this._pendingFit = true; this._hasRunningNodes = false; this._activeEdges = []; + this._detailDisclosures.clear(); if (this._animationFrame !== null) cancelAnimationFrame(this._animationFrame); this._animationFrame = null; this._lastAnimationPaint = 0; @@ -686,7 +681,6 @@ export class AgentGraphView { this._activeDetailNodeId = nodeId; const preserveScroll = Boolean(options.preserveScroll); const prevScrollTop = preserveScroll ? this._detailEl.scrollTop : 0; - const prevOpenToolCallKeys = preserveScroll ? this._captureOpenToolCallKeys() : new Set(); this._detailLabel.textContent = raw.label; this._detailStatus.textContent = raw.status; this._detailStatus.className = `badge badge-${raw.status}`; @@ -747,10 +741,10 @@ export class AgentGraphView { this._detailToolcallsCount.textContent = toolCalls.length; this._detailToolcalls.innerHTML = ""; if (toolCalls.length) { - toolCalls.forEach((tc) => { + toolCalls.forEach((tc, index) => { const d = document.createElement("details"); d.className = "timeline-function-call"; - d.setAttribute("data-toolcall-key", tc.id || `${tc.name}:${tc.start_time || ""}`); + const disclosureKey = `detail:${nodeId}:tool:${tc.id || `${index}:${tc.name}:${tc.start_time || ""}`}`; const dur = tc.start_time && tc.end_time ? ` (${((new Date(tc.end_time) - new Date(tc.start_time)) / 1000).toFixed(1)}s)` : ""; @@ -768,6 +762,7 @@ export class AgentGraphView { this._getStructurePaths(tc).forEach((path) => { d.appendChild(this._createStructureViewButton(path)); }); + this._detailDisclosures.wire(d, disclosureKey); this._detailToolcalls.appendChild(d); }); document.getElementById("detail-toolcalls-row").style.display = ""; @@ -779,7 +774,7 @@ export class AgentGraphView { const conversation = raw.type === "step" ? [] : (raw.conversation || []); this._detailConversation.innerHTML = ""; if (conversation.length) { - conversation.forEach((evt) => { + conversation.forEach((evt, index) => { const d = document.createElement("details"); d.className = `timeline-${evt.type}`; const s = document.createElement("summary"); @@ -787,6 +782,7 @@ export class AgentGraphView { s.textContent = `${icon} [${evt.author}] ${evt.type}`; d.appendChild(s); d.appendChild(this._createJsonBlock(evt.content)); + this._detailDisclosures.wire(d, `detail:${nodeId}:conversation:${index}:${evt.timestamp || ""}:${evt.type || ""}`); this._detailConversation.appendChild(d); }); document.getElementById("detail-conversation-row").style.display = ""; @@ -798,7 +794,6 @@ export class AgentGraphView { if (raw.type === "step" && options.scrollToStep !== false) this._stepExecutionFeed.highlight(raw.id); this._syncPanelResizerVisibility(); if (preserveScroll) { - this._restoreOpenToolCallKeys(prevOpenToolCallKeys); this._detailEl.scrollTop = prevScrollTop; } } @@ -831,10 +826,7 @@ export class StepExecutionFeed { constructor(dependencies) { this._chatArea = dependencies.chatArea; this._isSending = dependencies.isSending; - this._isChatNearBottom = dependencies.isChatNearBottom; - this._captureScrollPosition = dependencies.captureScrollPosition; - this._restoreScrollPosition = dependencies.restoreScrollPosition; - this._scrollToBottom = dependencies.scrollToBottom; + this._updatePreservingReadingPosition = dependencies.updatePreservingReadingPosition; this._createAgentAvatarEl = dependencies.createAgentAvatarEl; this._stepFeedTitle = dependencies.stepFeedTitle; this._formatStepDuration = dependencies.formatStepDuration; @@ -844,8 +836,7 @@ export class StepExecutionFeed { this._requestStepCancellation = dependencies.requestStepCancellation; this._createArtifactListItem = dependencies.createArtifactListItem; this._cards = new Map(); - this._userOpen = new Map(); - this._nestedOpen = new Map(); + this._disclosures = dependencies.disclosureController; this._highlightedId = null; this._liveAnchorEl = null; this._liveContainerEl = null; @@ -855,10 +846,9 @@ export class StepExecutionFeed { this._childNodes = new Map(); } - reset() { + reset({ preserveDisclosures = false } = {}) { this._cards.clear(); - this._userOpen.clear(); - this._nestedOpen.clear(); + if (!preserveDisclosures) this._disclosures.clear(); this._highlightedId = null; this._liveAnchorEl = null; this._liveContainerEl = null; @@ -868,6 +858,10 @@ export class StepExecutionFeed { this._childNodes = new Map(); } + captureDisclosureState() { + this._disclosures.capture(this._chatArea); + } + startLiveTurn(anchorEl, startedAt = Date.now(), hostEl = null) { this._liveAnchorEl = anchorEl || null; this._liveStartedAt = startedAt; @@ -928,15 +922,13 @@ export class StepExecutionFeed { for (const nodeId of this._cards.keys()) { if (!seen.has(nodeId)) { this._cards.delete(nodeId); - this._nestedOpen.delete(nodeId); + this._disclosures.deletePrefix(`step:${nodeId}:`); } } - const shouldStick = this._isChatNearBottom(); - const scrollPosition = shouldStick ? null : this._captureScrollPosition(); - rootSteps.forEach((node) => this._upsert(node)); - if (shouldStick) this._scrollToBottom({ preserveUserPosition: true }); - else this._restoreScrollPosition(scrollPosition); + this._updatePreservingReadingPosition(() => { + rootSteps.forEach((node) => this._upsert(node)); + }); } setHierarchy(stepNodes) { @@ -1163,8 +1155,8 @@ export class StepExecutionFeed { bubble.className = "message-bubble step-feed-bubble"; const details = document.createElement("details"); details.className = "step-feed-details"; - details.addEventListener("toggle", () => { - this._userOpen.set(node.id, details.open); + this._disclosures.wire(details, `step:${node.id}:card`, { + defaultOpen: node.status === "running", }); bubble.appendChild(details); outer.appendChild(bubble); @@ -1172,19 +1164,8 @@ export class StepExecutionFeed { } _wireNested(nodeId, key, details) { - let nodeState = this._nestedOpen.get(nodeId); - if (!nodeState) { - nodeState = new Map(); - this._nestedOpen.set(nodeId, nodeState); - } - if (nodeState.has(key)) { - details.open = nodeState.get(key); - } details.dataset.stepNestedKey = key; - details.addEventListener("toggle", (event) => { - if (event.target !== details) return; - nodeState.set(key, details.open); - }); + this._disclosures.wire(details, `step:${nodeId}:nested:${key}`); return details; } @@ -1197,8 +1178,14 @@ export class StepExecutionFeed { bubble?.querySelector(":scope > .step-feed-child-section")?.remove(); const details = outer.querySelector(".step-feed-details"); - const userChoice = this._userOpen.get(node.id); - details.open = userChoice === undefined ? node.status === "running" : userChoice; + const cardKey = `step:${node.id}:card`; + const userChoice = this._disclosures.state.get(cardKey); + // Preserve an already-open running card when polling changes its status + // to completed/cancelled. Otherwise that status transition collapses the + // Node before the following session snapshot can preserve its UI state. + details.open = userChoice === undefined + ? details.open || node.status === "running" + : userChoice; details.innerHTML = ""; const summary = document.createElement("summary"); @@ -1281,10 +1268,9 @@ export class StepExecutionFeed { const key = `conversation:${idx}:${evt.timestamp || ""}:${evt.type || ""}:${evt.author || ""}`; sectionDetails.appendChild(this._wireNested(node.id, key, this._renderStepConversationEvent(evt))); }); - sectionDetails.addEventListener("toggle", () => { - sectionSummary.classList.toggle("open", sectionDetails.open); - }); section.appendChild(this._wireNested(node.id, "section:conversation", sectionDetails)); + sectionSummary.classList.toggle("open", sectionDetails.open); + sectionDetails.addEventListener("toggle", () => sectionSummary.classList.toggle("open", sectionDetails.open)); body.appendChild(section); } @@ -1302,10 +1288,9 @@ export class StepExecutionFeed { const key = `tool:${idx}:${tc.name || ""}:${tc.start_time || ""}`; sectionDetails.appendChild(this._wireNested(node.id, key, this._renderStepToolCall(tc))); }); - sectionDetails.addEventListener("toggle", () => { - sectionSummary.classList.toggle("open", sectionDetails.open); - }); section.appendChild(this._wireNested(node.id, "section:toolcalls", sectionDetails)); + sectionSummary.classList.toggle("open", sectionDetails.open); + sectionDetails.addEventListener("toggle", () => sectionSummary.classList.toggle("open", sectionDetails.open)); body.appendChild(section); } diff --git a/web/vite-frontend/src/features/session/runtime.js b/web/vite-frontend/src/features/session/runtime.js index 313c4c50..ac5a231c 100644 --- a/web/vite-frontend/src/features/session/runtime.js +++ b/web/vite-frontend/src/features/session/runtime.js @@ -1,3 +1,9 @@ +import { + upsertTimelineEvent, + upsertTimelineText, + upsertTimelineThought, +} from "../chat/timeline.js"; + /** Owns restoring a persisted session and reconnecting to an active managed run. */ export function createSessionRuntime({ state, @@ -14,12 +20,15 @@ export function createSessionRuntime({ addMessage, addAgentTimelineMessage, addPlanApprovalActions, + beginScrollTransaction, + endScrollTransaction, renderSessionBanner, renderSessionFilesTree, refreshSessionFiles, generateSessionSummary, workdirDisplay, }) { + let renderedSessionKey = null; // Plan approval is UI-derived from persisted events. A cancelled turn can // still contain a successful validation event, so remember that its prompt // was dismissed until the user deliberately starts another turn. @@ -69,31 +78,24 @@ export function createSessionRuntime({ return responses; } - function eventToTimelineParts(event, responsesById, pairedResponseIds = new Set()) { - const timeline = []; - let accumulatedText = ""; + function eventToTimelineParts(event, responsesById, pairedResponseIds = new Set(), timeline = []) { for (const part of event.content?.parts || []) { if (part.thought) { - timeline.push({ type: "thought", text: part.text || "" }); + upsertTimelineThought(timeline, part.text || ""); } else if (part.functionCall || part.function_call) { const call = part.functionCall || part.function_call; const matchedResponse = responsesById[call.id]; - timeline.push({ type: "function_call", id: call.id, name: call.name || "Unknown", args: call.args || {} }); + upsertTimelineEvent(timeline, { type: "function_call", id: call.id, name: call.name || "Unknown", args: call.args || {} }); if (matchedResponse) { if (matchedResponse.id) pairedResponseIds.add(matchedResponse.id); - timeline.push({ type: "function_response", id: matchedResponse.id, name: matchedResponse.name || "Unknown", response: matchedResponse.response || {} }); + upsertTimelineEvent(timeline, { type: "function_response", id: matchedResponse.id, name: matchedResponse.name || "Unknown", response: matchedResponse.response || {} }); } } else if (getFunctionResponse(part)) { const response = getFunctionResponse(part); if (response.id && pairedResponseIds.has(response.id)) continue; - if (!timeline.some((item) => item.type === "function_response" && item.id === response.id)) { - timeline.push({ type: "function_response", id: response.id, name: response.name || "Unknown", response: response.response || {} }); - } + upsertTimelineEvent(timeline, { type: "function_response", id: response.id, name: response.name || "Unknown", response: response.response || {} }); } else if (part.text) { - accumulatedText += part.text; - const previous = timeline.at(-1); - if (previous?.type === "text") previous.text = accumulatedText; - else timeline.push({ type: "text", text: accumulatedText }); + upsertTimelineText(timeline, part.text); } } return timeline; @@ -174,10 +176,21 @@ export function createSessionRuntime({ if (sessionId) suppressedPlanApprovalTurns.delete(sessionId); } - function renderSessionTimeline(events, stepNodes, awaitingPlanApproval = false) { - chatArea.innerHTML = ""; - stepExecutionFeed.reset(); - stepExecutionFeed.setHierarchy(stepNodes || []); + function markSessionRendered(sessionId, owner = state.activeSessionUserId || state.userId) { + renderedSessionKey = sessionRequestKey(sessionId, owner); + } + + function renderSessionTimeline(events, stepNodes, awaitingPlanApproval = false, preserveDisclosures = false) { + beginScrollTransaction(); + try { + // Running cards are initially open by default, so their state is not in + // the user-choice map yet. Snapshot the actual DOM before an in-place + // refresh changes running nodes to completed/cancelled and changes that + // default to closed. + if (preserveDisclosures) stepExecutionFeed.captureDisclosureState(); + chatArea.innerHTML = ""; + stepExecutionFeed.reset({ preserveDisclosures }); + stepExecutionFeed.setHierarchy(stepNodes || []); const sortedEvents = (events || []).map((event, index) => ({ event, timestamp: eventTimestamp(event, index), index })) .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index).map(({ event }) => event); const pendingStepNodes = (stepNodes || []).filter((node) => stepExecutionFeed.isRootStep(node)).slice() @@ -187,17 +200,29 @@ export function createSessionRuntime({ let shownPlotPaths = new Set(); let messageIndex = 0; let lastAgentTimeline = null; + let pendingAgentTimeline = []; + const flushAgentTimeline = () => { + if (!pendingAgentTimeline.length) return; + const timeline = attachStepNodes(pendingAgentTimeline, pendingStepNodes); + lastAgentTimeline = addAgentTimelineMessage(timeline, shownPlotPaths, messageIndex++); + pendingAgentTimeline = []; + }; for (const event of sortedEvents) { if (event.author === "user") { + flushAgentTimeline(); const text = displayMessageFromStoredUserText((event.content?.parts || []).map((part) => part.text || "").join("")); if (text) addMessage("user", text, messageIndex++); shownPlotPaths = new Set(); continue; } - const timeline = attachStepNodes(eventToTimelineParts(event, responsesById, pairedResponseIds), pendingStepNodes); - if (timeline.length) lastAgentTimeline = addAgentTimelineMessage(timeline, shownPlotPaths, messageIndex++); + // Persisted ADK events are often split into separate records while the + // managed SSE stream delivers them as one assistant turn. Accumulating + // adjacent non-user events gives history and live output identical + // Thinking / IN / OUT grouping, including the same upsert semantics. + eventToTimelineParts(event, responsesById, pairedResponseIds, pendingAgentTimeline); } + flushAgentTimeline(); // Preserve the assistant-message containment even if an older/incomplete // persisted event stream cannot be matched to a specific executor call. // This is particularly important while reconnecting after a session @@ -215,7 +240,13 @@ export function createSessionRuntime({ lastAgentTimeline.appendChild(fallbackHost); pendingStepNodes.forEach((node) => stepExecutionFeed.appendStatic(node, fallbackHost)); } - if (awaitingPlanApproval && lastAgentTimeline) addPlanApprovalActions(lastAgentTimeline); + if (awaitingPlanApproval && lastAgentTimeline) addPlanApprovalActions(lastAgentTimeline); + } finally { + // Loading/polling a persisted snapshot is passive. The approval card is + // visible immediately only for an already attached viewport; a reader + // elsewhere keeps the same anchor and can reach it deliberately. + endScrollTransaction(); + } } function updateSessionWorkdirDisplay(sessionData) { @@ -244,12 +275,19 @@ export function createSessionRuntime({ state.summaryGeneratedFor.add(sessionId); } const summary = sessionData.summary || state.sessionSummaries[sessionId] || ""; + const preserveDisclosures = renderedSessionKey === viewKey; + // Snapshot probing (`render: false`) is the first half of the managed + // stream reload. Remember its view so the following rendered snapshot + // is treated as an in-place refresh, including the refresh immediately + // before the Approve plan prompt appears. + renderedSessionKey = viewKey; if (render) { renderSessionBanner(summary); renderSessionTimeline( events, graphNodes, shouldShowPlanApprovalActions(sessionId, sessionData, events), + preserveDisclosures, ); } state.sessionViewCache.set(viewKey, { sessionData, events, graphNodes, files: [], summary }); @@ -412,6 +450,7 @@ export function createSessionRuntime({ return { discoverManagedRun, loadSession, + markSessionRendered, renderSessionTimeline, restorePlanApproval, startManagedRunReconnect, diff --git a/web/vite-frontend/src/features/ui/disclosureState.js b/web/vite-frontend/src/features/ui/disclosureState.js new file mode 100644 index 00000000..a15836c6 --- /dev/null +++ b/web/vite-frontend/src/features/ui/disclosureState.js @@ -0,0 +1,83 @@ +/** + * Keeps native
state and viewport behavior consistent across + * frequently re-rendered views. + */ +export function createDisclosureController({ + captureScrollPosition, + restoreScrollPosition, +} = {}) { + const openState = new Map(); + + function wire(details, key, { defaultOpen = false, onToggle } = {}) { + if (!details || !key) return details; + + details.dataset.disclosureKey = key; + details.querySelectorAll("p, pre, blockquote, li, table, h1, h2, h3, h4, h5, h6").forEach((block, index) => { + block.dataset.readingAnchor = `${key}:block:${index}`; + }); + details.open = openState.has(key) ? openState.get(key) : Boolean(defaultOpen); + + let pendingViewport = null; + const captureViewport = () => { + // Use this disclosure as the reading anchor. At the bottom the renderer + // returns an explicit follow-bottom snapshot; elsewhere the Node stays + // at the same viewport offset while its body expands below it. + pendingViewport = captureScrollPosition?.(details, { + force: true, + detachBottom: true, + }); + }; + const isSummaryEvent = (event) => { + const target = event.target; + const summary = target?.closest?.("summary"); + const control = target?.closest?.("button, a, input, select, textarea"); + return summary?.parentElement === details && !control; + }; + details.addEventListener("click", (event) => { + if (isSummaryEvent(event)) captureViewport(); + }); + details.addEventListener("keydown", (event) => { + if (!isSummaryEvent(event)) return; + if (event.key === "Enter" || event.key === " ") captureViewport(); + }); + + details.addEventListener("toggle", (event) => { + if (event.target !== details) return; + const viewport = pendingViewport; + pendingViewport = null; + // Assigning `open` while rebuilding the view also emits `toggle`. + // Only a toggle preceded by interaction is a durable user choice. + if (!viewport) return; + openState.set(key, details.open); + onToggle?.(details.open); + restoreScrollPosition?.(viewport); + }); + return details; + } + + function prune(liveKeys) { + for (const key of openState.keys()) { + if (!liveKeys.has(key)) openState.delete(key); + } + } + + function prunePrefix(prefix, liveKeys) { + for (const key of openState.keys()) { + if (key.startsWith(prefix) && !liveKeys.has(key)) openState.delete(key); + } + } + + function deletePrefix(prefix) { + for (const key of openState.keys()) { + if (key.startsWith(prefix)) openState.delete(key); + } + } + + function capture(root) { + root?.querySelectorAll?.("details[data-disclosure-key]").forEach((details) => { + openState.set(details.dataset.disclosureKey, details.open); + }); + } + + return { capture, clear: () => openState.clear(), deletePrefix, prune, prunePrefix, state: openState, wire }; +} diff --git a/web/vite-frontend/src/main.js b/web/vite-frontend/src/main.js index d25fabc1..4f45c33f 100644 --- a/web/vite-frontend/src/main.js +++ b/web/vite-frontend/src/main.js @@ -10,6 +10,7 @@ import { AgentGraphView, StepExecutionFeed } from "./features/graphs/AgentGraphV import { ExecutionPlanView } from "./features/graphs/ExecutionPlanView.js"; import { createSkillGraphController } from "./features/skills/SkillGraphController.js"; import { createSettingsController } from "./features/settings/SettingsController.js"; +import { createDisclosureController } from "./features/ui/disclosureState.js"; import "./styles/index.css"; // --------------------------------------------------------------------------- @@ -67,6 +68,7 @@ const state = { const chatArea = document.getElementById("chat-area"); const textInput = document.getElementById("text-input"); +const inputArea = document.querySelector(".input-area"); const inputContainer = document.querySelector(".input-container"); const sendBtn = document.getElementById("send-btn"); const fileUploadBtn = document.getElementById("file-upload-btn"); @@ -162,15 +164,25 @@ const { addMessage, appendLiveTurnChild, applyUserAvatarToEl, + beginScrollTransaction, captureScrollPosition, createAgentAvatarEl, createJsonBlock, - isChatNearBottom, + endScrollTransaction, + markReadingAnchors, + protectAsyncContentLayout, renderMarkdown, restoreScrollPosition, scrollToBottom, setUserAvatar, -} = createChatRenderer({ chatArea }); + updatePreservingReadingPosition, +} = createChatRenderer({ chatArea, bottomOverlay: inputArea }); + +const createChatDisclosureController = () => createDisclosureController({ + captureScrollPosition, + restoreScrollPosition, +}); +const chatDisclosureController = createChatDisclosureController(); const settingsController = createSettingsController({ state, applyLogin }); @@ -980,10 +992,7 @@ document.getElementById("evaluation-template-delete")?.addEventListener("click", const stepExecutionFeed = new StepExecutionFeed({ chatArea, isSending: () => Boolean(activeSessionRequest()), - isChatNearBottom, - captureScrollPosition, - restoreScrollPosition, - scrollToBottom, + updatePreservingReadingPosition, createAgentAvatarEl, stepFeedTitle, formatStepDuration, @@ -992,6 +1001,7 @@ const stepExecutionFeed = new StepExecutionFeed({ renderStepToolCall, requestStepCancellation, createArtifactListItem, + disclosureController: chatDisclosureController, }); const agentGraph = new AgentGraphView("agent-graph", { stepExecutionFeed, @@ -2568,12 +2578,19 @@ function createTimelineImage(path) { img.hidden = true; img.style.cursor = "zoom-in"; img.addEventListener("load", () => { - loading.remove(); - img.hidden = false; + // Image decode changes layout asynchronously, outside the synchronous + // timeline update. Capture immediately before the DOM height changes so + // the anchor cannot be stale if other streamed events arrived meanwhile. + updatePreservingReadingPosition(() => { + loading.remove(); + img.hidden = false; + }); }); img.addEventListener("error", () => { - img.remove(); - loading.replaceWith(createImageLoadFallback(path)); + updatePreservingReadingPosition(() => { + img.remove(); + loading.replaceWith(createImageLoadFallback(path)); + }); }, { once: true }); img.addEventListener("click", () => lightbox.open(img.src)); img.src = pathToApiUrl(path); @@ -2590,12 +2607,34 @@ function isExecutorLauncherTool(name) { // collapsible
blocks; text parts render as markdown; // plot_path responses render as inline images. function renderTimeline(container, timeline, shownPlotPaths = null) { - const shouldStick = isChatNearBottom(); - const scrollPosition = shouldStick ? null : captureScrollPosition(); - container.innerHTML = ""; - const containerPlotPaths = container._plotPaths || new Set(); - const visiblePlotPaths = new Set(); - for (const item of timeline) { + const disclosures = chatDisclosureController; + const agentMessage = container.closest(".agent-message:not(.step-feed-message)"); + const agentMessages = [...chatArea.children] + .filter((element) => element.matches?.(".agent-message:not(.step-feed-message)")); + const agentIndex = agentMessages.indexOf(agentMessage); + // The live message has no persisted msgIndex yet. Its assistant-message + // ordinal remains stable when the Approve plan prompt causes a snapshot + // rebuild, so use that as the cross-render scope. + const messageKey = agentIndex >= 0 + ? `agent:${agentIndex}` + : `message:${agentMessage?.dataset.msgIndex || "live"}`; + const disclosurePrefix = `timeline:${messageKey}:`; + const liveKeys = new Set(); + const wireTimelineDetails = (details, key, defaultOpen = false) => { + const scopedKey = `${disclosurePrefix}${key}`; + liveKeys.add(scopedKey); + disclosures.wire(details, scopedKey, { defaultOpen }); + return scopedKey; + }; + updatePreservingReadingPosition(() => { + // Timeline updates rebuild Thinking/IN/OUT and any inline Node cards. + // Persist their actual DOM state first; defaults alone are insufficient + // once a running Node has become completed but remains visibly open. + disclosures.capture(chatArea); + container.innerHTML = ""; + const containerPlotPaths = container._plotPaths || new Set(); + const visiblePlotPaths = new Set(); + for (const item of timeline) { if (item.type === "thought") { const details = document.createElement("details"); details.className = "timeline-thought"; @@ -2605,16 +2644,20 @@ function renderTimeline(container, timeline, shownPlotPaths = null) { const body = document.createElement("div"); body.className = "markdown-content"; body.innerHTML = renderMarkdown(item.text || ""); + const thoughtKey = item.timelineId || `thought:${item.text || ""}`; + markReadingAnchors(body, `${disclosurePrefix}${thoughtKey}:content`); + protectAsyncContentLayout(body); details.appendChild(body); + wireTimelineDetails(details, thoughtKey); container.appendChild(details); } else if (item.type === "function_call") { const details = document.createElement("details"); details.className = "timeline-function-call"; - if (isExecutorLauncherTool(item.name)) details.open = true; const summary = document.createElement("summary"); summary.innerHTML = `IN ${item.name}`; details.appendChild(summary); details.appendChild(createJsonBlock(JSON.stringify(item.args, null, 2))); + wireTimelineDetails(details, item.timelineId || `function-call:${item.id || item.name || "Unknown"}`, isExecutorLauncherTool(item.name)); container.appendChild(details); if (isExecutorLauncherTool(item.name)) { const inlineHost = document.createElement("div"); @@ -2634,6 +2677,7 @@ function renderTimeline(container, timeline, shownPlotPaths = null) { summary.innerHTML = `OUT ${item.name}`; details.appendChild(summary); details.appendChild(createJsonBlock(JSON.stringify(item.response, null, 2))); + wireTimelineDetails(details, item.timelineId || `function-response:${item.id || item.name || "Unknown"}`); container.appendChild(details); for (const plotPath of getPlotPaths(item.response)) { if ( @@ -2652,13 +2696,15 @@ function renderTimeline(container, timeline, shownPlotPaths = null) { const div = document.createElement("div"); div.className = "markdown-content"; div.innerHTML = renderMarkdown(item.text || ""); + markReadingAnchors(div, `${disclosurePrefix}${item.timelineId || "text:legacy"}:content`); + protectAsyncContentLayout(div); container.appendChild(div); } - } - container._plotPaths = visiblePlotPaths; - visiblePlotPaths.forEach((path) => shownPlotPaths?.add(path)); - if (shouldStick) scrollToBottom({ preserveUserPosition: true }); - else restoreScrollPosition(scrollPosition); + } + disclosures.prunePrefix(disclosurePrefix, liveKeys); + container._plotPaths = visiblePlotPaths; + visiblePlotPaths.forEach((path) => shownPlotPaths?.add(path)); + }); } // Create an agent message div with an inner timeline container, append to @@ -2757,10 +2803,10 @@ function addPlanApprovalActions(timelineContainer) { bubble.append(prompt, actions, feedback); responseMessage.appendChild(bubble); agentMessage.after(responseMessage); - // A plan approval is an explicit request for input, so always reveal it. - requestAnimationFrame(() => { - requestAnimationFrame(() => responseMessage.scrollIntoView({ block: "center" })); - }); + // Approval prompts follow the same bottom placement as every other newly + // appended dialog. The shared reserve keeps the full prompt above the + // floating composer, regardless of composer/upload height. + scrollToBottom({ preserveUserPosition: true }); } function formatStepDuration(node) { @@ -2862,6 +2908,8 @@ const sessionRuntime = createSessionRuntime({ addMessage, addAgentTimelineMessage, addPlanApprovalActions, + beginScrollTransaction, + endScrollTransaction, renderSessionBanner, renderSessionFilesTree, refreshSessionFiles, @@ -3357,6 +3405,7 @@ function renderSessionSnapshot(snapshot) { if (!snapshot) return; renderSessionBanner(snapshot.summary || ""); sessionRuntime.renderSessionTimeline(snapshot.events || [], snapshot.graphNodes || []); + sessionRuntime.markSessionRendered(state.sessionId, state.activeSessionUserId || state.userId); renderSessionFilesTree(snapshot.files || []); sessionRuntime.updateSessionWorkdirDisplay(snapshot.sessionData || {}); } diff --git a/web/vite-frontend/src/styles/chat.css b/web/vite-frontend/src/styles/chat.css index 9b1ce890..09916008 100644 --- a/web/vite-frontend/src/styles/chat.css +++ b/web/vite-frontend/src/styles/chat.css @@ -700,8 +700,10 @@ .chat-area { flex: 1; min-height: 0; - padding: 16px 20px 130px; + padding: 16px 20px var(--chat-bottom-reserve, 130px); + scroll-padding-bottom: var(--chat-bottom-reserve, 130px); overflow-y: auto; + overflow-anchor: none; display: flex; flex-direction: column; position: relative; @@ -991,16 +993,34 @@ details.step-feed-nested { display: flex; align-items: center; justify-content: center; } -.markdown-content { padding: 12px 14px; border-radius: 12px; line-height: 1.6; font-size: 14px; } +.markdown-content { + min-width: 0; + max-width: 100%; + padding: 12px 14px; + border-radius: 12px; + line-height: 1.6; + font-size: 14px; + overflow-wrap: anywhere; +} .markdown-content a { color: var(--accent); text-decoration: none; font-weight: 600; } -.markdown-content table { - width: 100%; - border-collapse: collapse; +.markdown-content .markdown-table-scroll { + max-width: 100%; margin: 8px 0; - font-size: 13px; + overflow-x: auto; + overscroll-behavior-inline: contain; border: 1px solid var(--border); border-radius: 8px; - overflow: hidden; +} +.markdown-content .markdown-table-scroll:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.markdown-content table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + margin: 0; + font-size: 13px; } .markdown-content thead { background: rgba(125, 211, 252, 0.08); From 0cc0c84ae35f75de061a8554f3afa075316f02ef Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 20:23:42 +0800 Subject: [PATCH 07/14] feat: graph tree re-design --- src/matcreator/agents/graph_logger.py | 6 + src/matcreator/agents/orchestrator/agent.py | 11 +- tests/test_graph_logger.py | 18 ++ .../src/features/graphs/AgentGraphView.js | 271 ++++++++++++++---- 4 files changed, 253 insertions(+), 53 deletions(-) diff --git a/src/matcreator/agents/graph_logger.py b/src/matcreator/agents/graph_logger.py index 05e72ee6..c2f75ca0 100644 --- a/src/matcreator/agents/graph_logger.py +++ b/src/matcreator/agents/graph_logger.py @@ -14,6 +14,7 @@ "label": "", "status": "idle|running|success|failed|needs_replanning", "parent_id": "", + "batch_id": "", "start_time": "", "end_time": "", "summary": "", @@ -73,6 +74,7 @@ def log_node_start( node_type: NodeType, label: str, parent_id: Optional[str] = None, + batch_id: Optional[str] = None, ) -> None: """Create or overwrite a node with status=running.""" with self._lock: @@ -84,6 +86,10 @@ def log_node_start( "label": label, "status": "running", "parent_id": parent_id, + # Execution producers may share this value for work launched + # by one planning round. It is presentation metadata only: the + # actual parent edge remains parent_id -> node_id. + "batch_id": batch_id if batch_id is not None else (existing or {}).get("batch_id"), "start_time": existing["start_time"] if existing else _now(), "end_time": None, "summary": None, diff --git a/src/matcreator/agents/orchestrator/agent.py b/src/matcreator/agents/orchestrator/agent.py index 155961cc..f95b65af 100644 --- a/src/matcreator/agents/orchestrator/agent.py +++ b/src/matcreator/agents/orchestrator/agent.py @@ -201,7 +201,16 @@ async def _run_async_impl( ) exec_id = f"execution_{loop_idx}" - graph.log_node_start(exec_id, "execution", f"Execution {loop_idx + 1}", planning_id) + graph.log_node_start( + exec_id, + "execution", + f"Execution {loop_idx + 1}", + planning_id, + # This remains metadata: all execution nodes continue to + # point directly at the original planner. The frontend + # uses it to place one planning round on one vine layer. + batch_id=f"{planning_id}:round:{loop_idx}", + ) state["_graph_exec_node_id"] = exec_id async for event in self.execution_agent.run_async(ctx): diff --git a/tests/test_graph_logger.py b/tests/test_graph_logger.py index 64cd1b29..ed81c976 100644 --- a/tests/test_graph_logger.py +++ b/tests/test_graph_logger.py @@ -40,3 +40,21 @@ def test_consecutive_streamed_text_is_coalesced(tmp_path, monkeypatch) -> None: "type": "text", "content": "Calculating lattice parameters and checking convergence.", }] + + +def test_execution_batch_metadata_does_not_change_parent_edge(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(graph_logger, "ADK_DIR", tmp_path) + logger = graph_logger.AgentGraphLogger("batched-session") + logger.log_node_start("planning_0", "planning", "Planning", "orchestrator") + logger.log_node_start( + "execution_0", + "execution", + "Execution 1", + "planning_0", + batch_id="planning_0:round:0", + ) + + graph_path = tmp_path / "agent_graphs" / "batched-session.json" + graph = json.loads(graph_path.read_text(encoding="utf-8")) + assert graph["nodes"]["execution_0"]["batch_id"] == "planning_0:round:0" + assert {"from": "planning_0", "to": "execution_0"} in graph["edges"] diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index a54bc995..45fa5d2c 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -19,6 +19,15 @@ const STATUS_COLORS = { const rgba = (rgb, alpha) => `rgba(${rgb}, ${alpha})`; +// These distances describe time, rather than graph depth. In particular an +// execution batch gets its own row even though every execution node still has +// the same planning node as its real parent. +const VINE_BATCH_GAP = 112; +const VINE_DESCENDANT_GAP = 62; +const VINE_NODE_GAP = 64; +const VINE_PLANNER_GAP = 460; +const VINE_STEM_CLEARANCE = 42; + const STATUS_ALIASES = { completed: "success", succeeded: "success", @@ -53,6 +62,7 @@ export class AgentGraphView { this._lastAnimationPaint = 0; this._motionTime = 0; this._activeEdges = []; + this._vineEdges = []; this._hasRunningNodes = false; this._reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; this._detailEl = document.getElementById("graph-detail"); @@ -89,16 +99,10 @@ export class AgentGraphView { _init() { const edgeColors = this._edgeColors(); const options = { - layout: { - hierarchical: { - direction: "UD", - sortMethod: "directed", - nodeSpacing: 76, - levelSeparation: 86, - blockShifting: true, - edgeMinimization: true, - }, - }, + // Agent activity uses a chronology-aware layout below. A DAG layout + // would put every child of a planner at the same depth and erase the + // distinction between successive planning rounds. + layout: { hierarchical: false }, physics: { enabled: false }, edges: { arrows: { to: { enabled: true, scaleFactor: 0.72 } }, @@ -130,6 +134,7 @@ export class AgentGraphView { if (params.nodes.length) this._showDetail(params.nodes[0]); }); this._network.on("deselectNode", () => this._hideDetail()); + this._network.on("beforeDrawing", (ctx) => this._drawVines(ctx)); this._network.on("afterDrawing", (ctx) => this._drawActiveFlow(ctx)); window.addEventListener("matcreator-theme-change", () => this._applyTheme()); this._detailClose?.addEventListener("click", () => { @@ -429,41 +434,181 @@ export class AgentGraphView { this._animationFrame = requestAnimationFrame(animate); } - _computeLevels(rawNodes, edges) { - const nodeIds = rawNodes.map((node) => node.id); - const nodeIdSet = new Set(nodeIds); - const children = Object.fromEntries(nodeIds.map((id) => [id, []])); - const inDegree = Object.fromEntries(nodeIds.map((id) => [id, 0])); + _timeKey(node) { + const value = node?.start_time ? new Date(node.start_time).getTime() : NaN; + return Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER; + } - // Levels describe hierarchy, not elapsed time. Tasks with the same parent - // therefore remain siblings on the same row even if they ran sequentially. + _executionBatchId(node, plannerId) { + // batch_id is optional for older graph snapshots. Untagged direct siblings + // belong to one legacy batch rather than being split into one layer per + // node: sequential dispatch is still work from the same planning round. + // New snapshots carry explicit IDs, so replanning rounds remain separate. + return String( + node.batch_id + ?? node.execution_batch_id + ?? node.input?.batch_id + ?? node.input?.execution_batch_id + ?? `legacy:${plannerId}`, + ); + } + + _computeVineLayout(rawNodes, edges) { + const nodeMap = Object.fromEntries(rawNodes.map((node) => [node.id, node])); + const children = Object.fromEntries(rawNodes.map((node) => [node.id, []])); (edges || []).forEach((edge) => { - if (!nodeIdSet.has(edge.from) || !nodeIdSet.has(edge.to)) return; - children[edge.from].push(edge.to); - inDegree[edge.to] += 1; + if (children[edge.from] && nodeMap[edge.to]) children[edge.from].push(edge.to); }); + Object.values(children).forEach((ids) => ids.sort((a, b) => + this._timeKey(nodeMap[a]) - this._timeKey(nodeMap[b]) || String(a).localeCompare(String(b)))); + + const positions = {}; + const placed = new Set(); + const planners = rawNodes.filter((node) => node.type === "planning") + .sort((a, b) => this._timeKey(a) - this._timeKey(b) || a.id.localeCompare(b.id)); + const plannerCount = planners.length; + + planners.forEach((planner, plannerIndex) => { + const plannerX = (plannerIndex - (plannerCount - 1) / 2) * VINE_PLANNER_GAP; + positions[planner.id] = { x: plannerX, y: 0 }; + placed.add(planner.id); + + const batches = new Map(); + children[planner.id] + .filter((id) => nodeMap[id]?.type === "execution") + .forEach((id) => { + const batchId = this._executionBatchId(nodeMap[id], planner.id); + if (!batches.has(batchId)) batches.set(batchId, []); + batches.get(batchId).push(id); + }); + const orderedBatches = [...batches.entries()].sort(([aId, a], [bId, b]) => { + const aTime = Math.min(...a.map((id) => this._timeKey(nodeMap[id]))); + const bTime = Math.min(...b.map((id) => this._timeKey(nodeMap[id]))); + return aTime - bTime || aId.localeCompare(bId); + }); - const levels = {}; - const queue = nodeIds.filter((id) => inDegree[id] === 0); - queue.forEach((id) => { levels[id] = 0; }); + let batchY = VINE_BATCH_GAP; + orderedBatches.forEach(([, executionIds], batchIndex) => { + executionIds.sort((a, b) => this._timeKey(nodeMap[a]) - this._timeKey(nodeMap[b]) || a.localeCompare(b)); + const batchWidth = (executionIds.length - 1) * VINE_NODE_GAP; + const isLatestBatch = batchIndex === orderedBatches.length - 1; + const depthRows = []; + let maxDepth = 0; + const seen = new Set(executionIds); + let frontier = executionIds; + + // Descendants are still normal graph children of execution nodes. They + // occupy rows below their own batch so they cannot be mistaken for a + // later planning round. + while (frontier.length) { + const next = []; + frontier.forEach((parentId) => children[parentId].forEach((childId) => { + if (!seen.has(childId) && nodeMap[childId]?.type !== "execution") { + seen.add(childId); + next.push(childId); + } + })); + if (!next.length) break; + maxDepth += 1; + depthRows.push(next); + frontier = next; + } - while (queue.length) { - const parentId = queue.shift(); - children[parentId].forEach((childId) => { - levels[childId] = Math.max( - levels[childId] ?? 0, - (levels[parentId] ?? 0) + 1, + // Position a side batch only as far from the central stem as its + // widest descendant row requires. This prevents leaf nodes or their + // edges from crossing the main vine without wasting horizontal space. + const widestDescendantRow = Math.max( + batchWidth, + ...depthRows.map((ids) => (ids.length - 1) * VINE_NODE_GAP), ); - inDegree[childId] -= 1; - if (inDegree[childId] === 0) queue.push(childId); + const subtreeHalfWidth = widestDescendantRow / 2 + this._nodeRadius({ type: "step" }); + const side = batchIndex % 2 === 0 ? 1 : -1; + const batchCenterX = isLatestBatch + ? plannerX + : plannerX + side * (subtreeHalfWidth + VINE_STEM_CLEARANCE); + + executionIds.forEach((id, index) => { + positions[id] = { x: batchCenterX + index * VINE_NODE_GAP - batchWidth / 2, y: batchY }; + placed.add(id); + }); + depthRows.forEach((ids, depthIndex) => { + const rowWidth = (ids.length - 1) * VINE_NODE_GAP; + ids.forEach((id, index) => { + positions[id] = { + x: batchCenterX + index * VINE_NODE_GAP - rowWidth / 2, + y: batchY + (depthIndex + 1) * VINE_DESCENDANT_GAP, + }; + placed.add(id); + }); + }); + batchY += VINE_BATCH_GAP + maxDepth * VINE_DESCENDANT_GAP; }); - } + }); - // Keep malformed/cyclic payloads visible rather than dropping their nodes. - nodeIds.forEach((id) => { - if (!(id in levels)) levels[id] = 0; + // Keep the orchestrator immediately above its planners. Any malformed or + // unrelated node remains visible in a small fallback strip instead of + // being silently omitted from the graph. + rawNodes.filter((node) => node.type === "orchestrator").forEach((node, index) => { + positions[node.id] = { x: index * VINE_PLANNER_GAP, y: -VINE_BATCH_GAP }; + placed.add(node.id); }); - return levels; + rawNodes.filter((node) => !placed.has(node.id)).forEach((node, index) => { + positions[node.id] = { x: index * VINE_NODE_GAP, y: VINE_BATCH_GAP }; + }); + return positions; + } + + _drawVines(ctx) { + if (!this._network || !this._vineEdges.length) return; + const positions = this._network.getPositions(); + const isLight = document.body.dataset.theme === "light"; + const color = isLight ? "#8290a3" : "#64748b"; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 1.7; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + this._vineEdges.forEach(({ from, branches }) => { + const source = positions[from]; + if (!source || !branches.length) return; + const sourceY = source.y + this._nodeRadius(this._nodeData[from]) + 1; + const stemEndY = Math.max(...branches.map(({ to }) => { + const target = positions[to]; + return target ? target.y - this._nodeRadius(this._nodeData[to]) - 34 : sourceY; + })); + + // The stem is drawn once, so later planning rounds visibly extend the + // same P connection rather than appearing as unrelated long edges. + ctx.beginPath(); + ctx.moveTo(source.x, sourceY); + ctx.lineTo(source.x, stemEndY); + ctx.stroke(); + + branches.forEach(({ to }) => { + const target = positions[to]; + if (!target) return; + const targetY = target.y - this._nodeRadius(this._nodeData[to]) - 1; + const branchY = targetY - 34; + ctx.beginPath(); + ctx.moveTo(source.x, branchY); + ctx.bezierCurveTo( + source.x, branchY + 18, + target.x, branchY - 18, + target.x, targetY, + ); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(target.x, targetY); + ctx.lineTo(target.x - 4, targetY - 7); + ctx.lineTo(target.x + 4, targetY - 7); + ctx.closePath(); + ctx.fill(); + }); + }); + ctx.restore(); } _buildDisplayEdges(rawNodes, edges) { @@ -590,8 +735,24 @@ export class AgentGraphView { color: STATUS_COLORS.running, phase: (index * 0.173) % 1, })); - const levels = this._computeLevels(rawNodes, displayEdges); - this._resizeSurface(levels); + const positions = this._computeVineLayout(rawNodes, displayEdges); + const vineEdgeIds = new Set(displayEdges + .filter((edge) => rawNodeMap[edge.from]?.type === "planning" && rawNodeMap[edge.to]?.type === "execution") + .map((edge) => edge.id || `${edge.from}__${edge.to}`)); + const vineTargetsByPlanner = new Map(); + displayEdges.forEach((edge) => { + if (!vineEdgeIds.has(edge.id || `${edge.from}__${edge.to}`)) return; + if (!vineTargetsByPlanner.has(edge.from)) vineTargetsByPlanner.set(edge.from, []); + vineTargetsByPlanner.get(edge.from).push(edge.to); + }); + this._vineEdges = [...vineTargetsByPlanner.entries()].map(([from, targetIds]) => ({ + from, + // This one object produces one shared stem and a branch for every + // direct execution relation. It intentionally does not replace edges + // in graphData: every E still belongs directly to its planner. + branches: targetIds.map((to) => ({ to })), + })); + this._resizeSurface(); const nextNodeIds = new Set(rawNodes.map((raw) => raw.id)); const nextEdgeIds = new Set(displayEdges.map((e) => e.id || `${e.from}__${e.to}`)); const topologyChanged = @@ -606,7 +767,10 @@ export class AgentGraphView { rawNodes.forEach((raw) => { const vis = this._visNode(raw); - vis.level = levels[raw.id] ?? 0; + const position = positions[raw.id] || { x: 0, y: 0 }; + vis.x = position.x; + vis.y = position.y; + vis.fixed = { x: true, y: true }; if (this._nodes.get(raw.id)) { this._nodes.update(vis); } else { @@ -618,21 +782,23 @@ export class AgentGraphView { if (!nextEdgeIds.has(edgeId)) this._edges.remove(edgeId); }); - const existingEdgeIds = new Set(this._edges.getIds()); displayEdges.forEach((e) => { const edgeId = e.id || `${e.from}__${e.to}`; - if (!existingEdgeIds.has(edgeId)) { - this._edges.add({ - id: edgeId, - from: e.from, - to: e.to, - hidden: false, - physics: false, - width: 1.35, - color: this._edgeColors(), - smooth: { type: "cubicBezier", forceDirection: "vertical" }, - }); - } + const visEdge = { + id: edgeId, + from: e.from, + to: e.to, + // Planner -> execution links are painted as routed vines in + // beforeDrawing. The edge itself stays in the DataSet, preserving the + // real graph topology for interaction and future consumers. + hidden: vineEdgeIds.has(edgeId), + physics: false, + width: 1.35, + color: this._edgeColors(), + smooth: { type: "cubicBezier", forceDirection: "vertical" }, + }; + if (this._edges.get(edgeId)) this._edges.update(visEdge); + else this._edges.add(visEdge); }); if (rawNodes.length > 0 && (topologyChanged || !this._didInitialFit || this._pendingFit)) { @@ -699,6 +865,7 @@ export class AgentGraphView { this._pendingFit = true; this._hasRunningNodes = false; this._activeEdges = []; + this._vineEdges = []; if (this._animationFrame !== null) cancelAnimationFrame(this._animationFrame); this._animationFrame = null; this._lastAnimationPaint = 0; From 3752dd0bfb69107b53e8005f090997c55d0d88c2 Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 20:37:45 +0800 Subject: [PATCH 08/14] feat: detailed waiting info --- web/vite-frontend/index.html | 2 +- .../components/mountOrbitalAgentIndicator.js | 9 ++++--- .../src/features/chat/messageStream.js | 25 ++++++++++++++++--- web/vite-frontend/src/main.js | 21 ++++++++++++++-- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/web/vite-frontend/index.html b/web/vite-frontend/index.html index 5adfe15b..b20be8b0 100644 --- a/web/vite-frontend/index.html +++ b/web/vite-frontend/index.html @@ -186,7 +186,7 @@

Agent Graph

diff --git a/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js index 6739f665..5531c2a8 100644 --- a/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js +++ b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js @@ -7,11 +7,12 @@ import OrbitalAgentIndicator from "./OrbitalAgentIndicator.jsx"; export function mountOrbitalAgentIndicator(target) { if (!target) return null; const root = createRoot(target); - root.render(React.createElement(OrbitalAgentIndicator, { - state: "computing", + const render = (state = "idle") => root.render(React.createElement(OrbitalAgentIndicator, { + state, size: 18, color: "var(--accent)", - title: "MatCreator is working", + title: `MatCreator is ${state}`, })); - return root; + render(); + return { render, unmount: () => root.unmount() }; } diff --git a/web/vite-frontend/src/features/chat/messageStream.js b/web/vite-frontend/src/features/chat/messageStream.js index 8fbd55b7..a720ef18 100644 --- a/web/vite-frontend/src/features/chat/messageStream.js +++ b/web/vite-frontend/src/features/chat/messageStream.js @@ -12,7 +12,7 @@ export function createMessageStreamController(deps) { state, appName, chatArea, textInput, activeSessionRequest, sessionRequestKey, activeSessionBackendUserId, canWriteActiveSession, showLoginModal, createSession, addMessage, addAgentTimelineMessage, addPlanApprovalActions, renderTimeline, messageWithUploadNames, messageWithUploadContext, clearCurrentUploads, - autoResizeTextInput, stepExecutionFeed, agentGraph, planGraph, updateSendButtonState, + autoResizeTextInput, stepExecutionFeed, agentGraph, planGraph, updateSendButtonState, updateAgentRunningStatus, releaseSessionRequest, managedRunEventsUrl, shouldRefreshPlanGraphForTool, generateSessionSummary, refreshSessionFiles, sessionRuntime, } = deps; @@ -43,6 +43,7 @@ export function createMessageStreamController(deps) { const query = new URLSearchParams({ user_id: request.owner || state.userId }); fetch(`/api/sessions/${request.sessionId}/cancel?${query}`, { method: "POST" }).catch(() => {}); request.controller.abort(); + updateAgentRunningStatus("working"); pollCancellationConfirmed(request.sessionId, request.owner); } @@ -91,6 +92,7 @@ export function createMessageStreamController(deps) { backendUserId: activeSessionBackendUserId(), controller: new AbortController(), lastSequence: 0, runId: null, }; state.activeRequests.set(request.key, request); + updateAgentRunningStatus("thinking"); updateSendButtonState(); let accumulatedText = ""; @@ -105,17 +107,25 @@ export function createMessageStreamController(deps) { if (data === "[DONE]") return; try { for (const part of JSON.parse(data)?.content?.parts || []) { - if (part.thought) upsertTimelineThought(timeline, part.text || ""); - else if (part.functionCall) upsertTimelineEvent(timeline, { type: "function_call", id: part.functionCall.id, name: part.functionCall.name || "Unknown", args: part.functionCall.args || {} }); + if (part.thought) { + updateAgentRunningStatus("thinking"); + upsertTimelineThought(timeline, part.text || ""); + } else if (part.functionCall) { + const name = part.functionCall.name || "Unknown"; + updateAgentRunningStatus(phaseForTool(name)); + upsertTimelineEvent(timeline, { type: "function_call", id: part.functionCall.id, name, args: part.functionCall.args || {} }); + } else if (part.functionResponse) { const response = part.functionResponse; upsertTimelineEvent(timeline, { type: "function_response", id: response.id, name: response.name || "Unknown", response: response.response || {} }); + updateAgentRunningStatus(phaseForTool(response.name)); if (shouldRefreshPlanGraphForTool(response.name)) planGraph.refresh(request.sessionId); if ((response.name === "validate_graph" || response.name === "validate_plan") && response.response?.status === "ok") validatedPlanThisTurn = true; if ((response.name === "confirm_plan_and_start_execution" || response.name === "resume_execution") && response.response?.status === "ok") executionApprovedThisTurn = true; } else if (part.text) { + updateAgentRunningStatus("thinking"); accumulatedText = mergeReplayedText(accumulatedText, part.text); upsertTimelineText(timeline, compactRepeatedPrefixSnapshots(accumulatedText)); if (!summaryTriggered && !state.summaryGeneratedFor.has(request.sessionId) && !state.sessionSummaries[request.sessionId]) { @@ -212,5 +222,14 @@ export function createMessageStreamController(deps) { } } + function phaseForTool(name = "") { + const tool = String(name).toLowerCase(); + if (tool.includes("search") || tool.includes("retrieve") || tool.includes("lookup")) return "searching"; + if (tool.includes("plan") || tool.includes("graph") || tool.includes("decompos")) return "planning"; + if (tool.includes("run_") || tool.includes("execute") || tool.includes("submit") || tool.includes("resume")) return "executing"; + if (tool.includes("calc") || tool.includes("simulate") || tool.includes("compute")) return "computing"; + return "working"; + } + return { send, stop }; } diff --git a/web/vite-frontend/src/main.js b/web/vite-frontend/src/main.js index 255938c6..3b05f098 100644 --- a/web/vite-frontend/src/main.js +++ b/web/vite-frontend/src/main.js @@ -72,6 +72,7 @@ const inputArea = document.querySelector(".input-area"); const inputContainer = document.querySelector(".input-container"); const agentRunningIndicator = document.getElementById("agent-running-indicator"); const agentRunningOrbital = document.getElementById("agent-running-orbital"); +const agentRunningText = document.getElementById("agent-running-text"); const sendBtn = document.getElementById("send-btn"); const fileUploadBtn = document.getElementById("file-upload-btn"); const fileUploadInput = document.getElementById("file-upload-input"); @@ -1070,18 +1071,33 @@ function releaseSessionRequest(request) { } } +const orbitalIndicator = mountOrbitalAgentIndicator(agentRunningOrbital); + +function updateAgentRunningStatus(phase = "working") { + const phases = { + working: ["MatCreator is working. Please wait…", "thinking"], + thinking: ["MatCreator is thinking…", "thinking"], + planning: ["MatCreator is planning the workflow…", "thinking"], + searching: ["MatCreator is searching for information…", "searching"], + executing: ["MatCreator is executing the workflow…", "computing"], + computing: ["MatCreator is computing…", "computing"], + }; + const [label, orbitalState] = phases[phase] || phases.working; + if (agentRunningText) agentRunningText.textContent = label; + orbitalIndicator?.render(orbitalState); +} + function updateSendButtonState() { const running = Boolean(activeSessionRequest()); inputArea?.classList.toggle("is-agent-running", running); if (agentRunningIndicator) agentRunningIndicator.setAttribute("aria-hidden", String(!running)); + if (!running) updateAgentRunningStatus(); if (!sendBtn) return; sendBtn.textContent = running ? "■" : "➜"; sendBtn.title = running ? "Stop" : "Send"; sendBtn.classList.toggle("is-stopping", running); } -mountOrbitalAgentIndicator(agentRunningOrbital); - function storeSessionSelection(sessionId, owner) { localStorage.setItem(SESSION_ID_KEY, sessionId); localStorage.setItem(SESSION_OWNER_KEY, owner); @@ -2900,6 +2916,7 @@ const messageStreamController = createMessageStreamController({ agentGraph, planGraph, updateSendButtonState, + updateAgentRunningStatus, releaseSessionRequest, managedRunEventsUrl, shouldRefreshPlanGraphForTool, From c95b5347370d6b4857ad140cfd5c631590526f76 Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 20:37:59 +0800 Subject: [PATCH 09/14] feat: agent graph tighten --- .../src/features/graphs/AgentGraphView.js | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index 45fa5d2c..d2578aea 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -22,7 +22,8 @@ const rgba = (rgb, alpha) => `rgba(${rgb}, ${alpha})`; // These distances describe time, rather than graph depth. In particular an // execution batch gets its own row even though every execution node still has // the same planning node as its real parent. -const VINE_BATCH_GAP = 112; +const VINE_BATCH_GAP = 92; +const VINE_BATCH_STAGGER = 54; const VINE_DESCENDANT_GAP = 62; const VINE_NODE_GAP = 64; const VINE_PLANNER_GAP = 460; @@ -487,7 +488,11 @@ export class AgentGraphView { return aTime - bTime || aId.localeCompare(bId); }); - let batchY = VINE_BATCH_GAP; + let previousBatchY = VINE_BATCH_GAP - VINE_BATCH_STAGGER; + // Alternating branches use independent vertical lanes. A new branch on + // the opposite side may tuck in after a small stagger; returning to a + // side waits until that side's existing leaf fan has cleared. + const sideClearY = new Map([[-1, VINE_BATCH_GAP], [1, VINE_BATCH_GAP]]); orderedBatches.forEach(([, executionIds], batchIndex) => { executionIds.sort((a, b) => this._timeKey(nodeMap[a]) - this._timeKey(nodeMap[b]) || a.localeCompare(b)); const batchWidth = (executionIds.length - 1) * VINE_NODE_GAP; @@ -523,6 +528,13 @@ export class AgentGraphView { ); const subtreeHalfWidth = widestDescendantRow / 2 + this._nodeRadius({ type: "step" }); const side = batchIndex % 2 === 0 ? 1 : -1; + const nextStaggerY = previousBatchY + VINE_BATCH_STAGGER; + const batchY = isLatestBatch + // The centered tip shares horizontal space with both historical + // sides. Place it below the deepest task generation from either + // side, not merely below the preceding execution node. + ? Math.max(nextStaggerY, ...sideClearY.values()) + : Math.max(nextStaggerY, sideClearY.get(side)); const batchCenterX = isLatestBatch ? plannerX : plannerX + side * (subtreeHalfWidth + VINE_STEM_CLEARANCE); @@ -541,7 +553,13 @@ export class AgentGraphView { placed.add(id); }); }); - batchY += VINE_BATCH_GAP + maxDepth * VINE_DESCENDANT_GAP; + previousBatchY = batchY; + if (!isLatestBatch) { + sideClearY.set( + side, + batchY + maxDepth * VINE_DESCENDANT_GAP + VINE_BATCH_STAGGER, + ); + } }); }); From dc5d125a0fadabfb27a41be8cdf505d30a0d2fde Mon Sep 17 00:00:00 2001 From: theNotfish Date: Thu, 13 Aug 2026 20:52:49 +0800 Subject: [PATCH 10/14] fix: sequential tasks rendering in agent graphs --- .../src/features/graphs/AgentGraphView.js | 176 +++++++++++++----- 1 file changed, 131 insertions(+), 45 deletions(-) diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index d2578aea..e319ad6d 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -24,7 +24,9 @@ const rgba = (rgb, alpha) => `rgba(${rgb}, ${alpha})`; // the same planning node as its real parent. const VINE_BATCH_GAP = 92; const VINE_BATCH_STAGGER = 54; -const VINE_DESCENDANT_GAP = 62; +// Keep task chains legible vertically while batches themselves still use the +// tighter stagger below. +const VINE_DESCENDANT_GAP = 70; const VINE_NODE_GAP = 64; const VINE_PLANNER_GAP = 460; const VINE_STEM_CLEARANCE = 42; @@ -454,6 +456,88 @@ export class AgentGraphView { ); } + _chronologicalTaskRows(nodeIds, nodeMap) { + const timed = nodeIds.map((id) => { + const node = nodeMap[id]; + const startValue = node?.start_time ? new Date(node.start_time).getTime() : NaN; + const endValue = node?.end_time ? new Date(node.end_time).getTime() : NaN; + return { + id, + start: startValue, + hasStart: Number.isFinite(startValue), + // An unfinished task keeps its row open. That makes concurrently + // running work stay together instead of being rendered as a sequence. + end: Number.isFinite(endValue) ? endValue : Number.MAX_SAFE_INTEGER, + }; + }).sort((a, b) => { + if (a.hasStart !== b.hasStart) return a.hasStart ? -1 : 1; + return a.start - b.start || a.id.localeCompare(b.id); + }); + + if (!timed.some((task) => task.hasStart)) return [nodeIds]; + + const rows = []; + let rowEnd = -Infinity; + timed.forEach((task) => { + // A snapshot without a start time has no reliable chronology. Keep it + // beside the current wave instead of inventing a sequential ordering. + if (!task.hasStart) { + rows[rows.length - 1].push(task.id); + return; + } + if (!rows.length || task.start >= rowEnd) { + rows.push([task.id]); + rowEnd = task.end; + } else { + rows[rows.length - 1].push(task.id); + rowEnd = Math.max(rowEnd, task.end); + } + }); + return rows; + } + + _sequenceTaskDisplayEdges(displayEdges, nodeMap) { + const directTaskEdges = new Map(); + displayEdges.forEach((edge) => { + if (nodeMap[edge.from]?.type !== "execution" || nodeMap[edge.to]?.type !== "step") return; + if (!directTaskEdges.has(edge.from)) directTaskEdges.set(edge.from, []); + directTaskEdges.get(edge.from).push(edge); + }); + if (!directTaskEdges.size) return displayEdges; + + const replacedEdgeIds = new Set(); + const sequenceEdges = []; + directTaskEdges.forEach((taskEdges, executionId) => { + const rows = this._chronologicalTaskRows(taskEdges.map((edge) => edge.to), nodeMap); + if (rows.length < 2) return; + taskEdges.forEach((edge) => replacedEdgeIds.add(edge.id)); + + // The first concurrent wave keeps E as its source. Later waves receive + // their visual connection from the preceding wave, yielding E -> 1 -> 2 + // for a sequential pair while preserving same-row parallelism. + rows[0].forEach((to) => sequenceEdges.push({ + id: `sequence__${executionId}__${to}`, + from: executionId, + to, + })); + for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) { + const previous = rows[rowIndex - 1]; + // A following wave begins only after the preceding parallel wave has + // completed. Draw every predecessor so a join such as 1 + 2 -> 3 + // keeps both dependency lines rather than silently choosing task 1. + previous.forEach((from) => rows[rowIndex].forEach((to) => sequenceEdges.push({ + id: `sequence__${executionId}__${from}__${to}`, + from, + to, + }))); + } + }); + return [ + ...displayEdges.filter((edge) => !replacedEdgeIds.has(edge.id)), + ...sequenceEdges, + ]; + } + _computeVineLayout(rawNodes, edges) { const nodeMap = Object.fromEntries(rawNodes.map((node) => [node.id, node])); const children = Object.fromEntries(rawNodes.map((node) => [node.id, []])); @@ -495,38 +579,37 @@ export class AgentGraphView { const sideClearY = new Map([[-1, VINE_BATCH_GAP], [1, VINE_BATCH_GAP]]); orderedBatches.forEach(([, executionIds], batchIndex) => { executionIds.sort((a, b) => this._timeKey(nodeMap[a]) - this._timeKey(nodeMap[b]) || a.localeCompare(b)); - const batchWidth = (executionIds.length - 1) * VINE_NODE_GAP; const isLatestBatch = batchIndex === orderedBatches.length - 1; - const depthRows = []; - let maxDepth = 0; - const seen = new Set(executionIds); - let frontier = executionIds; - - // Descendants are still normal graph children of execution nodes. They - // occupy rows below their own batch so they cannot be mistaken for a - // later planning round. - while (frontier.length) { - const next = []; - frontier.forEach((parentId) => children[parentId].forEach((childId) => { - if (!seen.has(childId) && nodeMap[childId]?.type !== "execution") { - seen.add(childId); - next.push(childId); - } - })); - if (!next.length) break; - maxDepth += 1; - depthRows.push(next); - frontier = next; - } - - // Position a side batch only as far from the central stem as its - // widest descendant row requires. This prevents leaf nodes or their - // edges from crossing the main vine without wasting horizontal space. - const widestDescendantRow = Math.max( - batchWidth, - ...depthRows.map((ids) => (ids.length - 1) * VINE_NODE_GAP), - ); - const subtreeHalfWidth = widestDescendantRow / 2 + this._nodeRadius({ type: "step" }); + // Measure each E subtree independently. Pooling all sibling task + // nodes into global rows made their branches cross and obscured which + // execution owned each task. + const subtrees = executionIds.map((rootId) => { + const rows = []; + const seen = new Set([rootId]); + let frontier = [rootId]; + while (frontier.length) { + const next = []; + frontier.forEach((parentId) => children[parentId].forEach((childId) => { + if (!seen.has(childId) && nodeMap[childId]?.type !== "execution") { + seen.add(childId); + next.push(childId); + } + })); + if (!next.length) break; + rows.push(next); + frontier = next; + } + const widestRow = Math.max(0, ...rows.map((ids) => (ids.length - 1) * VINE_NODE_GAP)); + return { + rootId, + rows, + maxDepth: rows.length, + width: Math.max(VINE_NODE_GAP, widestRow + VINE_NODE_GAP), + }; + }); + const batchWidth = subtrees.reduce((total, subtree) => total + subtree.width, 0); + const maxDepth = Math.max(0, ...subtrees.map((subtree) => subtree.maxDepth)); + const subtreeHalfWidth = batchWidth / 2 + this._nodeRadius({ type: "step" }); const side = batchIndex % 2 === 0 ? 1 : -1; const nextStaggerY = previousBatchY + VINE_BATCH_STAGGER; const batchY = isLatestBatch @@ -539,19 +622,22 @@ export class AgentGraphView { ? plannerX : plannerX + side * (subtreeHalfWidth + VINE_STEM_CLEARANCE); - executionIds.forEach((id, index) => { - positions[id] = { x: batchCenterX + index * VINE_NODE_GAP - batchWidth / 2, y: batchY }; - placed.add(id); - }); - depthRows.forEach((ids, depthIndex) => { - const rowWidth = (ids.length - 1) * VINE_NODE_GAP; - ids.forEach((id, index) => { - positions[id] = { - x: batchCenterX + index * VINE_NODE_GAP - rowWidth / 2, - y: batchY + (depthIndex + 1) * VINE_DESCENDANT_GAP, - }; - placed.add(id); + let subtreeLeft = batchCenterX - batchWidth / 2; + subtrees.forEach((subtree) => { + const rootX = subtreeLeft + subtree.width / 2; + positions[subtree.rootId] = { x: rootX, y: batchY }; + placed.add(subtree.rootId); + subtree.rows.forEach((ids, depthIndex) => { + const rowWidth = (ids.length - 1) * VINE_NODE_GAP; + ids.forEach((id, index) => { + positions[id] = { + x: rootX + index * VINE_NODE_GAP - rowWidth / 2, + y: batchY + (depthIndex + 1) * VINE_DESCENDANT_GAP, + }; + placed.add(id); + }); }); + subtreeLeft += subtree.width; }); previousBatchY = batchY; if (!isLatestBatch) { @@ -701,7 +787,7 @@ export class AgentGraphView { }); }); - return displayEdges; + return this._sequenceTaskDisplayEdges(displayEdges, nodeMap); } _resizeSurface() { From 4c164cba232509de3790df1fa1ce5001674f1d02 Mon Sep 17 00:00:00 2001 From: Wang Ruoyu Date: Thu, 13 Aug 2026 21:19:57 +0800 Subject: [PATCH 11/14] feat: add plugin for bohr cli job --- docs/remote_job_monitoring.md | 189 ++++-- pyproject.toml | 3 +- .../agents/execution_agent/e2b_tools.py | 232 -------- .../agents/execution_agent/recovery.py | 5 + .../execution_agent/remote_job_tools.py | 545 ++++++++++++++++++ .../agents/execution_agent/step_executor.py | 88 ++- .../execution_agent/step_executor_runner.py | 15 +- src/matcreator/agents/thinking_agent/agent.py | 4 +- .../control_plane/providers/__init__.py | 48 ++ .../control_plane/providers/_bohr_cli.py | 75 +++ .../control_plane/providers/base.py | 126 ++++ .../control_plane/providers/bohr_job.py | 109 ++++ .../control_plane/providers/bohr_sandbox.py | 118 ++++ .../control_plane/{ => providers}/e2b.py | 96 ++- .../control_plane/providers/registry.py | 50 ++ .../control_plane/remote_job_monitor.py | 34 +- .../control_plane/remote_job_service.py | 437 ++++++++++---- src/matcreator/control_plane/remote_jobs.py | 41 ++ src/matcreator/skills/e2b/SKILL.md | 4 +- .../references/e2b-sandbox-execution.md | 2 +- tests/test_bohr_job_adapter.py | 159 +++++ tests/test_bohr_sandbox_adapter.py | 240 ++++++++ tests/test_e2b_adapter.py | 87 ++- tests/test_e2b_tools.py | 184 ------ tests/test_execution_grouping.py | 129 +++++ tests/test_execution_recovery.py | 1 + tests/test_matcreator_phase_and_skills.py | 48 ++ tests/test_remote_job_monitor.py | 99 +++- tests/test_remote_job_provider_registry.py | 117 ++++ tests/test_remote_job_service.py | 435 ++++++++++++-- tests/test_remote_job_tools.py | 404 +++++++++++++ tests/test_step_executor_runner.py | 21 +- web/main.py | 19 +- 33 files changed, 3450 insertions(+), 714 deletions(-) delete mode 100644 src/matcreator/agents/execution_agent/e2b_tools.py create mode 100644 src/matcreator/agents/execution_agent/remote_job_tools.py create mode 100644 src/matcreator/control_plane/providers/__init__.py create mode 100644 src/matcreator/control_plane/providers/_bohr_cli.py create mode 100644 src/matcreator/control_plane/providers/base.py create mode 100644 src/matcreator/control_plane/providers/bohr_job.py create mode 100644 src/matcreator/control_plane/providers/bohr_sandbox.py rename src/matcreator/control_plane/{ => providers}/e2b.py (63%) create mode 100644 src/matcreator/control_plane/providers/registry.py create mode 100644 tests/test_bohr_job_adapter.py create mode 100644 tests/test_bohr_sandbox_adapter.py delete mode 100644 tests/test_e2b_tools.py create mode 100644 tests/test_execution_grouping.py create mode 100644 tests/test_matcreator_phase_and_skills.py create mode 100644 tests/test_remote_job_provider_registry.py create mode 100644 tests/test_remote_job_tools.py diff --git a/docs/remote_job_monitoring.md b/docs/remote_job_monitoring.md index f96fce1d..b9a6df5b 100644 --- a/docs/remote_job_monitoring.md +++ b/docs/remote_job_monitoring.md @@ -1,19 +1,32 @@ # Remote Job Monitoring -MatCreator manages E2B sandboxes as durable, session-scoped remote jobs. The -remote-job control plane separates a sandbox's provider identity and liveness -from the agent step that created it, so the FastAPI frontend can observe and -control the sandbox after an agent, browser, or middleware request reconnects. +MatCreator manages sandboxes and batch jobs as durable, session-scoped remote +jobs. The remote-job control plane separates a job's provider identity and +liveness from the agent step that created it, so the FastAPI frontend can +observe and control it after an agent, browser, or middleware request +reconnects. + +Every provider-specific operation goes through a small adapter protocol (see +[Provider Plugin Architecture](#provider-plugin-architecture) below), so +`RemoteJobService`, `RemoteJobMonitor`, and the web API never branch on a +provider name. Built in providers today: `e2b` (interactive sandbox via the +E2B SDK), `bohr_sandbox` (interactive sandbox via the `bohr` CLI), and +`bohr_job` (batch/HPC-style job via `bohr job submit`). ## Architecture ```mermaid flowchart LR - Agent[Step executor] --> Tools[E2B tools] + Agent[Step executor] --> Tools[remote_job_tools] Tools --> Service[RemoteJobService] Service --> Store[(remote-jobs.db)] - Service --> Adapter[E2BSandboxAdapter] - Adapter --> Sandbox[E2B/Bohrium sandbox] + Service --> Registry[providers registry] + Registry --> E2B[E2BSandboxAdapter] + Registry --> BohrSbx[BohrSandboxAdapter] + Registry --> BohrJob[BohrJobAdapter] + E2B --> Sandbox[E2B/Bohrium sandbox] + BohrSbx --> Sandbox + BohrJob --> Batch[Bohrium batch job] Monitor[RemoteJobMonitor] --> Service Monitor --> Store @@ -23,7 +36,7 @@ flowchart LR ``` The SQLite record is the source of truth for MatCreator's normalized job -lifecycle. The provider sandbox remains the source of truth for provider +lifecycle. The provider job/sandbox remains the source of truth for provider liveness. This distinction lets the UI report both a meaningful lifecycle state and the latest connectivity observation without conflating them. @@ -32,28 +45,38 @@ and the latest connectivity observation without conflating them. | Component | Location | Responsibility | | --- | --- | --- | | `RemoteJobStore` | `src/matcreator/control_plane/remote_jobs.py` | Persists jobs, lifecycle transitions, provider snapshots, and user-control events in SQLite. | -| `RemoteJobService` | `src/matcreator/control_plane/remote_job_service.py` | Coordinates E2B operations with durable records and enforces valid lifecycle operations. | -| `E2BSandboxAdapter` | `src/matcreator/control_plane/e2b.py` | Small lazy-import boundary around the E2B SDK for sandbox creation, commands, files, pause, kill, and probing. | -| `RemoteJobMonitor` | `src/matcreator/control_plane/remote_job_monitor.py` | Periodically reconciles active E2B records and applies bounded backoff after failed probes. | -| Agent tools | `src/matcreator/agents/execution_agent/e2b_tools.py` | Submit and operate on jobs owned by the current session. | -| Middleware APIs | `web/main.py` | List jobs/events and offer session-owner pause, terminate, and refresh endpoints. | +| `RemoteJobService` | `src/matcreator/control_plane/remote_job_service.py` | Coordinates provider operations with durable records and enforces valid lifecycle operations, dispatching to the adapter registered for each job's `provider`. | +| `RemoteJobAdapter` protocol | `src/matcreator/control_plane/providers/base.py` | The boundary every provider implements: `create`/`status`/`cancel` are mandatory; `pause`/`resume`/`run_command`/`upload_file`/`download_file`/`collect_outputs` are gated by declared `RemoteJobCapability` flags. | +| Provider registry | `src/matcreator/control_plane/providers/registry.py` | Maps a provider name to a lazily constructed adapter instance. | +| `E2BSandboxAdapter` | `src/matcreator/control_plane/providers/e2b.py` | Interactive sandbox via the E2B SDK: create, commands, files, pause, kill, probe. | +| `BohrSandboxAdapter` | `src/matcreator/control_plane/providers/bohr_sandbox.py` | Interactive sandbox via the `bohr` CLI (`bohr sandbox create/exec/files/describe/delete`). No pause/resume — the CLI has no such subcommand. | +| `BohrJobAdapter` | `src/matcreator/control_plane/providers/bohr_job.py` | Batch/HPC-style job via the `bohr` CLI (`bohr job submit/describe/download/terminate`). Submit-time inputs only; no interactive exec. | +| `RemoteJobMonitor` | `src/matcreator/control_plane/remote_job_monitor.py` | Periodically reconciles active jobs of every registered provider, using each adapter's own `poll_interval_seconds` for backoff scheduling. | +| Agent tools | `src/matcreator/agents/execution_agent/remote_job_tools.py` | Provider-specific submit tools (`submit_e2b_sandbox`, `submit_bohr_sandbox`, `submit_bohr_job`) plus provider-generic post-submission tools that dispatch on `job_id` alone. | +| Middleware APIs | `web/main.py` | List jobs/events and offer session-owner pause, terminate, and refresh endpoints, generic across providers. | ## Submission and Persistence -`submit_e2b_sandbox` requires an explicit template. It creates a deterministic -idempotency key from the session, execution node, and template, then delegates -to `RemoteJobService.submit_e2b`. +Submission is provider-specific — an interactive sandbox needs a template +while a batch job needs a machine type and image — so there is one submit +tool per provider: `submit_e2b_sandbox`, `submit_bohr_sandbox`, +`submit_bohr_job`. Each builds a deterministic idempotency key from the +session, execution node, and a provider-specific discriminator, then +delegates to `RemoteJobService.submit_job(provider=..., spec=...)`. The service creates the SQLite job record before making the provider request. -The persisted specification contains the template, endpoint, project ID, -timeout, lifecycle policy, and metadata, but never the API key. Repeated calls -with the same idempotency key return the existing job instead of creating a -second sandbox. - -Once sandbox creation succeeds, the service stores the provider sandbox ID in -`external_id` and transitions the job to `running`. Agent recovery records the -job reference against the execution graph so an interrupted execution can wait -for or accurately report an existing sandbox rather than resubmitting it. +`persisted_specification` — everything in `spec` except secrets like an API +key — is what actually gets stored; `spec` itself (which may contain +secrets) is passed to the adapter's `create` but never persisted. Repeated +calls with the same idempotency key return the existing job instead of +creating a second sandbox or job. + +Once creation succeeds, the service stores the provider-side ID in +`external_id`, probes the adapter once for an initial status (letting a batch +provider start in `queued` instead of always assuming `running`), and +transitions the job accordingly. Agent recovery records the job reference +against the execution graph so an interrupted execution can wait for or +accurately report an existing job rather than resubmitting it. ## Lifecycle and Observations @@ -62,7 +85,8 @@ Important normalized states include: - `created`, `submitting`, `queued`, `running`, `paused`, and `resuming` for active work. -- `succeeded` and `collecting` while a job's results are being handled. +- `succeeded` and `collecting` while a batch job's results are being pulled + via `collect_remote_job_outputs`. - `collected`, `failed`, `cancelled`, `terminated`, and `lost` as terminal outcomes. @@ -71,16 +95,27 @@ transitions use optimistic concurrency checks, so stale pause, terminate, or provider updates cannot silently overwrite newer state. Provider probe data is stored in `snapshot`; examples include -`provider_status`, `sandbox_id`, `last_command_exit_code`, and `last_upload`. -An observation does not itself alter the normalized lifecycle state. +`provider_status`, `sandbox_id`, `phase` (for a batch job), `last_command_exit_code`, +and `last_upload`. An observation does not itself alter the normalized +lifecycle state unless the adapter reports a `normalized_status` that differs +from the current one — see [Provider Plugin Architecture](#provider-plugin-architecture). ## Monitoring and Refresh -`RemoteJobMonitor` considers active E2B jobs and probes jobs in `queued`, -`running`, `submitting`, or `resuming` states. A successful probe records a -reachable provider snapshot. A failed probe records `provider_status` as -`unreachable` and increases the next probe delay exponentially, bounded by the -configured maximum backoff. +`RemoteJobMonitor` considers active jobs of every registered provider and +probes jobs in `queued`, `running`, `submitting`, or `resuming` states, using +each job's own adapter to decide how — and how often — to probe. A batch +provider like `bohr_job` declares a much longer `poll_interval_seconds` (60s) +than an interactive sandbox (15s), so it is polled far less often without any +special-casing in the monitor itself. + +For an interactive adapter (`e2b`, `bohr_sandbox`) a successful probe records +a reachable provider snapshot; a failed probe records `provider_status` as +`unreachable` and increases the next probe delay exponentially, bounded by +the configured maximum backoff. For a batch adapter (`bohr_job`) the same +probe can report a `normalized_status` change (e.g. `queued` -> `running` -> +`succeeded`/`failed`/`cancelled`), which the service turns into an actual +lifecycle transition instead of just an observation. Monitor schedules are intentionally in memory. The job records themselves are durable, so a restarted monitor begins by reconciling active jobs from SQLite. @@ -129,11 +164,11 @@ mechanism rather than two. Re-attachment is explicit rather than accidental. When a node that already owns a job runs again, the runner injects the job's identity into the executor's -`prior_context` with instructions to call `get_e2b_job_status` and never call -`submit_e2b_sandbox` for that step. In Flash mode, which has no execution graph, -a step's node ID is derived from its label or a hash of its action, so a repeated -step keeps the same submission idempotency key and re-attaches instead of -creating a duplicate sandbox. +`prior_context` with instructions to call `get_remote_job_status` and never +call any of the `submit_*` tools for that step. In Flash mode, which has no +execution graph, a step's node ID is derived from its label or a hash of its +action, so a repeated step keeps the same submission idempotency key and +re-attaches instead of creating a duplicate job. ## Controls and Ownership @@ -147,11 +182,13 @@ POST /api/sessions/{session_id}/remote-jobs/{job_id}/terminate Both invoke the provider operation through `RemoteJobService`, update the durable job lifecycle, and append a `user_control` event. They do not cancel the step-executor process. The executor sees this event through -`get_e2b_job_status` and must report `needs_replanning` rather than retrying an -interrupted command or submitting a replacement sandbox. +`get_remote_job_status` and must report `needs_replanning` rather than +retrying an interrupted command or submitting a replacement job. `pause` +returns a 409 (via `CapabilityError`) for a provider that does not support +pausing, such as `bohr_job`. -`terminate_e2b_sandbox` irreversibly releases a sandbox. Agents should collect -or record required output before calling it. +`terminate_remote_job` irreversibly releases a job or sandbox. Agents should +collect or record required output before calling it. ## Storage Scope @@ -160,12 +197,72 @@ the middleware routes each owner to a per-user `.adk/remote-jobs.db` under the user's mounted MatCreator home. This keeps job records, controls, and monitoring isolated by owner and session. +## Provider Plugin Architecture + +Adding a new remote-job provider (a different HPC scheduler, another +sandbox platform, ...) means implementing `RemoteJobAdapter` and registering +it — nothing else in the control plane changes. + +1. **Implement the adapter** (`src/matcreator/control_plane/providers/.py`): + subclass `RemoteJobAdapter` from `providers/base.py` and implement the + three mandatory methods (`create`, `status`, `cancel`). Declare + `provider`, `capabilities` (a `frozenset[RemoteJobCapability]`), and + `poll_interval_seconds` as class attributes. Implement only the optional + methods your capabilities declare: + + | Capability | Optional method(s) | Example provider | + | --- | --- | --- | + | `PAUSE` / `RESUME` | `pause` / `resume` | `e2b` (pause only) | + | `INTERACTIVE_EXEC` | `run_command` | `e2b`, `bohr_sandbox` | + | `FILE_TRANSFER` | `upload_file` / `download_file` | `e2b`, `bohr_sandbox` | + | `BATCH_COLLECT` | `collect_outputs` | `bohr_job` | + + `status` returns a `RemoteJobStatus(normalized_status, snapshot, error)`. + Use `normalized_status=None` when the provider can only confirm liveness + (an interactive sandbox that stays "running" until explicitly stopped); + return one of the canonical statuses from `remote_jobs.py` (e.g. + `"succeeded"`, `"failed"`, `"cancelled"`) when the provider can report an + actual lifecycle observation (a batch job that finishes on its own). + +2. **Register it** in `src/matcreator/control_plane/providers/__init__.py` + with a lazy factory: + ```python + register_adapter("my_provider", lambda: MyProviderAdapter()) + ``` + The factory is not called until the first `get_adapter("my_provider")`, so + registering a provider never forces an optional SDK/CLI import at process + startup. + +3. **(Optional) add a submit tool** in + `src/matcreator/agents/execution_agent/remote_job_tools.py` if the agent + should be able to submit this provider's jobs — submission parameters are + inherently provider-specific (a template vs. a machine type + image), so + this is the one place a new provider needs new code beyond the adapter + itself. Every operation *after* submission + (`get_remote_job_status`/`pause_remote_job`/`terminate_remote_job`/ + `run_remote_job_command`/`upload_remote_job_input`/ + `download_remote_job_output`/`collect_remote_job_outputs`) already works + for any provider without changes, dispatching on the stored `job_id` alone. + +`RemoteJobService` and `RemoteJobMonitor` never import a specific adapter or +branch on a provider name — they resolve the adapter for a job through the +registry (`RemoteJobService.adapter_for`) and check `adapter.capabilities` +before calling an optional method, raising `CapabilityError` with a clear, +provider-attributed message if unsupported (e.g. pausing a `bohr_job`). + ## Operational Notes -- The control plane currently supports E2B sandboxes, although the persistent - store is provider-neutral by design. +- Built-in providers: `e2b` (interactive, via the E2B SDK), `bohr_sandbox` + (interactive, via the `bohr` CLI), and `bohr_job` (batch/HPC-style, via the + `bohr` CLI). The persistent store and service are provider-neutral by + design; see [Provider Plugin Architecture](#provider-plugin-architecture) + to add another. - Commands do not persist command text or output in the remote-job database; only limited operational telemetry is recorded. - A sandbox's configured creation timeout is distinct from the monitoring - interval. The adapter currently passes `timeout=0` to E2B command execution, - leaving command duration unrestricted by this control plane. + interval. The E2B adapter currently passes `timeout=0` to command + execution, leaving command duration unrestricted by this control plane. +- `bohr_job` only supports single-job submission (`bohr job submit`); `bohr + job_group` fan-out (many jobs sharing one group) is a possible future + adapter, not implemented here. + diff --git a/pyproject.toml b/pyproject.toml index a70e5f82..eb6b57be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "phonopy>=2.38.0", "seekpath>=2.1.0", "rdkit>=2025.9.1", - "google-adk>=1.28.0", + "google-adk>=2.0.0", "google-cloud-storage", "lbg>=1.2.29", "litellm>=1.77.4", @@ -29,6 +29,7 @@ dependencies = [ "mp-api", "bcrypt>=4.0.0", "docker>=7.0.0", + "e2b<=2.20.0", "e2b-code-interpreter", "matcraft-kit", "know-do-graph>=0.1.8", diff --git a/src/matcreator/agents/execution_agent/e2b_tools.py b/src/matcreator/agents/execution_agent/e2b_tools.py deleted file mode 100644 index 79fea5de..00000000 --- a/src/matcreator/agents/execution_agent/e2b_tools.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Tracked E2B sandbox tools available to isolated step executors.""" -from __future__ import annotations - -import hashlib -import os -from pathlib import Path -from typing import Any - -from google.adk.tools.tool_context import ToolContext - -from ...control_plane.remote_job_service import E2BConnectionConfig, RemoteJobService -from ...control_plane.remote_jobs import RemoteJobStore -from ...workspace import ADK_DIR -from .recovery import record_remote_job_reference - - -def _service() -> RemoteJobService: - return RemoteJobService(RemoteJobStore(ADK_DIR / "remote-jobs.db")) - - -def _owner_id(tool_context: ToolContext) -> str: - invocation = getattr(tool_context, "_invocation_context", None) - return str(getattr(invocation, "user_id", "") or tool_context.state.get("user_id") or "default") - - -def _node_id(tool_context: ToolContext) -> str: - graph_node = str(tool_context.state.get("_graph_exec_node_id") or "step") - return graph_node.rsplit("__node_", 1)[-1] - - -def _connection() -> E2BConnectionConfig: - # Bohrium E2B endpoint uses bare hex keys; disable SDK format validation - os.environ.setdefault("E2B_VALIDATE_API_KEY", "false") - return E2BConnectionConfig( - api_key=os.environ.get("E2B_API_KEY", ""), - api_url=os.environ.get("E2B_API_URL", ""), - project_id=os.environ.get("BOHRIUM_PROJECT_ID", ""), - template="", - ) - - -def submit_e2b_sandbox( - tool_context: ToolContext, - *, - timeout: int = 7200, - template: str = None, - lifecycle: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Create or reuse a tracked E2B sandbox for the current execution step. - - The configured E2B API key, endpoint, and project ID are used server-side. - Never use shell commands or include credentials in tool inputs. A repeated - call for the same step and template returns the existing sandbox record. - """ - session_id = str(tool_context.state.get("session_id") or "") - if not session_id: - return {"status": "error", "message": "No session_id is available for E2B submission."} - node_id = _node_id(tool_context) - connection = _connection() - if not template: - return { - "status": "error", - "message": "An explicit E2B sandbox template is required. Use 'lbg sdbx template ls -q' to list available templates.", - } - connection = E2BConnectionConfig( - api_key=connection.api_key, - api_url=connection.api_url, - project_id=connection.project_id, - template=template, - ) - identity = f"{session_id}:{node_id}:{connection.template}" - idempotency_key = f"e2b:{hashlib.sha256(identity.encode()).hexdigest()}" - try: - job = _service().submit_e2b( - owner_id=_owner_id(tool_context), - session_id=session_id, - node_id=node_id, - step_number=tool_context.state.get("step_number"), - idempotency_key=idempotency_key, - connection=connection, - timeout=timeout, - lifecycle=lifecycle or {"on_timeout": "pause", "auto_resume": True}, - ) - except Exception as exc: - return {"status": "error", "message": f"E2B submission failed: {exc}"} - record_remote_job_reference( - session_id=session_id, - node_id=node_id, - job_id=job["job_id"], - provider="e2b", - external_id=job["external_id"], - ) - return { - "status": job["status"], - "job_id": job["job_id"], - "sandbox_id": job["external_id"], - "message": "Tracked E2B sandbox is ready. Use its job_id for status or controls.", - } - - -def get_e2b_job_status(job_id: str, tool_context: ToolContext) -> dict[str, Any]: - """Read one tracked E2B job owned by the current session.""" - service = _service() - job = service.store.get_job(job_id) - if job is None or job["owner_id"] != _owner_id(tool_context) or job["session_id"] != tool_context.state.get("session_id"): - return {"status": "error", "message": "E2B job was not found in this session."} - result = {key: job[key] for key in ("job_id", "status", "external_id", "snapshot", "error", "updated_at")} - controls = [ - event["payload"] - for event in service.store.list_events(job_id) - if event["event_type"] == "user_control" - ] - if controls: - result["user_control"] = controls[-1] - return result - - -def pause_e2b_sandbox(job_id: str, tool_context: ToolContext) -> dict[str, Any]: - """Pause a tracked E2B sandbox belonging to the current session.""" - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - try: - paused = _service().pause_e2b(job_id) - except Exception as exc: - return {"status": "error", "message": f"E2B pause failed: {exc}"} - return {"job_id": paused["job_id"], "status": paused["status"], "sandbox_id": paused["external_id"]} - - -def terminate_e2b_sandbox(job_id: str, tool_context: ToolContext) -> dict[str, Any]: - """Terminate a tracked E2B sandbox belonging to the current session.""" - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - try: - terminated = _service().terminate_e2b(job_id) - except Exception as exc: - return {"status": "error", "message": f"E2B termination failed: {exc}"} - return {"job_id": terminated["job_id"], "status": terminated["status"], "sandbox_id": terminated["external_id"]} - - -def run_e2b_command( - job_id: str, - command: str, - tool_context: ToolContext, - user: str = "root", -) -> dict[str, Any]: - """Run one command inside a tracked E2B sandbox in the current session. - - Do not put credentials in ``command``. Command text and output are returned - to the current step but are not persisted in the durable job snapshot. - """ - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - try: - return _service().run_e2b_command(job_id, command, user=user) - except Exception as exc: - current = get_e2b_job_status(job_id, tool_context) - result = {"status": "error", "message": f"E2B command failed: {exc}"} - if current.get("user_control"): - result["user_control"] = current["user_control"] - return result - - -def _resolve_workspace_child( - tool_context: ToolContext, - user_path: str, -) -> tuple[Path | None, str | None]: - """Resolve ``user_path`` against the current workspace, confining it. - - Returns ``(resolved_path, None)`` on success or ``(None, message)`` if the - workspace is unavailable or the path escapes it. Shared by upload (source) - and download (destination) so confinement logic cannot drift between them. - """ - workspace_dir = tool_context.state.get("workspace_dir") - if not workspace_dir: - return None, "No workspace_dir is available for the current step." - workspace = Path(str(workspace_dir)).resolve() - candidate = Path(user_path).expanduser() - candidate = candidate.resolve() if candidate.is_absolute() else (workspace / candidate).resolve() - if not candidate.is_relative_to(workspace): - return None, "Path must resolve inside the current workspace." - return candidate, None - - -def upload_e2b_input( - job_id: str, - source_path: str, - destination_path: str, - tool_context: ToolContext, -) -> dict[str, Any]: - """Upload a workspace input file into a tracked E2B sandbox. - - ``source_path`` must resolve inside the current workspace. Use an absolute - sandbox path for ``destination_path`` such as ``/home/user/input.in``. - """ - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - source, error = _resolve_workspace_child(tool_context, source_path) - if error is not None: - return {"status": "error", "message": f"E2B upload failed: {error}"} - try: - return _service().upload_e2b_file(job_id, source, destination_path) - except Exception as exc: - return {"status": "error", "message": f"E2B upload failed: {exc}"} - - -def download_e2b_output( - job_id: str, - source_path: str, - destination_path: str, - tool_context: ToolContext, -) -> dict[str, Any]: - """Download a file from a tracked E2B sandbox into the local workspace. - - ``source_path`` is an absolute path inside the sandbox (e.g. - ``/home/user/CHGCAR``). ``destination_path`` must resolve inside the - current workspace. Large binary outputs are streamed via the E2B - filesystem API, so they are not truncated by command-output limits. - """ - job = get_e2b_job_status(job_id, tool_context) - if job.get("status") == "error": - return job - destination, error = _resolve_workspace_child(tool_context, destination_path) - if error is not None: - return {"status": "error", "message": f"E2B download failed: {error}"} - try: - return _service().download_e2b_file(job_id, source_path, destination) - except Exception as exc: - return {"status": "error", "message": f"E2B download failed: {exc}"} \ No newline at end of file diff --git a/src/matcreator/agents/execution_agent/recovery.py b/src/matcreator/agents/execution_agent/recovery.py index 5329b83a..1cab4242 100644 --- a/src/matcreator/agents/execution_agent/recovery.py +++ b/src/matcreator/agents/execution_agent/recovery.py @@ -286,11 +286,16 @@ def _active_remote_job( def remote_job_reference(job: dict[str, Any]) -> dict[str, Any]: """Return the identity subset of a remote job stored on a graph node.""" + snapshot = job.get("snapshot") if isinstance(job.get("snapshot"), dict) else {} return { "job_id": job["job_id"], "provider": job["provider"], "external_id": job["external_id"], "status": job["status"], + # Surfaced so re-attach instructions can tell a fresh executor to poll + # an in-flight background command instead of guessing whether one is + # running or safely re-issuing it (see start_job_command/poll_job_command). + "has_background_command": bool(snapshot.get("background_command")), } diff --git a/src/matcreator/agents/execution_agent/remote_job_tools.py b/src/matcreator/agents/execution_agent/remote_job_tools.py new file mode 100644 index 00000000..06d80a5f --- /dev/null +++ b/src/matcreator/agents/execution_agent/remote_job_tools.py @@ -0,0 +1,545 @@ +"""Remote-job tools available to isolated step executors. + +Submission is provider-specific — an E2B/bohr sandbox needs a template while +a batch job needs a machine type and image, so there is one submit tool per +provider (``submit_e2b_sandbox``, ``submit_bohr_sandbox``, +``submit_bohr_job``). Every operation after submission dispatches on the +``job_id`` alone and works the same for any provider, so adding a new +provider plugin never requires a new post-submission tool here. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any + +from google.adk.tools.tool_context import ToolContext + +from ...control_plane.providers.e2b import E2BConnectionConfig +from ...control_plane.remote_job_service import RemoteJobService +from ...control_plane.remote_jobs import TERMINAL_REMOTE_JOB_STATUSES, RemoteJobStore +from ...workspace import ADK_DIR +from .recovery import record_remote_job_reference + +# Every terminal status except "collected" (the successful end of a batch +# job) means the submission is not usable and must not be reported as ready. +_FAILED_SUBMISSION_STATUSES = TERMINAL_REMOTE_JOB_STATUSES - {"collected"} + + +def _service() -> RemoteJobService: + return RemoteJobService(RemoteJobStore(ADK_DIR / "remote-jobs.db")) + + +def _owner_id(tool_context: ToolContext) -> str: + invocation = getattr(tool_context, "_invocation_context", None) + return str(getattr(invocation, "user_id", "") or tool_context.state.get("user_id") or "default") + + +def _node_id(tool_context: ToolContext) -> str: + graph_node = str(tool_context.state.get("_graph_exec_node_id") or "step") + return graph_node.rsplit("__node_", 1)[-1] + + +def _idempotency_key(session_id: str, node_id: str, discriminator: str) -> str: + identity = f"{session_id}:{node_id}:{discriminator}" + return f"remote-job:{hashlib.sha256(identity.encode()).hexdigest()}" + + +def _submit( + tool_context: ToolContext, + *, + provider: str, + spec: dict[str, Any], + discriminator: str, + persisted_specification: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Shared submission plumbing used by every provider-specific submit tool.""" + session_id = str(tool_context.state.get("session_id") or "") + if not session_id: + return {"status": "error", "message": "No session_id is available for remote-job submission."} + node_id = _node_id(tool_context) + idempotency_key = _idempotency_key(session_id, node_id, discriminator) + try: + job = _service().submit_job( + owner_id=_owner_id(tool_context), + session_id=session_id, + provider=provider, + node_id=node_id, + step_number=tool_context.state.get("step_number"), + idempotency_key=idempotency_key, + spec=spec, + persisted_specification=persisted_specification, + ) + except Exception as exc: + return {"status": "error", "message": f"{provider} submission failed: {exc}"} + record_remote_job_reference( + session_id=session_id, + node_id=node_id, + job_id=job["job_id"], + provider=provider, + external_id=job["external_id"], + ) + return { + "status": job["status"], + "job_id": job["job_id"], + "external_id": job["external_id"], + "error": job.get("error"), + } + + +def _submission_response(result: dict[str, Any], *, id_field: str, success_message: str) -> dict[str, Any]: + """Convert a ``_submit`` result into the tool response, never claiming a + + failed/cancelled/terminated/lost job is ready. The durable record's + ``error`` is surfaced so the caller sees the actual cause. + """ + if result.get("status") == "error": + return result + response = { + "status": result["status"], + "job_id": result["job_id"], + id_field: result["external_id"], + } + if result["status"] in _FAILED_SUBMISSION_STATUSES: + cause = result.get("error") or f"the tracked job is in terminal status '{result['status']}'" + response["message"] = f"Remote job submission is not usable: {cause}" + return response + response["message"] = success_message + return response + + +def _connection() -> E2BConnectionConfig: + # Bohrium E2B endpoint uses bare hex keys; disable SDK format validation + os.environ.setdefault("E2B_VALIDATE_API_KEY", "false") + return E2BConnectionConfig( + api_key=os.environ.get("E2B_API_KEY", ""), + api_url=os.environ.get("E2B_API_URL", ""), + project_id=os.environ.get("BOHRIUM_PROJECT_ID", ""), + template="", + ) + + +def submit_e2b_sandbox( + tool_context: ToolContext, + *, + timeout: int = 7200, + template: str = None, + lifecycle: dict[str, Any] | str | None = None, +) -> dict[str, Any]: + """Create or reuse a tracked E2B sandbox for the current execution step. + + The configured E2B API key, endpoint, and project ID are used server-side. + Never use shell commands or include credentials in tool inputs. A repeated + call for the same step and template returns the existing sandbox record. + """ + session_id = str(tool_context.state.get("session_id") or "") + if not session_id: + return {"status": "error", "message": "No session_id is available for E2B submission."} + if not template: + return { + "status": "error", + "message": "An explicit E2B sandbox template is required. Use 'lbg sdbx template ls -q' to list available templates.", + } + if isinstance(lifecycle, str): + try: + lifecycle = json.loads(lifecycle) + except json.JSONDecodeError: + pass # still a str; rejected below + if lifecycle is not None and not isinstance(lifecycle, dict): + return { + "status": "error", + "message": "lifecycle must be a JSON object such as {\"on_timeout\": \"pause\", \"auto_resume\": true}.", + } + connection = _connection() + missing_config = [ + name + for name, value in ( + ("E2B_API_KEY", connection.api_key), + ("E2B_API_URL", connection.api_url), + ("BOHRIUM_PROJECT_ID", connection.project_id), + ) + if not value + ] + if missing_config: + return { + "status": "error", + "message": ( + f"E2B is not configured on the server: {', '.join(missing_config)} unset. " + "If only the `bohr` CLI is available, use submit_bohr_sandbox instead." + ), + } + connection = E2BConnectionConfig( + api_key=connection.api_key, + api_url=connection.api_url, + project_id=connection.project_id, + template=template, + ) + spec = connection.to_spec_dict(timeout=timeout, lifecycle=lifecycle or {"on_timeout": "pause", "auto_resume": True}) + persisted_specification = {key: value for key, value in spec.items() if key != "api_key"} + result = _submit( + tool_context, + provider="e2b", + spec=spec, + discriminator=template, + persisted_specification=persisted_specification, + ) + return _submission_response( + result, + id_field="sandbox_id", + success_message="Tracked E2B sandbox is ready. Use its job_id for status or controls.", + ) + + +def submit_bohr_sandbox( + tool_context: ToolContext, + *, + project_id: int = None, + template: str = None, + timeout: int = None, + image: str = None, + gpu: str = None, + never_timeout: bool = False, + env: dict[str, str] | None = None, +) -> dict[str, Any]: + """Create or reuse a tracked Bohrium CLI sandbox (`bohr sandbox`) for the current step. + + Both this and `submit_e2b_sandbox` reach the same Bohrium sandbox + platform; use this one when only the `bohr` CLI (not the E2B SDK/API key) + is available in the current environment. An explicit ``template`` is + required (e.g. ``doc-compiler``). ``gpu`` selects a GPU shortcut template + (``4090``/``5090``/``l20``). Falls back to the `BOHRIUM_PROJECT_ID` + environment variable if ``project_id`` is omitted. + """ + resolved_project_id = project_id or os.environ.get("BOHRIUM_PROJECT_ID", "") + if not resolved_project_id: + return {"status": "error", "message": "An explicit project_id is required for a bohr sandbox."} + if not template: + return { + "status": "error", + "message": ( + "An explicit sandbox template is required (e.g. 'doc-compiler'). " + "Use 'bohr sandbox template list' to see available templates." + ), + } + spec = { + "project_id": resolved_project_id, + "template": template, + "timeout": timeout, + "image": image, + "gpu": gpu, + "never_timeout": never_timeout, + "env": env or {}, + } + result = _submit( + tool_context, + provider="bohr_sandbox", + spec=spec, + discriminator=template, + ) + return _submission_response( + result, + id_field="sandbox_id", + success_message="Tracked bohr sandbox is ready. Use its job_id for status or controls.", + ) + + +def submit_bohr_job( + tool_context: ToolContext, + *, + project_id: int = None, + job_name: str = None, + machine_type: str = None, + image_address: str = None, + command: str = None, + input_directory: str | None = None, + result_path: str | None = None, + max_run_time: int | None = None, +) -> dict[str, Any]: + """Submit a batch/HPC-style Bohrium job (`bohr job submit`) for the current step. + + This is a fire-and-forget batch submission, not an interactive sandbox: + inputs are staged once via ``input_directory`` and there is no + `run_remote_job_command` for this provider — the whole computation must + be expressed in ``command``. Poll `get_remote_job_status` until it + reports ``succeeded``, then call `collect_remote_job_outputs`. + """ + resolved_project_id = project_id or os.environ.get("BOHRIUM_PROJECT_ID", "") + missing = [ + name + for name, value in ( + ("project_id", resolved_project_id), + ("job_name", job_name), + ("machine_type", machine_type), + ("image_address", image_address), + ("command", command), + ) + if not value + ] + if missing: + return { + "status": "error", + "message": f"Missing required field(s) for bohr job submission: {', '.join(missing)}", + } + spec = { + "project_id": resolved_project_id, + "job_name": job_name, + "machine_type": machine_type, + "image_address": image_address, + "command": command, + "input_directory": input_directory, + "result_path": result_path, + "max_run_time": max_run_time, + } + result = _submit( + tool_context, + provider="bohr_job", + spec=spec, + discriminator=f"{job_name}:{machine_type}:{image_address}", + ) + return _submission_response( + result, + id_field="bohr_job_id", + success_message=( + "Tracked bohr batch job is submitted. Poll get_remote_job_status until it " + "reports succeeded, then call collect_remote_job_outputs." + ), + ) + + +def get_remote_job_status(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Read one tracked remote job (any provider) owned by the current session.""" + service = _service() + job = service.store.get_job(job_id) + if ( + job is None + or job["owner_id"] != _owner_id(tool_context) + or job["session_id"] != tool_context.state.get("session_id") + ): + return {"status": "error", "message": "Remote job was not found in this session."} + result = { + key: job[key] for key in ("job_id", "provider", "status", "external_id", "snapshot", "error", "updated_at") + } + controls = [ + event["payload"] for event in service.store.list_events(job_id) if event["event_type"] == "user_control" + ] + if controls: + result["user_control"] = controls[-1] + return result + + +def pause_remote_job(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Pause a tracked remote job belonging to the current session. + + Returns an error if the job's provider does not support pausing (e.g. a + batch job); terminate it instead if it must stop. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + paused = _service().pause_job(job_id) + except Exception as exc: + return {"status": "error", "message": f"Pause failed: {exc}"} + return {"job_id": paused["job_id"], "status": paused["status"], "external_id": paused["external_id"]} + + +def terminate_remote_job(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Terminate a tracked remote job belonging to the current session.""" + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + terminated = _service().terminate_job(job_id) + except Exception as exc: + return {"status": "error", "message": f"Termination failed: {exc}"} + return {"job_id": terminated["job_id"], "status": terminated["status"], "external_id": terminated["external_id"]} + + +def run_remote_job_command( + job_id: str, + command: str, + tool_context: ToolContext, + user: str = "root", +) -> dict[str, Any]: + """Run one short command inside a tracked interactive remote job (e.g. a sandbox). + + This BLOCKS until the command finishes, with no timeout of its own. Only + use it for commands expected to finish in well under a minute (checking a + file, `mkdir`, `grep`, listing a directory, ...). For anything that might + run longer — a training run, a `vasp_std`/`mpirun` invocation, any real + computation — use `start_remote_job_command` + `poll_remote_job_command` + instead: those never block longer than one quick status check and the + command survives this process restarting or losing connection, unlike a + long blocking call here which has no way to recover if interrupted. + + Do not put credentials in ``command``. Command text and output are + returned to the current step but are not persisted in the durable job + snapshot. Not every provider supports this — a batch job (e.g. + `bohr_job`) returns an error explaining that its whole command must run + at submission time instead. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + return _service().run_job_command(job_id, command, user=user) + except Exception as exc: + current = get_remote_job_status(job_id, tool_context) + result = {"status": "error", "message": f"Remote command failed: {exc}"} + if current.get("user_control"): + result["user_control"] = current["user_control"] + return result + + +def start_remote_job_command( + job_id: str, + command: str, + tool_context: ToolContext, + user: str = "root", +) -> dict[str, Any]: + """Launch a long-running command inside a tracked interactive remote job WITHOUT blocking. + + Use this instead of `run_remote_job_command` for any real computation + (training, `vasp_std`/`mpirun`, anything that might take more than a + minute). Returns almost immediately once the command is launched in the + background; call `poll_remote_job_command` with the same `job_id` + afterward — repeatedly, across as many separate tool calls or even + separate step-executor attempts as needed — to check whether it has + finished. The command's progress is tracked durably on the job itself, so + re-attaching to this `job_id` after a step timeout, a crash, or a lost + connection always finds the same in-flight command rather than losing + track of it or risking a duplicate run. + + There is at most one in-flight background command per job; starting a + new one before polling the previous one to completion overwrites the + previous command's tracked handle. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + return _service().start_job_command(job_id, command, user=user) + except Exception as exc: + return {"status": "error", "message": f"Failed to start remote command: {exc}"} + + +def poll_remote_job_command(job_id: str, tool_context: ToolContext) -> dict[str, Any]: + """Check on the job's most recently started background command. + + Returns `{"running": true, ...}` if it is still executing — call this + again later (e.g. after doing other work, or in a fresh step-executor + attempt after re-attaching via `get_remote_job_status`) rather than + waiting in a tight loop. Once finished, returns `{"running": false, + "exit_code": ..., "output_tail": ...}`; `output_tail` is only the last + portion of combined stdout/stderr — for the full output of a long run, + use `download_remote_job_output` on the returned `log_path`. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + try: + return _service().poll_job_command(job_id) + except Exception as exc: + return {"status": "error", "message": f"Failed to poll remote command: {exc}"} + + +def _resolve_workspace_child( + tool_context: ToolContext, + user_path: str, +) -> tuple[Path | None, str | None]: + """Resolve ``user_path`` against the current workspace, confining it. + + Returns ``(resolved_path, None)`` on success or ``(None, message)`` if the + workspace is unavailable or the path escapes it. Shared by upload (source) + and download (destination) so confinement logic cannot drift between them. + """ + workspace_dir = tool_context.state.get("workspace_dir") + if not workspace_dir: + return None, "No workspace_dir is available for the current step." + workspace = Path(str(workspace_dir)).resolve() + candidate = Path(user_path).expanduser() + candidate = candidate.resolve() if candidate.is_absolute() else (workspace / candidate).resolve() + if not candidate.is_relative_to(workspace): + return None, "Path must resolve inside the current workspace." + return candidate, None + + +def upload_remote_job_input( + job_id: str, + source_path: str, + destination_path: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Upload a workspace input file into a tracked interactive remote job. + + ``source_path`` must resolve inside the current workspace. Use an + absolute remote path for ``destination_path`` such as + ``/home/user/input.in``. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + source, error = _resolve_workspace_child(tool_context, source_path) + if error is not None: + return {"status": "error", "message": f"Upload failed: {error}"} + try: + return _service().upload_job_file(job_id, source, destination_path) + except Exception as exc: + return {"status": "error", "message": f"Upload failed: {exc}"} + + +def download_remote_job_output( + job_id: str, + source_path: str, + destination_path: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Download a file from a tracked interactive remote job into the local workspace. + + ``source_path`` is an absolute path on the remote side (e.g. + ``/home/user/CHGCAR``). ``destination_path`` must resolve inside the + current workspace. For a batch job (e.g. `bohr_job`), use + `collect_remote_job_outputs` instead once the job has succeeded. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + destination, error = _resolve_workspace_child(tool_context, destination_path) + if error is not None: + return {"status": "error", "message": f"Download failed: {error}"} + try: + return _service().download_job_file(job_id, source_path, destination) + except Exception as exc: + return {"status": "error", "message": f"Download failed: {exc}"} + + +def collect_remote_job_outputs( + job_id: str, + destination_path: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Pull a finished batch job's declared output files into the local workspace. + + Only valid once `get_remote_job_status` reports ``status: succeeded``. + ``destination_path`` must resolve inside the current workspace as a + directory. A repeated call after outputs are already collected is a + durable no-op that returns the same artifact list rather than + downloading twice. + """ + job = get_remote_job_status(job_id, tool_context) + if job.get("status") == "error": + return job + destination, error = _resolve_workspace_child(tool_context, destination_path) + if error is not None: + return {"status": "error", "message": f"Output collection failed: {error}"} + try: + collected = _service().collect_job_outputs(job_id, destination) + except Exception as exc: + return {"status": "error", "message": f"Output collection failed: {exc}"} + return { + "job_id": collected["job_id"], + "status": collected["status"], + "artifacts": collected.get("artifacts", []), + } diff --git a/src/matcreator/agents/execution_agent/step_executor.py b/src/matcreator/agents/execution_agent/step_executor.py index 2e822f6c..a4c67a30 100644 --- a/src/matcreator/agents/execution_agent/step_executor.py +++ b/src/matcreator/agents/execution_agent/step_executor.py @@ -18,14 +18,19 @@ from ...tools.remoteagent_tool import load_remote_a2a_agents from ...tools.util_tools import show_artifact, show_plot, show_structure from ...tools.workspace_tools import get_user_skills_root, run_bash, run_python -from .e2b_tools import ( - download_e2b_output, - get_e2b_job_status, - pause_e2b_sandbox, - run_e2b_command, +from .remote_job_tools import ( + collect_remote_job_outputs, + download_remote_job_output, + get_remote_job_status, + pause_remote_job, + poll_remote_job_command, + run_remote_job_command, + start_remote_job_command, + submit_bohr_job, + submit_bohr_sandbox, submit_e2b_sandbox, - terminate_e2b_sandbox, - upload_e2b_input, + terminate_remote_job, + upload_remote_job_input, ) logger = logging.getLogger(__name__) @@ -135,20 +140,48 @@ def _fill_missing_fields(self) -> "StepExecutorResult": 3. **If submission.json exists but outputs are missing**, reuse the same submission file (dpdispatcher is idempotent — it skips completed tasks). Do NOT regenerate submission.json. 4. **Never resubmit a job that already completed** — this wastes GPU time and creates duplicate training runs. -## Re-attaching to an existing E2B job (CRITICAL) -If your `prior_context` contains "REMOTE JOB ALREADY SUBMITTED", a tracked sandbox job -for this exact step is already running: -1. Call `get_e2b_job_status` with the given `job_id` FIRST. -2. NEVER call `submit_e2b_sandbox` for that step — it would duplicate a running job. -3. If the job finished, collect its outputs with `download_e2b_output` and report success. -4. If it is still running, call `submit_step_result(status="needs_replanning", ...)` stating +## Re-attaching to an existing remote job (CRITICAL) +If your `prior_context` contains "REMOTE JOB ALREADY SUBMITTED", a tracked remote job +(sandbox or batch job) for this exact step is already running: +1. Call `get_remote_job_status` with the given `job_id` FIRST. +2. NEVER call `submit_e2b_sandbox`, `submit_bohr_sandbox`, or `submit_bohr_job` for that + step — it would duplicate a running job. +3. If its `snapshot` contains `background_command`, a command is (or was) running in the + background — call `poll_remote_job_command` FIRST rather than issuing a new command. Never + call `start_remote_job_command`/`run_remote_job_command` again for the same computation + just because you lost track of it; re-running a non-idempotent command (e.g. a training + run) can corrupt output or double-charge compute. +4. If the job finished (`status: succeeded`), collect its outputs — `download_remote_job_output` + for an interactive sandbox, or `collect_remote_job_outputs` for a batch job — and report success. +5. If it is still running, call `submit_step_result(status="needs_replanning", ...)` stating that the job has not finished yet and quoting its job_id and status. -## User controls for E2B sandboxes -`get_e2b_job_status` may return `user_control` when the user paused or terminated -the sandbox from the UI. This does not cancel your executor. Treat it as the -user's explicit instruction: do not retry the interrupted sandbox command or -submit a replacement sandbox. Report the pause or termination accurately with +## Choosing a remote-job submit tool +- `submit_e2b_sandbox`: interactive E2B sandbox (default choice when the E2B SDK/API key + is configured). +- `submit_bohr_sandbox`: interactive sandbox via the `bohr` CLI — use only when the E2B + path is unavailable; both reach the same Bohrium sandbox platform. +- `submit_bohr_job`: batch/HPC-style job via `bohr job submit`. There is no interactive + command execution for this provider — express the entire computation in `command`, then + poll `get_remote_job_status` until `succeeded` and call `collect_remote_job_outputs`. + +## Running commands inside a sandbox: blocking vs. background (CRITICAL) +- `run_remote_job_command` BLOCKS until the command finishes, with no timeout. Only use it + for short commands expected to finish in well under a minute (`mkdir`, `grep`, checking a + file, listing a directory). +- For any real computation (a training run, `vasp_std`/`mpirun`, anything that might run + longer than a minute), use `start_remote_job_command` instead — it launches the command in + the background and returns almost immediately — then call `poll_remote_job_command` to check + on it. Do useful work in between polls (e.g. `submit_step_result(status="needs_replanning", ...)` + reporting the job is still running, rather than looping tool calls back-to-back) so a single + step doesn't sit blocked. Re-attaching to the same `job_id` later always finds the same + tracked command via `poll_remote_job_command`, so it's always safe even after a step timeout. + +## User controls for remote jobs +`get_remote_job_status` may return `user_control` when the user paused or terminated +the job from the UI. This does not cancel your executor. Treat it as the +user's explicit instruction: do not retry the interrupted command or +submit a replacement job. Report the pause or termination accurately with `submit_step_result(status="needs_replanning", replan_reason=...)`. ## MANDATORY: Always call submit_step_result @@ -264,12 +297,17 @@ def build_step_executor_agent(llm_card: LLMCard) -> LlmAgent: FunctionTool(run_python), FunctionTool(run_bash), FunctionTool(submit_e2b_sandbox), - FunctionTool(get_e2b_job_status), - FunctionTool(run_e2b_command), - FunctionTool(upload_e2b_input), - FunctionTool(download_e2b_output), - FunctionTool(pause_e2b_sandbox), - FunctionTool(terminate_e2b_sandbox), + FunctionTool(submit_bohr_sandbox), + FunctionTool(submit_bohr_job), + FunctionTool(get_remote_job_status), + FunctionTool(run_remote_job_command), + FunctionTool(start_remote_job_command), + FunctionTool(poll_remote_job_command), + FunctionTool(upload_remote_job_input), + FunctionTool(download_remote_job_output), + FunctionTool(collect_remote_job_outputs), + FunctionTool(pause_remote_job), + FunctionTool(terminate_remote_job), ALL_SKILLS_TOOLSET, FunctionTool(show_plot), FunctionTool(show_structure), diff --git a/src/matcreator/agents/execution_agent/step_executor_runner.py b/src/matcreator/agents/execution_agent/step_executor_runner.py index cd79b3f6..763c8233 100644 --- a/src/matcreator/agents/execution_agent/step_executor_runner.py +++ b/src/matcreator/agents/execution_agent/step_executor_runner.py @@ -389,13 +389,20 @@ def _remote_job_prior_context(tool_context: ToolContext, node_id: Optional[str]) remote_job = node.get("remote_job") if not isinstance(remote_job, dict) or not remote_job.get("job_id"): return None + background_note = ( + " A background command may still be in flight — call poll_remote_job_command " + "before starting a new one." + if remote_job.get("has_background_command") + else "" + ) return ( "REMOTE JOB ALREADY SUBMITTED for this step: " f"job_id={remote_job['job_id']} provider={remote_job.get('provider')} " - f"sandbox_id={remote_job.get('external_id')} last_known_status={remote_job.get('status')}. " - "Call get_e2b_job_status with this job_id to re-attach. Do NOT call submit_e2b_sandbox " - "again for this step — that job is still tracked and must not be duplicated. If it is " - "still running, report needs_replanning explaining that the job has not finished yet." + f"external_id={remote_job.get('external_id')} last_known_status={remote_job.get('status')}. " + "Call get_remote_job_status with this job_id to re-attach. Do NOT call submit_e2b_sandbox, " + "submit_bohr_sandbox, or submit_bohr_job again for this step — that job is still tracked " + "and must not be duplicated. If it is still running, report needs_replanning explaining " + f"that the job has not finished yet.{background_note}" ) diff --git a/src/matcreator/agents/thinking_agent/agent.py b/src/matcreator/agents/thinking_agent/agent.py index ac5a5e53..68de5592 100644 --- a/src/matcreator/agents/thinking_agent/agent.py +++ b/src/matcreator/agents/thinking_agent/agent.py @@ -273,8 +273,8 @@ async def run_flash_step( its timeout while the step's `remote_job` is still running on the provider. Report the job identity to the user and stop. Do NOT re-run the step to "restart" the job. To check on it later, call `run_flash_step` again with the SAME `label` and an action - that says to call `get_e2b_job_status` with that job_id and collect results if finished — - never to submit a new sandbox. + that says to call `get_remote_job_status` with that job_id and collect results if finished — + never to submit a new job. """ _NORMAL_INSTRUCTION = """ diff --git a/src/matcreator/control_plane/providers/__init__.py b/src/matcreator/control_plane/providers/__init__.py new file mode 100644 index 00000000..41793365 --- /dev/null +++ b/src/matcreator/control_plane/providers/__init__.py @@ -0,0 +1,48 @@ +"""Built-in remote-job provider adapters. + +Importing this package registers every built-in adapter. Adding a new +provider means adding one adapter module and one ``register_adapter`` call +below — nothing else in the control plane needs to change. +""" +from __future__ import annotations + +from .base import CapabilityError, RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus +from .registry import get_adapter, register_adapter, registered_providers, reset_registry + + +def _register_builtin_adapters() -> None: + # Registered as lazy factories (not imported eagerly) so importing this + # package never pays for an adapter's own imports (SDK modules, env-var + # setup) until `get_adapter(...)` actually constructs one. + def _e2b_factory(): + from .e2b import E2BSandboxAdapter + + return E2BSandboxAdapter() + + def _bohr_sandbox_factory(): + from .bohr_sandbox import BohrSandboxAdapter + + return BohrSandboxAdapter() + + def _bohr_job_factory(): + from .bohr_job import BohrJobAdapter + + return BohrJobAdapter() + + register_adapter("e2b", _e2b_factory) + register_adapter("bohr_sandbox", _bohr_sandbox_factory) + register_adapter("bohr_job", _bohr_job_factory) + + +_register_builtin_adapters() + +__all__ = [ + "RemoteJobAdapter", + "RemoteJobCapability", + "RemoteJobStatus", + "CapabilityError", + "get_adapter", + "register_adapter", + "registered_providers", + "reset_registry", +] diff --git a/src/matcreator/control_plane/providers/_bohr_cli.py b/src/matcreator/control_plane/providers/_bohr_cli.py new file mode 100644 index 00000000..f9c1c369 --- /dev/null +++ b/src/matcreator/control_plane/providers/_bohr_cli.py @@ -0,0 +1,75 @@ +"""Shared subprocess boundary for `bohr`-CLI-backed provider adapters. + +Both ``bohr_sandbox`` (interactive) and ``bohr_job`` (batch) adapters shell +out to the same ``bohr`` binary and expect the same JSON envelope +(``{"ok": bool, "data": ..., "error": {...}}``), so the invocation and +error-handling logic lives here once instead of being duplicated per adapter. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from typing import Any + + +class BohrCLIError(RuntimeError): + """Raised when a `bohr` CLI invocation fails or returns unusable output.""" + + +def resolve_bohr_binary() -> str: + """Resolve the `bohr` executable, honoring an explicit override.""" + return os.environ.get("BOHR_CLI_PATH") or shutil.which("bohr") or "bohr" + + +def run_bohr_json(args: list[str], *, timeout: float | None = 120) -> Any: + """Run one `bohr` CLI invocation and return its parsed ``data`` payload. + + Every invocation appends ``-o json --no-interactive -y`` so output is + machine-parseable and no command blocks on an interactive confirmation + prompt. Raises :class:`BohrCLIError` with the CLI's own error message on + failure, so callers never need to parse stderr or exit codes themselves. + """ + command = [resolve_bohr_binary(), *args, "-o", "json", "--no-interactive", "-y"] + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise BohrCLIError("The 'bohr' CLI is not installed or not on PATH") from exc + except subprocess.TimeoutExpired as exc: + raise BohrCLIError(f"bohr {' '.join(args)} timed out after {timeout}s") from exc + + stdout = (completed.stdout or "").strip() + if not stdout: + message = (completed.stderr or "").strip() + raise BohrCLIError( + message or f"bohr {' '.join(args)} produced no output (exit {completed.returncode})" + ) + try: + payload = json.loads(stdout) + except json.JSONDecodeError as exc: + raise BohrCLIError( + f"bohr {' '.join(args)} returned non-JSON output: {stdout[:500]}" + ) from exc + + if not isinstance(payload, dict) or not payload.get("ok", False): + error = (payload or {}).get("error") if isinstance(payload, dict) else None + message = (error or {}).get("message") if isinstance(error, dict) else None + raise BohrCLIError(message or f"bohr {' '.join(args)} failed") + return payload.get("data") + + +def extract_id(data: Any, keys: tuple[str, ...]) -> str | None: + """Return the first present, truthy value among ``keys`` in a dict payload.""" + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if value: + return str(value) + return None diff --git a/src/matcreator/control_plane/providers/base.py b/src/matcreator/control_plane/providers/base.py new file mode 100644 index 00000000..45edd487 --- /dev/null +++ b/src/matcreator/control_plane/providers/base.py @@ -0,0 +1,126 @@ +"""Provider-neutral adapter protocol for the remote-job control plane. + +``RemoteJobStore`` and ``RemoteJobService`` are already provider-neutral (see +``remote_jobs.py``): the persisted schema, lifecycle state machine, and +recovery bookkeeping all key off a generic ``provider`` string. This module +defines the boundary a *new* provider must implement so that +``RemoteJobService`` never needs provider-specific branches — adding a +provider means adding one adapter module plus one registration call (see +``registry.py``), nothing else in the control plane changes. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + + +class RemoteJobCapability(Enum): + """Optional operations a provider adapter may support. + + ``create``/``status``/``cancel`` are mandatory for every adapter (they are + abstract methods on :class:`RemoteJobAdapter`). Everything else is gated + by a capability flag so :class:`RemoteJobService` can reject an + unsupported operation with a clear, provider-attributed error instead of + an ``AttributeError`` surfacing from deep inside an adapter. + """ + + PAUSE = "pause" + RESUME = "resume" + INTERACTIVE_EXEC = "interactive_exec" + FILE_TRANSFER = "file_transfer" + BATCH_COLLECT = "batch_collect" + + +class CapabilityError(NotImplementedError): + """Raised when a requested operation is not supported by a provider.""" + + def __init__(self, provider: str, capability: RemoteJobCapability) -> None: + super().__init__(f"Provider '{provider}' does not support '{capability.value}'") + self.provider = provider + self.capability = capability + + +@dataclass(frozen=True) +class RemoteJobStatus: + """Result of probing one external job. + + ``normalized_status`` must be one of the canonical statuses defined in + ``remote_jobs.py`` (for example ``running``, ``succeeded``, ``failed``, + ``cancelled``, ``lost``) when the provider can report a lifecycle + observation, or ``None`` when the provider can only confirm liveness + without knowing whether that changes the normalized lifecycle (e.g. an + interactive sandbox that stays "running" until an agent explicitly ends + it). ``RemoteJobService`` only transitions the durable record's status + when ``normalized_status`` is not ``None`` and differs from the job's + current status; otherwise it merges ``snapshot`` as a non-lifecycle + observation. + """ + + normalized_status: str | None + snapshot: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + +class RemoteJobAdapter(ABC): + """Boundary between the control plane and one external job provider. + + Subclasses implement only what their provider can actually do. Declaring + ``capabilities`` tells :class:`RemoteJobService` which of the optional + methods below are safe to call; the default implementations below raise + :class:`CapabilityError` as a safety net for a capability that was + declared but never overridden. + """ + + provider: str + capabilities: frozenset[RemoteJobCapability] = frozenset() + # Interval RemoteJobMonitor should wait between reconciliations of jobs + # owned by this provider. Batch/HPC-style providers whose status only + # changes on the order of minutes can declare a much longer interval than + # an interactively addressable sandbox. + poll_interval_seconds: float = 15.0 + + @abstractmethod + def create(self, spec: dict[str, Any]) -> str: + """Create one external job/sandbox and return its provider-side ID.""" + + @abstractmethod + def status(self, external_id: str) -> RemoteJobStatus: + """Probe one external job for liveness and/or lifecycle status.""" + + @abstractmethod + def cancel(self, external_id: str) -> None: + """Irreversibly stop/delete one external job.""" + + def pause(self, external_id: str) -> None: + raise CapabilityError(self.provider, RemoteJobCapability.PAUSE) + + def resume(self, external_id: str) -> None: + raise CapabilityError(self.provider, RemoteJobCapability.RESUME) + + def run_command(self, external_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + raise CapabilityError(self.provider, RemoteJobCapability.INTERACTIVE_EXEC) + + def upload_file(self, external_id: str, source: str | Path, destination: str) -> None: + raise CapabilityError(self.provider, RemoteJobCapability.FILE_TRANSFER) + + def download_file( + self, + external_id: str, + source: str, + destination: str | Path, + *, + user: str | None = None, + ) -> Path: + raise CapabilityError(self.provider, RemoteJobCapability.FILE_TRANSFER) + + def collect_outputs(self, external_id: str, destination_dir: str | Path) -> list[dict[str, Any]]: + """Pull a finished batch job's declared output files to ``destination_dir``. + + Returns a list of ``{"source": ..., "destination": ...}`` records + describing what was collected, so the caller can persist them as job + artifacts. + """ + raise CapabilityError(self.provider, RemoteJobCapability.BATCH_COLLECT) diff --git a/src/matcreator/control_plane/providers/bohr_job.py b/src/matcreator/control_plane/providers/bohr_job.py new file mode 100644 index 00000000..ddc64822 --- /dev/null +++ b/src/matcreator/control_plane/providers/bohr_job.py @@ -0,0 +1,109 @@ +"""Batch/HPC-style adapter over `bohr job` (submit/describe/download/terminate). + +Inputs are staged once at submission time (``--input_directory``); there is +no interactive exec or incremental file transfer, matching how HPC batch +schedulers work (submit, poll a queue, collect outputs once terminal). Only +single-job submission is supported here — `bohr job_group` fan-out (multiple +jobs sharing one group) is a possible future adapter, not this one. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ._bohr_cli import BohrCLIError, extract_id, run_bohr_json +from .base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus + +# `bohr job list`/`describe` report a lowercase `phase` string. Map it onto +# the canonical statuses defined in remote_jobs.py. Any phase not listed here +# (e.g. a future platform phase) leaves normalized_status as None so the +# service records an observation instead of guessing a lifecycle transition. +_PHASE_TO_NORMALIZED = { + "pending": "queued", + "scheduling": "queued", + "running": "running", + "completed": "succeeded", + "failed": "failed", + # "stopped" is the phase used for a job terminated by the user (see + # `bohr job terminate`); "cancelled" is the closest existing canonical + # status for a non-failure, user-initiated stop. + "stopped": "cancelled", +} + +_REQUIRED_SPEC_FIELDS = ("project_id", "job_name", "machine_type", "image_address", "command") + + +class BohrJobAdapter(RemoteJobAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + # Batch job status changes on the order of minutes, not seconds; poll far + # less often than an interactive sandbox to avoid hammering the platform. + poll_interval_seconds = 60.0 + + def create(self, spec: dict[str, Any]) -> str: + missing = [name for name in _REQUIRED_SPEC_FIELDS if not spec.get(name)] + if missing: + raise ValueError(f"bohr_job spec is missing required field(s): {', '.join(missing)}") + args = [ + "job", + "submit", + "--project_id", + str(spec["project_id"]), + "--job_name", + str(spec["job_name"]), + "--machine_type", + str(spec["machine_type"]), + "--image_address", + str(spec["image_address"]), + "--command", + str(spec["command"]), + ] + if spec.get("input_directory"): + args += ["--input_directory", str(spec["input_directory"])] + if spec.get("log_file"): + args += ["--log_file", str(spec["log_file"])] + if spec.get("result_path"): + args += ["--result_path", str(spec["result_path"])] + if spec.get("max_run_time"): + args += ["--max_run_time", str(spec["max_run_time"])] + if spec.get("nnode"): + args += ["--nnode", str(spec["nnode"])] + if spec.get("max_reschedule_times") is not None: + args += ["--max_reschedule_times", str(spec["max_reschedule_times"])] + if spec.get("job_group_id"): + args += ["--job_group_id", str(spec["job_group_id"])] + + data = run_bohr_json(args) + bohr_id = extract_id(data, ("bohrId", "bohr_id", "bohrID", "id", "jobId", "jobID")) + if not bohr_id: + keys = sorted(data) if isinstance(data, dict) else type(data).__name__ + raise BohrCLIError( + f"bohr job submit did not return a Bohr job ID (data: {keys})" + ) + return bohr_id + + def status(self, external_id: str) -> RemoteJobStatus: + data = run_bohr_json(["job", "describe", "-i", str(external_id)]) or {} + phase = str(data.get("phase", "")).lower() + normalized = _PHASE_TO_NORMALIZED.get(phase) + error = None + if phase == "failed": + error = str(data.get("errorInfo") or "").strip() or None + return RemoteJobStatus( + normalized_status=normalized, + snapshot={"phase": phase or None, "terminal": bool(data.get("terminal", False))}, + error=error, + ) + + def cancel(self, external_id: str) -> None: + run_bohr_json(["job", "terminate", "--id", str(external_id), "--no-wait"]) + + def collect_outputs(self, external_id: str, destination_dir: str | Path) -> list[dict[str, Any]]: + dest = Path(destination_dir).expanduser().resolve() + dest.mkdir(parents=True, exist_ok=True) + run_bohr_json(["job", "download", "-i", str(external_id), "--out", str(dest)]) + return [ + {"source": external_id, "destination": str(path)} + for path in sorted(dest.rglob("*")) + if path.is_file() + ] diff --git a/src/matcreator/control_plane/providers/bohr_sandbox.py b/src/matcreator/control_plane/providers/bohr_sandbox.py new file mode 100644 index 00000000..b3dc7bbe --- /dev/null +++ b/src/matcreator/control_plane/providers/bohr_sandbox.py @@ -0,0 +1,118 @@ +"""Interactive adapter over `bohr sandbox` (create/exec/files/describe/delete). + +Mirrors the E2B adapter's capability surface (interactive exec + file +transfer) so agent tools can treat a Bohrium CLI sandbox the same way as an +E2B one. The installed `bohr` CLI has no sandbox pause/resume subcommand +(only create/delete/describe/exec/files/list/...), so +``RemoteJobCapability.PAUSE`` is intentionally not declared here — add it if +a future CLI version exposes one. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ._bohr_cli import BohrCLIError, extract_id, run_bohr_json +from .base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus + +# Sandboxes reporting one of these as their describe-output status/state are +# no longer usable; treat them the same as an E2B "unreachable" liveness +# probe failure so the monitor marks the job lost rather than looping. +_UNREACHABLE_STATUSES = {"deleted", "terminated", "stopped", "killed"} + + +class BohrSandboxAdapter(RemoteJobAdapter): + provider = "bohr_sandbox" + capabilities = frozenset({RemoteJobCapability.INTERACTIVE_EXEC, RemoteJobCapability.FILE_TRANSFER}) + poll_interval_seconds = 15.0 + + def create(self, spec: dict[str, Any]) -> str: + project_id = spec.get("project_id") + if not project_id: + raise ValueError("bohr_sandbox spec requires 'project_id'") + template = spec.get("template") + if not template: + # Never fall back to the CLI's default template (sdbxagent): it + # silently creates the wrong (and possibly costlier) sandbox. + raise ValueError("bohr_sandbox spec requires 'template'") + args = ["sandbox", "create", "--template", str(template), "--project-id", str(project_id)] + if spec.get("timeout"): + args += ["--timeout", str(spec["timeout"])] + if spec.get("image"): + args += ["--image", str(spec["image"])] + if spec.get("gpu"): + args += ["--gpu", str(spec["gpu"])] + if spec.get("never_timeout"): + args.append("--never-timeout") + if spec.get("mount_user_storage"): + args.append("--mount-user-storage") + if spec.get("share_subpath"): + args += ["--share-subpath", str(spec["share_subpath"])] + if spec.get("session_id"): + args += ["--session-id", str(spec["session_id"])] + for key, value in dict(spec.get("env") or {}).items(): + args += ["--env", f"{key}={value}"] + + data = run_bohr_json(args) + # The CLI is inconsistent about the ID key across subcommands: + # `create`/`exec` return "sandboxID", `files read` returns + # "sandbox_id" — accept every observed spelling. + sandbox_id = extract_id(data, ("sandbox_id", "sandboxID", "sandboxId", "id")) + if not sandbox_id: + keys = sorted(data) if isinstance(data, dict) else type(data).__name__ + raise BohrCLIError( + f"bohr sandbox create did not return a sandbox ID (data: {keys})" + ) + return sandbox_id + + def status(self, external_id: str) -> RemoteJobStatus: + data = run_bohr_json(["sandbox", "describe", external_id]) or {} + raw_status = str(data.get("status") or data.get("state") or "").lower() + normalized = "lost" if raw_status in _UNREACHABLE_STATUSES else None + return RemoteJobStatus( + normalized_status=normalized, + snapshot={ + "provider_status": "unreachable" if normalized else "reachable", + "raw_status": raw_status or None, + }, + error=None, + ) + + def cancel(self, external_id: str) -> None: + run_bohr_json(["sandbox", "delete", external_id, "--force"]) + + def run_command(self, external_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + # `bohr sandbox exec` caps a command at 90s by default; pass + # `--timeout 0` to disable that CLI-side cap, and also don't bound our + # own subprocess wait, so a long-running command isn't silently + # truncated. This matches the E2B adapter's `timeout=0` semantics + # (see e2b.py run_command) so command duration behaves the same + # regardless of which interactive sandbox provider is in use. + data = run_bohr_json( + ["sandbox", "exec", external_id, "--command", command, "--user", user, "--timeout", "0"], + timeout=None, + ) or {} + return { + "stdout": str(data.get("stdout", "")), + "stderr": str(data.get("stderr", "")), + "exit_code": data.get("exit_code", data.get("exitCode")), + } + + def upload_file(self, external_id: str, source: str | Path, destination: str) -> None: + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + raise FileNotFoundError(source_path) + run_bohr_json(["sandbox", "files", "write", external_id, destination, "--source", str(source_path)]) + + def download_file( + self, + external_id: str, + source: str, + destination: str | Path, + *, + user: str | None = None, + ) -> Path: + dest_path = Path(destination).expanduser().resolve() + dest_path.parent.mkdir(parents=True, exist_ok=True) + run_bohr_json(["sandbox", "files", "read", external_id, source, "--destination", str(dest_path)]) + return dest_path diff --git a/src/matcreator/control_plane/e2b.py b/src/matcreator/control_plane/providers/e2b.py similarity index 63% rename from src/matcreator/control_plane/e2b.py rename to src/matcreator/control_plane/providers/e2b.py index fc0ca055..e3243893 100644 --- a/src/matcreator/control_plane/e2b.py +++ b/src/matcreator/control_plane/providers/e2b.py @@ -6,6 +6,8 @@ from typing import Any import os +from .base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus + # e2b SDK >=2.20 validates the API key format client-side (requires the # "e2b_" + hex pattern, see e2b.api.validate_api_key) and raises # AuthenticationException in ApiClient.__init__ before any network call. @@ -25,6 +27,27 @@ class E2BUnavailableError(RuntimeError): """Raised when the optional E2B SDK is unavailable at runtime.""" +@dataclass(frozen=True) +class E2BConnectionConfig: + """Server-side E2B/Bohrium endpoint configuration for one sandbox request.""" + + api_key: str + api_url: str + project_id: str + template: str + + def to_spec_dict(self, *, timeout: int = 600, lifecycle: dict[str, Any] | None = None) -> dict[str, Any]: + """Build the generic ``spec`` dict passed to ``E2BSandboxAdapter.create``.""" + return { + "template": self.template, + "api_key": self.api_key, + "api_url": self.api_url, + "project_id": self.project_id, + "timeout": timeout, + "lifecycle": lifecycle or {}, + } + + @dataclass(frozen=True) class E2BSandboxSpec: """Validated inputs for creating one E2B sandbox.""" @@ -54,10 +77,36 @@ def create_kwargs(self) -> dict[str, Any]: "metadata": self.metadata, } + @classmethod + def from_dict(cls, spec: dict[str, Any]) -> "E2BSandboxSpec": + """Build a validated spec from the generic dict a submit tool provides.""" + return cls( + template=str(spec.get("template", "")), + api_key=str(spec.get("api_key", "")), + api_url=str(spec.get("api_url", "")), + project_id=str(spec.get("project_id", "")), + timeout=int(spec.get("timeout", 600)), + lifecycle=dict(spec.get("lifecycle") or {}), + metadata=dict(spec.get("metadata") or {}), + ) + # The backend E2B SDK is imported lazily to avoid a hard dependency on the SDK for users who don't need it. The E2BSandboxAdapter class wraps the SDK and provides a simple interface for creating, connecting to, and managing E2B sandboxes. -class E2BSandboxAdapter: +class E2BSandboxAdapter(RemoteJobAdapter): """Small boundary around the E2B SDK with no SDK import at module load.""" + provider = "e2b" + # Resume is not implemented: the E2B SDK's "pause" produces a snapshot that + # is restored by simply connecting to the same sandbox_id again, so there + # is no separate resume operation to expose. + capabilities = frozenset( + { + RemoteJobCapability.PAUSE, + RemoteJobCapability.INTERACTIVE_EXEC, + RemoteJobCapability.FILE_TRANSFER, + } + ) + poll_interval_seconds = 15.0 + @staticmethod def _sandbox_class(): try: @@ -68,8 +117,20 @@ def _sandbox_class(): ) from exc return Sandbox - def create(self, spec: E2BSandboxSpec) -> str: - sandbox = self._sandbox_class().create(**spec.create_kwargs()) + def create(self, spec: dict[str, Any]) -> str: + sandbox_spec = E2BSandboxSpec.from_dict(spec) + try: + sandbox = self._sandbox_class().create(**sandbox_spec.create_kwargs()) + except (E2BConfigurationError, E2BUnavailableError): + raise + except Exception as exc: + # Include every non-secret request parameter so a provider-side + # error (404 wrong URL, unknown template, bad project) is + # diagnosable from the durable job record alone. + raise RuntimeError( + f"{exc} [create against api_url={sandbox_spec.api_url!r}, " + f"template={sandbox_spec.template!r}, project_id={sandbox_spec.project_id!r}]" + ) from exc sandbox_id = getattr(sandbox, "sandbox_id", "") if not sandbox_id: raise RuntimeError("E2B create returned a sandbox without sandbox_id") @@ -170,6 +231,9 @@ def pause(self, sandbox_id: str) -> None: def terminate(self, sandbox_id: str) -> None: self._connect(sandbox_id).kill() + def cancel(self, external_id: str) -> None: + self.terminate(external_id) + def probe(self, sandbox_id: str) -> dict[str, Any]: """Confirm an active sandbox is reachable without changing its files.""" result = self.run_command(sandbox_id, "true") @@ -177,7 +241,31 @@ def probe(self, sandbox_id: str) -> dict[str, Any]: raise RuntimeError(result["stderr"] or "E2B sandbox liveness probe failed") return {"provider_status": "reachable", "probe": result} + def status(self, external_id: str) -> RemoteJobStatus: + """Report liveness only: an E2B sandbox stays "running" until an agent + or user explicitly pauses/terminates it, so there is no independent + lifecycle status for the monitor to observe beyond reachability. + """ + snapshot = self.probe(external_id) + return RemoteJobStatus(normalized_status=None, snapshot=snapshot, error=None) + def _connect(self, sandbox_id: str): if not sandbox_id: raise ValueError("sandbox_id is required") - return self._sandbox_class().connect(sandbox_id) \ No newline at end of file + # Reconnects (run_command/pause/kill, possibly from a fresh process + # long after create) must target the same Bohrium endpoint as create. + # The SDK would silently fall back to the public e2b.dev API when + # called bare, so pass the configured connection explicitly and fail + # loudly when it is missing. + api_key = os.environ.get("E2B_API_KEY", "") + api_url = os.environ.get("E2B_API_URL", "") + if not api_key or not api_url: + raise E2BConfigurationError( + "E2B_API_KEY and E2B_API_URL must be set to connect to sandbox " + f"'{sandbox_id}'" + ) + opts: dict[str, Any] = {"api_key": api_key, "api_url": api_url} + project_id = os.environ.get("BOHRIUM_PROJECT_ID", "") + if project_id: + opts["headers"] = {"X-Project-Id": project_id} + return self._sandbox_class().connect(sandbox_id, **opts) \ No newline at end of file diff --git a/src/matcreator/control_plane/providers/registry.py b/src/matcreator/control_plane/providers/registry.py new file mode 100644 index 00000000..ed01ad3c --- /dev/null +++ b/src/matcreator/control_plane/providers/registry.py @@ -0,0 +1,50 @@ +"""Registry mapping remote-job provider names to adapter instances. + +Adding a new provider means adding one adapter module implementing +``RemoteJobAdapter`` and one ``register_adapter`` call in +``providers/__init__.py`` — nothing else in the control plane changes. +""" +from __future__ import annotations + +from typing import Callable + +from .base import RemoteJobAdapter + +_FACTORIES: dict[str, Callable[[], RemoteJobAdapter]] = {} +_INSTANCES: dict[str, RemoteJobAdapter] = {} + + +def register_adapter(provider: str, factory: Callable[[], RemoteJobAdapter]) -> None: + """Register a lazy factory for one provider's adapter. + + Factories are not called until first use, so importing the registry never + imports an optional provider SDK or shells out to a CLI. + """ + if not provider: + raise ValueError("provider is required") + _FACTORIES[provider] = factory + _INSTANCES.pop(provider, None) + + +def get_adapter(provider: str) -> RemoteJobAdapter: + """Return the (lazily constructed, cached) adapter for ``provider``.""" + if provider not in _FACTORIES: + raise KeyError(f"No remote-job adapter is registered for provider '{provider}'") + if provider not in _INSTANCES: + adapter = _FACTORIES[provider]() + if adapter.provider != provider: + raise ValueError( + f"Adapter registered for '{provider}' reports provider '{adapter.provider}'" + ) + _INSTANCES[provider] = adapter + return _INSTANCES[provider] + + +def registered_providers() -> list[str]: + return sorted(_FACTORIES) + + +def reset_registry() -> None: + """Test helper: drop all registrations and cached adapter instances.""" + _FACTORIES.clear() + _INSTANCES.clear() diff --git a/src/matcreator/control_plane/remote_job_monitor.py b/src/matcreator/control_plane/remote_job_monitor.py index dadb2967..e2cb5c45 100644 --- a/src/matcreator/control_plane/remote_job_monitor.py +++ b/src/matcreator/control_plane/remote_job_monitor.py @@ -10,10 +10,14 @@ class RemoteJobMonitor: - """Probe active E2B sandboxes with bounded retry backoff. + """Probe active remote jobs of any registered provider with bounded retry backoff. Job records are durable; this monitor's due times are intentionally process - local. On a restart its empty schedule reconciles every active sandbox once. + local. On a restart its empty schedule reconciles every active job once. + Each provider adapter declares its own ``poll_interval_seconds`` (see + ``providers/base.py``), so a batch/HPC-style provider can poll far less + often than an interactive sandbox with no change needed here — adding a + provider is a pure plugin. """ def __init__( @@ -45,30 +49,46 @@ async def run(self) -> None: def stop(self) -> None: self._stop.set() + def _base_interval(self, provider: str) -> float: + """Return the owning adapter's preferred poll cadence for one job. + + Resolved through ``self.service.adapter_for`` so this honors the same + adapter overrides (e.g. in tests) as every other service operation, + instead of querying the global registry directly. Falls back to this + monitor's own tick interval if the provider is unregistered (e.g. a + record left over from a removed plugin), so a missing adapter never + breaks reconciliation of other jobs. + """ + try: + return self.service.adapter_for(provider).poll_interval_seconds + except KeyError: + return self.interval_seconds + async def reconcile_once(self) -> list[dict[str, Any]]: now = time.monotonic() updates: list[dict[str, Any]] = [] active_ids: set[str] = set() - for job in self.store.list_active_jobs(provider="e2b"): + for job in self.store.list_active_jobs(): job_id = job["job_id"] active_ids.add(job_id) if job["status"] not in {"queued", "running", "submitting", "resuming"}: continue if now < self._next_due.get(job_id, 0): continue - updated = await asyncio.to_thread(self.service.reconcile_e2b, job_id) + base_interval = self._base_interval(job["provider"]) + updated = await asyncio.to_thread(self.service.reconcile_job, job_id) updates.append(updated) if updated["snapshot"].get("provider_status") == "unreachable": failures = self._failures.get(job_id, 0) + 1 self._failures[job_id] = failures - delay = min(self.interval_seconds * (2 ** (failures - 1)), self.max_backoff_seconds) + delay = min(base_interval * (2 ** (failures - 1)), self.max_backoff_seconds) else: self._failures.pop(job_id, None) - delay = self.interval_seconds + delay = base_interval self._next_due[job_id] = time.monotonic() + delay stale_ids = set(self._next_due) - active_ids for job_id in stale_ids: self._next_due.pop(job_id, None) self._failures.pop(job_id, None) - return updates \ No newline at end of file + return updates diff --git a/src/matcreator/control_plane/remote_job_service.py b/src/matcreator/control_plane/remote_job_service.py index 1e1cf060..b7162dc8 100644 --- a/src/matcreator/control_plane/remote_job_service.py +++ b/src/matcreator/control_plane/remote_job_service.py @@ -1,188 +1,389 @@ -"""Provider operations coordinated with durable remote-job records.""" +"""Provider operations coordinated with durable remote-job records. + +``RemoteJobService`` never branches on a provider name itself: every +operation looks up the adapter registered for ``job["provider"]`` (see +``providers/registry.py``) and checks its declared capabilities before +calling an optional method. Adding a new remote-job provider is therefore a +pure plugin: implement ``RemoteJobAdapter`` and register it — no changes +needed here. +""" from __future__ import annotations -from dataclasses import dataclass +import base64 +import time from pathlib import Path from typing import Any -from .e2b import E2BSandboxAdapter, E2BSandboxSpec +from .providers import CapabilityError, RemoteJobAdapter, RemoteJobCapability, get_adapter from .remote_jobs import RemoteJobStore -@dataclass(frozen=True) -class E2BConnectionConfig: - api_key: str - api_url: str - project_id: str - template: str - - class RemoteJobService: """Coordinates provider side effects with persisted job state.""" - def __init__(self, store: RemoteJobStore, *, e2b_adapter: E2BSandboxAdapter | None = None) -> None: + def __init__( + self, + store: RemoteJobStore, + *, + adapter_overrides: dict[str, RemoteJobAdapter] | None = None, + ) -> None: + """Create a service backed by ``store``. + + ``adapter_overrides`` lets callers (chiefly tests) inject a fake + adapter for one provider without mutating the global registry; + providers not present in the override map fall back to + ``providers.get_adapter``. + """ self.store = store - self.e2b_adapter = e2b_adapter or E2BSandboxAdapter() + self._adapter_overrides = dict(adapter_overrides or {}) + + def adapter_for(self, provider: str) -> RemoteJobAdapter: + """Resolve the adapter this service would use for ``provider``. + + Public so callers that need adapter metadata without performing an + operation (e.g. :class:`RemoteJobMonitor` reading + ``poll_interval_seconds``) resolve through the same override-aware + lookup as every service method, instead of querying the global + registry directly and silently ignoring test overrides. + """ + if provider in self._adapter_overrides: + return self._adapter_overrides[provider] + return get_adapter(provider) + + def _adapter(self, provider: str) -> RemoteJobAdapter: + return self.adapter_for(provider) - def submit_e2b( + def _get_job(self, job_id: str) -> dict[str, Any]: + job = self.store.get_job(job_id) + if job is None: + raise KeyError(f"Remote job '{job_id}' was not found") + if not job["external_id"]: + raise ValueError(f"Remote job '{job_id}' has no provider-side ID") + return job + + def submit_job( self, *, owner_id: str, session_id: str, + provider: str, idempotency_key: str, - connection: E2BConnectionConfig, + spec: dict[str, Any], node_id: str | None = None, step_number: int | None = None, - timeout: int = 600, - lifecycle: dict[str, Any] | None = None, - metadata: dict[str, str] | None = None, output_dir: str | None = None, + persisted_specification: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Create an E2B sandbox once and persist the resulting sandbox ID. + """Create one external job/sandbox once and persist its external ID. - The stored specification intentionally excludes ``api_key``. Replays with - the same idempotency key return the already-created record instead of - creating another sandbox. + ``spec`` is passed to the provider adapter's ``create`` verbatim and + may contain secrets (e.g. an API key). ``persisted_specification`` — + which must never contain secrets — is stored in the durable record + instead; if omitted, ``spec`` itself is persisted, so callers whose + spec has no secrets can rely on the default. Replays with the same + idempotency key return the already-created record instead of + creating a second external job; a record that failed before ever + acquiring an external ID is reset and retried instead. """ job = self.store.create_job( owner_id=owner_id, session_id=session_id, - provider="e2b", + provider=provider, idempotency_key=idempotency_key, node_id=node_id, step_number=step_number, - specification={ - "template": connection.template, - "api_url": connection.api_url, - "project_id": connection.project_id, - "timeout": timeout, - "lifecycle": lifecycle or {}, - "metadata": metadata or {}, - }, + specification=persisted_specification if persisted_specification is not None else spec, output_dir=output_dir, ) + if job["status"] == "failed" and not job["external_id"]: + # The previous attempt died before the provider handed back an + # external ID, so nothing external exists to duplicate — retry + # instead of returning the poisoned record forever. + job = self.store.reset_failed_job_for_retry(job["job_id"]) if job["external_id"] or job["status"] != "created": return job + adapter = self._adapter(provider) submitting = self.store.transition_job(job["job_id"], "submitting") try: - sandbox_id = self.e2b_adapter.create( - E2BSandboxSpec( - template=connection.template, - api_key=connection.api_key, - api_url=connection.api_url, - project_id=connection.project_id, - timeout=timeout, - lifecycle=lifecycle or {}, - metadata=metadata or {}, - ) - ) + external_id = adapter.create(spec) except Exception as exc: return self.store.transition_job( job["job_id"], "failed", - error=f"E2B sandbox creation failed: {exc}", + error=f"{provider} job creation failed: {exc}", expected_revision=submitting["state_revision"], ) + + # Give the adapter a chance to report an initial lifecycle status + # (e.g. a batch provider that queues before it runs) instead of + # always assuming "running". + initial_status = "running" + initial_snapshot: dict[str, Any] = {"provider_status": "running"} + try: + probe = adapter.status(external_id) + except Exception: + probe = None + if probe is not None: + if probe.normalized_status: + initial_status = probe.normalized_status + initial_snapshot = {**initial_snapshot, **probe.snapshot} + return self.store.transition_job( job["job_id"], - "running", - external_id=sandbox_id, - snapshot={"provider_status": "running", "sandbox_id": sandbox_id}, + initial_status, + external_id=external_id, + snapshot=initial_snapshot, expected_revision=submitting["state_revision"], ) - def pause_e2b(self, job_id: str) -> dict[str, Any]: - self._get_e2b_job(job_id) + def pause_job(self, job_id: str) -> dict[str, Any]: + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.PAUSE not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.PAUSE) requested = self.store.transition_job(job_id, "pause_requested") try: - self.e2b_adapter.pause(requested["external_id"]) + adapter.pause(requested["external_id"]) except Exception as exc: return self.store.transition_job( job_id, "failed", - error=f"E2B pause failed: {exc}", + error=f"{job['provider']} pause failed: {exc}", expected_revision=requested["state_revision"], ) - return self.store.transition_job( - job_id, "paused", expected_revision=requested["state_revision"] - ) + return self.store.transition_job(job_id, "paused", expected_revision=requested["state_revision"]) - def terminate_e2b(self, job_id: str) -> dict[str, Any]: - self._get_e2b_job(job_id) + def resume_job(self, job_id: str) -> dict[str, Any]: + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.RESUME not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.RESUME) + requested = self.store.transition_job(job_id, "resume_requested") + resuming = self.store.transition_job(job_id, "resuming", expected_revision=requested["state_revision"]) + try: + adapter.resume(resuming["external_id"]) + except Exception as exc: + return self.store.transition_job( + job_id, + "failed", + error=f"{job['provider']} resume failed: {exc}", + expected_revision=resuming["state_revision"], + ) + return self.store.transition_job(job_id, "running", expected_revision=resuming["state_revision"]) + + def terminate_job(self, job_id: str) -> dict[str, Any]: + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) requested = self.store.transition_job(job_id, "terminate_requested") try: - self.e2b_adapter.terminate(requested["external_id"]) + adapter.cancel(requested["external_id"]) except Exception as exc: return self.store.transition_job( job_id, "lost", - error=f"E2B termination could not be confirmed: {exc}", + error=f"{job['provider']} termination could not be confirmed: {exc}", expected_revision=requested["state_revision"], ) - return self.store.transition_job( - job_id, "terminated", expected_revision=requested["state_revision"] - ) + return self.store.transition_job(job_id, "terminated", expected_revision=requested["state_revision"]) - def pause_active_session_e2b_jobs(self, *, owner_id: str, session_id: str) -> list[dict[str, Any]]: - """Request a provider pause for each active E2B job in one session.""" + def pause_active_session_jobs(self, *, owner_id: str, session_id: str) -> list[dict[str, Any]]: + """Request a provider pause for each active, pausable job in one session.""" results: list[dict[str, Any]] = [] for job in self.store.list_jobs(owner_id=owner_id, session_id=session_id): - if job["provider"] != "e2b" or job["status"] not in {"queued", "running"}: + if job["status"] not in {"queued", "running"}: continue try: - results.append(self.pause_e2b(job["job_id"])) + adapter = self._adapter(job["provider"]) + except KeyError: + continue + if RemoteJobCapability.PAUSE not in adapter.capabilities: + continue + try: + results.append(self.pause_job(job["job_id"])) except Exception as exc: - results.append( - { - "job_id": job["job_id"], - "status": job["status"], - "pause_error": str(exc), - } - ) + results.append({"job_id": job["job_id"], "status": job["status"], "pause_error": str(exc)}) return results - def reconcile_e2b(self, job_id: str) -> dict[str, Any]: - """Probe a persisted active sandbox after a process restart or refresh.""" - job = self._get_e2b_job(job_id) + def reconcile_job(self, job_id: str) -> dict[str, Any]: + """Probe a persisted active job after a process restart or refresh. + + Transitions the durable status only when the adapter reports a + normalized status that differs from the current one; otherwise the + probe result is merged as a non-lifecycle observation. An illegal + transition reported by a confused/stale adapter observation falls + back to an observation rather than raising, since a monitor loop + must never crash on one bad probe. + """ + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "submitting", "resuming"}: return job + adapter = self._adapter(job["provider"]) try: - snapshot = self.e2b_adapter.probe(job["external_id"]) + probe = adapter.status(job["external_id"]) except Exception as exc: return self.store.record_observation( job_id, snapshot={"provider_status": "unreachable"}, - error=f"E2B reconciliation failed: {exc}", + error=f"{job['provider']} reconciliation failed: {exc}", expected_revision=job["state_revision"], ) + if probe.normalized_status and probe.normalized_status != job["status"]: + try: + return self.store.transition_job( + job_id, + probe.normalized_status, + snapshot=probe.snapshot, + error=probe.error, + expected_revision=job["state_revision"], + ) + except ValueError: + # Provider reported a status this job's current state cannot + # legally move to (e.g. a stale/out-of-order observation). + # Recording it as telemetry is always safe; only a lifecycle + # transition needs the strict check. + pass return self.store.record_observation( job_id, - snapshot=snapshot, - error=None, + snapshot=probe.snapshot, + error=probe.error, expected_revision=job["state_revision"], ) - def run_e2b_command(self, job_id: str, command: str, *, user: str = "root") -> dict[str, Any]: - """Run one command inside a tracked E2B sandbox without persisting command text.""" - job = self._get_e2b_job(job_id) + def run_job_command(self, job_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + """Run one command inside a tracked interactive job without persisting command text. + + This blocks the caller for the command's full duration with no + timeout of its own. Fine for short commands (seconds); for anything + that might run more than a minute or two, use + ``start_job_command``/``poll_job_command`` instead, which never + blocks longer than one bounded status check and durably survives a + process restart mid-command. + """ + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "resuming"}: - raise ValueError(f"E2B job '{job_id}' cannot run commands while {job['status']}") - result = self.e2b_adapter.run_command(job["external_id"], command, user=user) + raise ValueError(f"Job '{job_id}' cannot run commands while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.INTERACTIVE_EXEC not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.INTERACTIVE_EXEC) + result = adapter.run_command(job["external_id"], command, user=user) self.store.merge_observation( job_id, - snapshot={"provider_status": "reachable", "last_command_exit_code": result["exit_code"]}, + snapshot={"provider_status": "reachable", "last_command_exit_code": result.get("exit_code")}, error=None, ) return result - def upload_e2b_file(self, job_id: str, source: str | Path, destination: str) -> dict[str, Any]: - """Upload one local input file into a tracked E2B sandbox.""" - job = self._get_e2b_job(job_id) + def start_job_command(self, job_id: str, command: str, *, user: str = "root") -> dict[str, Any]: + """Launch one command in the background inside a tracked interactive job. + + Built entirely on the existing ``run_command`` capability — no new + adapter method or capability is required, so this works for any + current or future ``INTERACTIVE_EXEC`` provider automatically. The + launch call itself returns almost immediately (only the wrapper + shell backgrounds and detaches; it does not wait for ``command`` to + finish). ``command`` is base64-encoded before being embedded in the + wrapper so arbitrary shell content (quotes, `$`, backticks, newlines) + can never break out of or reinterpret the wrapper script. + + The command's stdout/stderr and exit code are redirected to marker + files whose paths are derived only from ``job_id`` and persisted in + the job's durable snapshot. This is what makes the command + recoverable: if this process crashes or the connection drops while + the command is still running, a fresh process re-attaches to the + same job, reads the same marker-file paths from the durable record, + and calls ``poll_job_command`` — it never has to guess whether an + earlier command already ran or re-issue it, which would be unsafe + for a non-idempotent computation. + """ + job = self._get_job(job_id) + if job["status"] not in {"queued", "running", "resuming"}: + raise ValueError(f"Job '{job_id}' cannot run commands while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.INTERACTIVE_EXEC not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.INTERACTIVE_EXEC) + + marker = f"/tmp/matcreator-cmd-{job_id}" + log_path = f"{marker}.log" + exit_path = f"{marker}.exit" + payload = base64.b64encode(command.encode("utf-8")).decode("ascii") + launch = ( + f"rm -f {exit_path}; " + f"nohup sh -c 'echo {payload} | base64 -d | sh; echo $? > {exit_path}' " + f"> {log_path} 2>&1 < /dev/null & echo LAUNCHED" + ) + launch_result = adapter.run_command(job["external_id"], launch, user=user) + handle = {"log_path": log_path, "exit_path": exit_path, "started_at": time.time()} + self.store.merge_observation( + job_id, + snapshot={"provider_status": "reachable", "background_command": handle}, + error=None, + ) + return {"job_id": job_id, "launch": launch_result, "handle": handle} + + def poll_job_command(self, job_id: str, *, tail_bytes: int = 8000) -> dict[str, Any]: + """Check on the job's most recently started background command. + + Reads only the durable marker-file paths from the job's snapshot, so + this works identically whether it's the same process that started + the command or a freshly re-attached one after a restart. Returns + ``{"running": True, ...}`` while the exit marker hasn't appeared yet, + or ``{"running": False, "exit_code": ..., "output_tail": ...}`` once + it has — ``output_tail`` is the last ``tail_bytes`` of combined + stdout/stderr; use ``download_job_file`` on ``log_path`` for the + full output of a long-running command. + """ + job = self._get_job(job_id) + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.INTERACTIVE_EXEC not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.INTERACTIVE_EXEC) + handle = (job.get("snapshot") or {}).get("background_command") + if not isinstance(handle, dict) or not handle.get("exit_path"): + raise ValueError(f"Job '{job_id}' has no in-flight background command") + + exit_path = handle["exit_path"] + log_path = handle["log_path"] + check = adapter.run_command( + job["external_id"], + f"if [ -f {exit_path} ]; then echo DONE:$(cat {exit_path}); else echo RUNNING; fi", + user="root", + ) + stdout = str(check.get("stdout", "")).strip() + if not stdout.startswith("DONE:"): + self.store.merge_observation( + job_id, snapshot={"provider_status": "reachable"}, error=None + ) + return {"running": True, "log_path": log_path} + + try: + exit_code = int(stdout.split(":", 1)[1].strip()) + except (IndexError, ValueError): + exit_code = None + tail = adapter.run_command( + job["external_id"], f"tail -c {int(tail_bytes)} {log_path} 2>/dev/null || true", user="root" + ) + self.store.merge_observation( + job_id, + snapshot={"provider_status": "reachable", "background_command": None, "last_command_exit_code": exit_code}, + error=None, + ) + return { + "running": False, + "exit_code": exit_code, + "output_tail": tail.get("stdout", ""), + "log_path": log_path, + } + + def upload_job_file(self, job_id: str, source: str | Path, destination: str) -> dict[str, Any]: + """Upload one local input file into a tracked interactive job.""" + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "resuming"}: - raise ValueError(f"E2B job '{job_id}' cannot receive files while {job['status']}") + raise ValueError(f"Job '{job_id}' cannot receive files while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.FILE_TRANSFER not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.FILE_TRANSFER) source_path = Path(source).expanduser().resolve() - self.e2b_adapter.upload_file(job["external_id"], source_path, destination) + adapter.upload_file(job["external_id"], source_path, destination) self.store.merge_observation( job_id, snapshot={"provider_status": "reachable", "last_upload": source_path.name}, @@ -190,17 +391,16 @@ def upload_e2b_file(self, job_id: str, source: str | Path, destination: str) -> ) return {"source": str(source_path), "destination": destination} - def download_e2b_file(self, job_id: str, source: str, destination: str | Path) -> dict[str, Any]: - """Download one sandbox file to a local destination path. - - Streams the file via the E2B filesystem API so large outputs (CHGCAR, - vasprun.xml, PNG) are not truncated by command-output limits. - """ - job = self._get_e2b_job(job_id) + def download_job_file(self, job_id: str, source: str, destination: str | Path) -> dict[str, Any]: + """Download one file from a tracked interactive job to a local path.""" + job = self._get_job(job_id) if job["status"] not in {"queued", "running", "resuming"}: - raise ValueError(f"E2B job '{job_id}' cannot serve files while {job['status']}") + raise ValueError(f"Job '{job_id}' cannot serve files while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.FILE_TRANSFER not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.FILE_TRANSFER) dest_path = Path(destination).expanduser().resolve() - self.e2b_adapter.download_file(job["external_id"], source, dest_path) + adapter.download_file(job["external_id"], source, dest_path) self.store.merge_observation( job_id, snapshot={"provider_status": "reachable", "last_download": Path(source).name}, @@ -208,12 +408,35 @@ def download_e2b_file(self, job_id: str, source: str, destination: str | Path) - ) return {"source": source, "destination": str(dest_path)} - def _get_e2b_job(self, job_id: str) -> dict[str, Any]: - job = self.store.get_job(job_id) - if job is None: - raise KeyError(f"Remote job '{job_id}' was not found") - if job["provider"] != "e2b": - raise ValueError(f"Remote job '{job_id}' is not managed by E2B") - if not job["external_id"]: - raise ValueError(f"Remote job '{job_id}' has no sandbox ID") - return job \ No newline at end of file + def collect_job_outputs(self, job_id: str, destination_dir: str | Path) -> dict[str, Any]: + """Pull a finished batch job's output files into ``destination_dir``. + + Only valid once the job has reached ``succeeded``; transitions the + job through ``collecting`` -> ``collected`` so a repeated collection + request is a durable no-op rather than a duplicate download. + """ + job = self._get_job(job_id) + if job["status"] == "collected": + return job + if job["status"] != "succeeded": + raise ValueError(f"Job '{job_id}' cannot collect outputs while {job['status']}") + adapter = self._adapter(job["provider"]) + if RemoteJobCapability.BATCH_COLLECT not in adapter.capabilities: + raise CapabilityError(job["provider"], RemoteJobCapability.BATCH_COLLECT) + collecting = self.store.transition_job(job_id, "collecting") + dest_path = Path(destination_dir).expanduser().resolve() + try: + artifacts = adapter.collect_outputs(job["external_id"], dest_path) + except Exception as exc: + return self.store.transition_job( + job_id, + "failed", + error=f"{job['provider']} output collection failed: {exc}", + expected_revision=collecting["state_revision"], + ) + return self.store.transition_job( + job_id, + "collected", + artifacts=artifacts, + expected_revision=collecting["state_revision"], + ) diff --git a/src/matcreator/control_plane/remote_jobs.py b/src/matcreator/control_plane/remote_jobs.py index e4a2c621..b736ea50 100644 --- a/src/matcreator/control_plane/remote_jobs.py +++ b/src/matcreator/control_plane/remote_jobs.py @@ -265,6 +265,47 @@ def transition_job( ) return self.get_job(job_id) or {} + def reset_failed_job_for_retry(self, job_id: str) -> dict[str, Any]: + """Return a failed job that never acquired an external ID to ``created``. + + ``failed`` is terminal for the normal transition machinery, but a job + that failed before the provider handed back an external ID has no + provider-side effect to duplicate, so re-running its submission is + safe. This is the one sanctioned exception, recorded as its own + ``retry`` event. Raises ``ValueError`` for any other job state. + """ + now = time.time() + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute("SELECT * FROM remote_jobs WHERE job_id = ?", (job_id,)).fetchone() + if row is None: + raise KeyError(f"Remote job '{job_id}' was not found") + current = self._decode(row) or {} + if current["status"] != "failed" or current["external_id"]: + raise ValueError( + f"Remote job '{job_id}' cannot be reset for retry " + f"(status={current['status']!r}, external_id={current['external_id']!r})" + ) + updated = connection.execute( + """ + UPDATE remote_jobs + SET status = 'created', error = NULL, + state_revision = state_revision + 1, updated_at = ? + WHERE job_id = ? AND state_revision = ? + """, + (now, job_id, current["state_revision"]), + ) + if updated.rowcount != 1: + raise RuntimeError("Remote job revision changed") + self._append_event( + connection, + job_id, + "retry", + {"from": "failed", "to": "created", "previous_error": current["error"]}, + now, + ) + return self.get_job(job_id) or {} + def record_observation( self, job_id: str, diff --git a/src/matcreator/skills/e2b/SKILL.md b/src/matcreator/skills/e2b/SKILL.md index 2023fe80..8bcf36f9 100644 --- a/src/matcreator/skills/e2b/SKILL.md +++ b/src/matcreator/skills/e2b/SKILL.md @@ -18,9 +18,7 @@ control the sandbox even after the agent or browser reconnects. ## Submission -1. Choose `template` explicitly for every `submit_e2b_sandbox` call. When the - template name is unknown, run `lbg sdbx template ls -q` to list available - templates. Install the command with `pip install -U --pre lbg` when needed. +1. Choose `template` explicitly for every `submit_e2b_sandbox` call. Ask for the `template` explicitly if unknown. 2. Call `submit_e2b_sandbox` once for the current step. It is idempotent for the current session, node, and template. 3. Use `upload_e2b_input` for workspace files, then use `run_e2b_command` for diff --git a/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md b/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md index dd65826a..9e8e458b 100644 --- a/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md +++ b/src/matcreator/skills/vasp-pymatgen/references/e2b-sandbox-execution.md @@ -100,7 +100,7 @@ export E2B_VALIDATE_API_KEY=false ``` The adapter already sets `E2B_VALIDATE_API_KEY=false` by default at import -time (`matcreator/control_plane/e2b.py`), so this is handled for Bohrium +time (`matcreator/control_plane/providers/e2b.py`), so this is handled for Bohrium deployments out of the box. An explicit value in the environment takes precedence — set `E2B_VALIDATE_API_KEY=true` only if you are talking to the public e2b.dev API with a standard `e2b_`-prefixed key. diff --git a/tests/test_bohr_job_adapter.py b/tests/test_bohr_job_adapter.py new file mode 100644 index 00000000..3f91dccb --- /dev/null +++ b/tests/test_bohr_job_adapter.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import json +import subprocess + +import pytest + +from matcreator.control_plane.providers._bohr_cli import BohrCLIError +from matcreator.control_plane.providers.base import RemoteJobCapability +from matcreator.control_plane.providers.bohr_job import BohrJobAdapter + + +class _FakeCompleted: + def __init__(self, stdout: str, returncode: int = 0, stderr: str = "") -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def _ok(data) -> str: + return json.dumps({"ok": True, "data": data}) + + +def _err(message: str) -> str: + return json.dumps({"ok": False, "error": {"message": message}}) + + +def test_create_submits_job_and_extracts_bohr_id(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok({"bohrId": 20543207, "id": 23197091})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + external_id = adapter.create( + { + "project_id": 42, + "job_name": "relax-job", + "machine_type": "c8_m32_cpu", + "image_address": "registry.dp.tech/dptech/vasp:5.4.4", + "command": "vasp_std", + "input_directory": "./input", + "max_run_time": 60, + } + ) + + assert external_id == "20543207" + command = captured["command"] + assert command[1:4] == ["job", "submit", "--project_id"] + assert "42" in command + assert "--input_directory" in command and "./input" in command + assert "--max_run_time" in command and "60" in command + assert "-o" in command and "json" in command + assert "--no-interactive" in command + assert "-y" in command + + +def test_create_requires_all_fields() -> None: + adapter = BohrJobAdapter() + + with pytest.raises(ValueError, match="job_name"): + adapter.create({"project_id": 1}) + + +def test_status_maps_phase_to_normalized_status(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"phase": "completed", "terminal": True})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + status = adapter.status("20543207") + + assert status.normalized_status == "succeeded" + assert status.snapshot == {"phase": "completed", "terminal": True} + assert status.error is None + + +def test_status_maps_failed_phase_and_captures_error_info(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"phase": "failed", "terminal": True, "errorInfo": "Command not found."})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + status = adapter.status("20543187") + + assert status.normalized_status == "failed" + assert status.error == "Command not found." + + +def test_status_maps_stopped_phase_to_cancelled(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"phase": "stopped", "terminal": True})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + status = adapter.status("20539094") + + assert status.normalized_status == "cancelled" + + +def test_status_raises_bohr_cli_error_on_failure(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_err("record not found")) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + with pytest.raises(BohrCLIError, match="record not found"): + adapter.status("nonexistent") + + +def test_cancel_invokes_terminate_with_no_wait(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + adapter.cancel("20543207") + + command = captured["command"] + assert command[1:5] == ["job", "terminate", "--id", "20543207"] + assert "--no-wait" in command + + +def test_collect_outputs_downloads_and_lists_files(monkeypatch, tmp_path) -> None: + def fake_run(command, **kwargs): + # Simulate the download command producing files in the destination. + dest = tmp_path / "out" + dest.mkdir(parents=True, exist_ok=True) + (dest / "OUTCAR").write_text("data") + (dest / "vasprun.xml").write_text("data") + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrJobAdapter() + + artifacts = adapter.collect_outputs("20543207", tmp_path / "out") + + sources = {artifact["source"] for artifact in artifacts} + destinations = {artifact["destination"] for artifact in artifacts} + assert sources == {"20543207"} + assert destinations == {str(tmp_path / "out" / "OUTCAR"), str(tmp_path / "out" / "vasprun.xml")} + + +def test_capabilities_are_batch_collect_only() -> None: + adapter = BohrJobAdapter() + + assert adapter.capabilities == frozenset({RemoteJobCapability.BATCH_COLLECT}) + assert adapter.provider == "bohr_job" diff --git a/tests/test_bohr_sandbox_adapter.py b/tests/test_bohr_sandbox_adapter.py new file mode 100644 index 00000000..3c541935 --- /dev/null +++ b/tests/test_bohr_sandbox_adapter.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import subprocess + +import pytest + +from matcreator.control_plane.providers._bohr_cli import BohrCLIError +from matcreator.control_plane.providers.base import RemoteJobCapability +from matcreator.control_plane.providers.bohr_sandbox import BohrSandboxAdapter + + +class _FakeCompleted: + def __init__(self, stdout: str, returncode: int = 0, stderr: str = "") -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def _ok(data) -> str: + return json.dumps({"ok": True, "data": data}) + + +def _err(message: str) -> str: + return json.dumps({"ok": False, "error": {"message": message}}) + + +def test_create_builds_command_and_extracts_sandbox_id(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + # Real `bohr sandbox create -o json` payload shape (CLI 2.6.15): + # the ID key is "sandboxID", not "sandbox_id". + return _FakeCompleted( + _ok( + { + "sandboxID": "default--sdbxdefault-abc12", + "templateID": "sdbxagent", + "state": "running", + "domain": "bohr-sandbox.bohrium.com", + } + ) + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + sandbox_id = adapter.create( + { + "project_id": 1234, + "template": "sdbxagent", + "timeout": 3600, + "env": {"FOO": "bar"}, + } + ) + + assert sandbox_id == "default--sdbxdefault-abc12" + command = captured["command"] + assert command[1:3] == ["sandbox", "create"] + assert "--template" in command and "sdbxagent" in command + assert "--project-id" in command and "1234" in command + assert "--timeout" in command and "3600" in command + assert "--env" in command and "FOO=bar" in command + + +def test_create_requires_template() -> None: + adapter = BohrSandboxAdapter() + + with pytest.raises(ValueError, match="template"): + adapter.create({"project_id": 1234}) + + +def test_create_accepts_snake_case_sandbox_id_spelling(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"sandbox_id": "default--sdbxdefault-abc12"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert BohrSandboxAdapter().create( + {"project_id": 1, "template": "sdbxagent"} + ) == "default--sdbxdefault-abc12" + + +def test_create_requires_project_id() -> None: + adapter = BohrSandboxAdapter() + + with pytest.raises(ValueError, match="project_id"): + adapter.create({}) + + +def test_create_raises_when_no_sandbox_id_returned(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"state": "running", "templateID": "sdbxagent"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + with pytest.raises(BohrCLIError, match=r"did not return a sandbox ID.*state.*templateID"): + adapter.create({"project_id": 1, "template": "sdbxagent"}) + + +def test_status_reports_liveness_without_normalized_status_by_default(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"status": "running"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + status = adapter.status("sbx-1") + + assert status.normalized_status is None + assert status.snapshot["provider_status"] == "reachable" + + +def test_status_maps_unreachable_states_to_lost(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(_ok({"status": "terminated"})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + status = adapter.status("sbx-1") + + assert status.normalized_status == "lost" + assert status.snapshot["provider_status"] == "unreachable" + + +def test_cancel_invokes_delete_with_force(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + adapter.cancel("sbx-1") + + command = captured["command"] + assert command[1:4] == ["sandbox", "delete", "sbx-1"] + assert "--force" in command + + +def test_run_command_parses_stdout_stderr_exit_code(monkeypatch) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + captured["kwargs"] = kwargs + return _FakeCompleted(_ok({"stdout": "hello\n", "stderr": "", "exit_code": 0})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + result = adapter.run_command("sbx-1", "echo hello") + + assert result == {"stdout": "hello\n", "stderr": "", "exit_code": 0} + command = captured["command"] + assert command[1:5] == ["sandbox", "exec", "sbx-1", "--command"] + assert "echo hello" in command + + +def test_run_command_disables_both_cli_and_subprocess_timeouts(monkeypatch) -> None: + """`bohr sandbox exec` caps a command at 90s by default; a long-running + remote computation must not be silently truncated, matching the E2B + adapter's unbounded `timeout=0` command semantics.""" + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + captured["kwargs"] = kwargs + return _FakeCompleted(_ok({"stdout": "", "stderr": "", "exit_code": 0})) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + adapter.run_command("sbx-1", "sleep 300") + + command = captured["command"] + timeout_index = command.index("--timeout") + assert command[timeout_index + 1] == "0" + assert captured["kwargs"]["timeout"] is None + + +def test_upload_file_rejects_missing_source(tmp_path) -> None: + adapter = BohrSandboxAdapter() + + with pytest.raises(FileNotFoundError): + adapter.upload_file("sbx-1", tmp_path / "missing.txt", "/home/user/missing.txt") + + +def test_upload_file_invokes_files_write(monkeypatch, tmp_path) -> None: + source = tmp_path / "input.txt" + source.write_text("hello") + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + + adapter.upload_file("sbx-1", source, "/home/user/input.txt") + + command = captured["command"] + assert command[1:5] == ["sandbox", "files", "write", "sbx-1"] + assert "/home/user/input.txt" in command + assert "--source" in command and str(source) in command + + +def test_download_file_invokes_files_read_and_creates_parent_dir(monkeypatch, tmp_path) -> None: + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return _FakeCompleted(_ok(None)) + + monkeypatch.setattr(subprocess, "run", fake_run) + adapter = BohrSandboxAdapter() + dest = tmp_path / "outputs" / "CHGCAR" + + result = adapter.download_file("sbx-1", "/home/user/CHGCAR", dest) + + assert result == dest.resolve() + assert dest.parent.is_dir() + command = captured["command"] + assert command[1:5] == ["sandbox", "files", "read", "sbx-1"] + assert "--destination" in command and str(dest.resolve()) in command + + +def test_capabilities_have_no_pause() -> None: + adapter = BohrSandboxAdapter() + + assert adapter.capabilities == frozenset( + {RemoteJobCapability.INTERACTIVE_EXEC, RemoteJobCapability.FILE_TRANSFER} + ) + assert adapter.provider == "bohr_sandbox" diff --git a/tests/test_e2b_adapter.py b/tests/test_e2b_adapter.py index ed363c8e..b3a43cbe 100644 --- a/tests/test_e2b_adapter.py +++ b/tests/test_e2b_adapter.py @@ -5,7 +5,7 @@ import pytest -from matcreator.control_plane.e2b import E2BConfigurationError, E2BSandboxAdapter, E2BSandboxSpec +from matcreator.control_plane.providers.e2b import E2BConfigurationError, E2BSandboxAdapter, E2BSandboxSpec class _FakeResult: @@ -52,6 +52,7 @@ class _FakeSandbox: sandbox_id = "sandbox-123" created_with: dict = {} connected_to: list[str] = [] + connect_opts: list[dict] = [] paused = False killed = False files = _FakeFiles() @@ -62,14 +63,17 @@ def create(cls, **kwargs): return cls() @classmethod - def connect(cls, sandbox_id): + def connect(cls, sandbox_id, **opts): cls.connected_to.append(sandbox_id) + cls.connect_opts.append(opts) return cls() class commands: + last_command: str | None = None + @staticmethod def run(command, user, **kwargs): - assert command == "echo hello" + _FakeSandbox.commands.last_command = command assert user == "root" return _FakeResult() @@ -84,22 +88,27 @@ def kill(self): def fake_e2b_module(monkeypatch): _FakeSandbox.created_with = {} _FakeSandbox.connected_to = [] + _FakeSandbox.connect_opts = [] _FakeSandbox.paused = False _FakeSandbox.killed = False _FakeSandbox.files = _FakeFiles() monkeypatch.setitem(sys.modules, "e2b_code_interpreter", types.SimpleNamespace(Sandbox=_FakeSandbox)) + # Reconnects require the endpoint configuration in the environment. + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") def test_adapter_creates_sandbox_with_project_header() -> None: adapter = E2BSandboxAdapter() sandbox_id = adapter.create( - E2BSandboxSpec( - template="doc-compiler", - api_key="secret", - api_url="https://e2b.example", - project_id="project-42", - lifecycle={"on_timeout": "pause"}, - ) + { + "template": "doc-compiler", + "api_key": "secret", + "api_url": "https://e2b.example", + "project_id": "project-42", + "lifecycle": {"on_timeout": "pause"}, + } ) assert sandbox_id == "sandbox-123" @@ -115,12 +124,70 @@ def test_adapter_connects_for_command_and_controls() -> None: "stderr": "", "exit_code": 0, } + assert _FakeSandbox.commands.last_command == "echo hello" adapter.pause("sandbox-123") adapter.terminate("sandbox-123") assert _FakeSandbox.connected_to == ["sandbox-123", "sandbox-123", "sandbox-123"] assert _FakeSandbox.paused is True assert _FakeSandbox.killed is True + # Reconnects must carry the configured endpoint, never the SDK default. + assert _FakeSandbox.connect_opts[0] == { + "api_key": "secret", + "api_url": "https://e2b.example", + "headers": {"X-Project-Id": "project-42"}, + } + + +def test_adapter_connect_fails_loudly_without_endpoint_configuration(monkeypatch) -> None: + adapter = E2BSandboxAdapter() + monkeypatch.delenv("E2B_API_KEY", raising=False) + + with pytest.raises(E2BConfigurationError, match="E2B_API_KEY and E2B_API_URL"): + adapter.run_command("sandbox-123", "echo hello") + assert _FakeSandbox.connected_to == [] + + +def test_adapter_cancel_aliases_terminate() -> None: + adapter = E2BSandboxAdapter() + + adapter.cancel("sandbox-123") + + assert _FakeSandbox.killed is True + + +def test_adapter_create_error_includes_request_context(monkeypatch) -> None: + def _boom(**kwargs): + raise RuntimeError("404: Resource not found") + + monkeypatch.setattr(_FakeSandbox, "create", _boom) + adapter = E2BSandboxAdapter() + + with pytest.raises(RuntimeError) as excinfo: + adapter.create( + { + "template": "doc-compiler", + "api_key": "secret", + "api_url": "https://open.bohrium.com/wrong/path", + "project_id": "project-42", + } + ) + + message = str(excinfo.value) + assert "404: Resource not found" in message + assert "https://open.bohrium.com/wrong/path" in message + assert "doc-compiler" in message + assert "project-42" in message + assert "secret" not in message + + +def test_adapter_status_reports_liveness_without_a_normalized_status() -> None: + adapter = E2BSandboxAdapter() + + status = adapter.status("sandbox-123") + + assert status.normalized_status is None + assert status.snapshot["provider_status"] == "reachable" def test_adapter_download_file_streams_to_local_destination(tmp_path) -> None: diff --git a/tests/test_e2b_tools.py b/tests/test_e2b_tools.py deleted file mode 100644 index 75839cea..00000000 --- a/tests/test_e2b_tools.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from matcreator.agents.execution_agent import e2b_tools -from matcreator.control_plane.remote_job_service import E2BConnectionConfig - - -class _FakeService: - def __init__(self) -> None: - self.submissions: list[dict] = [] - self.store = self - - def submit_e2b(self, **kwargs): - self.submissions.append(kwargs) - return { - "job_id": "job-123", - "status": "running", - "external_id": "sandbox-123", - } - - def get_job(self, job_id: str): - if job_id != "job-123": - return None - return { - "job_id": job_id, - "owner_id": "alice", - "session_id": "session-1", - "status": "running", - "external_id": "sandbox-123", - "snapshot": {}, - "error": None, - "updated_at": 1, - } - - def list_events(self, job_id: str): - return [{"event_type": "user_control", "payload": {"action": "terminate", "source": "ui"}}] - - def pause_e2b(self, job_id: str): - return {"job_id": job_id, "status": "paused", "external_id": "sandbox-123"} - - def terminate_e2b(self, job_id: str): - return {"job_id": job_id, "status": "terminated", "external_id": "sandbox-123"} - - def run_e2b_command(self, job_id: str, command: str, *, user: str): - return {"stdout": f"ran {command}", "stderr": "", "exit_code": 0} - - def upload_e2b_file(self, job_id: str, source, destination: str): - return {"source": str(source), "destination": destination} - - def download_e2b_file(self, job_id: str, source: str, destination: str): - return {"source": source, "destination": str(destination)} - - -def _context(): - return SimpleNamespace( - state={ - "session_id": "session-1", - "_graph_exec_node_id": "execution_0__node_relax", - "step_number": 2, - }, - _invocation_context=SimpleNamespace(user_id="alice"), - ) - - -def test_submit_e2b_tool_uses_current_session_and_node(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - monkeypatch.setenv("E2B_API_KEY", "secret") - monkeypatch.setenv("E2B_API_URL", "https://e2b.example") - monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") - - result = e2b_tools.submit_e2b_sandbox(_context(), timeout=120, template="doc-compiler") - - assert result == { - "status": "running", - "job_id": "job-123", - "sandbox_id": "sandbox-123", - "message": "Tracked E2B sandbox is ready. Use its job_id for status or controls.", - } - submission = service.submissions[0] - assert submission["owner_id"] == "alice" - assert submission["session_id"] == "session-1" - assert submission["node_id"] == "relax" - assert submission["step_number"] == 2 - assert submission["connection"] == E2BConnectionConfig( - api_key="secret", - api_url="https://e2b.example", - project_id="project-42", - template="doc-compiler", - ) - - -def test_submit_e2b_tool_requires_explicit_template(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - - result = e2b_tools.submit_e2b_sandbox(_context()) - - assert result["status"] == "error" - assert "template is required" in result["message"] - assert service.submissions == [] - - -def test_e2b_connection_uses_configured_environment_names(monkeypatch) -> None: - monkeypatch.setenv("E2B_API_KEY", "access-key") - monkeypatch.setenv("E2B_API_URL", "https://e2b.example") - monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-7") - - connection = e2b_tools._connection() - - assert connection == E2BConnectionConfig( - api_key="access-key", - api_url="https://e2b.example", - project_id="project-7", - template="", - ) - - -def test_e2b_tools_reject_jobs_from_another_session(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() - context._invocation_context.user_id = "bob" - - assert e2b_tools.get_e2b_job_status("job-123", context) == { - "status": "error", - "message": "E2B job was not found in this session.", - } - - -def test_e2b_status_exposes_user_sandbox_control(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - - status = e2b_tools.get_e2b_job_status("job-123", _context()) - - assert status["user_control"] == {"action": "terminate", "source": "ui"} - - -def test_e2b_command_and_workspace_upload_are_scoped_to_owned_job(tmp_path, monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() - context.state["workspace_dir"] = str(tmp_path) - source = tmp_path / "input.txt" - source.write_text("input", encoding="utf-8") - - assert e2b_tools.run_e2b_command("job-123", "echo hello", context) == { - "stdout": "ran echo hello", "stderr": "", "exit_code": 0 - } - assert e2b_tools.upload_e2b_input("job-123", "input.txt", "/home/user/input.txt", context) == { - "source": str(source), "destination": "/home/user/input.txt" - } - assert e2b_tools.upload_e2b_input("job-123", "/tmp/outside.txt", "/tmp/outside.txt", context)["status"] == "error" - - -def test_download_e2b_output_is_scoped_to_workspace(tmp_path, monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() - context.state["workspace_dir"] = str(tmp_path) - destination = tmp_path / "outputs" / "CHGCAR" - - result = e2b_tools.download_e2b_output( - "job-123", "/home/user/CHGCAR", str(destination), context - ) - assert result == {"source": "/home/user/CHGCAR", "destination": str(destination.resolve())} - - assert e2b_tools.download_e2b_output( - "job-123", "/home/user/CHGCAR", "/tmp/outside.txt", context - )["status"] == "error" - - -def test_download_e2b_output_rejects_missing_workspace_dir(monkeypatch) -> None: - service = _FakeService() - monkeypatch.setattr(e2b_tools, "_service", lambda: service) - context = _context() # no workspace_dir set - - result = e2b_tools.download_e2b_output( - "job-123", "/home/user/CHGCAR", "outputs/CHGCAR", context - ) - assert result["status"] == "error" - assert "workspace_dir" in result["message"] \ No newline at end of file diff --git a/tests/test_execution_grouping.py b/tests/test_execution_grouping.py new file mode 100644 index 00000000..cc8943b4 --- /dev/null +++ b/tests/test_execution_grouping.py @@ -0,0 +1,129 @@ +import unittest + +from pydantic import ValidationError + +from agents.MatCreator.agents.execution_agent.agent import ( + build_execution_groups, + build_execution_waves, +) +from agents.MatCreator.agents.execution_agent.step_executor import StepExecutorInput + + +class _DummyToolContext: + def __init__(self, state: dict): + self.state = state + + +class TestExecutionGrouping(unittest.TestCase): + def test_groups_consecutive_same_skill(self) -> None: + ctx = _DummyToolContext( + { + "current_step_index": 0, + "plan": { + "steps": [ + {"step_number": 1, "skill": "vasp", "action": "Prepare input files."}, + {"step_number": 2, "skill": "vasp", "action": "Run relaxation."}, + {"step_number": 3, "skill": "plot", "action": "Plot total energy."}, + ] + }, + } + ) + + result = build_execution_groups(ctx) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["groups"]), 2) + self.assertEqual(result["groups"][0]["step_numbers"], [1, 2]) + self.assertEqual(result["groups"][0]["skill_name"], "vasp") + self.assertEqual(result["groups"][1]["step_numbers"], [3]) + self.assertEqual(result["groups"][1]["skill_name"], "plot") + + def test_dependency_marker_starts_new_group(self) -> None: + ctx = _DummyToolContext( + { + "current_step_index": 0, + "plan": { + "steps": [ + {"step_number": 1, "skill": "vasp", "action": "Run static calculation."}, + { + "step_number": 2, + "skill": "vasp", + "action": "Using previous step results, extract DOS.", + }, + ] + }, + } + ) + + result = build_execution_groups(ctx) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["groups"]), 2) + self.assertEqual(result["groups"][0]["step_numbers"], [1]) + self.assertEqual(result["groups"][1]["step_numbers"], [2]) + + def test_build_execution_waves_parallelizes_distinct_skills(self) -> None: + groups = [ + { + "group_id": "group_1_2", + "skill_name": "vasp", + "step_numbers": [1, 2], + "actions": ["Prepare inputs.", "Run relax."], + }, + { + "group_id": "group_3_3", + "skill_name": "plot", + "step_numbers": [3], + "actions": ["Plot band structure."], + }, + ] + + result = build_execution_waves(groups) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["waves"]), 1) + self.assertEqual(len(result["waves"][0]), 2) + + def test_build_execution_waves_serializes_dependency_marked_group(self) -> None: + groups = [ + { + "group_id": "group_1_1", + "skill_name": "vasp", + "step_numbers": [1], + "actions": ["Run SCF."], + }, + { + "group_id": "group_2_2", + "skill_name": "plot", + "step_numbers": [2], + "actions": ["Using previous step results, plot DOS."], + }, + ] + + result = build_execution_waves(groups) + self.assertEqual(result["status"], "ok") + self.assertEqual(len(result["waves"]), 2) + self.assertEqual(result["waves"][0][0]["group_id"], "group_1_1") + self.assertEqual(result["waves"][1][0]["group_id"], "group_2_2") + + +class TestStepExecutorInputNormalization(unittest.TestCase): + def test_legacy_fields_are_normalized(self) -> None: + payload = StepExecutorInput( + step_number=2, + action="Run calculation.", + skill_name="vasp", + workspace_dir="/tmp/work", + ) + self.assertEqual(payload.step_numbers, [2]) + self.assertEqual(payload.actions, ["Run calculation."]) + + def test_mismatched_lengths_fail_validation(self) -> None: + with self.assertRaises(ValidationError): + StepExecutorInput( + step_numbers=[1, 2], + actions=["one"], + skill_name="vasp", + workspace_dir="/tmp/work", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_execution_recovery.py b/tests/test_execution_recovery.py index a41072db..c9a2a862 100644 --- a/tests/test_execution_recovery.py +++ b/tests/test_execution_recovery.py @@ -449,6 +449,7 @@ def test_reconcile_waits_for_active_remote_job_instead_of_resubmitting(tmp_path, "provider": "e2b", "external_id": "sandbox-123", "status": "running", + "has_background_command": False, } diff --git a/tests/test_matcreator_phase_and_skills.py b/tests/test_matcreator_phase_and_skills.py new file mode 100644 index 00000000..285990a2 --- /dev/null +++ b/tests/test_matcreator_phase_and_skills.py @@ -0,0 +1,48 @@ +import unittest + +from agents.MatCreator.agent import before_agent_callback_root +from agents.MatCreator.planning_agent import planning_agent +from agents.MatCreator.prompts.workflow import get_all_workflow_types, search_skills + + +class _DummySession: + def __init__(self): + self.id = "test-session" + self.user_id = "test-user" + self.app_name = "test-app" + self.state = {} + + +class _DummyInvocationContext: + def __init__(self): + self.session = _DummySession() + + +class _DummyCallbackContext: + def __init__(self): + self._invocation_context = _DummyInvocationContext() + + +class TestMatCreatorPhaseAndSkills(unittest.TestCase): + def test_before_agent_callback_sets_default_phase(self) -> None: + callback_context = _DummyCallbackContext() + before_agent_callback_root(callback_context) + self.assertEqual(callback_context._invocation_context.session.state["phase"], "thinking") + + def test_skill_registry_loads_expected_workflows(self) -> None: + workflow_types = get_all_workflow_types() + self.assertIn("default", workflow_types) + self.assertIn("pfd", workflow_types) + + def test_skill_search_returns_matching_workflow(self) -> None: + results = search_skills("fine-tune distillation active learning", workflow_type="pfd", top_k=2) + self.assertGreaterEqual(len(results), 1) + self.assertEqual(results[0].workflow_type, "pfd") + + def test_planning_agent_has_toolized_subagents(self) -> None: + tools = getattr(planning_agent, "tools", []) or [] + self.assertGreaterEqual(len(tools), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_remote_job_monitor.py b/tests/test_remote_job_monitor.py index ced335e1..1ba0e0e7 100644 --- a/tests/test_remote_job_monitor.py +++ b/tests/test_remote_job_monitor.py @@ -2,57 +2,71 @@ import asyncio +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus from matcreator.control_plane.remote_job_monitor import RemoteJobMonitor -from matcreator.control_plane.remote_job_service import E2BConnectionConfig, RemoteJobService +from matcreator.control_plane.remote_job_service import RemoteJobService from matcreator.control_plane.remote_jobs import RemoteJobStore -class _FakeE2BAdapter: +class _FakeAdapter(RemoteJobAdapter): + provider = "e2b" + capabilities = frozenset({RemoteJobCapability.PAUSE}) + poll_interval_seconds = 1.0 + def __init__(self, *, reachable: bool = True) -> None: self.reachable = reachable self.probes: list[str] = [] - def create(self, _spec): + def create(self, spec: dict) -> str: return "sandbox-123" - def probe(self, sandbox_id: str): - self.probes.append(sandbox_id) + def status(self, external_id: str) -> RemoteJobStatus: + self.probes.append(external_id) if not self.reachable: raise RuntimeError("sandbox unavailable") - return {"provider_status": "reachable", "sandbox_id": sandbox_id} + return RemoteJobStatus(normalized_status=None, snapshot={"provider_status": "reachable", "sandbox_id": external_id}) + + def cancel(self, external_id: str) -> None: + pass + + def pause(self, external_id: str) -> None: + pass -def _create_running_job(tmp_path, adapter: _FakeE2BAdapter): +def _create_running_job(tmp_path, adapter: _FakeAdapter): store = RemoteJobStore(tmp_path / "remote-jobs.db") - service = RemoteJobService(store, e2b_adapter=adapter) - job = service.submit_e2b( + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=E2BConnectionConfig( - api_key="secret", - api_url="https://e2b.example", - project_id="project-42", - template="doc-compiler", - ), + spec={ + "template": "doc-compiler", + "api_key": "secret", + "api_url": "https://e2b.example", + "project_id": "project-42", + }, ) return store, service, job def test_monitor_reconciles_running_job_after_restart(tmp_path) -> None: - adapter = _FakeE2BAdapter() + adapter = _FakeAdapter() store, service, job = _create_running_job(tmp_path, adapter) monitor = RemoteJobMonitor(store, service, interval_seconds=1) updates = asyncio.run(monitor.reconcile_once()) assert [item["job_id"] for item in updates] == [job["job_id"]] - assert adapter.probes == ["sandbox-123"] + # One status() call happens inside submit_job itself (initial probe), one + # more from the explicit reconcile_once() call above. + assert adapter.probes == ["sandbox-123", "sandbox-123"] assert store.get_job(job["job_id"])["snapshot"]["provider_status"] == "reachable" def test_monitor_backs_off_unreachable_job_and_skips_paused_jobs(tmp_path) -> None: - adapter = _FakeE2BAdapter(reachable=False) + adapter = _FakeAdapter(reachable=False) store, service, job = _create_running_job(tmp_path, adapter) monitor = RemoteJobMonitor(store, service, interval_seconds=1, max_backoff_seconds=4) @@ -61,8 +75,53 @@ def test_monitor_backs_off_unreachable_job_and_skips_paused_jobs(tmp_path) -> No assert first[0]["snapshot"]["provider_status"] == "unreachable" assert second == [] - assert adapter.probes == ["sandbox-123"] paused = store.transition_job(job["job_id"], "pause_requested") store.transition_job(job["job_id"], "paused", expected_revision=paused["state_revision"]) monitor._next_due.clear() - assert asyncio.run(monitor.reconcile_once()) == [] \ No newline at end of file + assert asyncio.run(monitor.reconcile_once()) == [] + + +def test_monitor_reconciles_jobs_across_multiple_providers(tmp_path) -> None: + """A batch-style provider with a longer poll interval is reconciled the + same way as an interactive one — the monitor never branches on provider + name, only on each adapter's declared poll_interval_seconds.""" + + class _BatchAdapter(RemoteJobAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + poll_interval_seconds = 60.0 + + def __init__(self) -> None: + self.probes: list[str] = [] + + def create(self, spec: dict) -> str: + return "bohr-1" + + def status(self, external_id: str) -> RemoteJobStatus: + self.probes.append(external_id) + return RemoteJobStatus(normalized_status=None, snapshot={"phase": "running"}) + + def cancel(self, external_id: str) -> None: + pass + + e2b_adapter = _FakeAdapter() + batch_adapter = _BatchAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"e2b": e2b_adapter, "bohr_job": batch_adapter}) + e2b_job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", + spec={"template": "t", "api_key": "k", "api_url": "u", "project_id": "p"}, + ) + batch_job = service.submit_job( + owner_id="alice", session_id="session-1", provider="bohr_job", + idempotency_key="session-1:node-2:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + + monitor = RemoteJobMonitor(store, service, interval_seconds=1) + updates = asyncio.run(monitor.reconcile_once()) + + reconciled_ids = {item["job_id"] for item in updates} + assert reconciled_ids == {e2b_job["job_id"], batch_job["job_id"]} + assert monitor._next_due[batch_job["job_id"]] > monitor._next_due[e2b_job["job_id"]] diff --git a/tests/test_remote_job_provider_registry.py b/tests/test_remote_job_provider_registry.py new file mode 100644 index 00000000..40fa5a58 --- /dev/null +++ b/tests/test_remote_job_provider_registry.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import subprocess + +import pytest + +from matcreator.control_plane.providers import registry +from matcreator.control_plane.providers._bohr_cli import BohrCLIError, run_bohr_json +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobStatus + + +class _FakeCompleted: + def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0) -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def test_run_bohr_json_raises_on_missing_binary(monkeypatch) -> None: + def fake_run(command, **kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="not installed"): + run_bohr_json(["job", "list"]) + + +def test_run_bohr_json_raises_on_timeout(monkeypatch) -> None: + def fake_run(command, **kwargs): + raise subprocess.TimeoutExpired(cmd=command, timeout=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="timed out"): + run_bohr_json(["job", "list"], timeout=1) + + +def test_run_bohr_json_raises_on_empty_output(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(stdout="", stderr="permission denied", returncode=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="permission denied"): + run_bohr_json(["job", "list"]) + + +def test_run_bohr_json_raises_on_non_json_output(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(stdout="not json") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(BohrCLIError, match="non-JSON"): + run_bohr_json(["job", "list"]) + + +def test_run_bohr_json_returns_data_on_success(monkeypatch) -> None: + def fake_run(command, **kwargs): + return _FakeCompleted(stdout='{"ok": true, "data": {"a": 1}}') + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert run_bohr_json(["job", "list"]) == {"a": 1} + + +class _DummyAdapter(RemoteJobAdapter): + provider = "dummy" + + def create(self, spec): + return "id-1" + + def status(self, external_id): + return RemoteJobStatus(normalized_status=None) + + def cancel(self, external_id): + pass + + +def test_registry_lazy_construction_and_provider_mismatch_detection(): + registry.reset_registry() + try: + calls = [] + + def factory(): + calls.append(1) + return _DummyAdapter() + + registry.register_adapter("dummy", factory) + assert calls == [] # not constructed until first get_adapter call + + adapter = registry.get_adapter("dummy") + assert isinstance(adapter, _DummyAdapter) + assert calls == [1] + + # Cached: second call does not re-invoke the factory. + registry.get_adapter("dummy") + assert calls == [1] + + with pytest.raises(KeyError): + registry.get_adapter("nonexistent") + + class _MismatchedAdapter(_DummyAdapter): + provider = "other-name" + + registry.register_adapter("mismatched", _MismatchedAdapter) + with pytest.raises(ValueError, match="reports provider"): + registry.get_adapter("mismatched") + finally: + registry.reset_registry() + # Re-register built-ins so later tests in the same process still see them. + import importlib + + import matcreator.control_plane.providers as providers_pkg + + importlib.reload(providers_pkg) diff --git a/tests/test_remote_job_service.py b/tests/test_remote_job_service.py index febc81e0..cd740882 100644 --- a/tests/test_remote_job_service.py +++ b/tests/test_remote_job_service.py @@ -1,65 +1,124 @@ from __future__ import annotations -from matcreator.control_plane.remote_job_service import E2BConnectionConfig, RemoteJobService +import base64 +import re + +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus +from matcreator.control_plane.remote_job_service import RemoteJobService from matcreator.control_plane.remote_jobs import RemoteJobStore -class _FakeE2BAdapter: +class _FakeAdapter(RemoteJobAdapter): + """Fake adapter conforming to the provider protocol, used via adapter_overrides. + + Declares every optional capability by default so one fake can cover the + submit/pause/terminate/command/upload/download surface; a test can shrink + ``capabilities`` to exercise CapabilityError handling. + """ + + provider = "e2b" + capabilities = frozenset( + { + RemoteJobCapability.PAUSE, + RemoteJobCapability.INTERACTIVE_EXEC, + RemoteJobCapability.FILE_TRANSFER, + } + ) + def __init__(self) -> None: - self.created_specs = [] - self.paused = [] - self.terminated = [] + self.created_specs: list[dict] = [] + self.paused: list[str] = [] + self.cancelled: list[str] = [] self.on_run = None + self.files: dict[str, bytes] = {} + self.launched_commands: list[str] = [] - def create(self, spec): + def create(self, spec: dict) -> str: self.created_specs.append(spec) return "sandbox-123" - def pause(self, sandbox_id: str) -> None: - self.paused.append(sandbox_id) + def status(self, external_id: str) -> RemoteJobStatus: + return RemoteJobStatus(normalized_status=None, snapshot={"provider_status": "reachable"}) + + def cancel(self, external_id: str) -> None: + self.cancelled.append(external_id) - def terminate(self, sandbox_id: str) -> None: - self.terminated.append(sandbox_id) + def pause(self, external_id: str) -> None: + self.paused.append(external_id) - def run_command(self, sandbox_id: str, command: str, *, user: str) -> dict: + def run_command(self, external_id: str, command: str, *, user: str = "root") -> dict: if self.on_run: self.on_run() + # Minimal shell simulation covering the three wrapper patterns + # RemoteJobService.start_job_command/poll_job_command construct, so + # tests can exercise them without a real shell. + launch = re.search( + r"rm -f (\S+); nohup sh -c 'echo (\S+) \| base64 -d \| sh; echo \$\? > \S+' > (\S+) 2>&1", + command, + ) + if launch: + exit_path, payload, log_path = launch.groups() + self.launched_commands.append(base64.b64decode(payload).decode("utf-8")) + self.files.pop(exit_path, None) + self.files.setdefault(log_path, b"") + return {"stdout": "LAUNCHED\n", "stderr": "", "exit_code": 0} + check = re.match(r"if \[ -f (\S+) \]; then echo DONE:\$\(cat \S+\); else echo RUNNING; fi", command) + if check: + exit_path = check.group(1) + if exit_path in self.files: + code = self.files[exit_path].decode("utf-8").strip() + return {"stdout": f"DONE:{code}\n", "stderr": "", "exit_code": 0} + return {"stdout": "RUNNING\n", "stderr": "", "exit_code": 0} + tail = re.match(r"tail -c (\d+) (\S+)", command) + if tail: + n, log_path = tail.groups() + data = self.files.get(log_path, b"") + return {"stdout": data[-int(n):].decode("utf-8", errors="replace"), "stderr": "", "exit_code": 0} return {"stdout": "", "stderr": "", "exit_code": 0} - def upload_file(self, sandbox_id: str, source, destination: str) -> None: + def upload_file(self, external_id: str, source, destination: str) -> None: self.uploads = getattr(self, "uploads", []) - self.uploads.append((sandbox_id, str(source), destination)) + self.uploads.append((external_id, str(source), destination)) - def download_file(self, sandbox_id: str, source: str, destination) -> str: + def download_file(self, external_id: str, source: str, destination, *, user: str | None = None): self.downloads = getattr(self, "downloads", []) - self.downloads.append((sandbox_id, source, str(destination))) - return str(destination) + self.downloads.append((external_id, source, str(destination))) + return destination -def _connection() -> E2BConnectionConfig: - return E2BConnectionConfig( - api_key="super-secret", - api_url="https://e2b.example", - project_id="project-42", - template="doc-compiler", - ) +def _spec() -> dict: + return { + "template": "doc-compiler", + "api_key": "super-secret", + "api_url": "https://e2b.example", + "project_id": "project-42", + "timeout": 600, + } + +def _persisted_spec() -> dict: + return {key: value for key, value in _spec().items() if key != "api_key"} -def test_submit_e2b_persists_sandbox_without_api_key_and_is_idempotent(tmp_path) -> None: - adapter = _FakeE2BAdapter() - service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), e2b_adapter=adapter) - job = service.submit_e2b( +def test_submit_job_persists_sandbox_without_api_key_and_is_idempotent(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), + persisted_specification=_persisted_spec(), ) - replay = service.submit_e2b( + replay = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), + persisted_specification=_persisted_spec(), ) assert job["status"] == "running" @@ -69,34 +128,128 @@ def test_submit_e2b_persists_sandbox_without_api_key_and_is_idempotent(tmp_path) assert len(adapter.created_specs) == 1 -def test_e2b_job_controls_update_durable_state(tmp_path) -> None: - adapter = _FakeE2BAdapter() - service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), e2b_adapter=adapter) - job = service.submit_e2b( +def test_submit_job_retries_after_a_creation_failure(tmp_path) -> None: + """A job that failed before acquiring an external ID must not poison its + + idempotency key forever: the next submission with the same key resets the + record and re-attempts provider creation.""" + + class _FlakyAdapter(_FakeAdapter): + def __init__(self) -> None: + super().__init__() + self.create_calls = 0 + + def create(self, spec: dict) -> str: + self.create_calls += 1 + if self.create_calls == 1: + raise ValueError("dictionary update sequence element #0 has length 1; 2 is required") + return super().create(spec) + + adapter = _FlakyAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + + failed = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + assert failed["status"] == "failed" + assert failed["external_id"] is None + assert "dictionary update sequence" in failed["error"] + + retried = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + assert retried["job_id"] == failed["job_id"] + assert retried["status"] == "running" + assert retried["external_id"] == "sandbox-123" + assert retried["error"] is None + assert adapter.create_calls == 2 + + +def test_submit_job_does_not_retry_a_failure_that_has_an_external_id(tmp_path) -> None: + adapter = _FakeAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), ) + store.transition_job(job["job_id"], "failed", error="provider died mid-run") - paused = service.pause_e2b(job["job_id"]) + replay = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + assert replay["status"] == "failed" + assert replay["error"] == "provider died mid-run" + assert len(adapter.created_specs) == 1 + + +def test_job_controls_update_durable_state(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + paused = service.pause_job(job["job_id"]) assert paused["status"] == "paused" assert adapter.paused == ["sandbox-123"] - terminated = service.terminate_e2b(paused["job_id"]) + terminated = service.terminate_job(paused["job_id"]) assert terminated["status"] == "terminated" - assert adapter.terminated == ["sandbox-123"] + assert adapter.cancelled == ["sandbox-123"] -def test_e2b_command_merges_telemetry_after_monitor_observation(tmp_path) -> None: - adapter = _FakeE2BAdapter() +def test_pause_job_raises_capability_error_for_pause_unsupported_provider(tmp_path) -> None: + class _NoPauseAdapter(_FakeAdapter): + capabilities = frozenset({RemoteJobCapability.INTERACTIVE_EXEC, RemoteJobCapability.FILE_TRANSFER}) + + adapter = _NoPauseAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + try: + service.pause_job(job["job_id"]) + assert False, "expected CapabilityError" + except Exception as exc: + assert "does not support 'pause'" in str(exc) + + +def test_command_merges_telemetry_after_monitor_observation(tmp_path) -> None: + adapter = _FakeAdapter() store = RemoteJobStore(tmp_path / "remote-jobs.db") - service = RemoteJobService(store, e2b_adapter=adapter) - job = service.submit_e2b( + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), ) adapter.on_run = lambda: store.record_observation( @@ -105,7 +258,7 @@ def test_e2b_command_merges_telemetry_after_monitor_observation(tmp_path) -> Non expected_revision=store.get_job(job["job_id"])["state_revision"], ) - assert service.run_e2b_command(job["job_id"], "echo done") == { + assert service.run_job_command(job["job_id"], "echo done") == { "stdout": "", "stderr": "", "exit_code": 0 } assert store.get_job(job["job_id"])["snapshot"] == { @@ -115,25 +268,205 @@ def test_e2b_command_merges_telemetry_after_monitor_observation(tmp_path) -> Non } -def test_download_e2b_file_merges_telemetry_and_returns_paths(tmp_path) -> None: - adapter = _FakeE2BAdapter() +def test_download_job_file_merges_telemetry_and_returns_paths(tmp_path) -> None: + adapter = _FakeAdapter() store = RemoteJobStore(tmp_path / "remote-jobs.db") - service = RemoteJobService(store, e2b_adapter=adapter) - job = service.submit_e2b( + service = RemoteJobService(store, adapter_overrides={"e2b": adapter}) + job = service.submit_job( owner_id="alice", session_id="session-1", + provider="e2b", idempotency_key="session-1:node-1:1", - connection=_connection(), + spec=_spec(), ) dest = tmp_path / "CHGCAR" - result = service.download_e2b_file(job["job_id"], "/home/user/CHGCAR", dest) + result = service.download_job_file(job["job_id"], "/home/user/CHGCAR", dest) assert result == {"source": "/home/user/CHGCAR", "destination": str(dest.resolve())} assert adapter.downloads == [("sandbox-123", "/home/user/CHGCAR", str(dest.resolve()))] assert store.get_job(job["job_id"])["snapshot"] == { "provider_status": "reachable", - "sandbox_id": "sandbox-123", "last_download": "CHGCAR", } + +def test_reconcile_job_transitions_on_normalized_status_change(tmp_path) -> None: + class _BatchAdapter(_FakeAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + def __init__(self) -> None: + super().__init__() + self.status_calls = 0 + + def status(self, external_id: str) -> RemoteJobStatus: + # First probe (right after create, inside submit_job) reports + # "queued"; only a later explicit reconcile reports "succeeded" — + # this exercises reconcile_job's own transition, not submission. + self.status_calls += 1 + if self.status_calls == 1: + return RemoteJobStatus(normalized_status="queued", snapshot={"phase": "pending"}) + return RemoteJobStatus(normalized_status="succeeded", snapshot={"phase": "completed"}) + + adapter = _BatchAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"bohr_job": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="bohr_job", + idempotency_key="session-1:node-1:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + assert job["status"] == "queued" + + reconciled = service.reconcile_job(job["job_id"]) + assert reconciled["status"] == "succeeded" + assert reconciled["snapshot"]["phase"] == "completed" + + +def test_collect_job_outputs_is_idempotent(tmp_path) -> None: + class _BatchAdapter(_FakeAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + def __init__(self) -> None: + super().__init__() + self.collect_calls: list[str] = [] + + def status(self, external_id: str) -> RemoteJobStatus: + return RemoteJobStatus(normalized_status="succeeded", snapshot={"phase": "completed"}) + + def collect_outputs(self, external_id: str, destination_dir): + self.collect_calls.append(external_id) + return [{"source": external_id, "destination": str(destination_dir)}] + + adapter = _BatchAdapter() + store = RemoteJobStore(tmp_path / "remote-jobs.db") + service = RemoteJobService(store, adapter_overrides={"bohr_job": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="bohr_job", + idempotency_key="session-1:node-1:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + service.reconcile_job(job["job_id"]) + + collected = service.collect_job_outputs(job["job_id"], tmp_path / "out") + assert collected["status"] == "collected" + assert len(collected["artifacts"]) == 1 + assert adapter.collect_calls == ["sandbox-123"] + + replay = service.collect_job_outputs(job["job_id"], tmp_path / "out") + assert replay["status"] == "collected" + assert adapter.collect_calls == ["sandbox-123"] + + +def test_start_job_command_persists_handle_with_derived_marker_paths(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", + session_id="session-1", + provider="e2b", + idempotency_key="session-1:node-1:1", + spec=_spec(), + ) + + result = service.start_job_command(job["job_id"], "sleep 300 && echo done") + + assert result["handle"]["log_path"] == f"/tmp/matcreator-cmd-{job['job_id']}.log" + assert result["handle"]["exit_path"] == f"/tmp/matcreator-cmd-{job['job_id']}.exit" + assert adapter.launched_commands == ["sleep 300 && echo done"] + persisted = service.store.get_job(job["job_id"]) + assert persisted["snapshot"]["background_command"]["log_path"] == result["handle"]["log_path"] + + +def test_start_job_command_base64_round_trips_arbitrary_shell_content(tmp_path) -> None: + """Quotes, `$()`, and backticks in the command must survive intact — + proving the wrapper can't be broken out of or reinterpreted.""" + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + tricky_command = """echo 'it'"'"'s a test' && echo "$(date)" && echo `whoami`""" + + service.start_job_command(job["job_id"], tricky_command) + + assert adapter.launched_commands == [tricky_command] + + +def test_poll_job_command_reports_running_while_no_exit_marker(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + service.start_job_command(job["job_id"], "sleep 300") + + result = service.poll_job_command(job["job_id"]) + + assert result["running"] is True + + +def test_poll_job_command_reports_result_once_finished_and_clears_handle(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + started = service.start_job_command(job["job_id"], "echo hello") + # Simulate the background command finishing inside the sandbox. + adapter.files[started["handle"]["exit_path"]] = b"0\n" + adapter.files[started["handle"]["log_path"]] = b"hello\n" + + result = service.poll_job_command(job["job_id"]) + + assert result == { + "running": False, + "exit_code": 0, + "output_tail": "hello\n", + "log_path": started["handle"]["log_path"], + } + assert service.store.get_job(job["job_id"])["snapshot"]["background_command"] is None + + +def test_poll_job_command_requires_a_started_command(tmp_path) -> None: + adapter = _FakeAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"e2b": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="e2b", + idempotency_key="session-1:node-1:1", spec=_spec(), + ) + + try: + service.poll_job_command(job["job_id"]) + assert False, "expected ValueError" + except ValueError as exc: + assert "no in-flight background command" in str(exc) + + +def test_start_job_command_raises_capability_error_for_batch_provider(tmp_path) -> None: + class _BatchAdapter(_FakeAdapter): + provider = "bohr_job" + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + adapter = _BatchAdapter() + service = RemoteJobService(RemoteJobStore(tmp_path / "remote-jobs.db"), adapter_overrides={"bohr_job": adapter}) + job = service.submit_job( + owner_id="alice", session_id="session-1", provider="bohr_job", + idempotency_key="session-1:node-1:1", + spec={"project_id": 1, "job_name": "n", "machine_type": "c2", "image_address": "img", "command": "cmd"}, + ) + + try: + service.start_job_command(job["job_id"], "echo hi") + assert False, "expected CapabilityError" + except Exception as exc: + assert "does not support 'interactive_exec'" in str(exc) diff --git a/tests/test_remote_job_tools.py b/tests/test_remote_job_tools.py new file mode 100644 index 00000000..995bc383 --- /dev/null +++ b/tests/test_remote_job_tools.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from matcreator.agents.execution_agent import remote_job_tools +from matcreator.control_plane.providers.e2b import E2BConnectionConfig + + +class _FakeService: + def __init__(self) -> None: + self.submissions: list[dict] = [] + self.store = self + + def submit_job(self, **kwargs): + self.submissions.append(kwargs) + return { + "job_id": "job-123", + "status": "running", + "external_id": "sandbox-123", + } + + def get_job(self, job_id: str): + if job_id != "job-123": + return None + return { + "job_id": job_id, + "owner_id": "alice", + "session_id": "session-1", + "provider": "e2b", + "status": "running", + "external_id": "sandbox-123", + "snapshot": {}, + "error": None, + "updated_at": 1, + } + + def list_events(self, job_id: str): + return [{"event_type": "user_control", "payload": {"action": "terminate", "source": "ui"}}] + + def pause_job(self, job_id: str): + return {"job_id": job_id, "status": "paused", "external_id": "sandbox-123"} + + def terminate_job(self, job_id: str): + return {"job_id": job_id, "status": "terminated", "external_id": "sandbox-123"} + + def run_job_command(self, job_id: str, command: str, *, user: str): + return {"stdout": f"ran {command}", "stderr": "", "exit_code": 0} + + def upload_job_file(self, job_id: str, source, destination: str): + return {"source": str(source), "destination": destination} + + def download_job_file(self, job_id: str, source: str, destination: str): + return {"source": source, "destination": str(destination)} + + def collect_job_outputs(self, job_id: str, destination_dir): + return {"job_id": job_id, "status": "collected", "artifacts": [{"source": "x", "destination": str(destination_dir)}]} + + def start_job_command(self, job_id: str, command: str, *, user: str): + return {"job_id": job_id, "launch": {"stdout": "LAUNCHED\n"}, "handle": {"log_path": "/tmp/x.log", "exit_path": "/tmp/x.exit"}} + + def poll_job_command(self, job_id: str): + return {"running": False, "exit_code": 0, "output_tail": "done\n", "log_path": "/tmp/x.log"} + + +def _context(): + return SimpleNamespace( + state={ + "session_id": "session-1", + "_graph_exec_node_id": "execution_0__node_relax", + "step_number": 2, + }, + _invocation_context=SimpleNamespace(user_id="alice"), + ) + + +def test_submit_e2b_tool_uses_current_session_and_node(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + result = remote_job_tools.submit_e2b_sandbox(_context(), timeout=120, template="doc-compiler") + + assert result == { + "status": "running", + "job_id": "job-123", + "sandbox_id": "sandbox-123", + "message": "Tracked E2B sandbox is ready. Use its job_id for status or controls.", + } + submission = service.submissions[0] + assert submission["owner_id"] == "alice" + assert submission["session_id"] == "session-1" + assert submission["provider"] == "e2b" + assert submission["node_id"] == "relax" + assert submission["step_number"] == 2 + assert submission["spec"]["template"] == "doc-compiler" + assert submission["spec"]["api_key"] == "secret" + assert "api_key" not in submission["persisted_specification"] + + +def test_submit_e2b_tool_requires_explicit_template(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_e2b_sandbox(_context()) + + assert result["status"] == "error" + assert "template is required" in result["message"] + assert service.submissions == [] + + +def test_submit_e2b_tool_requires_server_configuration(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.delenv("E2B_API_KEY", raising=False) + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.delenv("BOHRIUM_PROJECT_ID", raising=False) + + result = remote_job_tools.submit_e2b_sandbox(_context(), template="doc-compiler") + + assert result["status"] == "error" + assert "E2B_API_KEY" in result["message"] + assert "BOHRIUM_PROJECT_ID" in result["message"] + assert "E2B_API_URL" not in result["message"] + assert service.submissions == [] + + +def test_submit_e2b_tool_coerces_json_string_lifecycle(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + result = remote_job_tools.submit_e2b_sandbox( + _context(), template="doc-compiler", lifecycle='{"on_timeout": "pause", "auto_resume": false}' + ) + + assert result["status"] == "running" + assert service.submissions[0]["spec"]["lifecycle"] == {"on_timeout": "pause", "auto_resume": False} + + +def test_submit_e2b_tool_rejects_non_object_lifecycle(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + for bad_lifecycle in ("pause on timeout", '["pause"]'): + result = remote_job_tools.submit_e2b_sandbox( + _context(), template="doc-compiler", lifecycle=bad_lifecycle + ) + assert result["status"] == "error" + assert "lifecycle must be a JSON object" in result["message"] + assert service.submissions == [] + + +def test_submit_e2b_tool_surfaces_failed_job_error_instead_of_claiming_ready(monkeypatch) -> None: + service = _FakeService() + + def _failed_submit(**kwargs): + service.submissions.append(kwargs) + return { + "job_id": "job-123", + "status": "failed", + "external_id": None, + "error": "e2b job creation failed: boom", + } + + service.submit_job = _failed_submit + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.setenv("E2B_API_KEY", "secret") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-42") + + result = remote_job_tools.submit_e2b_sandbox(_context(), template="doc-compiler") + + assert result["status"] == "failed" + assert result["sandbox_id"] is None + assert "e2b job creation failed: boom" in result["message"] + assert "ready" not in result["message"] + + +def test_e2b_connection_uses_configured_environment_names(monkeypatch) -> None: + monkeypatch.setenv("E2B_API_KEY", "access-key") + monkeypatch.setenv("E2B_API_URL", "https://e2b.example") + monkeypatch.setenv("BOHRIUM_PROJECT_ID", "project-7") + + connection = remote_job_tools._connection() + + assert connection == E2BConnectionConfig( + api_key="access-key", + api_url="https://e2b.example", + project_id="project-7", + template="", + ) + + +def test_submit_bohr_sandbox_tool_requires_project_id(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.delenv("BOHRIUM_PROJECT_ID", raising=False) + + result = remote_job_tools.submit_bohr_sandbox(_context()) + + assert result["status"] == "error" + assert "project_id" in result["message"] + assert service.submissions == [] + + +def test_submit_bohr_sandbox_tool_requires_explicit_template(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_bohr_sandbox(_context(), project_id=42) + + assert result["status"] == "error" + assert "template is required" in result["message"] + assert service.submissions == [] + + +def test_submit_bohr_sandbox_tool_submits_with_provider(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_bohr_sandbox(_context(), project_id=42, template="sdbxagent") + + assert result["status"] == "running" + assert result["sandbox_id"] == "sandbox-123" + assert service.submissions[0]["provider"] == "bohr_sandbox" + assert service.submissions[0]["spec"]["project_id"] == 42 + + +def test_submit_bohr_job_tool_requires_all_fields(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + monkeypatch.delenv("BOHRIUM_PROJECT_ID", raising=False) + + result = remote_job_tools.submit_bohr_job(_context(), job_name="n") + + assert result["status"] == "error" + assert "project_id" in result["message"] + assert "machine_type" in result["message"] + assert service.submissions == [] + + +def test_submit_bohr_job_tool_submits_batch_spec(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.submit_bohr_job( + _context(), + project_id=42, + job_name="relax-job", + machine_type="c8_m32_cpu", + image_address="registry.dp.tech/dptech/vasp:5.4.4", + command="vasp_std", + ) + + assert result["status"] == "running" + assert result["bohr_job_id"] == "sandbox-123" + submission = service.submissions[0] + assert submission["provider"] == "bohr_job" + assert submission["spec"]["command"] == "vasp_std" + + +def test_remote_job_tools_reject_jobs_from_another_session(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context._invocation_context.user_id = "bob" + + assert remote_job_tools.get_remote_job_status("job-123", context) == { + "status": "error", + "message": "Remote job was not found in this session.", + } + + +def test_remote_job_status_exposes_user_control(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + status = remote_job_tools.get_remote_job_status("job-123", _context()) + + assert status["user_control"] == {"action": "terminate", "source": "ui"} + + +def test_remote_job_command_and_workspace_upload_are_scoped_to_owned_job(tmp_path, monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context.state["workspace_dir"] = str(tmp_path) + source = tmp_path / "input.txt" + source.write_text("input", encoding="utf-8") + + assert remote_job_tools.run_remote_job_command("job-123", "echo hello", context) == { + "stdout": "ran echo hello", "stderr": "", "exit_code": 0 + } + assert remote_job_tools.upload_remote_job_input("job-123", "input.txt", "/home/user/input.txt", context) == { + "source": str(source), "destination": "/home/user/input.txt" + } + assert remote_job_tools.upload_remote_job_input( + "job-123", "/tmp/outside.txt", "/tmp/outside.txt", context + )["status"] == "error" + + +def test_download_remote_job_output_is_scoped_to_workspace(tmp_path, monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context.state["workspace_dir"] = str(tmp_path) + destination = tmp_path / "outputs" / "CHGCAR" + + result = remote_job_tools.download_remote_job_output( + "job-123", "/home/user/CHGCAR", str(destination), context + ) + assert result == {"source": "/home/user/CHGCAR", "destination": str(destination.resolve())} + + assert remote_job_tools.download_remote_job_output( + "job-123", "/home/user/CHGCAR", "/tmp/outside.txt", context + )["status"] == "error" + + +def test_download_remote_job_output_rejects_missing_workspace_dir(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() # no workspace_dir set + + result = remote_job_tools.download_remote_job_output( + "job-123", "/home/user/CHGCAR", "outputs/CHGCAR", context + ) + assert result["status"] == "error" + assert "workspace_dir" in result["message"] + + +def test_collect_remote_job_outputs_is_scoped_to_workspace(tmp_path, monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context.state["workspace_dir"] = str(tmp_path) + + result = remote_job_tools.collect_remote_job_outputs("job-123", "outputs", context) + + assert result["status"] == "collected" + assert result["artifacts"][0]["destination"] == str((tmp_path / "outputs").resolve()) + + assert remote_job_tools.collect_remote_job_outputs( + "job-123", "/tmp/outside", context + )["status"] == "error" + + +def test_start_remote_job_command_delegates_to_service(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.start_remote_job_command("job-123", "sleep 300", _context()) + + assert result["job_id"] == "job-123" + assert result["handle"]["log_path"] == "/tmp/x.log" + + +def test_start_remote_job_command_rejects_jobs_from_another_session(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + context = _context() + context._invocation_context.user_id = "bob" + + result = remote_job_tools.start_remote_job_command("job-123", "sleep 300", context) + + assert result == { + "status": "error", + "message": "Remote job was not found in this session.", + } + + +def test_poll_remote_job_command_delegates_to_service(monkeypatch) -> None: + service = _FakeService() + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.poll_remote_job_command("job-123", _context()) + + assert result == { + "running": False, + "exit_code": 0, + "output_tail": "done\n", + "log_path": "/tmp/x.log", + } + + +def test_poll_remote_job_command_surfaces_service_errors(monkeypatch) -> None: + service = _FakeService() + + def _boom(job_id): + raise ValueError("no in-flight background command") + + service.poll_job_command = _boom + monkeypatch.setattr(remote_job_tools, "_service", lambda: service) + + result = remote_job_tools.poll_remote_job_command("job-123", _context()) + + assert result["status"] == "error" + assert "no in-flight background command" in result["message"] diff --git a/tests/test_step_executor_runner.py b/tests/test_step_executor_runner.py index 340b574e..a78e9f57 100644 --- a/tests/test_step_executor_runner.py +++ b/tests/test_step_executor_runner.py @@ -19,18 +19,23 @@ from matcreator.agents.session_log import SESSION_ARTIFACTS_KEY -def test_step_executor_registers_tracked_e2b_tools() -> None: +def test_step_executor_registers_tracked_remote_job_tools() -> None: agent = build_step_executor_agent(LLMCard(name="test", model="test-model")) tool_names = {tool.name for tool in agent.tools if hasattr(tool, "name")} assert { "submit_e2b_sandbox", - "get_e2b_job_status", - "run_e2b_command", - "upload_e2b_input", - "download_e2b_output", - "pause_e2b_sandbox", - "terminate_e2b_sandbox", + "submit_bohr_sandbox", + "submit_bohr_job", + "get_remote_job_status", + "run_remote_job_command", + "start_remote_job_command", + "poll_remote_job_command", + "upload_remote_job_input", + "download_remote_job_output", + "collect_remote_job_outputs", + "pause_remote_job", + "terminate_remote_job", } <= tool_names @@ -431,7 +436,7 @@ def test_resumed_node_receives_explicit_reattachment_instructions(): assert context is not None assert "job-1" in context assert "sbx-1" in context - assert "get_e2b_job_status" in context + assert "get_remote_job_status" in context assert "Do NOT call submit_e2b_sandbox" in context diff --git a/web/main.py b/web/main.py index f0b1e549..26755cf3 100644 --- a/web/main.py +++ b/web/main.py @@ -99,6 +99,7 @@ from matcreator.control_plane.remote_job_monitor import RemoteJobMonitor # noqa: E402 from matcreator.control_plane.remote_job_service import RemoteJobService # noqa: E402 from matcreator.control_plane.remote_jobs import RemoteJobStore # noqa: E402 +from matcreator.control_plane.providers import CapabilityError # noqa: E402 from matcreator.control_plane.benchmark_client import BenchmarkApiError, BenchmarkClient, sanitize_bank_id # noqa: E402 from matcreator.control_plane.evaluation_manager import EvaluationManager # noqa: E402 from matcreator.control_plane.evaluation_runtime import RuntimeOutcome, RuntimeSpec # noqa: E402 @@ -2669,12 +2670,14 @@ async def pause_session_remote_job( job_id: str, user_id: str = Query(..., description="Current signed-in user"), ) -> JSONResponse: - """Pause one E2B sandbox and notify its linked executor without stopping it.""" + """Pause one remote job (if its provider supports pausing) and notify its linked executor without stopping it.""" job = _get_owned_remote_job(session_id, job_id, user_id) try: - paused = await asyncio.to_thread(_remote_job_service_for_owner(user_id).pause_e2b, job_id) + paused = await asyncio.to_thread(_remote_job_service_for_owner(user_id).pause_job, job_id) except (KeyError, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except CapabilityError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc await asyncio.to_thread( _remote_job_store_for_owner(user_id).record_user_control, job_id, @@ -2689,10 +2692,10 @@ async def terminate_session_remote_job( job_id: str, user_id: str = Query(..., description="Current signed-in user"), ) -> JSONResponse: - """Terminate one E2B sandbox and notify its linked executor without stopping it.""" + """Terminate one remote job and notify its linked executor without stopping it.""" job = _get_owned_remote_job(session_id, job_id, user_id) try: - terminated = await asyncio.to_thread(_remote_job_service_for_owner(user_id).terminate_e2b, job_id) + terminated = await asyncio.to_thread(_remote_job_service_for_owner(user_id).terminate_job, job_id) except (KeyError, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc await asyncio.to_thread( @@ -2709,12 +2712,10 @@ async def refresh_session_remote_job( job_id: str, user_id: str = Query(..., description="Current signed-in user"), ) -> JSONResponse: - """Synchronize a caller-owned active E2B job with its sandbox.""" + """Synchronize a caller-owned active remote job with its provider.""" job = _get_owned_remote_job(session_id, job_id, user_id) - if job["provider"] != "e2b": - raise HTTPException(status_code=409, detail="Remote job is not managed by E2B") try: - refreshed = await asyncio.to_thread(_remote_job_service_for_owner(user_id).reconcile_e2b, job_id) + refreshed = await asyncio.to_thread(_remote_job_service_for_owner(user_id).reconcile_job, job_id) except (KeyError, ValueError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return JSONResponse(refreshed) @@ -3770,7 +3771,7 @@ async def cancel_session_execution( paused_jobs = [] if user_id: paused_jobs = await asyncio.to_thread( - _remote_job_service_for_owner(user_id).pause_active_session_e2b_jobs, + _remote_job_service_for_owner(user_id).pause_active_session_jobs, owner_id=user_id, session_id=session_id, ) From bf04a224177994b57c3e038241208746f63d1261 Mon Sep 17 00:00:00 2001 From: Fillianore <37468338+Fillianore@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:21:22 +0800 Subject: [PATCH 12/14] fix: harden subprocess cleanup to avoid masking CancelledError The run_python/run_bash/run_python_file/run_skill_script tools called proc.kill() inside their TimeoutError/CancelledError handlers without guarding against an already-reaped process. Under uvloop this raises ProcessLookupError, which masks the original CancelledError and surfaces a full traceback (observed during step cancellation while a long bash job is running; see the run_bash traceback in api-server.log). Extract _terminate_subprocess(): it only kills when the process is still alive, tolerates the race that leaves no PID, and never raises, so the caller's original exception propagates correctly. Timeout output is unchanged. --- src/matcreator/tools/workspace_tools.py | 47 ++++++++++++++++--------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/matcreator/tools/workspace_tools.py b/src/matcreator/tools/workspace_tools.py index 18b51e6e..b23ef6db 100644 --- a/src/matcreator/tools/workspace_tools.py +++ b/src/matcreator/tools/workspace_tools.py @@ -289,6 +289,29 @@ def _execution_timeout_seconds() -> int: return timeout if timeout > 0 else _DEFAULT_EXEC_TIMEOUT_SECONDS +async def _terminate_subprocess(proc: asyncio.subprocess.Process) -> tuple[bytes, bytes]: + """Best-effort kill + pipe drain for use in exception/cleanup paths. + + Never raises: tolerates the process having already exited (uvloop raises + ``ProcessLookupError`` on a redundant ``kill()``) and tolerates drain + failures, so it never masks the original exception (TimeoutError / + CancelledError) raised by the caller. + + Returns whatever ``(stdout, stderr)`` could be drained; values may be + ``b""`` when the drain did not complete. + """ + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + # Process exited between the returncode check and kill() + pass + try: + return await proc.communicate() + except (Exception, asyncio.CancelledError): + return b"", b"" + + async def run_python(code: str, tool_context: ToolContext) -> str: """Execute a Python code snippet and return its stdout/stderr. @@ -316,12 +339,10 @@ async def run_python(code: str, tool_context: ToolContext) -> str: timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") @@ -357,12 +378,10 @@ async def run_bash(script: str, tool_context: ToolContext) -> str: timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") @@ -396,12 +415,10 @@ async def run_python_file(relative_path: str) -> str: timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") @@ -489,12 +506,10 @@ async def run_skill_script( timeout = _execution_timeout_seconds() stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: - proc.kill() - stdout, stderr = await proc.communicate() + stdout, stderr = await _terminate_subprocess(proc) output = f"[TimeoutExpired after {timeout}s]\n" + stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") except asyncio.CancelledError: - proc.kill() - await proc.communicate() + await _terminate_subprocess(proc) raise else: output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace") From d0595b0b422f60559a417fd5317e4107f1f2703a Mon Sep 17 00:00:00 2001 From: Fillianore <37468338+Fillianore@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:50:07 +0800 Subject: [PATCH 13/14] refactor(skills): split MLFF concept + address review feedback (#227) Concept skill split (qchempku2017): - Extract the fine-tuning and distillation procedures from the main concept SKILL.md into references/fine-tuning.md and references/distillation.md. SKILL.md now stays a concise overview that points to the two reference files (365 -> 97 lines). Wording & correctness fixes per review: - Rename "Phase" -> "Stage" throughout (avoid confusion with crystalline phases). - Distillation: strengthen the seed-vs-training-set rule to explicitly forbid relabeling existing DFT structures with the teacher. - Distillation Stage Zero gate: "DO NOT use pretrained models"; clarify that multi-task-capable models (e.g. DPA-3) must be single-task fine-tuned on the target system to qualify as a teacher. - Unify the distillation frame multiplier to ~100x (was inconsistent 20x/100x). - Stage B: rephrase labeling as "inferencing with the teacher model to obtain energy, forces and virial". - Clarify the configuration-diversity check (NPT lattice-vector fluctuation on the same supercell), and classify systems whose initial structures span multiple distinct cell types as complex. - Use ~1000 epochs (num_epochs keyword) instead of training steps. deepmd skill: - Remove the duplicated DPA-4c distillation block from SKILL.md; keep a short pointer to supported_deepmd_models.md. Keep the --init-model flag; make the --finetune prohibition wording stricter. - supported_deepmd_models.md: rename the DPA-4c section heading. --- .../machine-learning-force-field/SKILL.md | 290 +----------------- .../references/distillation.md | 137 +++++++++ .../references/fine-tuning.md | 153 +++++++++ src/matcreator/skills/deepmd/SKILL.md | 49 +-- .../references/supported_deepmd_models.md | 13 +- 5 files changed, 316 insertions(+), 326 deletions(-) create mode 100644 src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md create mode 100644 src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md b/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md index 4522c5e0..3694e526 100644 --- a/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md +++ b/src/matcreator/skills/concepts/machine-learning-force-field/SKILL.md @@ -51,288 +51,20 @@ Load the appropriate tool skill when needing detailed instructions (e.g., `load_ --- -# MLFF fine-tuning instructions +# MLFF workflows -When a user asks to generate a MLFF, they often imply fine-tuning a pretrained model, rather than training from scratch, -because the latter is way more computationally expensive. +The detailed, stage-by-stage procedures for generating a force field live in two dedicated reference files. +**Load the relevant reference before executing either workflow:** -When performing fine-tuning, the following procedure is preferred. +| Workflow | When to use | Reference | +|----------|-------------|-----------| +| **Fine-tuning** | Fine-tune a pre-trained model on DFT-labeled data of a target system (the common case). | [references/fine-tuning.md](references/fine-tuning.md) | +| **Distillation** | Train a smaller DPA-4c student model from scratch on teacher-labeled data, for large-scale / long-timescale production MD. | [references/distillation.md](references/distillation.md) | -## Recommended Procedure — Generate a force field via fine-tuning pretrained-model - - -### Phase Zero — Ask the user: Do you have a DFT-labelled dataset? - -A "DFT-labeled dataset" means structures whose energy, forces, and virial -were computed by DFT (VASP, ABACUS, etc.), **not** by a pretrained machine-learning model. - -> **Key principle:** The pretrained model is only a **surrogate for structural-space exploration** -> via molecular dynamics (MD), **not a ground truth**. The target that fine-tuning aims to match must be DFT data. -> All ground-truth labels used for fine-tuning and evaluation must come from **DFT calculations**. - -- **Bench mode** (`agent_mode == "bench"`): skip this question — assume NO dataset and - proceed directly to the "NO dataset" branch below. - -- **If the user HAS a DFT-labeled dataset:** -Proceed directly to Phase B below. - -- **If the user has NO DFT-labeled dataset:** - -Follow Phases A–C below. - - -### Phase A — Generate candidate structures for labeling via structure exploration - -1. **Classify the problem complexity:** - - **Simple systems** — bulk crystals, random alloys, simple compounds. - - **Complex systems** — defects, dopants, surfaces, interfaces, transition states, - high-entropy alloys, amorphous structures, etc. - -2. **For simple systems:** proceed directly to step 4 below. - -3. **For complex systems: ask the user if they already have initial structure files.** - If yes, use the user's structures as the starting point. If no, generate an intial structure - (or multiple initial structures, if needed) using the `atomic-structure` skill - (or `matcraft-kit` for surfaces/defects). - -4. **Choose simulation cell size for MD**: According to the following rules, determine whether the - initial structures need to be replicated into supercells. Do supercell operations only if needed, - and perform it only **ONCE** in the entire workflow. - - > **Rules for judging MD simulation cell size:** - > Keep each structure at roughly **50 atoms** when possible. - > For systems exceeding this size, - > do NOT perform supercell operations — use the original cell as-is. - -5. **Generate candidate structures** for MD exploration: - - Refer to the `ase` skill for details of using ASE. - - Use the resulting structure (or structures) from step 4 as the starting simulation cell (ase.Atoms). - - Use the pretrained model to set the simulation cell's calculator. - - Relax the structure (optimize both atomic coordinates and lattice vectors) first to avoid MD collapse. - - Explore configuration space via **NPT-ensamble MD**. - - **MD sampling skill choice:** `ase` >> `lammps`. Try `ase` first; - if it fails repeatedly, switch to `lammps`. Never use `atomic-structure` for MD. - - - **MD sampling parameters (NPT ensemble):** - - Adjust the following parameters according to the table below and the specific needs of the system. - - | Parameter | Default value | Description | - |---------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| - | Ensemble | **NPT** | NPT ensemble is mandatory for structure exploration | - | Temperature | **300 K, 600 K, 900 K** | Target temperatures. Use 300K, 600K, 900K as default. Adjust to user needs. For solid-state materials, **approach but never exceed the melting point**! | | - | Pressure | **1 bar, 10 Gpa** | Target pressure. For regular conditions, try from 1 bar and 10 GPa; adjust to user needs. | - | Step size | **2 fs** | Highest safe step size, decrease to 1 fs above 2000 K or when unstable (volume explosion) | - | Structure saving interval | Every **5** steps | Recommend to have at least **10 fs** spacing between two saved frames to have enough variation between structures. | - | Duration | **10 ps** | Total simulation time per temperature and per pressure | - | Output frames | **100** | Number of MD frames to retain from all temperatures and pressure samples. 100 is default. For more complex systems, use up to 500. | - - > Output frames recommendation: - > - **100** for simple systems (bulk crystals, random alloys, simple compounds) - > - **200** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) - > - **500** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) - -6. **Entropy-based structure selection (MANDATORY)** - After MD sampling, use entropy-based filtering to select a subset of 50% of the structures **with diversity** - from the obtained MD frames before DFT labeling to reduce DFT cost. For example: - ``` - run_skill_script( - skill_name="quests", - script_name="active_learning.py", - args="filter-by-entropy md_trajectory.extxyz --max-sel 50 --chunk-size 10" - ) - ``` - `chunk-size` had better be 1/50 of the total number of MD frames, but never below 10. - - > **CRITICAL:** Always run entropy-based selection BEFORE DFT labeling. Never send - > all sampled frames directly to DFT — use the selected structures instead. - - -### Phase B — DFT labeling - -Run DFT single-point calculations on the **selected structures** to obtain energy, -force, and virial labels. - -- Use the `vasp` or `abacus` skill for DFT input preparation and execution (`vasp` preferred). -- See `concepts/dft-calculation` for guidance on choosing a DFT code. -- Job submission is handled by the `bohrium` skill. - - -### Phase C — Fine-tuning & Evaluation - -> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**. - -1. Create the fresh workdir, and prepare input files in the fine-tuning workdir. For example, - for DPA models, you may run the script [deepmd/scripts/deepmd_prepare.py](deepmd/scripts/deepmd_prepare.py) - under the `deepmd` skill. - In this preparation stage, train/test split is performed. - Recommended train vs test split ratio is **4:1** for all DFT-labeled frames. - -2. Submit finetune job on Bohrium via the `bohrium` skill . - - 3. **Evaluate:** - Perform testing to obtain predicted energy (and per-atom energy), forces, virials (and per-atom virials) or - stress, then compute MAE errors. Also, perform such evaluation with the original pretrained model for comparison - with the fine-tuned model. - > For **DPA models**, the evaluation of both the pretrained and fine-tuned models are already taken care of - > by the commands generated - > with script [deepmd/scripts/deepmd_prepare.py](deepmd/scripts/deepmd_prepare.py), therefore the evaluation - > results will come back together with the fine-tuned model. - - > For **other MLFF models**, you may need to manually run the evaluation through the MLFF's native ase calculator interface. - > Refer to `ase` skill for guidance. - -4. When the system of your study used very different first-principle computation settings from the training set - of your pretrained model, energy MAE may not be comparable between the pretrained and fine-tuned models as - the zero point of energy may be different. In this case, you may need to adjust the energy bias of the pretrained - model for rational comparison. You may perform a quick adjustment like the following: - ```python - e_shift = np.mean(all_e_peratom_dft - all_e_peratom_predicted) - ``` - Then do: - ```python - get_mae( - all_e_peratom_dft, - all_e_peratom_predicted + e_shift - ), - ``` - to get comparable energy MAE. - -5. **Report and compare the results:** - - Pretrained: energy per atom MAE = X, force MAE = Y - - Finetuned: energy per atom MAE = X', force MAE = Y' - - Improvement: energy per atom MAE reduced by Z%, force MAE reduced by W% - ---- - -# MLFF distillation instructions - -Distillation is the process of training a smaller MLFF model **from scratch** using labels from a larger MLFF model. -The smaller MLFF model is called the **student model**, and the larger MLFF model is called the **teacher model**. - -MLFF must be appropriately distilled before applying the MLFF model to a large-scale simulation (> 100 K atoms, > 1 ns). - -The distillation workflow mirrors the fine-tuning workflow phase by phase, except that the labels are -generated by the teacher model instead of DFT, and the student model is trained from scratch on a much -larger dataset. The concept-level procedure below is the direct counterpart of the fine-tuning procedure above. - -## Recommended Procedure — Generate a force field via distillation - -> **Strict phase ordering — Phase A → Phase B → Phase C.** -> These three phases must be executed **in order, with no phase skipped, no phase -> reordered, and no phase omitted because it "seems unnecessary" for the structure at -> hand.** The only allowed variation is tuning *parameters within each phase* -> (temperature, pressure, frame count, training steps, etc.); the phase *sequence and -> presence* are fixed. -> -> **If you believe a phase can be skipped or shortened for your particular structure, -> that belief is precisely the error this rule exists to prevent.** Skipping Phase A -> (teacher MD) or Phase B (proper labeling) and jumping straight to Phase C (student -> training) produces a student that reproduces the teacher on MD-sampled frames but -> degrades by an order of magnitude on DFT-relaxed structures — the classic -> distillation failure mode. -### Phase Zero — Gate: Is a valid teacher model available? - -A valid **teacher model** must satisfy BOTH conditions: - -1. It is a **fine-tuned** model on the target system, i.e., it has already gone through the - fine-tuning procedure above on DFT-labeled data of that system. -2. It is a **single-task** model (dedicated to the target system). - -- **If no valid teacher exists** (the user only has a pretrained model, or only a multi-task model): - do NOT proceed with distillation — a pretrained model must **NEVER** be used as a teacher. - Return to the fine-tuning workflow above first, produce a fine-tuned single-task model for the - target system, and only then come back to distillation. -- **Student model:** the student is restricted to **DPA-4c**. - -> All DPA-4c-specific CLI commands, scripts and environment setup are owned by the `deepmd` skill. -> Load the `deepmd` skill when executing any phase below; this concept file deliberately does not -> repeat concrete commands. - -### Phase A — Teacher MD exploration (candidate structure generation) - -Generate candidate structures using the **fine-tuned teacher model** as the MD calculator, -**reusing the fine-tuning Phase A sampling rules**: - -- Same structure preparation and cell-size rules (~50 atoms per structure; supercell replication at - most once and never for large systems). -- Explore configuration space via **NPT-ensemble MD** with the same parameter conventions as - fine-tuning (NPT ensemble is mandatory; same temperature/pressure/step-size/saving-interval rules). -- **Entropy-based structure selection is MANDATORY** after MD sampling and before labeling, - exactly as in fine-tuning Phase A. - -The only systematic difference is the sampling scale: because the student is trained from scratch, -distillation needs roughly **20 times** the number of frames used in fine-tuning. - -> Output frames recommendation (distillation): -> - **20000** for simple systems (bulk crystals, random alloys, simple compounds) -> - **40000** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) -> - **100000** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) - -> **Seed ≠ training set.** The POSCAR / cif structure you start with is a **seed** for -> MD exploration, not a training frame. You MUST first run teacher **NPT-ensemble MD** -> on the seed (Phase A) to generate diverse configurations, then label those -> configurations with the teacher (Phase B), and only then train the student on the -> labeled MD-sampled frames (Phase C). **Directly labeling the static seed structure -> (or a handful of manually-built structures) and training on it is a typical -> distillation failure and is strictly forbidden.** -> -> Recall the three non-negotiable Phase A rules that the seed-based workflow depends on: -> **(1) NPT ensemble** — mandatory for strain diversity; never switch to NVT/NVE without -> explicit user approval. **(2) Entropy-based structure selection** — mandatory after -> MD sampling and before labeling. **(3) ~100× frame scale** — distillation needs -> roughly 100× the frames of fine-tuning (≥ 20000 for simple systems). - -### Phase B — Teacher inference labeling (replaces DFT) - -Label the entropy-selected structures by **teacher model inference** (single-point energy, force and -virial predictions) instead of DFT: - -- The teacher's predictions fully replace DFT single-point calculations in this phase; no DFT jobs - are launched. -- The teacher is guaranteed to be a fine-tuned **single-task** model by the Phase Zero gate, so there - is **no model head selection** involved — run inference with the teacher model as-is. -- MD/inference skill choice follows the same rules as fine-tuning (`ase` first, `lammps` as fallback). - -### Phase C — Student training from scratch & Evaluation - -> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**, as in fine-tuning. - -> **GATE — verify training-set provenance and size before proceeding to student -> training.** Before step 1 below, confirm ALL of the following: -> 1. The training frames were **produced by Phase A → Phase B** (teacher NPT MD -> sampling + entropy selection + teacher inference labeling), **not** statically -> labeled from the seed structure. -> 2. The total labeled frame count is **≥ ~20000** (simple systems) / **≥ ~40000** -> (complex systems) / **≥ ~100000** (very complex systems), consistent with the -> Phase A output recommendation above. -> 3. The frames span a **diverse configuration space** (different cell shapes, strains, -> and atomic positions from the NPT trajectory), not a single relaxed geometry. -> -> **If any check fails, STOP and return to Phase A** — regenerate/expand the MD -> trajectory and re-label. Do not proceed to student training on insufficient or -> non-MD-sourced data. - -1. Prepare the teacher-labeled data in a fresh workdir. The data preparation and **domain split must - follow exactly the same rules as fine-tuning Phase C**: train vs test split ratio is **4:1** for - all teacher-labeled frames. - -2. Train the DPA-4c student model **from scratch** (no pretrained initialization). Because the student - starts from scratch, training is much longer than fine-tuning: use about **1,000,000 (1M) training - steps**. For the concrete DPA-4c training commands and environment, load and follow the `deepmd` skill. - -3. **Evaluate on two levels (both are required):** - - **Level 1 — student vs teacher:** compare the student's predictions against the teacher's labels - on the held-out test set (the 1/5 test split from step 1). This checks that the student faithfully - reproduces the teacher. - - **Level 2 — student vs DFT ground truth:** compare the student's predictions against DFT. - **Prefer reusing the DFT-labeled test set from the corresponding fine-tuning workflow.** If that - is not available, choose an appropriate subset of the distillation testing set and re-compute it - with DFT (subset number of frames times number of atoms in each frame should not exceed 10000 - total atoms). - -4. Distillation is a **single-round** procedure: train the student once and evaluate. Do NOT iterate - (no repeated teacher-relabeling rounds) within this workflow. +Both procedures follow the same three-stage shape — **Stage A** (candidate structure generation via NPT MD), +**Stage B** (labeling: DFT for fine-tuning, teacher inference for distillation) and **Stage C** (training + +evaluation). Distillation additionally requires a valid **teacher model** (a fine-tuned, single-task model on +the target system — **never** a pretrained model) and a much larger dataset (~100× the fine-tuning scale). --- diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md b/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md new file mode 100644 index 00000000..7b08c6ff --- /dev/null +++ b/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md @@ -0,0 +1,137 @@ +# MLFF distillation instructions + +Distillation is the process of training a smaller MLFF model **from scratch** using labels from a larger MLFF model. +The smaller MLFF model is called the **student model**, and the larger MLFF model is called the **teacher model**. + +MLFF must be appropriately distilled before applying the MLFF model to a large-scale simulation (> 100 K atoms, > 1 ns). + +The distillation workflow mirrors the fine-tuning workflow stage by stage, except that the labels are +generated by the teacher model instead of DFT, and the student model is trained from scratch on a much +larger dataset. The concept-level procedure below is the direct counterpart of the fine-tuning procedure. + +## Recommended Procedure — Generate a force field via distillation + +> **Strict stage ordering — Stage A → Stage B → Stage C.** +> These three stages must be executed **in order, with no stage skipped, no stage +> reordered, and no stage omitted because it "seems unnecessary" for the structure at +> hand.** The only allowed variation is tuning *parameters within each stage* +> (temperature, pressure, frame count, epochs, etc.); the stage *sequence and +> presence* are fixed. +> +> **Skipping the generation and labeling of new samples by running MD with the teacher +> model is NOT allowed.** In particular, do NOT simply relabel existing DFT structures +> with the teacher model and then use those structures to train the student model — this +> is not distillation and will not generalize. You MUST run a fresh teacher NPT-ensemble +> MD trajectory (Stage A), label the newly sampled frames with the teacher (Stage B), and +> only then train the student (Stage C). Jumping straight to Stage C (student training) on +> statically relabeled structures produces a student that reproduces the teacher on those +> frames but degrades by an order of magnitude on DFT-relaxed structures — the classic +> distillation failure mode. + +### Stage Zero — Gate: Is a valid teacher model available? + +A valid **teacher model** must satisfy BOTH conditions: + +1. It is a **fine-tuned** model on the target system, i.e., it has already gone through the + fine-tuning procedure on DFT-labeled data of that system. **DO NOT use pretrained models** — + a pretrained model must **NEVER** be used as a teacher. +2. It is a **single-task** model (dedicated to the target system). For a model that supports + multi-task training (such as DPA-3), the teacher must be a single-task model specifically + fine-tuned on the target system — a multi-task model is **not** a valid teacher. + +- **If no valid teacher exists** (the user only has a pretrained model, or only a multi-task model): + do NOT proceed with distillation. Return to the fine-tuning workflow first, produce a + fine-tuned single-task model for the target system, and only then come back to distillation. +- **Student model:** the student is restricted to **DPA-4c**. + +> All DPA-4c-specific CLI commands, scripts and environment setup are owned by the `deepmd` skill. +> Load the `deepmd` skill when executing any stage below; this concept file deliberately does not +> repeat concrete commands. + +### Stage A — Teacher MD exploration (candidate structure generation) + +Generate candidate structures using the **fine-tuned teacher model** as the MD calculator, +**reusing the fine-tuning Stage A sampling rules**: + +- Same structure preparation and cell-size rules (~50 atoms per structure; supercell replication at + most once and never for large systems). +- Explore configuration space via **NPT-ensemble MD** with the same parameter conventions as + fine-tuning (NPT ensemble is mandatory; same temperature/pressure/step-size/saving-interval rules). +- **Entropy-based structure selection is MANDATORY** after MD sampling and before labeling, + exactly as in fine-tuning Stage A. + +The only systematic difference is the sampling scale: because the student is trained from scratch, +distillation needs roughly **100 times** the number of frames used in fine-tuning. + +> Output frames recommendation (distillation): +> - **20000** for simple systems (bulk crystals, random alloys, simple compounds) +> - **40000** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) +> - **100000** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) + +> **Seed ≠ training set.** The POSCAR / cif structure you start with is a **seed** for +> MD exploration, not a training frame. You MUST first run teacher **NPT-ensemble MD** +> on the seed (Stage A) to generate diverse configurations, then label those +> configurations with the teacher (Stage B), and only then train the student on the +> labeled MD-sampled frames (Stage C). **Directly labeling the static seed structure +> (or a handful of manually-built structures) and training on it is a typical +> distillation failure and is strictly forbidden.** +> +> Recall the three non-negotiable Stage A rules that the seed-based workflow depends on: +> **(1) NPT ensemble** — mandatory for strain diversity; never switch to NVT/NVE without +> explicit user approval. **(2) Entropy-based structure selection** — mandatory after +> MD sampling and before labeling. **(3) ~100× frame scale** — distillation needs +> roughly 100× the frames of fine-tuning (≥ 20000 for simple systems). + +### Stage B — Teacher inference labeling (replaces DFT) + +By inferencing with the teacher model, obtain the energy, forces and virial labels for the +entropy-selected structures, instead of running DFT single-point calculations: + +- The teacher's predictions fully replace DFT single-point calculations in this stage; no DFT jobs + are launched. +- The teacher is guaranteed to be a fine-tuned **single-task** model by the Stage Zero gate, so there + is **no model head selection** involved — run inference with the teacher model as-is. +- MD/inference skill choice follows the same rules as fine-tuning (`ase` first, `lammps` as fallback). + +### Stage C — Student training from scratch & Evaluation + +> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**, as in fine-tuning. + +> **GATE — verify training-set provenance and size before proceeding to student +> training.** Before step 1 below, confirm ALL of the following: +> 1. The training frames were **produced by Stage A → Stage B** (teacher NPT MD +> sampling + entropy selection + teacher inference labeling), **not** statically +> labeled from the seed structure or relabeled from existing DFT structures. +> 2. The total labeled frame count is **≥ ~20000** (simple systems) / **≥ ~40000** +> (complex systems) / **≥ ~100000** (very complex systems), consistent with the +> Stage A output recommendation above. +> 3. The frames span a **diverse configuration space** — different strains, cell volumes +> and atomic positions sampled across the NPT trajectory (the NPT ensemble lets the +> lattice vectors fluctuate, producing a range of instantaneous cell shapes from the +> same starting supercell), not a single relaxed geometry. +> +> **If any check fails, STOP and return to Stage A** — regenerate/expand the MD +> trajectory and re-label. Do not proceed to student training on insufficient or +> non-MD-sourced data. + +1. Prepare the teacher-labeled data in a fresh workdir. The data preparation and **domain split must + follow exactly the same rules as fine-tuning Stage C**: train vs test split ratio is **4:1** for + all teacher-labeled frames. + +2. Train the DPA-4c student model **from scratch** (no pretrained initialization). Because the student + starts from scratch, training is much longer than fine-tuning: use about **1000 epochs** (set via + the `num_epochs` keyword — do NOT instruct training in steps). For the concrete DPA-4c training + commands and environment, load and follow the `deepmd` skill. + +3. **Evaluate on two levels (both are required):** + - **Level 1 — student vs teacher:** compare the student's predictions against the teacher's labels + on the held-out test set (the 1/5 test split from step 1). This checks that the student faithfully + reproduces the teacher. + - **Level 2 — student vs DFT ground truth:** compare the student's predictions against DFT. + **Prefer reusing the DFT-labeled test set from the corresponding fine-tuning workflow.** If that + is not available, choose an appropriate subset of the distillation testing set and re-compute it + with DFT (subset number of frames times number of atoms in each frame should not exceed 10000 + total atoms). + +4. Distillation is a **single-round** procedure: train the student once and evaluate. Do NOT iterate + (no repeated teacher-relabeling rounds) within this workflow. diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md b/src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md new file mode 100644 index 00000000..3c43a468 --- /dev/null +++ b/src/matcreator/skills/concepts/machine-learning-force-field/references/fine-tuning.md @@ -0,0 +1,153 @@ +# MLFF fine-tuning instructions + +When a user asks to generate a MLFF, they often imply fine-tuning a pretrained model, rather than training from scratch, +because the latter is way more computationally expensive. + +When performing fine-tuning, the following procedure is preferred. + +## Recommended Procedure — Generate a force field via fine-tuning pretrained-model + + +### Stage Zero — Ask the user: Do you have a DFT-labelled dataset? + +A "DFT-labeled dataset" means structures whose energy, forces, and virial +were computed by DFT (VASP, ABACUS, etc.), **not** by a pretrained machine-learning model. + +> **Key principle:** The pretrained model is only a **surrogate for structural-space exploration** +> via molecular dynamics (MD), **not a ground truth**. The target that fine-tuning aims to match must be DFT data. +> All ground-truth labels used for fine-tuning and evaluation must come from **DFT calculations**. + +- **Bench mode** (`agent_mode == "bench"`): skip this question — assume NO dataset and + proceed directly to the "NO dataset" branch below. + +- **If the user HAS a DFT-labeled dataset:** +Proceed directly to Stage B below. + +- **If the user has NO DFT-labeled dataset:** + +Follow Stages A–C below. + + +### Stage A — Generate candidate structures for labeling via structure exploration + +1. **Classify the problem complexity:** + - **Simple systems** — bulk crystals, random alloys, simple compounds. + - **Complex systems** — defects, dopants, surfaces, interfaces, transition states, + high-entropy alloys, amorphous structures, etc. Also treat the system as complex when + the provided initial structures span **multiple distinct cell types** (e.g. different + Bravais lattices or coordination environments). + +2. **For simple systems:** proceed directly to step 4 below. + +3. **For complex systems: ask the user if they already have initial structure files.** + If yes, use the user's structures as the starting point. If no, generate an intial structure + (or multiple initial structures, if needed) using the `atomic-structure` skill + (or `matcraft-kit` for surfaces/defects). + +4. **Choose simulation cell size for MD**: According to the following rules, determine whether the + initial structures need to be replicated into supercells. Do supercell operations only if needed, + and perform it only **ONCE** in the entire workflow. + + > **Rules for judging MD simulation cell size:** + > Keep each structure at roughly **50 atoms** when possible. + > For systems exceeding this size, + > do NOT perform supercell operations — use the original cell as-is. + +5. **Generate candidate structures** for MD exploration: + - Refer to the `ase` skill for details of using ASE. + - Use the resulting structure (or structures) from step 4 as the starting simulation cell (ase.Atoms). + - Use the pretrained model to set the simulation cell's calculator. + - Relax the structure (optimize both atomic coordinates and lattice vectors) first to avoid MD collapse. + - Explore configuration space via **NPT-ensamble MD**. + - **MD sampling skill choice:** `ase` >> `lammps`. Try `ase` first; + if it fails repeatedly, switch to `lammps`. Never use `atomic-structure` for MD. + + - **MD sampling parameters (NPT ensemble):** + + Adjust the following parameters according to the table below and the specific needs of the system. + + | Parameter | Default value | Description | + |---------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| + | Ensemble | **NPT** | NPT ensemble is mandatory for structure exploration | + | Temperature | **300 K, 600 K, 900 K** | Target temperatures. Use 300K, 600K, 900K as default. Adjust to user needs. For solid-state materials, **approach but never exceed the melting point**! | | + | Pressure | **1 bar, 10 Gpa** | Target pressure. For regular conditions, try from 1 bar and 10 GPa; adjust to user needs. | + | Step size | **2 fs** | Highest safe step size, decrease to 1 fs above 2000 K or when unstable (volume explosion) | + | Structure saving interval | Every **5** steps | Recommend to have at least **10 fs** spacing between two saved frames to have enough variation between structures. | + | Duration | **10 ps** | Total simulation time per temperature and per pressure | + | Output frames | **100** | Number of MD frames to retain from all temperatures and pressure samples. 100 is default. For more complex systems, use up to 500. | + + > Output frames recommendation: + > - **100** for simple systems (bulk crystals, random alloys, simple compounds) + > - **200** for complex systems (defects, dopants, surfaces, interfaces, transition states, etc.) + > - **500** for very complex systems (e.g., high-entropy alloys, amorphous structures, etc.) + +6. **Entropy-based structure selection (MANDATORY)** + After MD sampling, use entropy-based filtering to select a subset of 50% of the structures **with diversity** + from the obtained MD frames before DFT labeling to reduce DFT cost. For example: + ``` + run_skill_script( + skill_name="quests", + script_name="active_learning.py", + args="filter-by-entropy md_trajectory.extxyz --max-sel 50 --chunk-size 10" + ) + ``` + `chunk-size` had better be 1/50 of the total number of MD frames, but never below 10. + + > **CRITICAL:** Always run entropy-based selection BEFORE DFT labeling. Never send + > all sampled frames directly to DFT — use the selected structures instead. + + +### Stage B — DFT labeling + +Run DFT single-point calculations on the **selected structures** to obtain energy, +force, and virial labels. + +- Use the `vasp` or `abacus` skill for DFT input preparation and execution (`vasp` preferred). +- See `concepts/dft-calculation` for guidance on choosing a DFT code. +- Job submission is handled by the `bohrium` skill. + + +### Stage C — Fine-tuning & Evaluation + +> Note: Do NOT reuse any existing workdir. **Always create a fresh workdir**. + +1. Create the fresh workdir, and prepare input files in the fine-tuning workdir. For example, + for DPA models, you may run the script [deepmd/scripts/deepmd_prepare.py](../../../deepmd/scripts/deepmd_prepare.py) + under the `deepmd` skill. + In this preparation stage, train/test split is performed. + Recommended train vs test split ratio is **4:1** for all DFT-labeled frames. + +2. Submit finetune job on Bohrium via the `bohrium` skill . + + 3. **Evaluate:** + Perform testing to obtain predicted energy (and per-atom energy), forces, virials (and per-atom virials) or + stress, then compute MAE errors. Also, perform such evaluation with the original pretrained model for comparison + with the fine-tuned model. + > For **DPA models**, the evaluation of both the pretrained and fine-tuned models are already taken care of + > by the commands generated + > with script [deepmd/scripts/deepmd_prepare.py](../../../deepmd/scripts/deepmd_prepare.py), therefore the evaluation + > results will come back together with the fine-tuned model. + + > For **other MLFF models**, you may need to manually run the evaluation through the MLFF's native ase calculator interface. + > Refer to `ase` skill for guidance. + +4. When the system of your study used very different first-principle computation settings from the training set + of your pretrained model, energy MAE may not be comparable between the pretrained and fine-tuned models as + the zero point of energy may be different. In this case, you may need to adjust the energy bias of the pretrained + model for rational comparison. You may perform a quick adjustment like the following: + ```python + e_shift = np.mean(all_e_peratom_dft - all_e_peratom_predicted) + ``` + Then do: + ```python + get_mae( + all_e_peratom_dft, + all_e_peratom_predicted + e_shift + ), + ``` + to get comparable energy MAE. + +5. **Report and compare the results:** + - Pretrained: energy per atom MAE = X, force MAE = Y + - Finetuned: energy per atom MAE = X', force MAE = Y' + - Improvement: energy per atom MAE reduced by Z%, force MAE reduced by W% diff --git a/src/matcreator/skills/deepmd/SKILL.md b/src/matcreator/skills/deepmd/SKILL.md index fe7bb462..39300ee0 100644 --- a/src/matcreator/skills/deepmd/SKILL.md +++ b/src/matcreator/skills/deepmd/SKILL.md @@ -145,47 +145,14 @@ training a model is advised (see the description of this skill). The student arc for distillation is **DPA-4c** (`descriptor.type = "dpa4c"`), whose CLI usage differs from regular fine-tuning. -> **Prerequisite gate — Phase A and Phase B must be completed first.** -> Before using any command in this section, verify that: -> 1. **Phase A (teacher NPT MD exploration)** has been completed — the teacher model -> was run as an NPT-ensemble MD calculator on the seed structure to generate a -> diverse set of candidate frames (~2000+ frames for simple systems, more for -> complex ones). The seed POSCAR/cif is **not** a training frame. -> 2. **Phase B (teacher inference labeling)** has been completed — the MD-sampled -> frames were labeled by single-point teacher inference (energy, forces, virial). -> 3. The resulting labeled dataset has a sufficient frame count and is sourced -> exclusively from the A→B pipeline. -> -> **Training directly on the seed structure (or any statically-built structures) -> is forbidden** and will produce a student that reproduces the teacher on -> MD-sampled frames but degrades by an order of magnitude on DFT-relaxed structures. -> If Phase A/B have not been done, return to the `machine-learning-force-field` skill -> and complete them before proceeding. - -> **For DPA-4c, ALWAYS use `--pt-expt` in the `dp` CLI. NEVER use `--finetune`:** -> the bias-adjustment dense forward pass of `--finetune` runs out of memory (OOM) -> for the DPA-4c selection `sel=[999999]`. - -Training command (run inside the workdir): - -```bash -dp --pt-expt train input.json --init-model --skip-neighbor-stat -``` - -- ``: the DPA-4c pretrained checkpoint used to initialize the student - (historically `dpa4c_pretrain_rmse_epoch.pt`). -- `--skip-neighbor-stat`: must be appended for DPA-4c. - -**Verified input template:** [references/dpa4c_distill_input.json](references/dpa4c_distill_input.json) -is the input.json actually used in a historical, completed DPA-4c distillation run -(1,000,000 steps, `Training finished`). This template is written **exclusively for -DPA-4c — do NOT use it for any other architecture** (dpa2, dpa3, dpa4/SeZM, se_atten_v2, ...). - -**Bohrium submission (bohr skill):** the recommended image for DPA-4c distillation is -`registry.dp.tech/dptech/dpa-calculator:dpa4-mlip-340e01f9` on a **5090** GPU machine. - -**Historical reference values** (SrO/TiO2 slab distillation, single 5090 GPU): -~2400 training frames / ~600 test frames, `numb_steps = 1,000,000`, wall time ~3.4 h. +> **Prerequisite gate:** distillation requires the concept-level Stage A (teacher NPT MD +> exploration) and Stage B (teacher inference labeling) to be completed first — see the +> `machine-learning-force-field` skill ([references/distillation.md](../concepts/machine-learning-force-field/references/distillation.md)). +> Training directly on the seed/static structures is forbidden. + +The concrete DPA-4c training command, the verified input template, and the recommended +Bohrium image/machine are documented in +[references/supported_deepmd_models.md](references/supported_deepmd_models.md) ("DPA-4c" section). --- # DeePMD-kit python interface (ASE calculator) diff --git a/src/matcreator/skills/deepmd/references/supported_deepmd_models.md b/src/matcreator/skills/deepmd/references/supported_deepmd_models.md index 1e68a72c..e63528bf 100644 --- a/src/matcreator/skills/deepmd/references/supported_deepmd_models.md +++ b/src/matcreator/skills/deepmd/references/supported_deepmd_models.md @@ -136,21 +136,22 @@ DPA-2 and DPA-3 models are supported but not strongly recommended in any scenari 5. Also, do not use nvidia GPUs older than V100 as they no longer support the triton AOT induction route of modern pytorch, which is compulsory for deepmd-kit>=3.2.0. -## DPA-4c distillation student (dpa4c descriptor) +## DPA-4c ("dpa4c" descriptor, only used as student models during distillation) DPA-4c is the student architecture for distillation from a fine-tuned DPA-4 teacher. Its CLI usage differs from the fine-tuning flow documented elsewhere in this skill: -**CLI — always `--pt-expt`, never `--finetune`:** +**`--finetune` is STRICTLY PROHIBITED for DPA-4c; always train from scratch with `--pt-expt`:** ```bash dp --pt-expt train input.json --init-model --skip-neighbor-stat ``` -- `--finetune` is **prohibited** for DPA-4c: its bias-adjustment dense forward pass - runs out of memory (OOM) for the DPA-4c selection `sel=[999999]`. -- `` is the DPA-4c pretrained checkpoint (historically - `dpa4c_pretrain_rmse_epoch.pt`); `--skip-neighbor-stat` must be appended. +- **NEVER use `--finetune` with DPA-4c.** Its bias-adjustment dense forward pass runs out + of memory (OOM) for the DPA-4c selection `sel=[999999]`; the job will crash. Always use + `--pt-expt ... --init-model ... --skip-neighbor-stat` to train from scratch instead. +- `` is the DPA-4c pretrained checkpoint used to initialize the student + (historically `dpa4c_pretrain_rmse_epoch.pt`); `--skip-neighbor-stat` must be appended. **Verified input template:** [dpa4c_distill_input.json](dpa4c_distill_input.json) (in this `references/` directory) is the input.json actually used in a historical, completed From 73a19002007cbf988fa1f9d66092f5f61b504143 Mon Sep 17 00:00:00 2001 From: Fillianore <37468338+Fillianore@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:53:31 +0800 Subject: [PATCH 14/14] chore(skills): unify distillation training length to 50 epochs (num_epochs) - distillation.md: 1000 epochs -> 50 epochs. - dpa4c_distill_input.json: replace numb_steps=1000000 with num_epochs=50, consistent with the num_epochs keyword recommended since deepmd 3.2.0. --- .../machine-learning-force-field/references/distillation.md | 2 +- .../skills/deepmd/references/dpa4c_distill_input.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md b/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md index 7b08c6ff..9d87ed80 100644 --- a/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md +++ b/src/matcreator/skills/concepts/machine-learning-force-field/references/distillation.md @@ -119,7 +119,7 @@ entropy-selected structures, instead of running DFT single-point calculations: all teacher-labeled frames. 2. Train the DPA-4c student model **from scratch** (no pretrained initialization). Because the student - starts from scratch, training is much longer than fine-tuning: use about **1000 epochs** (set via + starts from scratch, training is much longer than fine-tuning: use about **50 epochs** (set via the `num_epochs` keyword — do NOT instruct training in steps). For the concrete DPA-4c training commands and environment, load and follow the `deepmd` skill. diff --git a/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json b/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json index 1163cade..4df6e7f5 100644 --- a/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json +++ b/src/matcreator/skills/deepmd/references/dpa4c_distill_input.json @@ -175,7 +175,7 @@ "batch_size": 1, "numb_batch": 1 }, - "numb_steps": 1000000, + "num_epochs": 50, "enable_compile": true, "gradient_max_norm": 5, "save_freq": 2000,