@aryacode/dsh-engineering 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/cordis.patch.yml +122 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -3
- package/vendor/ftview/README.md +55 -0
- package/vendor/ftview/SKILL.md +74 -0
- package/vendor/ftview/lib/checks.d.ts +27 -0
- package/vendor/ftview/lib/checks.js +39 -0
- package/vendor/ftview/lib/core.d.ts +208 -0
- package/vendor/ftview/lib/core.js +160 -0
- package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
- package/vendor/ftview/lib/edit/drafts.js +84 -0
- package/vendor/ftview/lib/edit/operations.d.ts +62 -0
- package/vendor/ftview/lib/edit/operations.js +126 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
- package/vendor/ftview/lib/edit/prepare.js +133 -0
- package/vendor/ftview/lib/edit/tools.d.ts +25 -0
- package/vendor/ftview/lib/edit/tools.js +198 -0
- package/vendor/ftview/lib/edit.d.ts +30 -0
- package/vendor/ftview/lib/edit.js +38 -0
- package/vendor/ftview/lib/index.d.ts +31 -0
- package/vendor/ftview/lib/index.js +36 -0
- package/vendor/ftview/lib/model.d.ts +80 -0
- package/vendor/ftview/lib/model.js +267 -0
- package/vendor/ftview/lib/session.d.ts +48 -0
- package/vendor/ftview/lib/session.js +98 -0
- package/vendor/ftview/lib/skill.d.ts +29 -0
- package/vendor/ftview/lib/skill.js +43 -0
- package/vendor/ftview/lib/studio/discover.d.ts +36 -0
- package/vendor/ftview/lib/studio/discover.js +87 -0
- package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
- package/vendor/ftview/lib/studio/preflight.js +64 -0
- package/vendor/ftview/lib/studio/tools.d.ts +15 -0
- package/vendor/ftview/lib/studio/tools.js +83 -0
- package/vendor/ftview/lib/studio.d.ts +30 -0
- package/vendor/ftview/lib/studio.js +37 -0
- package/vendor/ftview/lib/tools.d.ts +14 -0
- package/vendor/ftview/lib/tools.js +202 -0
- package/vendor/l5x/README.md +62 -0
- package/vendor/l5x/SKILL.md +81 -0
- package/vendor/l5x/lib/approvals.d.ts +50 -0
- package/vendor/l5x/lib/approvals.js +93 -0
- package/vendor/l5x/lib/client.js +412 -0
- package/vendor/l5x/lib/client.js.map +1 -0
- package/vendor/l5x/lib/core/errors.d.ts +38 -0
- package/vendor/l5x/lib/core/errors.js +48 -0
- package/vendor/l5x/lib/core/params.d.ts +49 -0
- package/vendor/l5x/lib/core/params.js +39 -0
- package/vendor/l5x/lib/core/result.d.ts +46 -0
- package/vendor/l5x/lib/core/result.js +21 -0
- package/vendor/l5x/lib/core/tool.d.ts +102 -0
- package/vendor/l5x/lib/core/tool.js +19 -0
- package/vendor/l5x/lib/drafts.d.ts +66 -0
- package/vendor/l5x/lib/drafts.js +98 -0
- package/vendor/l5x/lib/editing.d.ts +30 -0
- package/vendor/l5x/lib/editing.js +42 -0
- package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
- package/vendor/l5x/lib/engine/acd-edit.js +115 -0
- package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
- package/vendor/l5x/lib/engine/analysis.js +258 -0
- package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
- package/vendor/l5x/lib/engine/capabilities.js +40 -0
- package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
- package/vendor/l5x/lib/engine/change-set.js +446 -0
- package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
- package/vendor/l5x/lib/engine/configuration.js +42 -0
- package/vendor/l5x/lib/engine/details.d.ts +47 -0
- package/vendor/l5x/lib/engine/details.js +173 -0
- package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
- package/vendor/l5x/lib/engine/direct-polling.js +193 -0
- package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
- package/vendor/l5x/lib/engine/download-review.js +63 -0
- package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
- package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
- package/vendor/l5x/lib/engine/observations.d.ts +41 -0
- package/vendor/l5x/lib/engine/observations.js +67 -0
- package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
- package/vendor/l5x/lib/engine/project-analysis.js +314 -0
- package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
- package/vendor/l5x/lib/engine/review-evidence.js +49 -0
- package/vendor/l5x/lib/engine/review.d.ts +18 -0
- package/vendor/l5x/lib/engine/review.js +123 -0
- package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
- package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
- package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
- package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
- package/vendor/l5x/lib/engine/usage.d.ts +16 -0
- package/vendor/l5x/lib/engine/usage.js +92 -0
- package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
- package/vendor/l5x/lib/engine/xml-regions.js +77 -0
- package/vendor/l5x/lib/fs.d.ts +17 -0
- package/vendor/l5x/lib/fs.js +36 -0
- package/vendor/l5x/lib/index.d.ts +36 -0
- package/vendor/l5x/lib/index.js +42 -0
- package/vendor/l5x/lib/live.d.ts +48 -0
- package/vendor/l5x/lib/live.js +48 -0
- package/vendor/l5x/lib/model/aois.d.ts +39 -0
- package/vendor/l5x/lib/model/aois.js +112 -0
- package/vendor/l5x/lib/model/errors.d.ts +15 -0
- package/vendor/l5x/lib/model/errors.js +18 -0
- package/vendor/l5x/lib/model/index.d.ts +20 -0
- package/vendor/l5x/lib/model/index.js +19 -0
- package/vendor/l5x/lib/model/module.d.ts +37 -0
- package/vendor/l5x/lib/model/module.js +54 -0
- package/vendor/l5x/lib/model/modules.d.ts +24 -0
- package/vendor/l5x/lib/model/modules.js +55 -0
- package/vendor/l5x/lib/model/parse.d.ts +32 -0
- package/vendor/l5x/lib/model/parse.js +116 -0
- package/vendor/l5x/lib/model/project.d.ts +15 -0
- package/vendor/l5x/lib/model/project.js +31 -0
- package/vendor/l5x/lib/model/routines.d.ts +71 -0
- package/vendor/l5x/lib/model/routines.js +183 -0
- package/vendor/l5x/lib/model/tags.d.ts +51 -0
- package/vendor/l5x/lib/model/tags.js +119 -0
- package/vendor/l5x/lib/model/types.d.ts +304 -0
- package/vendor/l5x/lib/model/types.js +12 -0
- package/vendor/l5x/lib/model/udts.d.ts +34 -0
- package/vendor/l5x/lib/model/udts.js +81 -0
- package/vendor/l5x/lib/model/xml.d.ts +139 -0
- package/vendor/l5x/lib/model/xml.js +291 -0
- package/vendor/l5x/lib/model/xref.d.ts +28 -0
- package/vendor/l5x/lib/model/xref.js +154 -0
- package/vendor/l5x/lib/session.d.ts +119 -0
- package/vendor/l5x/lib/session.js +236 -0
- package/vendor/l5x/lib/skill.d.ts +30 -0
- package/vendor/l5x/lib/skill.js +44 -0
- package/vendor/l5x/lib/studio-state.d.ts +77 -0
- package/vendor/l5x/lib/studio-state.js +79 -0
- package/vendor/l5x/lib/studio.d.ts +29 -0
- package/vendor/l5x/lib/studio.js +40 -0
- package/vendor/l5x/lib/tools/checks.d.ts +19 -0
- package/vendor/l5x/lib/tools/checks.js +140 -0
- package/vendor/l5x/lib/tools/common.d.ts +41 -0
- package/vendor/l5x/lib/tools/common.js +84 -0
- package/vendor/l5x/lib/tools/controller.d.ts +21 -0
- package/vendor/l5x/lib/tools/controller.js +170 -0
- package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
- package/vendor/l5x/lib/tools/drafts.js +270 -0
- package/vendor/l5x/lib/tools/live.d.ts +21 -0
- package/vendor/l5x/lib/tools/live.js +175 -0
- package/vendor/l5x/lib/tools/read.d.ts +19 -0
- package/vendor/l5x/lib/tools/read.js +245 -0
- package/vendor/l5x/lib/tools/references.d.ts +20 -0
- package/vendor/l5x/lib/tools/references.js +269 -0
- package/vendor/l5x/lib/tools/studio.d.ts +21 -0
- package/vendor/l5x/lib/tools/studio.js +193 -0
- package/vendor/l5x/poll-worker.mjs +77 -0
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
- package/vendor/l5x/studio-bridge/Program.cs +304 -0
- package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
- package/vendor/mwi/README.md +60 -0
- package/vendor/mwi/SKILL.md +87 -0
- package/vendor/mwi/bridge/README.md +8 -0
- package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
- package/vendor/mwi/bridge/start_bridge.cmd +21 -0
- package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
- package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
- package/vendor/mwi/code/mw_code.py +1258 -0
- package/vendor/mwi/legacy/edit-session.js +50 -0
- package/vendor/mwi/legacy/fb-insertion.js +74 -0
- package/vendor/mwi/legacy/native-code.js +83 -0
- package/vendor/mwi/legacy/native-groups.js +75 -0
- package/vendor/mwi/legacy/native-structure.js +143 -0
- package/vendor/mwi/legacy/native-variables.js +77 -0
- package/vendor/mwi/legacy/pou-conversion.js +47 -0
- package/vendor/mwi/legacy/pou-package.js +66 -0
- package/vendor/mwi/lib/core/bridge.d.ts +71 -0
- package/vendor/mwi/lib/core/bridge.js +53 -0
- package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
- package/vendor/mwi/lib/core/code-engine.js +109 -0
- package/vendor/mwi/lib/core/errors.d.ts +37 -0
- package/vendor/mwi/lib/core/errors.js +47 -0
- package/vendor/mwi/lib/core/params.d.ts +52 -0
- package/vendor/mwi/lib/core/params.js +46 -0
- package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
- package/vendor/mwi/lib/core/ps-bridge.js +127 -0
- package/vendor/mwi/lib/core/result.d.ts +45 -0
- package/vendor/mwi/lib/core/result.js +20 -0
- package/vendor/mwi/lib/core/tool.d.ts +80 -0
- package/vendor/mwi/lib/core/tool.js +55 -0
- package/vendor/mwi/lib/edit/compare.d.ts +33 -0
- package/vendor/mwi/lib/edit/compare.js +70 -0
- package/vendor/mwi/lib/edit.d.ts +20 -0
- package/vendor/mwi/lib/edit.js +32 -0
- package/vendor/mwi/lib/index.d.ts +21 -0
- package/vendor/mwi/lib/index.js +33 -0
- package/vendor/mwi/lib/read/service.d.ts +117 -0
- package/vendor/mwi/lib/read/service.js +229 -0
- package/vendor/mwi/lib/read.d.ts +21 -0
- package/vendor/mwi/lib/read.js +33 -0
- package/vendor/mwi/lib/session/project.d.ts +34 -0
- package/vendor/mwi/lib/session/project.js +54 -0
- package/vendor/mwi/lib/session/service.d.ts +167 -0
- package/vendor/mwi/lib/session/service.js +566 -0
- package/vendor/mwi/lib/session/stage.d.ts +84 -0
- package/vendor/mwi/lib/session/stage.js +187 -0
- package/vendor/mwi/lib/session/workspace.d.ts +52 -0
- package/vendor/mwi/lib/session/workspace.js +90 -0
- package/vendor/mwi/lib/skill.d.ts +36 -0
- package/vendor/mwi/lib/skill.js +56 -0
- package/vendor/mwi/lib/tools/edit.d.ts +20 -0
- package/vendor/mwi/lib/tools/edit.js +286 -0
- package/vendor/mwi/lib/tools/read.d.ts +19 -0
- package/vendor/mwi/lib/tools/read.js +161 -0
- package/vendor/mwi/lib/tools/session.d.ts +20 -0
- package/vendor/mwi/lib/tools/session.js +166 -0
- package/vendor/mwi/lib/tools/verify.d.ts +19 -0
- package/vendor/mwi/lib/tools/verify.js +85 -0
- package/vendor/mwi/lib/verify.d.ts +17 -0
- package/vendor/mwi/lib/verify.js +29 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
"""MotionWorks Use - file-level CODE engine.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS
|
|
4
|
+
---------------
|
|
5
|
+
The public plugin edits code through native ExecuteDdeCommand/ChangeCodeWS in
|
|
6
|
+
the IDE bridge. This engine independently reads saved source, declarations,
|
|
7
|
+
library interfaces and verification evidence. Legacy private format writers
|
|
8
|
+
remain only for regression coverage; they are retired public operations and
|
|
9
|
+
are not an alternative to native IDE editing.
|
|
10
|
+
|
|
11
|
+
PROTOCOL
|
|
12
|
+
--------
|
|
13
|
+
python mw_code.py <verb> <request.json> <response.json>
|
|
14
|
+
|
|
15
|
+
Files rather than stdio: a confined harness cannot open named pipes, so a piped
|
|
16
|
+
child fails with EPERM. The response is written to a temp file and renamed into
|
|
17
|
+
place, and carries no BOM so the Node half can JSON.parse it.
|
|
18
|
+
|
|
19
|
+
SAFETY
|
|
20
|
+
------
|
|
21
|
+
* Session/stage provenance is checked before project access.
|
|
22
|
+
* Retired offline writes are not callable public editing tools.
|
|
23
|
+
* This module never downloads to a controller and never commands motion.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import traceback
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
# The engine lives in the sibling package; the caller passes its root so this
|
|
35
|
+
# file carries no hard-coded absolute path.
|
|
36
|
+
# The engine is VENDORED beside this file (code/engine/motionworks_iec_mcp), so the
|
|
37
|
+
# plugin is self-contained and does not depend on any other checkout. MW_SRC still
|
|
38
|
+
# overrides it, for developing against a different engine tree.
|
|
39
|
+
_SRC = os.environ.get("MW_SRC")
|
|
40
|
+
if not _SRC:
|
|
41
|
+
_SRC = str(Path(__file__).resolve().parent / "engine")
|
|
42
|
+
if _SRC and _SRC not in sys.path:
|
|
43
|
+
sys.path.insert(0, _SRC)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ok(**kw):
|
|
47
|
+
return {"ok": True, **kw}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fail(message: str, **kw):
|
|
51
|
+
return {"ok": False, "error": message, **kw}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _project(root: str):
|
|
55
|
+
from motionworks_iec_mcp import project as P
|
|
56
|
+
|
|
57
|
+
return P.Project(root=Path(root))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def verb_worksheet_target(req):
|
|
61
|
+
from motionworks_iec_mcp.navigation import worksheet_target
|
|
62
|
+
return _ok(**worksheet_target(_project(req['project']), req['kind'], req.get('pou')))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def verb_graphical_listing(req):
|
|
66
|
+
from motionworks_iec_mcp.graphical_listing import inspect
|
|
67
|
+
return _ok(**inspect(_project(req['project']), req['pou'], req.get('start', 1), req.get('limit', 10)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verb_compiled_source_evidence(req):
|
|
71
|
+
from motionworks_iec_mcp.graphical_listing import inspect
|
|
72
|
+
return _ok(**inspect(_project(req['project']), req['pou'], source_only=True))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verb_structure_snapshot(req):
|
|
76
|
+
from motionworks_iec_mcp.structure import snapshot
|
|
77
|
+
return _ok(**snapshot(Path(req['project'])))
|
|
78
|
+
|
|
79
|
+
def verb_block_interface(req):
|
|
80
|
+
from motionworks_iec_mcp.block_interfaces import inspect
|
|
81
|
+
return _ok(**inspect(Path(req['project']),req['native_libraries'],req.get('name'),req.get('library')))
|
|
82
|
+
|
|
83
|
+
def verb_installed_help(req):
|
|
84
|
+
from motionworks_iec_mcp.installed_help import search
|
|
85
|
+
return _ok(**search(req.get('module'),req.get('query',''),req.get('topic'),req.get('limit',5)))
|
|
86
|
+
|
|
87
|
+
# ── read ─────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def verb_pous(req):
|
|
90
|
+
"""List the POUs of a project directory, straight from its files."""
|
|
91
|
+
proj = _project(req["project"])
|
|
92
|
+
out = []
|
|
93
|
+
for info in proj.pous():
|
|
94
|
+
# NOTE: `name` is a dataclass FIELD on PouInfo, while language/st_body
|
|
95
|
+
# are methods. Measured with inspect, not assumed.
|
|
96
|
+
entry = {"name": info.name}
|
|
97
|
+
try:
|
|
98
|
+
entry["language"] = info.language()
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
entry["language"] = None
|
|
101
|
+
entry["language_error"] = str(exc)
|
|
102
|
+
try:
|
|
103
|
+
stream = info.body_stream()
|
|
104
|
+
entry["body_stream"] = stream[0] if stream else None
|
|
105
|
+
entry["stream_language"] = stream[1] if stream else None
|
|
106
|
+
if stream:
|
|
107
|
+
import hashlib
|
|
108
|
+
body = info.st_body() if stream[1] == 'ST' else None
|
|
109
|
+
raw = body.encode('utf-8') if body is not None else info.source().read_stream(stream[0])
|
|
110
|
+
entry["body_sha256"] = hashlib.sha256(raw).hexdigest()
|
|
111
|
+
# Native bodies can contain comment references whose actual text
|
|
112
|
+
# is in Translation.xml. Keep the raw hash for conversion guards,
|
|
113
|
+
# and expose a distinct hash of the exact readable ST/IL body.
|
|
114
|
+
if stream[1] in ('ST', 'IL'):
|
|
115
|
+
try:
|
|
116
|
+
readable = info.text_body_text()
|
|
117
|
+
if readable is not None:
|
|
118
|
+
entry["text_body_sha256"] = hashlib.sha256(readable.encode('utf-8')).hexdigest()
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
entry["text_body_error"] = str(exc)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
entry["body_error"] = str(exc)
|
|
123
|
+
try:
|
|
124
|
+
entry["has_st_body"] = info.st_body() is not None
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
entry["has_st_body"] = None
|
|
127
|
+
out.append(entry)
|
|
128
|
+
return _ok(project=req["project"], count=len(out), pous=out)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verb_read_st(req, text=False):
|
|
132
|
+
"""Read one POU: language, ST body text, and its variable declarations."""
|
|
133
|
+
proj = _project(req["project"])
|
|
134
|
+
info = proj.pou(req["pou"])
|
|
135
|
+
body = None
|
|
136
|
+
body_error = None
|
|
137
|
+
try:
|
|
138
|
+
body = info.text_body_text() if text else info.st_body_text()
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
body_error = str(exc)
|
|
141
|
+
|
|
142
|
+
variables = []
|
|
143
|
+
try:
|
|
144
|
+
# DeclarationTable exposes `.variables` (a flat list of Variable), plus
|
|
145
|
+
# `warnings` and `source_stream`. It has NO `.blocks` — iterating that
|
|
146
|
+
# silently produced an empty list, so callers could read a POU's body but
|
|
147
|
+
# never its declarations.
|
|
148
|
+
table = info.declarations()
|
|
149
|
+
for v in getattr(table, "variables", []) or []:
|
|
150
|
+
variables.append({
|
|
151
|
+
"name": getattr(v, "name", None),
|
|
152
|
+
"type": getattr(v, "type_name", None),
|
|
153
|
+
"section": getattr(v, "section", None),
|
|
154
|
+
"group": getattr(v, "group", None),
|
|
155
|
+
"address": getattr(v, "address", None),
|
|
156
|
+
"initial_value": getattr(v, "initial_value", None),
|
|
157
|
+
"description": getattr(v, "description", None),
|
|
158
|
+
})
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
variables = [{"error": f"{type(exc).__name__}: {exc}"}]
|
|
161
|
+
|
|
162
|
+
return _ok(
|
|
163
|
+
pou=req["pou"],
|
|
164
|
+
language=info.language() if hasattr(info, "language") else None,
|
|
165
|
+
body=body,
|
|
166
|
+
body_error=body_error,
|
|
167
|
+
variables=variables,
|
|
168
|
+
summary=info.summary() if hasattr(info, "summary") else None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def verb_unsupported(req):
|
|
173
|
+
"""Name the POUs whose bodies cannot be edited safely."""
|
|
174
|
+
proj = _project(req["project"])
|
|
175
|
+
blocked = []
|
|
176
|
+
for info in proj.pous():
|
|
177
|
+
try:
|
|
178
|
+
if info.text_body() is None:
|
|
179
|
+
stream = None
|
|
180
|
+
try:
|
|
181
|
+
bs = info.body_stream()
|
|
182
|
+
stream = bs[0] if bs else None
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
blocked.append({
|
|
186
|
+
"name": info.name,
|
|
187
|
+
"reason": "no supported ST/IL text body (graphical LD/FBD or compressed)",
|
|
188
|
+
"body_stream": stream,
|
|
189
|
+
})
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
blocked.append({"name": info.name, "reason": str(exc)})
|
|
192
|
+
return _ok(blocked=blocked, count=len(blocked))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── write ────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
def verb_write_st(req):
|
|
198
|
+
"""Replace a POU's Structured Text body. dry_run defaults to True."""
|
|
199
|
+
from motionworks_iec_mcp import writer as W
|
|
200
|
+
|
|
201
|
+
root = Path(req["project"])
|
|
202
|
+
dry = bool(req.get("dry_run", True))
|
|
203
|
+
from motionworks_iec_mcp.program_checks import check_project
|
|
204
|
+
programming_review = check_project(root, pou=req["pou"], body=req["body"])
|
|
205
|
+
plan = W.plan_st_body(root, req["pou"], req["body"], run_lint=bool(req.get("run_lint", True)))
|
|
206
|
+
result = W.apply_st_body(plan, root, dry_run=dry)
|
|
207
|
+
return _ok(dry_run=dry, result=_jsonable(result), programming_review=programming_review)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def verb_var_add(req):
|
|
211
|
+
"""Add a variable declaration. dry_run defaults to True."""
|
|
212
|
+
from motionworks_iec_mcp import writer as W
|
|
213
|
+
|
|
214
|
+
root = Path(req["project"])
|
|
215
|
+
dry = bool(req.get("dry_run", True))
|
|
216
|
+
plan = W.plan_variable_add(
|
|
217
|
+
root,
|
|
218
|
+
req.get("pou"),
|
|
219
|
+
req["name"],
|
|
220
|
+
req["type"],
|
|
221
|
+
section=req.get("section", "VAR"),
|
|
222
|
+
address=req.get("address"),
|
|
223
|
+
initial_value=req.get("initial_value"),
|
|
224
|
+
description=req.get("description"),
|
|
225
|
+
donor=req.get("donor"), donor_pou=req.get("donor_pou"),
|
|
226
|
+
)
|
|
227
|
+
return _ok(dry_run=dry, result=_jsonable(W.apply_declaration(plan, root, dry_run=dry)))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def verb_var_add_many(req):
|
|
231
|
+
"""Declare many variables in one call, atomically by default.
|
|
232
|
+
|
|
233
|
+
WHY THIS EXISTS: a port with twenty signals took twenty tool calls, and a caller declaring a
|
|
234
|
+
twenty-field structure has to sequence them by hand. Every item here goes through exactly the
|
|
235
|
+
per-item machinery verb_var_add uses - the same planner, the same donor matching, the same
|
|
236
|
+
grid update - so this is a loop and not a second implementation.
|
|
237
|
+
|
|
238
|
+
A failed write item raises with its index and name. The surrounding project transaction then
|
|
239
|
+
restores every file, including earlier items in this batch. Explicit allow_partial=True keeps
|
|
240
|
+
the old best-effort behavior, but still executes within one recoverable transaction.
|
|
241
|
+
|
|
242
|
+
dry_run defaults to True, and is all-or-nothing in the other direction: a dry run plans every
|
|
243
|
+
item and applies none, so the report shows what WOULD happen without changing the project.
|
|
244
|
+
"""
|
|
245
|
+
from motionworks_iec_mcp import writer as W
|
|
246
|
+
|
|
247
|
+
root = Path(req["project"])
|
|
248
|
+
dry = bool(req.get("dry_run", True))
|
|
249
|
+
allow_partial = req.get("allow_partial", False) is True
|
|
250
|
+
items = req.get("variables")
|
|
251
|
+
if not isinstance(items, list) or not items:
|
|
252
|
+
raise ValueError("variables must be a non-empty array of {name, type, ...} objects")
|
|
253
|
+
|
|
254
|
+
pou = req.get("pou")
|
|
255
|
+
applied: list[dict] = []
|
|
256
|
+
failed: list[dict] = []
|
|
257
|
+
|
|
258
|
+
for index, raw in enumerate(items):
|
|
259
|
+
if not isinstance(raw, dict):
|
|
260
|
+
if not dry and not allow_partial:
|
|
261
|
+
raise ValueError(f"variable batch item [{index}] is not an object")
|
|
262
|
+
failed.append({"index": index, "name": None,
|
|
263
|
+
"error": "not an object; expected {name, type, section, ...}"})
|
|
264
|
+
continue
|
|
265
|
+
# Per-item overrides fall back to the call-level values, so a caller declaring twenty
|
|
266
|
+
# globals writes `pou` once and a caller mixing sections sets it on the items that differ.
|
|
267
|
+
name = raw.get("name")
|
|
268
|
+
type_name = raw.get("type")
|
|
269
|
+
# CHECKED HERE, not left to the planner. Measured: an item with no `type` reached the donor
|
|
270
|
+
# matcher and came back as `AttributeError: 'NoneType' object has no attribute 'casefold'`,
|
|
271
|
+
# which names neither the item nor the field. A batch reports per item, so the message has
|
|
272
|
+
# to be about the item.
|
|
273
|
+
missing = [field for field, value in (("name", name), ("type", type_name)) if not value]
|
|
274
|
+
if missing:
|
|
275
|
+
if not dry and not allow_partial:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"variable batch item [{index}] {name or '(unnamed)'} missing required "
|
|
278
|
+
f"field(s): {', '.join(missing)}"
|
|
279
|
+
)
|
|
280
|
+
failed.append({"index": index, "name": name,
|
|
281
|
+
"error": f"missing required field(s): {', '.join(missing)}"})
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
plan = W.plan_variable_add(
|
|
285
|
+
root,
|
|
286
|
+
raw.get("pou", pou),
|
|
287
|
+
name,
|
|
288
|
+
type_name,
|
|
289
|
+
section=raw.get("section", req.get("section", "VAR")),
|
|
290
|
+
address=raw.get("address"),
|
|
291
|
+
initial_value=raw.get("initial_value"),
|
|
292
|
+
description=raw.get("description"),
|
|
293
|
+
donor=raw.get("donor", req.get("donor")),
|
|
294
|
+
donor_pou=raw.get("donor_pou", req.get("donor_pou")),
|
|
295
|
+
)
|
|
296
|
+
if dry:
|
|
297
|
+
applied.append({"index": index, "name": name, "planned": True,
|
|
298
|
+
**_plan_summary(plan)})
|
|
299
|
+
else:
|
|
300
|
+
result = _jsonable(W.apply_declaration(plan, root, dry_run=False))
|
|
301
|
+
applied.append({"index": index, "name": name, "applied": True, "result": result})
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
if not dry and not allow_partial:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"variable batch item [{index}] {name or '(unnamed)'} failed: "
|
|
306
|
+
f"{type(exc).__name__}: {exc}"
|
|
307
|
+
) from exc
|
|
308
|
+
failed.append({"index": index, "name": name,
|
|
309
|
+
"error": f"{type(exc).__name__}: {exc}"})
|
|
310
|
+
|
|
311
|
+
return _ok(
|
|
312
|
+
dry_run=dry,
|
|
313
|
+
pou=pou,
|
|
314
|
+
requested=len(items),
|
|
315
|
+
applied=len(applied),
|
|
316
|
+
failed=len(failed),
|
|
317
|
+
results=applied,
|
|
318
|
+
failures=failed,
|
|
319
|
+
note=("partial application was explicitly requested; read failures and re-issue only "
|
|
320
|
+
"those items" if allow_partial else "write batches are atomic; any failure "
|
|
321
|
+
"rolls back the whole batch"),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def verb_var_edit(req):
|
|
326
|
+
"""Edit an existing variable declaration. dry_run defaults to True."""
|
|
327
|
+
from motionworks_iec_mcp import writer as W
|
|
328
|
+
|
|
329
|
+
root = Path(req["project"])
|
|
330
|
+
dry = bool(req.get("dry_run", True))
|
|
331
|
+
plan = W.plan_variable_edit(
|
|
332
|
+
root,
|
|
333
|
+
req.get("pou"),
|
|
334
|
+
req["name"],
|
|
335
|
+
new_name=req.get("new_name"),
|
|
336
|
+
type_name=req.get("type"),
|
|
337
|
+
address=req.get("address"),
|
|
338
|
+
initial_value=req.get("initial_value"),
|
|
339
|
+
description=req.get("description"),
|
|
340
|
+
clear_address=bool(req.get("clear_address", False)),
|
|
341
|
+
force=bool(req.get("force", False)),
|
|
342
|
+
donor=req.get("donor"),
|
|
343
|
+
)
|
|
344
|
+
return _ok(dry_run=dry, result=_jsonable(W.apply_declaration(plan, root, dry_run=dry)))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def verb_var_delete(req):
|
|
348
|
+
"""Delete a variable declaration. dry_run defaults to True."""
|
|
349
|
+
from motionworks_iec_mcp import writer as W
|
|
350
|
+
|
|
351
|
+
root = Path(req["project"])
|
|
352
|
+
dry = bool(req.get("dry_run", True))
|
|
353
|
+
plan = W.plan_variable_delete(
|
|
354
|
+
root, req.get("pou"), req["name"], force=bool(req.get("force", False))
|
|
355
|
+
)
|
|
356
|
+
return _ok(dry_run=dry, result=_jsonable(W.apply_declaration(plan, root, dry_run=dry)))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _plan_summary(plan):
|
|
360
|
+
"""Flatten a planner's dataclass into JSON-friendly fields (no methods)."""
|
|
361
|
+
out = {}
|
|
362
|
+
for key, value in vars(plan).items():
|
|
363
|
+
if callable(value):
|
|
364
|
+
continue
|
|
365
|
+
out[key] = _jsonable(value)
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _backup_dir(req, root: Path) -> Path:
|
|
370
|
+
"""Backups go beside the plugin, NOT inside the staging root.
|
|
371
|
+
|
|
372
|
+
Writing into stage/ creates a directory that a naive "newest staged project"
|
|
373
|
+
search can then mistake for a project, so the staging root is kept to
|
|
374
|
+
projects only.
|
|
375
|
+
"""
|
|
376
|
+
raw = req.get("backup_dir")
|
|
377
|
+
from motionworks_iec_mcp.staging import workspace_root
|
|
378
|
+
workspace = workspace_root()
|
|
379
|
+
directory = Path(raw) if raw else workspace / ".motionworks" / "backups"
|
|
380
|
+
if not directory.resolve().is_relative_to(workspace):
|
|
381
|
+
raise ValueError("REFUSED: backups must remain inside the workspace")
|
|
382
|
+
from uuid import uuid4
|
|
383
|
+
directory = directory / uuid4().hex
|
|
384
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
385
|
+
return directory
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def verb_pou_create(req):
|
|
389
|
+
"""Create a new POU by cloning a template POU already in the project.
|
|
390
|
+
|
|
391
|
+
``template`` must be the name of an existing POU: creation clones that POU's
|
|
392
|
+
directory and renames its streams, so there is no way to author a POU from
|
|
393
|
+
nothing. dry_run defaults to True and returns the plan instead of applying it.
|
|
394
|
+
"""
|
|
395
|
+
from motionworks_iec_mcp import pou_writer as PW
|
|
396
|
+
|
|
397
|
+
root = Path(req["project"])
|
|
398
|
+
dry = bool(req.get("dry_run", True))
|
|
399
|
+
plan = PW.plan_pou_creation(root, req["name"], req["template"])
|
|
400
|
+
if dry:
|
|
401
|
+
return _ok(dry_run=True, plan=_plan_summary(plan))
|
|
402
|
+
result = PW.apply_pou_creation(
|
|
403
|
+
plan, backup_dir=_backup_dir(req, root), project_root=root
|
|
404
|
+
)
|
|
405
|
+
return _ok(dry_run=False, result=_jsonable(result))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def verb_pou_delete(req):
|
|
409
|
+
"""Delete a POU, its task assignments and its registry entries.
|
|
410
|
+
|
|
411
|
+
The POU directory is moved to an archive rather than removed, so the deletion
|
|
412
|
+
is recoverable. Refuses when another POU still calls it unless ``force`` is set.
|
|
413
|
+
dry_run defaults to True and returns the plan instead of applying it.
|
|
414
|
+
"""
|
|
415
|
+
from motionworks_iec_mcp import pou_writer as PW
|
|
416
|
+
|
|
417
|
+
root = Path(req["project"])
|
|
418
|
+
dry = bool(req.get("dry_run", True))
|
|
419
|
+
plan = PW.plan_pou_deletion(root, req["name"], force=bool(req.get("force", False)))
|
|
420
|
+
if dry:
|
|
421
|
+
return _ok(dry_run=True, plan=_plan_summary(plan))
|
|
422
|
+
result = PW.apply_pou_deletion(plan, backup_dir=_backup_dir(req, root))
|
|
423
|
+
return _ok(dry_run=False, result=_jsonable(result))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def verb_tasks(req):
|
|
427
|
+
"""List exact saved instance/type bindings and settings in native order."""
|
|
428
|
+
from motionworks_iec_mcp.task_bindings import inspect
|
|
429
|
+
root = Path(req["project"])
|
|
430
|
+
report = inspect(root)
|
|
431
|
+
duplicate_names=len({t['name'].casefold() for t in report['bindings']})!=len(report['bindings'])
|
|
432
|
+
assignments={t['path'] if duplicate_names else t['name']:[x['name'] for x in t['instances']] for t in report['bindings']}
|
|
433
|
+
return _ok(
|
|
434
|
+
project=str(root),
|
|
435
|
+
tasks={k: list(v) for k, v in assignments.items()},
|
|
436
|
+
task_count=len(assignments),
|
|
437
|
+
unassigned=report['unassigned'],bindings=report['bindings'],
|
|
438
|
+
binding_resolution=report['binding_resolution'],source_hashes=report['source_hashes'],
|
|
439
|
+
unassigned_note=(
|
|
440
|
+
"PROGRAM types without direct saved task instances; instance names are resolved to their types. "
|
|
441
|
+
"Indirect calls and current unsaved IDE state still require inspection. This does not prove runtime execution."
|
|
442
|
+
),
|
|
443
|
+
next_step="Compare mw_code_task_model for the live IDE state. Use mw_ide_task_change for authorized native assignment/settings changes; then verify saved bindings and fresh Build/Make.",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def verb_assign(req):
|
|
448
|
+
"""Assign a POU to a task. dry_run defaults to True.
|
|
449
|
+
|
|
450
|
+
Without this, a created POU is inert: it is not called by anything, so it never
|
|
451
|
+
executes and the compiler does not check it.
|
|
452
|
+
"""
|
|
453
|
+
from motionworks_iec_mcp.cfb import CompoundFile
|
|
454
|
+
from motionworks_iec_mcp.tree import parse_document
|
|
455
|
+
from motionworks_iec_mcp.tree_writer import apply_assignment, plan_assign
|
|
456
|
+
|
|
457
|
+
root = Path(req["project"])
|
|
458
|
+
src = root / "src.st1"
|
|
459
|
+
dry = bool(req.get("dry_run", True))
|
|
460
|
+
document = parse_document(
|
|
461
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
462
|
+
)
|
|
463
|
+
kwargs = {}
|
|
464
|
+
if req.get("cycle"):
|
|
465
|
+
kwargs["cycle"] = str(req["cycle"])
|
|
466
|
+
if req.get("controller"):
|
|
467
|
+
kwargs["controller"] = str(req["controller"])
|
|
468
|
+
plan = plan_assign(document, str(req["task"]), str(req["pou"]), **kwargs)
|
|
469
|
+
if dry:
|
|
470
|
+
return _ok(dry_run=True, plan=_plan_summary(plan), summary=plan.summary())
|
|
471
|
+
result = apply_assignment(src, root, plan, backup_dir=_backup_dir(req, root))
|
|
472
|
+
return _ok(dry_run=False, result=_jsonable(result))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def verb_unassign(req):
|
|
476
|
+
"""Remove a POU's assignment from a task. dry_run defaults to True."""
|
|
477
|
+
from motionworks_iec_mcp.cfb import CompoundFile
|
|
478
|
+
from motionworks_iec_mcp.tree import parse_document
|
|
479
|
+
from motionworks_iec_mcp.tree_writer import apply_assignment, plan_unassign
|
|
480
|
+
|
|
481
|
+
root = Path(req["project"])
|
|
482
|
+
src = root / "src.st1"
|
|
483
|
+
dry = bool(req.get("dry_run", True))
|
|
484
|
+
document = parse_document(
|
|
485
|
+
CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
|
|
486
|
+
)
|
|
487
|
+
plan = plan_unassign(document, str(req["task"]), str(req["pou"]))
|
|
488
|
+
if dry:
|
|
489
|
+
return _ok(dry_run=True, plan=_plan_summary(plan), summary=plan.summary())
|
|
490
|
+
result = apply_assignment(src, root, plan, backup_dir=_backup_dir(req, root))
|
|
491
|
+
return _ok(dry_run=False, result=_jsonable(result))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def verb_globals(req):
|
|
495
|
+
"""List the project's VAR_GLOBAL declarations.
|
|
496
|
+
|
|
497
|
+
The automation API exposes NO project-level variable collection - measured:
|
|
498
|
+
project.Variables, project.Globals and project.VariableGroups are all absent or
|
|
499
|
+
empty - so the live variable model cannot show globals even though it reads
|
|
500
|
+
POU-scoped declarations well. Without this an agent can ADD a global and never see
|
|
501
|
+
it again, and cannot discover the tags several POUs share.
|
|
502
|
+
"""
|
|
503
|
+
proj = _project(req["project"])
|
|
504
|
+
table = proj.global_variables()
|
|
505
|
+
out = []
|
|
506
|
+
for v in getattr(table, "variables", []) or []:
|
|
507
|
+
out.append({
|
|
508
|
+
"name": getattr(v, "name", None),
|
|
509
|
+
"type": getattr(v, "type_name", None),
|
|
510
|
+
"section": getattr(v, "section", None),
|
|
511
|
+
"group": getattr(v, "group", None),
|
|
512
|
+
"address": getattr(v, "address", None),
|
|
513
|
+
"initial_value": getattr(v, "initial_value", None),
|
|
514
|
+
"description": getattr(v, "description", None),
|
|
515
|
+
})
|
|
516
|
+
warnings = list(getattr(table, "warnings", []) or [])
|
|
517
|
+
return _ok(
|
|
518
|
+
project=str(req["project"]),
|
|
519
|
+
count=len(out),
|
|
520
|
+
variables=out,
|
|
521
|
+
source_stream=getattr(table, "source_stream", None),
|
|
522
|
+
warnings=warnings,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def verb_ide_closed(req):
|
|
527
|
+
"""Report whether the IDE gate would let a write through."""
|
|
528
|
+
from motionworks_iec_mcp import ide as I
|
|
529
|
+
|
|
530
|
+
running = None
|
|
531
|
+
for attr in ("motionworks_running", "is_motionworks_running"):
|
|
532
|
+
fn = getattr(I, attr, None)
|
|
533
|
+
if callable(fn):
|
|
534
|
+
try:
|
|
535
|
+
running = bool(fn())
|
|
536
|
+
break
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
return _ok(running=running, helper_names=[n for n in dir(I) if not n.startswith("_")][:40])
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def verb_types(request):
|
|
543
|
+
"""Read the project's user-defined data types, or one of them in full.
|
|
544
|
+
|
|
545
|
+
Nothing else in this plugin could see a UDT, so an agent editing a POU that declares
|
|
546
|
+
``CamData : CamSegmentStruct`` had no way to learn what a CamSegmentStruct contains.
|
|
547
|
+
The definitions are plain text in DT/Tyllist.typ, parsed by motionworks_iec_mcp
|
|
548
|
+
.datatypes, whose module docstring records the field layout and how it was measured.
|
|
549
|
+
|
|
550
|
+
Returns through ``_ok`` like every other verb: the Node side rejects a response whose
|
|
551
|
+
``ok`` is not true, so a bare dict here fails as "code engine failed 'types'" with no
|
|
552
|
+
message, which is what happened the first time this verb was wired up.
|
|
553
|
+
"""
|
|
554
|
+
from motionworks_iec_mcp import datatypes as DT
|
|
555
|
+
|
|
556
|
+
project = Path(request["project"])
|
|
557
|
+
name = request.get("name")
|
|
558
|
+
if name:
|
|
559
|
+
t = DT.find_type(project, str(name))
|
|
560
|
+
return _ok(
|
|
561
|
+
project=str(project),
|
|
562
|
+
name=t.name,
|
|
563
|
+
kind=t.kind,
|
|
564
|
+
type_id=t.type_id,
|
|
565
|
+
container=t.container,
|
|
566
|
+
declared_members=t.declared_members,
|
|
567
|
+
element_type=t.element_type,
|
|
568
|
+
members=[
|
|
569
|
+
{"name": m.name, "type": m.type_name, "type_id": m.type_id,
|
|
570
|
+
"array_size": m.array_size}
|
|
571
|
+
for m in t.members
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
types, counts = DT.read_types(project)
|
|
576
|
+
return _ok(
|
|
577
|
+
project=str(project),
|
|
578
|
+
defined=len(types),
|
|
579
|
+
header_counts=counts,
|
|
580
|
+
types=[
|
|
581
|
+
{"name": t.name, "kind": t.kind, "type_id": t.type_id,
|
|
582
|
+
"container": t.container, "members": len(t.members),
|
|
583
|
+
"declared_members": t.declared_members, "element_type": t.element_type}
|
|
584
|
+
for t in sorted(types, key=lambda x: (x.container, x.name))
|
|
585
|
+
],
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def verb_library(request):
|
|
590
|
+
"""Read the library side: which blocks the project uses, and what one offers.
|
|
591
|
+
|
|
592
|
+
Two questions, two sources. ``eCLRPouDependencies.dat`` is a plain-text manifest naming
|
|
593
|
+
every POU and library block with its kind and what it calls, so "what does this POU use"
|
|
594
|
+
needs no binary at all. A block's own members come from its compiled assembly, which is
|
|
595
|
+
a real .NET assembly (BSJB, v4.0.30319) whose identifiers sit in the metadata #Strings
|
|
596
|
+
heap.
|
|
597
|
+
|
|
598
|
+
WHAT THE MEMBER LIST IS NOT: it does not record input/output DIRECTION. Execute and Done
|
|
599
|
+
are distinguishable by convention, not by anything in the heap, and this does not guess.
|
|
600
|
+
Use the native block definition or matching versioned reference to establish direction.
|
|
601
|
+
"""
|
|
602
|
+
from motionworks_iec_mcp import libraries as L
|
|
603
|
+
|
|
604
|
+
project = Path(request["project"])
|
|
605
|
+
name = request.get("name")
|
|
606
|
+
if name:
|
|
607
|
+
m = L.read_block_members(project, str(name))
|
|
608
|
+
return _ok(
|
|
609
|
+
project=str(project),
|
|
610
|
+
name=m.name,
|
|
611
|
+
assembly=m.assembly,
|
|
612
|
+
runtime=m.runtime,
|
|
613
|
+
identifiers=m.identifiers,
|
|
614
|
+
filtered_out=m.filtered_out,
|
|
615
|
+
note=(
|
|
616
|
+
"these are the identifiers the assembly defines; input/output DIRECTION is "
|
|
617
|
+
"not recorded in them, so inspect the native function-block definition or matching versioned manual "
|
|
618
|
+
"(mw_code_reference) to tell an input from an output; caller declarations alone do not establish direction"
|
|
619
|
+
),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
blocks = L.read_dependencies(project)
|
|
623
|
+
return _ok(
|
|
624
|
+
project=str(project),
|
|
625
|
+
count=len(blocks),
|
|
626
|
+
blocks=[
|
|
627
|
+
{"index": b.index, "name": b.name, "kind": b.kind,
|
|
628
|
+
"library": b.is_library, "depends_on": b.depends_on}
|
|
629
|
+
for b in blocks
|
|
630
|
+
],
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def verb_manual(request):
|
|
635
|
+
"""Search installed PDFs and the versioned vendor-reference catalog together."""
|
|
636
|
+
from motionworks_iec_mcp import manuals as M, knowledge as K
|
|
637
|
+
name, term = request.get('name'), request.get('term')
|
|
638
|
+
limit = max(1, min(int(request.get('limit', 5)), 10))
|
|
639
|
+
if not term:
|
|
640
|
+
try: listed = M.list_manuals()
|
|
641
|
+
except Exception: listed = []
|
|
642
|
+
try: topics = M.help_topics()
|
|
643
|
+
except Exception: topics = []
|
|
644
|
+
return _ok(manuals=[{'name': m.name, 'path': str(m.path), 'bytes': m.bytes,
|
|
645
|
+
'readable': m.readable, 'note': m.note, 'revision': 'not_verified'} for m in listed],
|
|
646
|
+
help_topics=topics, references=K.search(),
|
|
647
|
+
note='Use mw_code_reference for reviewed revisions/page citations and mw_code_installed_help for actual installed CHM topic text; this list contains archive names only.')
|
|
648
|
+
try:
|
|
649
|
+
results = M.search(str(term), name=str(name) if name else None, limit=limit)
|
|
650
|
+
for result in results:
|
|
651
|
+
result.update(extraction='legacy_heuristic', revision='not_verified', page_citations_available=False)
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
results = [{'manual': 'installed PDFs', 'hits': 0, 'error': str(exc)}]
|
|
654
|
+
references = K.search(str(term), limit=limit)
|
|
655
|
+
return _ok(term=str(term), found=sum(bool(r.get('hits')) for r in results), results=results,
|
|
656
|
+
references=references, note='Legacy extracted text may be imperfect. Prefer reviewed reference citations and the actual installed FB interface.')
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def verb_restore_pou(req):
|
|
661
|
+
"""Put a POU back from a snapshot. dry_run defaults to True.
|
|
662
|
+
|
|
663
|
+
Added because mw_ide_build now DETECTS a destroyed POU and says to restore it, and there was no
|
|
664
|
+
way to do that. A diagnosis with no remedy is half a fix.
|
|
665
|
+
"""
|
|
666
|
+
from motionworks_iec_mcp import restore as R
|
|
667
|
+
|
|
668
|
+
root = Path(req["project"])
|
|
669
|
+
dry = bool(req.get("dry_run", True))
|
|
670
|
+
# pou is OPTIONAL: omitting it asks what is restorable rather than naming one. Reading it
|
|
671
|
+
# with [] raised KeyError before the guard below could run - the guard was right and the
|
|
672
|
+
# access above it was wrong, which is the pair written the wrong way round.
|
|
673
|
+
pou = req.get("pou")
|
|
674
|
+
which = int(req.get("which", 0) or 0)
|
|
675
|
+
|
|
676
|
+
if not pou:
|
|
677
|
+
# No POU named: report what is restorable and what looks damaged, so a caller that only
|
|
678
|
+
# knows something is wrong can find out what.
|
|
679
|
+
return _ok(dry_run=dry, result=R.list_restorable(root))
|
|
680
|
+
|
|
681
|
+
report = R.restore_pou(root, pou, which=which, dry_run=dry)
|
|
682
|
+
return _ok(dry_run=dry, result=_jsonable(report))
|
|
683
|
+
|
|
684
|
+
def verb_bind_mwt(req):
|
|
685
|
+
"""Rewrite the staged .mwt so the path inside it is the staged directory.
|
|
686
|
+
|
|
687
|
+
The wrapper is a pointer. Left alone, opening it loads whatever directory
|
|
688
|
+
was stored in it — measured as a project outside the workspace. This only
|
|
689
|
+
accepts paths already inside the staging root.
|
|
690
|
+
"""
|
|
691
|
+
from motionworks_iec_mcp.mwt_bind import retarget
|
|
692
|
+
from motionworks_iec_mcp.staging import assert_staged
|
|
693
|
+
|
|
694
|
+
mwt = assert_staged(req["mwt"], what="mwt wrapper")
|
|
695
|
+
directory = assert_staged(req["directory"], what="staged directory")
|
|
696
|
+
return _ok(**retarget(mwt, directory))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def verb_check_mwt(req):
|
|
700
|
+
"""Reject a wrapper that would open a different project before COM sees it."""
|
|
701
|
+
from motionworks_iec_mcp.mwt_bind import embedded_paths
|
|
702
|
+
from motionworks_iec_mcp.staging import assert_proven, assert_direct_attached, StagingRefused
|
|
703
|
+
try:
|
|
704
|
+
mwt = assert_proven(req["project"])
|
|
705
|
+
except StagingRefused:
|
|
706
|
+
mwt = assert_direct_attached(req["project"])
|
|
707
|
+
if mwt.suffix.lower() != ".mwt":
|
|
708
|
+
raise ValueError("open requires a .mwt wrapper")
|
|
709
|
+
directory = mwt.with_suffix("").resolve()
|
|
710
|
+
paths = embedded_paths(mwt)
|
|
711
|
+
# Some valid wrappers carry no absolute path and let the IDE open the
|
|
712
|
+
# sibling expanded directory. Staging recorded this exact pair, and the
|
|
713
|
+
# caller checks the IDE's active project after opening. Reject only a
|
|
714
|
+
# concrete embedded path that points elsewhere.
|
|
715
|
+
if paths and any(Path(p).resolve() != directory for p in paths):
|
|
716
|
+
raise ValueError("REFUSED: wrapper is not bound to the staged workspace project; stage it again.")
|
|
717
|
+
import hashlib
|
|
718
|
+
return _ok(bound_to=str(directory), wrapper_sha256=hashlib.sha256(mwt.read_bytes()).hexdigest())
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
#: Reads that may look at a project outside the staging root when the caller
|
|
722
|
+
#: sets reference=true. Everything else still has to be a staged copy.
|
|
723
|
+
READ_VERBS = frozenset({
|
|
724
|
+
"worksheet_target",
|
|
725
|
+
"structure_snapshot", "block_interface",
|
|
726
|
+
"pous", "read_st", "read_text", "unsupported", "globals", "tasks", "types", "library",
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
# Direct attach enables these inspection verbs only. Retired offline mutation
|
|
730
|
+
# verbs continue through the staging guard even when a direct identity exists.
|
|
731
|
+
DIRECT_READ_VERBS = READ_VERBS | frozenset({
|
|
732
|
+
"workflow_check", "source_manifest", "check_mwt", "validate", "check_program",
|
|
733
|
+
"eip_map", "graphical_listing", "compiled_source_evidence",
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
def verb_sync_back(req):
|
|
737
|
+
"""Copy the staged edit back to the real project it was staged from. dry_run defaults to True.
|
|
738
|
+
|
|
739
|
+
WHY THIS EXISTS: the release loop is close IDE -> edit the stage -> build -> copy back ->
|
|
740
|
+
re-stage -> verify, and every step was a tool except the copy back, which had to be a script
|
|
741
|
+
that knew by convention what to carry. The convention belongs here, next to the staging guard
|
|
742
|
+
that already knows what a staged copy is.
|
|
743
|
+
|
|
744
|
+
WHAT IT CARRIES
|
|
745
|
+
* the project tree, minus the .mwt WRAPPER - whose stored path is bound to the stage, so
|
|
746
|
+
copying it to the real project would point the real project at a temp copy of itself;
|
|
747
|
+
* the source project's own .mwt is NOT this function's business and is left byte-for-byte
|
|
748
|
+
alone;
|
|
749
|
+
* build outputs and caches are skipped, so a copy back does not drag the IDE's scratch state
|
|
750
|
+
over a project the IDE will open again.
|
|
751
|
+
|
|
752
|
+
WHAT IT REFUSES
|
|
753
|
+
* a staged copy with no recorded source (identity file), because there is nowhere to copy to;
|
|
754
|
+
* a destination outside the staging root's recorded workspace, via the same assert_staged
|
|
755
|
+
gate every other verb passes through;
|
|
756
|
+
* a destination that is itself a staged copy, which would be a copy stage-to-stage and
|
|
757
|
+
almost certainly not what was meant.
|
|
758
|
+
|
|
759
|
+
Verified by hash, not by the copy call returning: the report lists the files that landed and
|
|
760
|
+
the ones whose digest differs afterwards, so "synced" is an observation rather than an intent.
|
|
761
|
+
"""
|
|
762
|
+
import hashlib
|
|
763
|
+
|
|
764
|
+
from motionworks_iec_mcp.staging import assert_proven, assert_staged
|
|
765
|
+
|
|
766
|
+
staged = assert_staged(req["project"], what="staged project")
|
|
767
|
+
# assert_proven is the gate that makes the identity file safe to read: it proves the copy was
|
|
768
|
+
# staged from this workspace and that source, wrapper and directory all agree. Reading the
|
|
769
|
+
# identity file without it would trust a file this engine did not necessarily write.
|
|
770
|
+
assert_proven(staged)
|
|
771
|
+
dry = bool(req.get("dry_run", True))
|
|
772
|
+
|
|
773
|
+
# The identity file sits beside the staged project and records where the copy came from.
|
|
774
|
+
# Without it there is no destination to guess at, and guessing is how a copy back writes into
|
|
775
|
+
# the wrong project.
|
|
776
|
+
identity_path = staged.parent / f"{staged.name}.identity.json"
|
|
777
|
+
if not identity_path.exists():
|
|
778
|
+
alt = staged.with_suffix(".identity.json")
|
|
779
|
+
identity_path = alt if alt.exists() else identity_path
|
|
780
|
+
if not identity_path.exists():
|
|
781
|
+
raise ValueError(
|
|
782
|
+
f"REFUSED: no identity file beside the staged project ({identity_path.name}). "
|
|
783
|
+
"sync_back needs the recorded source directory, and there is no safe way to guess it."
|
|
784
|
+
)
|
|
785
|
+
identity = json.loads(identity_path.read_text(encoding="utf-8-sig"))
|
|
786
|
+
source_dir = identity.get("source_directory") or identity.get("source")
|
|
787
|
+
if not source_dir:
|
|
788
|
+
raise ValueError(
|
|
789
|
+
f"REFUSED: {identity_path.name} records no source_directory, so there is nowhere to "
|
|
790
|
+
"copy back to."
|
|
791
|
+
)
|
|
792
|
+
destination = Path(source_dir).resolve()
|
|
793
|
+
|
|
794
|
+
if destination == staged.resolve():
|
|
795
|
+
raise ValueError("REFUSED: the recorded source is the staged copy itself.")
|
|
796
|
+
|
|
797
|
+
# THE PROTECTION THAT MATTERS, and it is NOT the staging guard.
|
|
798
|
+
#
|
|
799
|
+
# Every other verb refuses to touch anything under stage/, because a real project tree holds
|
|
800
|
+
# the owner's work and a mistake there is not recoverable. sync_back is the one verb whose
|
|
801
|
+
# PURPOSE is to write the real tree - a copy back that only wrote the stage would be a no-op
|
|
802
|
+
# - so assert_staged would make it impossible rather than safe, which is what it did when this
|
|
803
|
+
# was first written.
|
|
804
|
+
#
|
|
805
|
+
# The safety comes from the identity file instead, and it is narrower than the guard: the
|
|
806
|
+
# source must be a path mw_ide_stage itself recorded, and it must lie inside the workspace
|
|
807
|
+
# that recording names. That is provenance, not a pattern match on a path, and it is the same
|
|
808
|
+
# evidence assert_proven already checks on the staged side.
|
|
809
|
+
workspace = Path(identity.get("workspace") or "").resolve() if identity.get("workspace") else None
|
|
810
|
+
if workspace is None:
|
|
811
|
+
raise ValueError(
|
|
812
|
+
f"REFUSED: {identity_path.name} records no workspace, so the recorded source cannot "
|
|
813
|
+
"be proven to lie inside the workspace this engine is allowed to write."
|
|
814
|
+
)
|
|
815
|
+
try:
|
|
816
|
+
destination.relative_to(workspace)
|
|
817
|
+
except ValueError:
|
|
818
|
+
raise ValueError(
|
|
819
|
+
f"REFUSED: the recorded source '{destination}' lies outside the recorded workspace "
|
|
820
|
+
f"'{workspace}'. sync_back writes only a project this staging was taken from."
|
|
821
|
+
) from None
|
|
822
|
+
# Writing the stage again is a no-op the caller did not ask for, and a destination inside
|
|
823
|
+
# stage/ would mean the identity file named the stage as its own source.
|
|
824
|
+
from motionworks_iec_mcp.staging import is_staged
|
|
825
|
+
if is_staged(destination):
|
|
826
|
+
raise ValueError(
|
|
827
|
+
f"REFUSED: the recorded source '{destination}' is itself inside the staging root. "
|
|
828
|
+
"There is nothing to copy back to - stage the project again from the real one."
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
if not destination.exists():
|
|
832
|
+
raise ValueError(f"REFUSED: the recorded source directory does not exist: {destination}")
|
|
833
|
+
|
|
834
|
+
# WHAT IS CARRIED, and why it is a list of SOURCE roles rather than a list of exclusions.
|
|
835
|
+
#
|
|
836
|
+
# Measured on the real project: a naive "everything that changed" copy back selected 90 files,
|
|
837
|
+
# and the first eight were `__1stResourceEx.DLL`, `__BG.DLL`, `__FastTsk.pdb` and so on - the
|
|
838
|
+
# compiler's own output, which the IDe regenerates and which this engine never edits. Carrying
|
|
839
|
+
# those back over a project the IDE will open again is the "broad sync" a session had to do by
|
|
840
|
+
# hand, and it was the likely cause of the build failure that followed.
|
|
841
|
+
#
|
|
842
|
+
# So the rule is: copy back the files this engine can WRITE, and only those. A blanket copy
|
|
843
|
+
# cannot be made safe by lengthening an exclusion list, because the next build output the IDE
|
|
844
|
+
# invents is not on it; an inclusion list fails safe instead, since an unlisted new artifact is
|
|
845
|
+
# simply not carried.
|
|
846
|
+
SOURCE_SUFFIXES = (
|
|
847
|
+
".st1", # a POU container: the .VB declarations, .VGR grid and body streams inside it
|
|
848
|
+
".vb", # a bare declaration stream, for a project that stores them separately
|
|
849
|
+
".vgr", # a bare grid, likewise
|
|
850
|
+
".tre", # the project tree
|
|
851
|
+
".pou", # the POU registry
|
|
852
|
+
".typ", # the data-type list
|
|
853
|
+
".xml", # NodeProperties and the library manifest
|
|
854
|
+
".dat", # the dependency manifest
|
|
855
|
+
".txt", # the POU's textual sidecars
|
|
856
|
+
".st", # an exported source, when one is kept in the tree
|
|
857
|
+
".set", # task settings
|
|
858
|
+
".ldi",
|
|
859
|
+
)
|
|
860
|
+
#: Names that are project state rather than source, even though they match a suffix above.
|
|
861
|
+
SOURCE_NAMES = {"list.pou", "project.tre", "nodes.lst", "tyllist.typ", "eclrpoudependencies.dat"}
|
|
862
|
+
SKIP_DIRS = {"__pycache__", ".git", "node_modules", "backups", ".motionworks"}
|
|
863
|
+
|
|
864
|
+
def carry_reason(name: str) -> str | None:
|
|
865
|
+
"""None to carry the file, or the reason it is not source."""
|
|
866
|
+
low = name.lower()
|
|
867
|
+
if low.endswith(".mwt"):
|
|
868
|
+
# THE WRAPPER IS NEVER CARRIED. Its stored path names the stage, so copying it would
|
|
869
|
+
# bind the real project to a temporary copy of itself - the exact failure the staging
|
|
870
|
+
# guard exists to prevent, and the one thing the hand-written script also had to know.
|
|
871
|
+
return "the .mwt wrapper stays bound to the stage"
|
|
872
|
+
if low.startswith("tmp.") or low.endswith((".tmp", ".sto", ".log", ".cache")):
|
|
873
|
+
return "scratch or log"
|
|
874
|
+
if low.endswith((".dll", ".pdb", ".exe", ".obj", ".o", ".lib", ".exp", ".ilk")):
|
|
875
|
+
return "compiler output, not source"
|
|
876
|
+
if low in SOURCE_NAMES:
|
|
877
|
+
return None
|
|
878
|
+
if low.endswith(SOURCE_SUFFIXES):
|
|
879
|
+
return None
|
|
880
|
+
return "not a file this engine writes"
|
|
881
|
+
|
|
882
|
+
copied: list[dict] = []
|
|
883
|
+
skipped: list[dict] = []
|
|
884
|
+
mismatched: list[dict] = []
|
|
885
|
+
|
|
886
|
+
for path in sorted(staged.rglob("*")):
|
|
887
|
+
if not path.is_file():
|
|
888
|
+
continue
|
|
889
|
+
rel = path.relative_to(staged)
|
|
890
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
891
|
+
continue
|
|
892
|
+
reason = carry_reason(path.name)
|
|
893
|
+
if reason is not None:
|
|
894
|
+
skipped.append({"file": str(rel), "reason": reason})
|
|
895
|
+
continue
|
|
896
|
+
target = destination / rel
|
|
897
|
+
record = {
|
|
898
|
+
"file": str(rel),
|
|
899
|
+
"bytes": path.stat().st_size,
|
|
900
|
+
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
901
|
+
}
|
|
902
|
+
if target.exists():
|
|
903
|
+
current = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
904
|
+
if current == record["sha256"]:
|
|
905
|
+
skipped.append({"file": str(rel), "reason": "already identical"})
|
|
906
|
+
continue
|
|
907
|
+
record["previous_sha256"] = current
|
|
908
|
+
copied.append(record)
|
|
909
|
+
|
|
910
|
+
if dry:
|
|
911
|
+
return _ok(
|
|
912
|
+
dry_run=True,
|
|
913
|
+
staged=str(staged),
|
|
914
|
+
destination=str(destination),
|
|
915
|
+
would_copy=len(copied),
|
|
916
|
+
would_skip=len(skipped),
|
|
917
|
+
files=copied,
|
|
918
|
+
skipped=skipped,
|
|
919
|
+
note="nothing was written; pass dry_run=false to apply",
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
for record in copied:
|
|
923
|
+
source = staged / record["file"]
|
|
924
|
+
target = destination / record["file"]
|
|
925
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
926
|
+
target.write_bytes(source.read_bytes())
|
|
927
|
+
# VERIFIED, not assumed: re-read the file that landed and compare digests.
|
|
928
|
+
landed = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
929
|
+
if landed != record["sha256"]:
|
|
930
|
+
mismatched.append({"file": record["file"], "expected": record["sha256"], "got": landed})
|
|
931
|
+
record["verified"] = landed == record["sha256"]
|
|
932
|
+
|
|
933
|
+
return _ok(
|
|
934
|
+
dry_run=False,
|
|
935
|
+
staged=str(staged),
|
|
936
|
+
destination=str(destination),
|
|
937
|
+
copied=len(copied),
|
|
938
|
+
skipped=len(skipped),
|
|
939
|
+
mismatched=len(mismatched),
|
|
940
|
+
files=copied,
|
|
941
|
+
failures=mismatched,
|
|
942
|
+
wrapper="not carried - the .mwt stays bound to the stage",
|
|
943
|
+
note=(
|
|
944
|
+
"files are verified by sha256 after the copy; a non-empty `failures` means the copy "
|
|
945
|
+
"did not land and the release is not synced"
|
|
946
|
+
),
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def verb_wrapper_binding(req):
|
|
951
|
+
"""Read-only: how each staged project's .mwt wrapper is bound, and whether it is stale.
|
|
952
|
+
|
|
953
|
+
WHY THIS EXISTS: mw_ide_open refuses a wrapper whose stored path is not the staged directory,
|
|
954
|
+
and the refusal was reached in a session where the PROJECT had just been renumbered by a POU
|
|
955
|
+
creation. Re-staging to clear it is worse than the problem - it overwrites the stage and takes
|
|
956
|
+
the new POU with it - so the useful answer is "which wrapper is stale, and is re-binding it
|
|
957
|
+
enough", reported before anything tries to open it.
|
|
958
|
+
|
|
959
|
+
Reports every staged project, so a caller does not have to know which one is at fault.
|
|
960
|
+
"""
|
|
961
|
+
from motionworks_iec_mcp.mwt_bind import embedded_paths
|
|
962
|
+
from motionworks_iec_mcp.staging import staging_root
|
|
963
|
+
|
|
964
|
+
binding: list[dict] = []
|
|
965
|
+
targets = []
|
|
966
|
+
if req.get("project"):
|
|
967
|
+
from motionworks_iec_mcp.staging import assert_staged
|
|
968
|
+
targets.append(assert_staged(req["project"], what="project"))
|
|
969
|
+
else:
|
|
970
|
+
targets = [p for p in sorted(staging_root().glob("*.mwt"))]
|
|
971
|
+
|
|
972
|
+
for mwt in targets:
|
|
973
|
+
if mwt.suffix.lower() != ".mwt":
|
|
974
|
+
continue
|
|
975
|
+
directory = mwt.with_suffix("")
|
|
976
|
+
try:
|
|
977
|
+
paths = embedded_paths(mwt)
|
|
978
|
+
except Exception as exc:
|
|
979
|
+
binding.append({"wrapper": str(mwt), "error": f"{type(exc).__name__}: {exc}"})
|
|
980
|
+
continue
|
|
981
|
+
stale = [p for p in paths if Path(p).resolve() != directory.resolve()]
|
|
982
|
+
binding.append({
|
|
983
|
+
"wrapper": str(mwt),
|
|
984
|
+
"should_name": str(directory),
|
|
985
|
+
"stores": paths,
|
|
986
|
+
"bound": not stale,
|
|
987
|
+
"binding_mode": "embedded_path" if paths else "native_sibling_directory",
|
|
988
|
+
"stale_paths": stale,
|
|
989
|
+
"note": (
|
|
990
|
+
"native sibling-directory wrapper; verify the IDE active project after opening" if not paths
|
|
991
|
+
else ("re-binding is enough: mw_ide_stage would overwrite the staged project and "
|
|
992
|
+
"take any POU created since with it" if stale else "")
|
|
993
|
+
),
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
return _ok(
|
|
997
|
+
count=len(binding),
|
|
998
|
+
wrappers=binding,
|
|
999
|
+
stale=sum(1 for b in binding if b.get("stale_paths")),
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def verb_rebind_wrapper(req):
|
|
1004
|
+
"""Point a staged .mwt back at its staged directory, in place. dry_run defaults to True.
|
|
1005
|
+
|
|
1006
|
+
Idempotent and cheap when already bound - measured: an already-bound wrapper returns
|
|
1007
|
+
changed=false and the file digest is unchanged, so a caller may call this unconditionally
|
|
1008
|
+
rather than working out whether it needs to.
|
|
1009
|
+
"""
|
|
1010
|
+
from motionworks_iec_mcp.mwt_bind import embedded_paths, retarget
|
|
1011
|
+
from motionworks_iec_mcp.staging import assert_staged
|
|
1012
|
+
|
|
1013
|
+
mwt = assert_staged(req["mwt"], what="mwt wrapper")
|
|
1014
|
+
if mwt.suffix.lower() != ".mwt":
|
|
1015
|
+
raise ValueError("rebind_wrapper needs a .mwt wrapper")
|
|
1016
|
+
directory = assert_staged(req["directory"] if req.get("directory") else mwt.with_suffix(""),
|
|
1017
|
+
what="staged directory")
|
|
1018
|
+
dry = bool(req.get("dry_run", True))
|
|
1019
|
+
|
|
1020
|
+
if dry:
|
|
1021
|
+
paths = embedded_paths(mwt)
|
|
1022
|
+
stale = [p for p in paths if Path(p).resolve() != directory.resolve()]
|
|
1023
|
+
return _ok(
|
|
1024
|
+
dry_run=True,
|
|
1025
|
+
would_change=bool(stale),
|
|
1026
|
+
bound_to=str(directory),
|
|
1027
|
+
stores=paths,
|
|
1028
|
+
stale_paths=stale,
|
|
1029
|
+
note="nothing was written; pass dry_run=false to re-bind",
|
|
1030
|
+
)
|
|
1031
|
+
return _ok(dry_run=False, **retarget(mwt, directory))
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def verb_eip_map(req):
|
|
1035
|
+
"""Summarize the EtherNet/IP assembly map: declared size, words used, words left.
|
|
1036
|
+
|
|
1037
|
+
Answers "is there room for another status value?" - the question that comes up every time a
|
|
1038
|
+
diagnostic is added - from the two sources that hold the two halves of the answer: the L5X
|
|
1039
|
+
module definition for the declared size, the project's IEC addresses for what is used.
|
|
1040
|
+
"""
|
|
1041
|
+
from motionworks_iec_mcp import eip as E
|
|
1042
|
+
|
|
1043
|
+
return _ok(result=E.build_map(
|
|
1044
|
+
project_root=req.get("project"),
|
|
1045
|
+
l5x=req.get("l5x"),
|
|
1046
|
+
module_name=req.get("module_name"),
|
|
1047
|
+
))
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def verb_validate(req):
|
|
1051
|
+
from motionworks_iec_mcp.validation import validate
|
|
1052
|
+
return _ok(result=validate(req['project']))
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def verb_reference(req):
|
|
1056
|
+
from motionworks_iec_mcp import knowledge as K
|
|
1057
|
+
if req.get('block'):
|
|
1058
|
+
result = K.signature(req['block'])
|
|
1059
|
+
return _ok(result={'block': req['block'], 'signature': result,
|
|
1060
|
+
'note': 'No reviewed signature found; inspect the installed FB definition.' if result is None else 'Historical vendor interface; confirm installed version.'})
|
|
1061
|
+
return _ok(result=K.search(req.get('query', ''), source_id=req.get('source_id'), limit=req.get('limit', 5)))
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def verb_reference_sync(req):
|
|
1065
|
+
from motionworks_iec_mcp.knowledge import sync
|
|
1066
|
+
return _ok(result=sync(req.get('source_ids')))
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def verb_diagnose(req):
|
|
1070
|
+
from motionworks_iec_mcp.knowledge import diagnose
|
|
1071
|
+
return _ok(result=diagnose(req['message']))
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def verb_pattern(req):
|
|
1075
|
+
from motionworks_iec_mcp.knowledge import patterns
|
|
1076
|
+
return _ok(result=patterns(req.get('name')))
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def verb_check_program(req):
|
|
1080
|
+
from motionworks_iec_mcp.program_checks import check_project
|
|
1081
|
+
if 'body' in req and not req.get('pou'):
|
|
1082
|
+
raise ValueError('A proposed body requires a target POU')
|
|
1083
|
+
return _ok(result=check_project(req['project'], pou=req.get('pou'), body=req.get('body'), native_libraries=req.get('native_libraries'), interface_libraries=req.get('interface_libraries'), native_compiled_interfaces=req.get('native_compiled_interfaces')))
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
VERBS = {
|
|
1087
|
+
"workflow_check": lambda req: _ok(**__import__("motionworks_iec_mcp.workflow", fromlist=["check"]).check(req["project"])),
|
|
1088
|
+
"source_manifest": lambda req: _ok(**__import__("motionworks_iec_mcp.workflow", fromlist=["source_manifest"]).source_manifest(Path(req["project"]))),
|
|
1089
|
+
"reference": verb_reference,
|
|
1090
|
+
"reference_sync": verb_reference_sync,
|
|
1091
|
+
"diagnose": verb_diagnose,
|
|
1092
|
+
"pattern": verb_pattern,
|
|
1093
|
+
"check_program": verb_check_program,
|
|
1094
|
+
"validate": verb_validate,
|
|
1095
|
+
"check_mwt": verb_check_mwt,
|
|
1096
|
+
"eip_map": verb_eip_map,
|
|
1097
|
+
"types": verb_types,
|
|
1098
|
+
"manual": verb_manual,
|
|
1099
|
+
"library": verb_library,
|
|
1100
|
+
"pous": verb_pous,
|
|
1101
|
+
"read_st": verb_read_st,
|
|
1102
|
+
"read_text": lambda req: verb_read_st(req, text=True),
|
|
1103
|
+
"worksheet_target": verb_worksheet_target,
|
|
1104
|
+
"graphical_listing": verb_graphical_listing,
|
|
1105
|
+
"compiled_source_evidence": verb_compiled_source_evidence,
|
|
1106
|
+
"structure_snapshot": verb_structure_snapshot,
|
|
1107
|
+
"block_interface": verb_block_interface,
|
|
1108
|
+
"installed_help": verb_installed_help,
|
|
1109
|
+
"unsupported": verb_unsupported,
|
|
1110
|
+
"write_st": verb_write_st,
|
|
1111
|
+
"var_add": verb_var_add,
|
|
1112
|
+
"var_add_many": verb_var_add_many,
|
|
1113
|
+
"sync_back": verb_sync_back,
|
|
1114
|
+
"wrapper_binding": verb_wrapper_binding,
|
|
1115
|
+
"rebind_wrapper": verb_rebind_wrapper,
|
|
1116
|
+
"restore_pou": verb_restore_pou,
|
|
1117
|
+
"var_edit": verb_var_edit,
|
|
1118
|
+
"var_delete": verb_var_delete,
|
|
1119
|
+
"pou_create": verb_pou_create,
|
|
1120
|
+
"pou_delete": verb_pou_delete,
|
|
1121
|
+
"globals": verb_globals,
|
|
1122
|
+
"tasks": verb_tasks,
|
|
1123
|
+
"assign": verb_assign,
|
|
1124
|
+
"unassign": verb_unassign,
|
|
1125
|
+
"ide_closed": verb_ide_closed,
|
|
1126
|
+
"bind_mwt": verb_bind_mwt,
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _jsonable(value):
|
|
1131
|
+
"""Best-effort conversion of engine results into JSON-friendly data."""
|
|
1132
|
+
try:
|
|
1133
|
+
json.dumps(value)
|
|
1134
|
+
return value
|
|
1135
|
+
except (TypeError, ValueError):
|
|
1136
|
+
pass
|
|
1137
|
+
if isinstance(value, dict):
|
|
1138
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
1139
|
+
if isinstance(value, (list, tuple)):
|
|
1140
|
+
return [_jsonable(v) for v in value]
|
|
1141
|
+
if hasattr(value, "__dict__"):
|
|
1142
|
+
return {str(k): _jsonable(v) for k, v in vars(value).items()}
|
|
1143
|
+
return str(value)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def main(argv):
|
|
1147
|
+
if len(argv) != 4:
|
|
1148
|
+
print("usage: mw_code.py <verb> <request.json> <response.json>", file=sys.stderr)
|
|
1149
|
+
return 2
|
|
1150
|
+
verb, req_path, res_path = argv[1], argv[2], argv[3]
|
|
1151
|
+
res = Path(res_path)
|
|
1152
|
+
|
|
1153
|
+
try:
|
|
1154
|
+
req = json.loads(Path(req_path).read_text(encoding="utf-8-sig"))
|
|
1155
|
+
except Exception as exc:
|
|
1156
|
+
_write(res, _fail(f"could not read request: {exc}"))
|
|
1157
|
+
return 1
|
|
1158
|
+
|
|
1159
|
+
handler = VERBS.get(verb)
|
|
1160
|
+
if handler is None:
|
|
1161
|
+
_write(res, _fail(f"unknown verb {verb!r}", allowed=sorted(VERBS)))
|
|
1162
|
+
return 1
|
|
1163
|
+
|
|
1164
|
+
# THE STAGING GUARD, at the one point every verb passes through.
|
|
1165
|
+
#
|
|
1166
|
+
# index.js refuses a project outside the staging root, but that only covers the plugin's TOOLS.
|
|
1167
|
+
# Measured: the workspace holds 25-odd scripts the agent wrote for itself, and they call this
|
|
1168
|
+
# engine directly - Tools/_mw_call.py, Tools/_mw_call2.py, Tools/_real_state.py and the rest -
|
|
1169
|
+
# so the agent routed around the tool-level guard by not using the tools, and the owner reported
|
|
1170
|
+
# a third time that a project outside the workspace had been opened.
|
|
1171
|
+
#
|
|
1172
|
+
# Guarding here catches all of them, because every one of them comes through this dispatch. It
|
|
1173
|
+
# is also the honest place for it: this engine is what writes the files.
|
|
1174
|
+
#
|
|
1175
|
+
# A request with no `project` is not refused. That is not a hole - the verbs without one do not
|
|
1176
|
+
# touch a project tree - and refusing them would mean a caller could not ask a question like
|
|
1177
|
+
# "which POUs exist" without first proving where it stands.
|
|
1178
|
+
try:
|
|
1179
|
+
from engine.motionworks_iec_mcp.staging import StagingRefused, assert_staged
|
|
1180
|
+
except ImportError: # running from a checkout where the engine is a sibling package
|
|
1181
|
+
try:
|
|
1182
|
+
from motionworks_iec_mcp.staging import StagingRefused, assert_staged
|
|
1183
|
+
except ImportError:
|
|
1184
|
+
assert_staged = None
|
|
1185
|
+
if req.get("reference") is True and verb not in READ_VERBS:
|
|
1186
|
+
_write(res, _fail(
|
|
1187
|
+
f"REFUSED: '{verb}' cannot use reference mode. Reference is read-only. "
|
|
1188
|
+
"A project outside the workspace can be inspected, and it is never "
|
|
1189
|
+
"staged, opened, or edited.",
|
|
1190
|
+
refused_by="reference guard",
|
|
1191
|
+
verb=verb,
|
|
1192
|
+
))
|
|
1193
|
+
return 1
|
|
1194
|
+
|
|
1195
|
+
reference_read = req.get("reference") is True and verb in READ_VERBS
|
|
1196
|
+
if assert_staged is not None and req.get("project") and not reference_read:
|
|
1197
|
+
try:
|
|
1198
|
+
if verb in DIRECT_READ_VERBS:
|
|
1199
|
+
try:
|
|
1200
|
+
from engine.motionworks_iec_mcp.staging import assert_direct_attached
|
|
1201
|
+
except ImportError:
|
|
1202
|
+
from motionworks_iec_mcp.staging import assert_direct_attached
|
|
1203
|
+
try:
|
|
1204
|
+
assert_direct_attached(req["project"])
|
|
1205
|
+
direct_read = True
|
|
1206
|
+
except StagingRefused:
|
|
1207
|
+
direct_read = False
|
|
1208
|
+
else:
|
|
1209
|
+
direct_read = False
|
|
1210
|
+
if not direct_read:
|
|
1211
|
+
assert_staged(req["project"], what=f"{verb} project")
|
|
1212
|
+
try:
|
|
1213
|
+
from engine.motionworks_iec_mcp.staging import assert_proven
|
|
1214
|
+
except ImportError:
|
|
1215
|
+
from motionworks_iec_mcp.staging import assert_proven
|
|
1216
|
+
if verb != "workflow_check" and not direct_read:
|
|
1217
|
+
assert_proven(req["project"])
|
|
1218
|
+
except StagingRefused as exc:
|
|
1219
|
+
_write(res, _fail(str(exc), refused_by="staging guard", verb=verb))
|
|
1220
|
+
return 1
|
|
1221
|
+
|
|
1222
|
+
try:
|
|
1223
|
+
if verb in {"write_st", "var_add", "var_add_many", "var_edit", "var_delete", "pou_create", "pou_delete", "restore_pou"} and req.get("dry_run", True) is False:
|
|
1224
|
+
from motionworks_iec_mcp.transaction import run
|
|
1225
|
+
payload = run(Path(req["project"]), lambda: handler(req))
|
|
1226
|
+
else:
|
|
1227
|
+
payload = handler(req)
|
|
1228
|
+
if reference_read and isinstance(payload, dict):
|
|
1229
|
+
payload = dict(payload)
|
|
1230
|
+
payload["read_only"] = True
|
|
1231
|
+
workspace = req.get("workspace")
|
|
1232
|
+
outside = None
|
|
1233
|
+
if workspace and req.get("project"):
|
|
1234
|
+
try:
|
|
1235
|
+
Path(req["project"]).resolve().relative_to(Path(workspace).resolve())
|
|
1236
|
+
outside = False
|
|
1237
|
+
except ValueError:
|
|
1238
|
+
outside = True
|
|
1239
|
+
payload["outside_workspace"] = outside
|
|
1240
|
+
_write(res, payload)
|
|
1241
|
+
return 0
|
|
1242
|
+
except Exception as exc:
|
|
1243
|
+
_write(res, _fail(
|
|
1244
|
+
f"{type(exc).__name__}: {exc}",
|
|
1245
|
+
traceback=traceback.format_exc().splitlines()[-6:],
|
|
1246
|
+
))
|
|
1247
|
+
return 1
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _write(path: Path, payload) -> None:
|
|
1251
|
+
"""Write without a BOM, via a temp file, so a reader never sees a partial file."""
|
|
1252
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
1253
|
+
tmp.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
1254
|
+
os.replace(tmp, path)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
if __name__ == "__main__":
|
|
1258
|
+
sys.exit(main(sys.argv))
|