From 7ba212e36dfa57e7c5322fa8b8ab5b1a61cc055b Mon Sep 17 00:00:00 2001 From: Wacc <8445647+jiweiqi@users.noreply.github.com> Date: Tue, 5 May 2026 11:17:30 +0800 Subject: [PATCH] Add Workbench health and journal diagnostics --- .github/workflows/ci.yml | 20 ++ .github/workflows/publish-wheel.yml | 11 + .github/workflows/release.yml | 34 ++ CHANGELOG.md | 13 + README.md | 124 +++++-- pyproject.toml | 4 +- .../_skills/workbench/SKILL.md | 24 +- .../base/examples/cyclic_symmetry_analysis.md | 2 +- .../base/examples/pymechanical_integration.md | 2 +- .../_skills/workbench/base/known_issues.md | 2 +- .../base/reference/sub_solver_integration.md | 2 +- .../base/reference/system_templates.md | 10 +- .../base/workflows/debug_failed_exec.md | 44 +++ .../base/workflows/mechanical_handoff.md | 38 ++ .../base/workflows/project_review_loop.md | 30 ++ src/sim_plugin_workbench/driver.py | 331 +++++++++++++++++- .../workbench_mechanical_handoff_smoke.py | 58 +++ tests/test_wheel_contents.py | 14 +- tests/test_workbench_driver.py | 156 ++++++++- uv.lock | 2 +- 20 files changed, 872 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish-wheel.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 src/sim_plugin_workbench/_skills/workbench/base/workflows/debug_failed_exec.md create mode 100644 src/sim_plugin_workbench/_skills/workbench/base/workflows/mechanical_handoff.md create mode 100644 src/sim_plugin_workbench/_skills/workbench/base/workflows/project_review_loop.md create mode 100644 tests/execution/workbench_mechanical_handoff_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..08be1a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.12"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python }} + - run: uv run --extra test pytest tests -q -m "not integration" diff --git a/.github/workflows/publish-wheel.yml b/.github/workflows/publish-wheel.yml new file mode 100644 index 0000000..080173e --- /dev/null +++ b/.github/workflows/publish-wheel.yml @@ -0,0 +1,11 @@ +name: Publish wheel to R2 + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + publish: + uses: svd-ai-lab/sim-plugin-shared/.github/workflows/publish-wheel.yml@main + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d6be354 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Verify tag matches pyproject.toml version + run: | + tag="${GITHUB_REF#refs/tags/v}" + ver=$(uv run --no-project python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$tag" != "$ver" ]; then + echo "tag v$tag does not match pyproject version $ver" >&2 + exit 1 + fi + + - run: uv build + + - uses: softprops/action-gh-release@v2 + with: + files: | + dist/*.whl + dist/*.tar.gz + generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c88c81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 0.1.1 - 2026-05-05 + +- Add runtime health, UI mode, project identity, and systems summary inspect targets. +- Enable GUI diagnostics for visible Workbench sessions and add bounded session execution. +- Add user-scenario README guidance, project review loops, and Workbench-to-Mechanical handoff guidance. +- Add CI, release publishing workflow, and stricter wheel-content checks. + +## 0.1.0 - 2026-04-29 + +- Extract the Workbench driver and bundled skill into an out-of-tree sim plugin. +- Add protocol conformance and initial packaging smoke tests. diff --git a/README.md b/README.md index f57d438..6f2658f 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,127 @@ # sim-plugin-workbench -[Ansys Workbench](https://www.ansys.com/products/ansys-workbench) (PyWorkbench / RunWB2) driver for [sim-cli](https://github.com/svd-ai-lab/sim-cli), distributed as an out-of-tree plugin via Python `entry_points`. +Use Codex, Claude Code, or another AI agent to orchestrate +[Ansys Workbench](https://www.ansys.com/products/ansys-workbench) projects from +the workflow you already use. + +`sim-plugin-workbench` gives an agent practical Workbench control paths: +create and update analysis systems, run Workbench journals, inspect live session +health, summarize systems and cells, manage project checkpoints, and hand the +Model cell off to solver-specific plugins such as Mechanical. + +The Workbench application and SDK are not bundled. Bring your own Workbench +installation. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md). + +## What an agent can do with Workbench + +- Create or update Workbench analysis systems through journals. +- Keep Engineering Data, Geometry, and Model cells organized before solver work. +- Use a persistent PyWorkbench session when the SDK is available. +- Fall back to RunWB2 journal execution when the SDK path is unavailable. +- Inspect session health, UI capabilities, project identity, and system/cell + state before continuing a workflow. +- Prepare a clean handoff to Mechanical for setup, solve, and result extraction. + +## Choose the right Workbench workflow + +### 1. Persistent SDK session + +Use this for repeatable agent-driven orchestration: + +```powershell +sim connect --solver workbench --ui-mode gui +sim inspect session.health +sim exec --file step.wbjn +sim inspect workbench.systems.summary +``` + +The SDK path is preferred because it keeps state across steps and lets the +agent inspect Workbench state between bounded journal snippets. + +### 2. RunWB2 fallback + +Use this when the SDK is unavailable or incompatible: + +```powershell +sim run --solver workbench path/to/journal.wbjn +``` + +RunWB2 is reliable for one-shot journals, but it should not be treated as a +rich live session. Inspect the JSON result written by the journal and save a +project checkpoint before handing off to another solver. + +### 3. Workbench-to-Mechanical handoff + +Use Workbench for cells 1-3: Engineering Data, Geometry, and Model. Use +Mechanical for cells 4-6: setup, solution, and results. Before handoff, inspect: + +```powershell +sim inspect workbench.project.identity +sim inspect workbench.systems.summary +``` + +The Workbench side should have a project checkpoint and a refreshed Model cell. +The Mechanical side should confirm the expected analysis tree before adding +loads, supports, mesh controls, solve settings, or result objects. ## Install ```bash -pip install git+https://github.com/svd-ai-lab/sim-plugin-workbench@main +pip install sim-plugin-workbench ``` -You also need a working Ansys Workbench installation on the same host. The driver detects via `AWP_ROOTxxx` env vars, `PATH` (`RunWB2`), and default install dirs. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md). +You can also install through sim-cli: + +```bash +sim plugin install sim-plugin-workbench +``` -After install, sim-cli auto-discovers the driver: +After installation, sim-cli auto-discovers the driver and bundled skill: ```bash -sim drivers | grep workbench +sim check workbench sim run --solver workbench path/to/journal.wbjn ``` -## How it works +## Agent quickstart -The plugin registers via two entry-point groups: +Give an agent this instruction when the task is about Workbench: -```toml -[project.entry-points."sim.drivers"] -workbench = "sim_plugin_workbench:WorkbenchDriver" +```text +Use the bundled Workbench skill from sim-plugin-workbench. First identify +whether the task needs a persistent SDK session, a RunWB2 one-shot journal, or +a Workbench-to-Mechanical handoff. For persistent work, connect with +`sim connect --solver workbench --ui-mode gui`, then inspect `session.health`, +`workbench.project.identity`, and `workbench.systems.summary`. Execute one +bounded journal step at a time, inspect the result, and save or update a +project checkpoint before handing the Model cell to Mechanical. +``` -[project.entry-points."sim.skills"] -workbench = "sim_plugin_workbench:skills_dir" +The bundled skill entry point is: + +```text +src/sim_plugin_workbench/_skills/workbench/SKILL.md ``` -`sim.drivers` exposes the driver class; `sim.skills` exposes a directory of skill files bundled inside the wheel. +## How it relates to sim-cli -The driver is dual-mode: +`sim-plugin-workbench` extends [sim-cli](https://github.com/svd-ai-lab/sim-cli). +sim-cli provides the common agent runtime surface (`connect`, `exec`, `inspect`, +`run`, `screenshot`), while this plugin supplies Workbench detection, journal +execution, persistent session handling, and bundled Workbench agent guidance. -1. **PyWorkbench SDK** (`ansys-workbench-core`) — preferred, persistent gRPC session. -2. **RunWB2 batch** (`RunWB2 -B -R journal.wbjn`) — automatic fallback when SDK is unavailable or its `launch_workbench` fails. +The plugin registers three entry-point groups: -IronPython journals write JSON results to `%TEMP%/sim_wb_result.json` because RunWB2 does not pipe stdout. +```toml +[project.entry-points."sim.drivers"] +workbench = "sim_plugin_workbench:WorkbenchDriver" -## Supported versions +[project.entry-points."sim.skills"] +workbench = "sim_plugin_workbench:skills_dir" -See [`src/sim_plugin_workbench/compatibility.yaml`](src/sim_plugin_workbench/compatibility.yaml) for the SDK / solver matrix. Profiles cover Ansys 24.1, 24.2, 25.1, 25.2 against `ansys-workbench-core` 0.4.x through 0.13.x. +[project.entry-points."sim.plugins"] +workbench = "sim_plugin_workbench:plugin_info" +``` ## Develop @@ -48,10 +129,11 @@ See [`src/sim_plugin_workbench/compatibility.yaml`](src/sim_plugin_workbench/com git clone https://github.com/svd-ai-lab/sim-plugin-workbench cd sim-plugin-workbench uv sync -uv run pytest +uv run pytest tests -m "not integration" ``` -End-to-end (`@pytest.mark.integration`) tests require a real Workbench install and are skipped otherwise. +End-to-end tests require a local Workbench installation and are skipped unless +their prerequisites are available. ## License diff --git a/pyproject.toml b/pyproject.toml index 1e0d1d5..6cbc500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sim-plugin-workbench" -version = "0.1.0" +version = "0.1.1" description = "Ansys Workbench (PyWorkbench) driver for sim-cli, distributed as an out-of-tree plugin" readme = "README.md" license = "Apache-2.0" @@ -47,7 +47,9 @@ workbench = "sim_plugin_workbench:plugin_info" [project.urls] Homepage = "https://github.com/svd-ai-lab/sim-plugin-workbench" +Repository = "https://github.com/svd-ai-lab/sim-plugin-workbench" Issues = "https://github.com/svd-ai-lab/sim-plugin-workbench/issues" +Changelog = "https://github.com/svd-ai-lab/sim-plugin-workbench/blob/main/CHANGELOG.md" [tool.hatch.build.targets.wheel] packages = ["src/sim_plugin_workbench"] diff --git a/src/sim_plugin_workbench/_skills/workbench/SKILL.md b/src/sim_plugin_workbench/_skills/workbench/SKILL.md index a491c73..125b6f5 100644 --- a/src/sim_plugin_workbench/_skills/workbench/SKILL.md +++ b/src/sim_plugin_workbench/_skills/workbench/SKILL.md @@ -39,6 +39,9 @@ Concepts, API patterns, and version-agnostic reference: | `base/snippets/` | Numbered IronPython journal snippets (01 through 05). Each writes results to `%TEMP%/sim_wb_result.json`. | | `base/examples/` | Official PyWorkbench examples from pyansys.com — Fluent workflow, PyMechanical integration, logging, cooled turbine blade, cyclic symmetry, axisymmetric rotor, material designer. | | `base/workflows/static_structural/` | **6-step Static Structural walkthrough** (Engineering Data → Geometry → Model → Setup → Solution → Results). Per-cell API reference + gotchas + executable `walk_workflow.py`. | +| `base/workflows/project_review_loop.md` | Required checkpoint loop: inspect health, project identity, systems summary, then run one bounded journal step. | +| `base/workflows/debug_failed_exec.md` | Failure triage loop for failed Workbench snippets and RunWB2 journals. | +| `base/workflows/mechanical_handoff.md` | Workbench-to-Mechanical checklist for Static Structural workflows. | | `base/known_issues.md` | Vendor quirks, SDK version constraints, IronPython limitations. | ## sdk// — PyWorkbench SDK specifics @@ -71,17 +74,20 @@ Concepts, API patterns, and version-agnostic reference: acceptance criteria — if missing, ask the user. 5. **Acceptance ≠ exit code.** Validate against physics-based criteria (e.g., component count, temperature range), not just exit code. +6. **Workbench owns cells 1-3.** Engineering Data, Geometry, and Model + orchestration belongs here. Mechanical setup, solve, and results belong to + `solver=mechanical`. --- ## Required protocol (one paragraph) -After `/connect` succeeds: read `base/reference/pyworkbench_api.md` and -`base/reference/journal_scripting.md` to understand the execution model. -Gather Category A inputs from the user. Execute IronPython journals -incrementally via `sim exec`, checking `last.result` after every step. -Use snippets from `base/snippets/` adapted to the user's task. After the -final step, evaluate against the user's acceptance criteria. For the -canonical smoke test, the snippet sequence is `01_smoke_test` → -`02_create_static_structural`, followed by verifying 6 standard -components exist. +After `/connect` succeeds: read `base/reference/pyworkbench_api.md`, +`base/reference/journal_scripting.md`, and +`base/workflows/project_review_loop.md`. Inspect `session.health`, +`workbench.project.identity`, and `workbench.systems.summary`. Execute +IronPython journals incrementally via `sim exec`, checking `last.result` and +`workbench.systems.summary` after every step. Use snippets from +`base/snippets/` adapted to the user's task. Before handoff, read +`base/workflows/mechanical_handoff.md` and confirm Workbench has a refreshed +Model cell. Mechanical owns setup, solve, and result extraction. diff --git a/src/sim_plugin_workbench/_skills/workbench/base/examples/cyclic_symmetry_analysis.md b/src/sim_plugin_workbench/_skills/workbench/base/examples/cyclic_symmetry_analysis.md index 091908c..f075c6b 100644 --- a/src/sim_plugin_workbench/_skills/workbench/base/examples/cyclic_symmetry_analysis.md +++ b/src/sim_plugin_workbench/_skills/workbench/base/examples/cyclic_symmetry_analysis.md @@ -62,7 +62,7 @@ mechanical = connect_to_mechanical(ip='localhost', port=server_port) print(mechanical.project_directory) ``` -Output: `C:\Users\ansys\AppData\Local\Temp\WB_ansys_19576_2\wbnew_files\` +Output: `` ## Analysis Execution diff --git a/src/sim_plugin_workbench/_skills/workbench/base/examples/pymechanical_integration.md b/src/sim_plugin_workbench/_skills/workbench/base/examples/pymechanical_integration.md index 8e6f5e0..1268b27 100644 --- a/src/sim_plugin_workbench/_skills/workbench/base/examples/pymechanical_integration.md +++ b/src/sim_plugin_workbench/_skills/workbench/base/examples/pymechanical_integration.md @@ -66,7 +66,7 @@ print(mechanical.project_directory) Output: ``` -C:\Users\ansys\AppData\Local\Temp\WB_ansys_8664_2\wbnew_files\ + ``` ## Running Solver Script diff --git a/src/sim_plugin_workbench/_skills/workbench/base/known_issues.md b/src/sim_plugin_workbench/_skills/workbench/base/known_issues.md index c76b9e1..b0e7ff7 100644 --- a/src/sim_plugin_workbench/_skills/workbench/base/known_issues.md +++ b/src/sim_plugin_workbench/_skills/workbench/base/known_issues.md @@ -54,7 +54,7 @@ systems. Use the Fluent solver API for mesh and result operations. **Affects**: All SDK versions, all solver versions **Category**: Skill **Description**: Files uploaded via `wb.upload_file()` land in the server's -TEMP directory (`C:\Users\\AppData\Local\Temp`), not in the IronPython +TEMP directory (`%TEMP%`), not in the IronPython process's `os.getcwd()` (which is `c:\windows\system32\spool\drivers\x64\3`). IronPython scripts must use `os.environ.get("TEMP")` as base path to find uploaded files. diff --git a/src/sim_plugin_workbench/_skills/workbench/base/reference/sub_solver_integration.md b/src/sim_plugin_workbench/_skills/workbench/base/reference/sub_solver_integration.md index ede7e01..579a643 100644 --- a/src/sim_plugin_workbench/_skills/workbench/base/reference/sub_solver_integration.md +++ b/src/sim_plugin_workbench/_skills/workbench/base/reference/sub_solver_integration.md @@ -80,5 +80,5 @@ when the Workbench session terminates. - `system_name` is the Workbench system identifier (e.g., "SYS", "SYS 1") - The system must exist in the project before starting its server -- Sub-solver licenses are consumed when the server starts +- Start only the sub-solver server needed for the current workflow - Only one server per system at a time diff --git a/src/sim_plugin_workbench/_skills/workbench/base/reference/system_templates.md b/src/sim_plugin_workbench/_skills/workbench/base/reference/system_templates.md index 62f8641..c3de845 100644 --- a/src/sim_plugin_workbench/_skills/workbench/base/reference/system_templates.md +++ b/src/sim_plugin_workbench/_skills/workbench/base/reference/system_templates.md @@ -1,7 +1,6 @@ # Workbench Analysis System Templates Available system templates for `GetTemplate(TemplateName=...)`. -Verified on Ansys 24.1. Before assuming a template exists, ask the live Workbench session: @@ -9,7 +8,7 @@ Before assuming a template exists, ask the live Workbench session: templates = GetAllVisibleTemplates() ``` -Template availability depends on installed products and license features. +Template availability depends on the local Workbench configuration. For example, a Workbench environment may expose CFD/project templates but not Mechanical templates. If `GetTemplate(...)` raises a `Template ... not found in Project` error, treat it as a missing @@ -70,11 +69,10 @@ system2 = template2.CreateSystem() ## Notes -- The exact list of available templates depends on the installed Ansys - products and licenses. +- The exact list of available templates depends on the local Workbench + configuration. - Use `GetAllVisibleTemplates()` during smoke setup and skip or choose a template that is present instead of assuming Mechanical templates exist. - `Solver` parameter values: `"ANSYS"` (Mechanical), `"FLUENT"`, `"CFX"`, `"MAXWELL"`, etc. -- Some templates may not be available if the corresponding product is - not installed. +- Some templates may not be available in a given environment. diff --git a/src/sim_plugin_workbench/_skills/workbench/base/workflows/debug_failed_exec.md b/src/sim_plugin_workbench/_skills/workbench/base/workflows/debug_failed_exec.md new file mode 100644 index 0000000..88d19d1 --- /dev/null +++ b/src/sim_plugin_workbench/_skills/workbench/base/workflows/debug_failed_exec.md @@ -0,0 +1,44 @@ +# Debug failed Workbench exec + +When `sim exec` or `sim run --solver workbench` fails, stop sending large +journals. Inspect the failure and the current project state, then retry with the +smallest focused journal. + +## Triage + +1. Inspect structured state: + + ```bash + sim inspect session.health + sim inspect last.result + sim inspect workbench.project.identity + sim inspect workbench.systems.summary + ``` + +2. Classify the failure: + +| Class | Typical signal | First check | +|---|---|---| +| Journal syntax | Syntax error or IronPython parse failure | Fix the journal only. | +| Missing template | Static Structural or other template not found | Inspect available templates or choose a supported workflow. | +| Missing cell | Model, Geometry, Setup, or Solution cell is unavailable | Inspect `workbench.systems.summary`. | +| SDK/fallback mismatch | SDK launch failed, RunWB2 fallback used | Check `session.health.backend`. | +| Stale project state | Cell status does not match expected step | Refresh/update one cell, then re-inspect. | +| Handoff mismatch | Mechanical opens a different or empty model | Re-check Workbench Model cell and Mechanical project identity. | + +3. Retry with one bounded repair step. Do not rebuild the full project unless + the current project state is intentionally disposable. + +## Minimal retry pattern + +Write a compact JSON result through the Workbench result-file convention: + +```python +import json, os, codecs +out = os.path.join(os.environ.get("TEMP", "C:/Temp"), "sim_wb_result.json") +f = codecs.open(out, "w", "utf-8") +f.write(json.dumps({"ok": True, "changed": "geometry_refreshed"})) +f.close() +``` + +Record repeated version-specific workarounds in `solver//notes.md`. diff --git a/src/sim_plugin_workbench/_skills/workbench/base/workflows/mechanical_handoff.md b/src/sim_plugin_workbench/_skills/workbench/base/workflows/mechanical_handoff.md new file mode 100644 index 0000000..a7dafaf --- /dev/null +++ b/src/sim_plugin_workbench/_skills/workbench/base/workflows/mechanical_handoff.md @@ -0,0 +1,38 @@ +# Workbench to Mechanical handoff + +Workbench owns the project scaffold. Mechanical owns setup, solve, and +results. Keep the boundary clear so the agent does not add boundary conditions +through Workbench journals or create a blank standalone Mechanical session when +the user expects the Workbench Model cell. + +## Static Structural handoff + +1. In Workbench, create or update the Static Structural system. +2. Refresh Engineering Data, Geometry, and Model as needed. +3. Inspect: + + ```bash + sim inspect workbench.project.identity + sim inspect workbench.systems.summary + ``` + +4. Save or update a project checkpoint before opening Mechanical. +5. In Mechanical, inspect: + + ```bash + sim inspect session.health + sim inspect mechanical.project.identity + sim inspect mechanical.model.summary + ``` + +6. Continue only when Mechanical sees the expected analysis tree and non-empty + geometry/body state. + +## Acceptance + +- Workbench reports the expected system and standard cells. +- The Model cell has been refreshed or intentionally opened. +- Mechanical reports at least one analysis for the intended system. +- Mechanical geometry/body count is nonzero before applying supports or loads. +- Any solve/result extraction happens through the Mechanical plugin, not through + Workbench journal guesses. diff --git a/src/sim_plugin_workbench/_skills/workbench/base/workflows/project_review_loop.md b/src/sim_plugin_workbench/_skills/workbench/base/workflows/project_review_loop.md new file mode 100644 index 0000000..8b1897f --- /dev/null +++ b/src/sim_plugin_workbench/_skills/workbench/base/workflows/project_review_loop.md @@ -0,0 +1,30 @@ +# Workbench project review loop + +Use this loop for every non-trivial Workbench project. Workbench creates the +project structure that downstream solver plugins rely on, so a small cell-state +mistake can become a Mechanical setup or solve failure later. + +## Loop + +1. Inspect `sim inspect session.health`. +2. Inspect `sim inspect workbench.project.identity`. +3. Inspect `sim inspect workbench.systems.summary`. +4. Execute one bounded journal step. +5. Inspect `sim inspect last.result`. +6. Re-inspect `workbench.systems.summary`. +7. Save or update a project checkpoint before handoff or risky edits. +8. Continue only when the system and cell state match the intended workflow. + +## Checkpoints + +| Layer | Expected evidence | +|---|---| +| Project | Session health is ok; a project checkpoint or saved project name is known when the work is durable. | +| Systems | The expected analysis system exists and has the expected cell set. | +| Engineering Data | Materials needed by downstream solver setup are present or intentionally deferred. | +| Geometry | Geometry cell is present and refreshed before opening the Model cell. | +| Model | Model cell is available for the solver handoff. | +| Handoff | Workbench side has a refreshed Model cell; Mechanical confirms the analysis tree before adding setup objects. | + +Screenshots help with human review, but `workbench.systems.summary` and the +journal JSON result are the primary acceptance signals. diff --git a/src/sim_plugin_workbench/driver.py b/src/sim_plugin_workbench/driver.py index def9ef0..f2e85bb 100644 --- a/src/sim_plugin_workbench/driver.py +++ b/src/sim_plugin_workbench/driver.py @@ -24,6 +24,7 @@ import shutil import subprocess import time +import traceback import uuid from pathlib import Path from typing import Any @@ -105,6 +106,35 @@ def _detect_wb_errors(stdout: str, stderr: str) -> list[str]: _RESULT_FILE = Path(os.environ.get("TEMP", "C:/Temp")) / "sim_wb_result.json" +def _safe_text(value: object, *, limit: int = 200) -> str | None: + """Return a short ASCII-safe string for public diagnostics.""" + if value is None: + return None + text = str(value) + text = "".join(ch if 32 <= ord(ch) < 127 else "?" for ch in text) + return text[:limit] + + +def _safe_name(value: object) -> str | None: + """Expose only a basename-like identifier, never a host-local path.""" + text = _safe_text(value) + if not text: + return None + return Path(text.replace("\\", "/")).name or text + + +def _ui_capabilities(ui_mode: str | None, backend: str | None = None) -> dict: + mode = ui_mode or "no_gui" + visible = mode != "no_gui" + return { + "visible_window_expected": visible, + "screenshot_expected": visible, + "live_project_tree": visible and backend == "pyworkbench", + "persistent_sdk": backend == "pyworkbench", + "fallback_batch": backend == "runwb2", + } + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -219,9 +249,15 @@ def __init__(self): self._client: Any = None # PyWorkbench client or None self._session_id: str | None = None self._mode: str | None = None + self._ui_mode: str | None = None self._run_count: int = 0 self._version: str | None = None self._backend: str | None = None # "pyworkbench" | "runwb2" + self._connected_at: float | None = None + self._last_run: dict | None = None + self._last_error: str | None = None + self._last_health: dict | None = None + self._launch_options: dict = {} self._sim_dir: Path = Path(os.environ.get("SIM_DIR") or (Path.cwd() / ".sim")) self.probes: list = _default_workbench_probes(enable_gui=False) @@ -294,7 +330,7 @@ def connect(self) -> ConnectionInfo: solver="workbench", version=top.version, status="ok", - message=f"Ansys Workbench {top.version} at {top.path}{sdk_note}", + message=f"Ansys Workbench {top.version}{sdk_note}", solver_version=top.version, ) @@ -409,6 +445,98 @@ def supports_session(self) -> bool: def is_connected(self) -> bool: return self._client is not None or self._backend == "runwb2" + def _visible_window_summary(self) -> dict: + if self._ui_mode == "no_gui": + return {"available": False, "match_count": 0, "processes": []} + try: + from sim.gui import GuiController # noqa: PLC0415 + gui = GuiController( + process_name_substrings=("AnsysWBU", "Workbench", "RunWB2"), + workdir=str(self._sim_dir), + ) + if not gui.available: + return {"available": False, "match_count": 0, "processes": []} + data = gui.list_windows() + except Exception as exc: # noqa: BLE001 - GUI support is optional + return { + "available": False, + "match_count": 0, + "processes": [], + "error": type(exc).__name__, + } + if not data.get("ok"): + return { + "available": True, + "match_count": 0, + "processes": [], + "error": _safe_text(data.get("error")), + } + windows = data.get("windows", []) or [] + processes = sorted({ + _safe_text(w.get("proc"), limit=80) or "" + for w in windows if w.get("proc") + }) + return { + "available": True, + "match_count": len(windows), + "processes": [p for p in processes if p], + "has_visible_window": bool(windows), + } + + def _sdk_health_status(self) -> tuple[bool | None, str | None]: + if self._backend != "pyworkbench" or self._client is None: + return None, None + for name in ("is_alive", "is_connected", "connected"): + member = getattr(self._client, name, None) + try: + value = member() if callable(member) else member + except Exception as exc: # noqa: BLE001 - SDK health methods vary + return False, f"{type(exc).__name__}: {exc}" + if value is not None: + return bool(value), None + return None, None + + def health(self) -> dict: + """Best-effort live-session health without exposing host details.""" + sdk_alive, sdk_error = self._sdk_health_status() + connected = self.is_connected and sdk_alive is not False + if not self.is_connected: + code = "workbench.session.disconnected" + message = "Workbench session is not connected" + elif self._backend == "runwb2": + code = "workbench.session.fallback_ready" + message = "Workbench RunWB2 fallback is ready for one-shot snippets" + elif sdk_alive is False: + code = "workbench.sdk.health_failed" + message = "Workbench SDK health check failed" + elif sdk_alive is None: + code = "workbench.session.connected_unverified" + message = "Workbench session is connected; no SDK health method is available" + else: + code = "workbench.session.connected" + message = "Workbench session is connected" + health = { + "ok": connected, + "connected": connected, + "code": code, + "message": message, + "session_id": self._session_id, + "backend": self._backend, + "run_count": self._run_count, + "ui_mode": self._ui_mode, + "ui_capabilities": _ui_capabilities(self._ui_mode, self._backend), + "last_error": _safe_text(self._last_error or sdk_error), + "version": self._version, + "connected_at": self._connected_at, + "windows": self._visible_window_summary(), + "launch_options": { + k: v for k, v in self._launch_options.items() + if k in {"mode", "ui_mode", "processors"} + }, + } + self._last_health = health + return health + def launch(self, mode: str = "workbench", ui_mode: str = "gui", processors: int = 2, **kwargs) -> dict: """Start a Workbench session. SDK first, RunWB2 fallback.""" if self._client is not None: @@ -443,8 +571,19 @@ def launch(self, mode: str = "workbench", ui_mode: str = "gui", processors: int self._session_id = str(uuid.uuid4()) self._mode = mode + self._ui_mode = ui_mode self._run_count = 0 self._version = top.version + self._connected_at = time.time() + self._last_run = None + self._last_error = None + self._launch_options = { + "mode": mode, + "ui_mode": ui_mode, + "processors": processors, + } + self.probes = _default_workbench_probes(enable_gui=ui_mode != "no_gui") + self._last_health = self.health() return { "ok": True, @@ -453,6 +592,8 @@ def launch(self, mode: str = "workbench", ui_mode: str = "gui", processors: int "ui_mode": ui_mode, "version": top.version, "backend": self._backend, + "ui_capabilities": _ui_capabilities(ui_mode, self._backend), + "health": self._last_health, } def _dispatch(self, code: str, label: str = "snippet") -> dict: @@ -480,9 +621,15 @@ def _dispatch(self, code: str, label: str = "snippet") -> dict: "elapsed_s": round(time.time() - started, 4), } - def run(self, code: str, label: str = "snippet") -> dict: + def run( + self, + code: str, + label: str = "snippet", + timeout_s: float | None = None, + ) -> dict: """Execute a snippet and attach inspect diagnostics.""" from sim.inspect import InspectCtx, collect_diagnostics # noqa: PLC0415 + from sim._timeout import DEFAULT_TIMEOUT_S, call_with_timeout # noqa: PLC0415 wd = self._sim_dir try: @@ -495,8 +642,75 @@ def run(self, code: str, label: str = "snippet") -> dict: before = [] t0 = time.monotonic() - result = self._dispatch(code, label) + timeout_budget = DEFAULT_TIMEOUT_S if timeout_s is None else timeout_s + t_result = call_with_timeout( + lambda: self._dispatch(code, label), + timeout_s=timeout_budget, + ) wall = time.monotonic() - t0 + extras: dict[str, Any] = {} + + if t_result.hung: + self._last_error = ( + f"snippet exceeded timeout_s={timeout_budget}; " + "disconnect and re-launch the Workbench session" + ) + self._last_health = { + **self.health(), + "ok": False, + "connected": False, + "code": "workbench.runtime.timeout_session_degraded", + "message": "Workbench snippet timed out", + } + result = { + "ok": False, + "label": label, + "stdout": "", + "stderr": "", + "error": self._last_error, + "result": None, + "elapsed_s": round(wall, 4), + } + extras.update({ + "timeout_hit": True, + "timeout_s": timeout_budget, + "timeout_elapsed_s": wall, + }) + elif t_result.exception is not None: + exc = t_result.exception + self._last_error = f"{type(exc).__name__}: {exc}" + result = { + "ok": False, + "label": label, + "stdout": "", + "stderr": "", + "error": "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ), + "result": None, + "elapsed_s": round(wall, 4), + } + else: + result = t_result.value + + guard_diagnostics: list[dict] = [] + parsed = result.get("result") + if result.get("ok") and isinstance(parsed, dict) and parsed.get("ok") is False: + error = ( + _safe_text(parsed.get("error") or parsed.get("message")) + or "Workbench journal reported ok=false" + ) + result["ok"] = False + result["error"] = error + guard_diagnostics.append({ + "severity": "error", + "source": "workbench:journal", + "code": "workbench.journal.result_failed", + "message": "Workbench journal transport succeeded, but the parsed journal result reported failure.", + "extra": { + "result_code": _safe_text(parsed.get("code")), + }, + }) ctx = InspectCtx( stdout=result.get("stdout", ""), @@ -507,17 +721,120 @@ def run(self, code: str, label: str = "snippet") -> dict: driver_name=self.name, session_ns={"_result": result.get("result")}, workdir_before=before, + extras=extras, ) diags, arts = collect_diagnostics(self.probes, ctx) - result["diagnostics"] = [d.to_dict() for d in diags] + result["diagnostics"] = [d.to_dict() for d in diags] + guard_diagnostics result["artifacts"] = [a.to_dict() for a in arts] + if not result.get("ok") and result.get("error"): + self._last_error = _safe_text(result.get("error")) + self._last_run = result return result + def _last_result_dict(self) -> dict: + if not self._last_run: + return {} + value = self._last_run.get("result") + return value if isinstance(value, dict) else {} + + def _systems_from_result(self, data: dict) -> list[dict]: + systems = data.get("systems") + if isinstance(systems, list): + out = [] + for i, system in enumerate(systems): + if not isinstance(system, dict): + continue + out.append({ + "index": i, + "name": _safe_text(system.get("name") or system.get("type")), + "type": _safe_text(system.get("type") or system.get("template")), + "cells": system.get("cells", []), + "status": _safe_text(system.get("status") or "unknown"), + }) + return out + components = data.get("components") + if isinstance(components, list) or data.get("component_count"): + return [{ + "index": 0, + "name": _safe_text(data.get("created") or "Static Structural"), + "type": "Static Structural", + "cells": [_safe_text(c) for c in (components or [])], + "status": "unknown", + }] + return [] + + def systems_summary(self) -> dict: + data = self._last_result_dict() + systems = self._systems_from_result(data) + return { + "ok": True, + "connected": self.is_connected, + "backend": self._backend, + "source": "last.result" if data else "unknown", + "system_count": len(systems), + "systems": systems, + "standard_cells": [ + "Engineering Data", "Geometry", "Model", + "Setup", "Solution", "Results", + ], + } + + def project_identity(self) -> dict: + if not self.is_connected: + return { + "ok": False, + "connected": False, + "code": "workbench.session.disconnected", + "message": "Workbench session is not connected", + "checkpoint_ready": False, + } + systems = self.systems_summary() + data = self._last_result_dict() + project_name = ( + _safe_name(data.get("project_file")) + or _safe_name(data.get("archive")) + or _safe_name(data.get("project")) + ) + return { + "ok": True, + "connected": True, + "backend": self._backend, + "project_state": "unknown", + "project_file_name": project_name, + "has_saved_location": bool(project_name), + "system_count": systems["system_count"], + "systems": systems["systems"], + "checkpoint_ready": bool(project_name and systems["system_count"]), + "diagnostics": [] if project_name else [{ + "severity": "info", + "code": "workbench.project.location_unknown", + "message": "No saved Workbench project location has been reported yet", + }], + } + def query(self, name: str) -> dict: + if name in {"health", "session.health"}: + return self.health() + if name in {"ui.modes", "session.ui_modes"}: + return { + "ok": True, + "modes": { + "no_gui": "Workbench SDK/fallback execution without an intentional visible window.", + "gui": "Visible Workbench session when the SDK backend supports it.", + "batch-fallback": "RunWB2-backed one-shot journal execution.", + }, + "aliases": {"gui": "gui", "visible": "gui", "no-gui": "no_gui", "no_gui": "no_gui"}, + "capabilities": _ui_capabilities(self._ui_mode, self._backend), + } + if name in {"workbench.systems.summary", "systems.summary"}: + return self.systems_summary() + if name in {"workbench.project.identity", "project.identity"}: + return self.project_identity() if name == "session.summary": return { "session_id": self._session_id, "mode": self._mode, + "ui_mode": self._ui_mode, "connected": self.is_connected, "run_count": self._run_count, "version": self._version, @@ -534,9 +851,15 @@ def disconnect(self, **kwargs) -> None: self._client = None self._session_id = None self._mode = None + self._ui_mode = None self._run_count = 0 self._version = None self._backend = None + self._connected_at = None + self._last_run = None + self._last_error = None + self._launch_options = {} + self.probes = _default_workbench_probes(enable_gui=False) # ── SDK execution ────────────────────────────────────────────── diff --git a/tests/execution/workbench_mechanical_handoff_smoke.py b/tests/execution/workbench_mechanical_handoff_smoke.py new file mode 100644 index 0000000..deb7c32 --- /dev/null +++ b/tests/execution/workbench_mechanical_handoff_smoke.py @@ -0,0 +1,58 @@ +"""Optional real-solver Workbench-to-Mechanical handoff smoke. + +Run manually on a machine with both plugins installed and solver prerequisites +available. The script prints only structured status and avoids committing logs +or screenshots. +""" +from __future__ import annotations + +import json + +from sim_plugin_workbench import WorkbenchDriver + + +def main() -> None: + wb = WorkbenchDriver() + info = wb.launch(mode="workbench", ui_mode="gui") + try: + print(json.dumps({"launch": info, "health": wb.query("session.health")})) + script = ''' +SetScriptVersion(Version="24.1") +import json, os, codecs + +def write_result(payload): + out = os.path.join(os.environ.get("TEMP", "C:/Temp"), "sim_wb_result.json") + f = codecs.open(out, "w", "utf-8") + f.write(json.dumps(payload)) + f.close() + +try: + template1 = GetTemplate(TemplateName="Static Structural", Solver="ANSYS") + system1 = template1.CreateSystem() + write_result({ + "ok": True, + "component_count": 6, + "components": [ + "Engineering Data", "Geometry", "Model", + "Setup", "Solution", "Results" + ] + }) +except Exception as e: + write_result({ + "ok": False, + "code": "workbench.template.unavailable", + "error": str(e)[:240] + }) +''' + result = wb.run(script, label="handoff-static-structural") + print(json.dumps({ + "run_ok": result.get("ok"), + "systems": wb.query("workbench.systems.summary"), + "identity": wb.query("workbench.project.identity"), + })) + finally: + wb.disconnect() + + +if __name__ == "__main__": + main() diff --git a/tests/test_wheel_contents.py b/tests/test_wheel_contents.py index 6fae228..4b7dab2 100644 --- a/tests/test_wheel_contents.py +++ b/tests/test_wheel_contents.py @@ -36,6 +36,18 @@ def test_wheel_contains_skills(tmp_path: Path) -> None: "sim_plugin_workbench/__init__.py", "sim_plugin_workbench/driver.py", "sim_plugin_workbench/_skills/workbench/SKILL.md", + "sim_plugin_workbench/_skills/workbench/base/known_issues.md", + "sim_plugin_workbench/_skills/workbench/base/reference/pyworkbench_api.md", + "sim_plugin_workbench/_skills/workbench/base/reference/journal_scripting.md", + "sim_plugin_workbench/_skills/workbench/base/reference/system_templates.md", + "sim_plugin_workbench/_skills/workbench/base/snippets/01_smoke_test.py", + "sim_plugin_workbench/_skills/workbench/base/snippets/02_create_static_structural.py", + "sim_plugin_workbench/_skills/workbench/base/workflows/static_structural/README.md", + "sim_plugin_workbench/_skills/workbench/base/workflows/static_structural/evidence/README.md", + "sim_plugin_workbench/_skills/workbench/base/workflows/project_review_loop.md", + "sim_plugin_workbench/_skills/workbench/base/workflows/debug_failed_exec.md", + "sim_plugin_workbench/_skills/workbench/base/workflows/mechanical_handoff.md", + "sim_plugin_workbench/_skills/workbench/tests/test_orchestration.py", } missing = required - names - assert not missing, f"missing from wheel: {missing}" \ No newline at end of file + assert not missing, f"missing from wheel: {missing}" diff --git a/tests/test_workbench_driver.py b/tests/test_workbench_driver.py index a9b24be..6763188 100644 --- a/tests/test_workbench_driver.py +++ b/tests/test_workbench_driver.py @@ -184,13 +184,28 @@ def test_disconnect_noop_when_not_connected(self, driver): assert driver.is_connected is False def test_run_raises_without_session(self, driver): - with pytest.raises(RuntimeError, match="No active session"): - driver.run("print('hello')") + result = driver.run("print('hello')") + assert result["ok"] is False + assert "No active session" in result["error"] def test_query_raises_unknown(self, driver): with pytest.raises(ValueError, match="unknown query"): driver.query("nonexistent") + def test_health_disconnected(self, driver): + health = driver.query("session.health") + assert health["ok"] is False + assert health["connected"] is False + assert health["code"] == "workbench.session.disconnected" + assert "license" not in str(health).lower() + + def test_ui_modes(self, driver): + modes = driver.query("ui.modes") + assert modes["ok"] is True + assert "gui" in modes["modes"] + assert "no_gui" in modes["modes"] + assert "batch-fallback" in modes["modes"] + # --------------------------------------------------------------------------- # Fallback logic @@ -238,8 +253,145 @@ def launch_workbench(**kwargs): info = driver.launch(mode="workbench") assert info["ok"] is True assert info["backend"] == "runwb2" + assert driver.query("session.health")["code"] == "workbench.session.fallback_ready" + driver.disconnect() + + def test_launch_enables_gui_probes_for_visible_ui(self, driver, monkeypatch, tmp_path): + def _mock_pywb(): + class _Client: + def is_alive(self): + return True + + def run_script_string(self, code, log_level="warning"): + return '{"ok": true}' + + def exit(self): + return None + + class _FakeModule: + __version__ = "0.0.0" + + @staticmethod + def launch_workbench(**kwargs): + return _Client() + + return _FakeModule() + + monkeypatch.setattr( + "sim_plugin_workbench.driver._try_import_pyworkbench", _mock_pywb + ) + monkeypatch.setattr(driver, "detect_installed", lambda: [_fake_install(tmp_path)]) + + info = driver.launch(mode="workbench", ui_mode="gui") + + assert info["backend"] == "pyworkbench" + assert any(getattr(p, "name", "") == "gui-dialog" for p in driver.probes) + health = driver.query("session.health") + assert health["ok"] is True + assert health["ui_capabilities"]["screenshot_expected"] is True driver.disconnect() + def test_launch_keeps_gui_probes_off_for_no_gui(self, driver, monkeypatch, tmp_path): + monkeypatch.setattr( + "sim_plugin_workbench.driver._try_import_pyworkbench", lambda: None + ) + monkeypatch.setattr(driver, "detect_installed", lambda: [_fake_install(tmp_path)]) + + driver.launch(mode="workbench", ui_mode="no_gui") + + assert not any(getattr(p, "name", "") == "gui-dialog" for p in driver.probes) + driver.disconnect() + + def test_systems_summary_from_last_result(self, driver): + driver._backend = "pyworkbench" + driver._client = object() + driver._last_run = { + "result": { + "ok": True, + "component_count": 6, + "components": [ + "Engineering Data", "Geometry", "Model", + "Setup", "Solution", "Results", + ], + "project_file": "example.wbpj", + } + } + + summary = driver.query("workbench.systems.summary") + identity = driver.query("workbench.project.identity") + + assert summary["system_count"] == 1 + assert summary["systems"][0]["type"] == "Static Structural" + assert identity["checkpoint_ready"] is True + assert identity["project_file_name"] == "example.wbpj" + + def test_run_timeout_returns_structured_failure(self, driver, monkeypatch): + import time + + driver._backend = "pyworkbench" + driver._client = object() + monkeypatch.setattr(driver, "_dispatch", lambda code, label: time.sleep(0.2)) + + result = driver.run("slow()", timeout_s=0.01) + + assert result["ok"] is False + assert "timeout_s" in result["error"] + assert any( + d["code"] == "sim.runtime.snippet_timeout" + for d in result["diagnostics"] + ) + + def test_run_marks_parsed_journal_failure(self, driver, monkeypatch): + driver._backend = "pyworkbench" + driver._client = object() + monkeypatch.setattr( + driver, + "_dispatch", + lambda code, label: { + "ok": True, + "label": label, + "stdout": '{"ok": false, "error": "template missing"}', + "stderr": "", + "error": None, + "result": {"ok": False, "error": "template missing"}, + "elapsed_s": 0.01, + }, + ) + + result = driver.run("bad journal", label="template-probe") + + assert result["ok"] is False + assert result["error"] == "template missing" + assert any( + d["code"] == "workbench.journal.result_failed" + for d in result["diagnostics"] + ) + + def test_run_keeps_parsed_journal_success(self, driver, monkeypatch): + driver._backend = "pyworkbench" + driver._client = object() + monkeypatch.setattr( + driver, + "_dispatch", + lambda code, label: { + "ok": True, + "label": label, + "stdout": '{"ok": true}', + "stderr": "", + "error": None, + "result": {"ok": True}, + "elapsed_s": 0.01, + }, + ) + + result = driver.run("good journal", label="template-probe") + + assert result["ok"] is True + assert not any( + d["code"] == "workbench.journal.result_failed" + for d in result["diagnostics"] + ) + # --------------------------------------------------------------------------- # run_file() diff --git a/uv.lock b/uv.lock index 9d1053a..0349036 100644 --- a/uv.lock +++ b/uv.lock @@ -1058,7 +1058,7 @@ wheels = [ [[package]] name = "sim-plugin-workbench" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "ansys-workbench-core", version = "0.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },