Update .gitignore and README.md for CODESYS MCP project. Added entries to .gitignore for VSIX files and VS Code settings. Expanded README to include architecture, components, deployment instructions, and details about the Cursor extension.
This commit is contained in:
301
python_scripting/windows_codesys_agent/agent.py
Normal file
301
python_scripting/windows_codesys_agent/agent.py
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Windows-side CODESYS agent with async job processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
API_TOKEN = os.environ.get("CODESYS_AGENT_TOKEN", "")
|
||||
SCRIPTS_DIR = Path(
|
||||
os.environ.get("CODESYS_AGENT_SCRIPTS_DIR", r"C:\codesys-agent\scripts")
|
||||
).resolve()
|
||||
RUNTIME_DIR = Path(
|
||||
os.environ.get("CODESYS_AGENT_RUNTIME_DIR", r"C:\codesys-agent\runtime")
|
||||
).resolve()
|
||||
PYTHON_BIN = os.environ.get("CODESYS_AGENT_PYTHON_BIN", "python")
|
||||
DEFAULT_TIMEOUT_SEC = int(os.environ.get("CODESYS_AGENT_TIMEOUT_SEC", "180"))
|
||||
ALLOW_DOWNLOAD = os.environ.get("CODESYS_AGENT_ALLOW_DOWNLOAD", "false").lower() == "true"
|
||||
CODESYS_EXE_PATH = os.environ.get("CODESYS_EXE_PATH", "")
|
||||
CODESYS_PROFILE = os.environ.get("CODESYS_PROFILE", "")
|
||||
CODESYS_NO_UI = os.environ.get("CODESYS_NO_UI", "true").lower() == "true"
|
||||
CODESYS_SCRIPT_TIMEOUT_SEC = int(os.environ.get("CODESYS_SCRIPT_TIMEOUT_SEC", "600"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
action: str
|
||||
payload: Dict[str, Any]
|
||||
dry_run: bool = False
|
||||
status: str = "queued"
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SubmitJobRequest(BaseModel):
|
||||
action: str = Field(..., description="Action name")
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class JobStore:
|
||||
def __init__(self) -> None:
|
||||
self._jobs: Dict[str, Job] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def create(self, action: str, payload: Dict[str, Any], dry_run: bool) -> Job:
|
||||
job = Job(
|
||||
id=str(uuid.uuid4()),
|
||||
action=action,
|
||||
payload=payload,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> Job:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
raise KeyError(job_id)
|
||||
return job
|
||||
|
||||
def update(self, job: Job) -> None:
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
|
||||
|
||||
app = FastAPI(title="CODESYS VM Agent", version="0.2.0")
|
||||
store = JobStore()
|
||||
job_queue: "queue.Queue[str]" = queue.Queue()
|
||||
|
||||
|
||||
def _check_auth(token: Optional[str]) -> None:
|
||||
if not API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="CODESYS_AGENT_TOKEN is not configured.",
|
||||
)
|
||||
if token != API_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def _action_to_script(action: str) -> str:
|
||||
mapping = {
|
||||
"create_project": "create_project.py",
|
||||
"read_project_inventory": "read_project_inventory.py",
|
||||
"read_device_io": "read_device_io.py",
|
||||
"write_pou": "write_pou.py",
|
||||
"write_io_mapping": "write_io_mapping.py",
|
||||
"manage_device": "manage_device.py",
|
||||
"sync_project": "sync_project.py",
|
||||
"stage_existing_project": "stage_existing_project.py",
|
||||
"build_project": "build_project.py",
|
||||
"download_to_device": "download_to_device.py",
|
||||
}
|
||||
script = mapping.get(action)
|
||||
if not script:
|
||||
raise RuntimeError(f"Unsupported action: {action}")
|
||||
if action == "download_to_device" and not ALLOW_DOWNLOAD:
|
||||
raise RuntimeError("download_to_device is disabled by policy.")
|
||||
return script
|
||||
|
||||
|
||||
def _run_script_python(script_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
script_path = (SCRIPTS_DIR / script_name).resolve()
|
||||
if not script_path.is_file():
|
||||
raise RuntimeError(f"Script not found: {script_path}")
|
||||
|
||||
cmd = [PYTHON_BIN, str(script_path), json.dumps(payload)]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=DEFAULT_TIMEOUT_SEC,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"command": cmd,
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
}
|
||||
|
||||
|
||||
def _run_script_codesys(script_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not CODESYS_EXE_PATH or not CODESYS_PROFILE:
|
||||
raise RuntimeError(
|
||||
"CODESYS_EXE_PATH and CODESYS_PROFILE must be configured for ScriptEngine actions."
|
||||
)
|
||||
script_path = (SCRIPTS_DIR / script_name).resolve()
|
||||
if not script_path.is_file():
|
||||
raise RuntimeError(f"Script not found: {script_path}")
|
||||
|
||||
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload_fd, payload_name = tempfile.mkstemp(
|
||||
prefix="codesys_payload_",
|
||||
suffix=".json",
|
||||
dir=str(RUNTIME_DIR),
|
||||
)
|
||||
result_fd, result_name = tempfile.mkstemp(
|
||||
prefix="codesys_result_",
|
||||
suffix=".json",
|
||||
dir=str(RUNTIME_DIR),
|
||||
)
|
||||
os.close(payload_fd)
|
||||
os.close(result_fd)
|
||||
payload_file = Path(payload_name)
|
||||
result_file = Path(result_name)
|
||||
payload_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
script_args = f'--scriptargs:"{payload_file}"'
|
||||
cmd = (
|
||||
f'"{CODESYS_EXE_PATH}" '
|
||||
f'--profile="{CODESYS_PROFILE}" '
|
||||
f'--runscript="{script_path}" '
|
||||
f"{script_args}"
|
||||
)
|
||||
if CODESYS_NO_UI:
|
||||
cmd += " --noUI"
|
||||
|
||||
proc_env = os.environ.copy()
|
||||
proc_env["CODESYS_SCRIPT_RESULT_PATH"] = str(result_file)
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
env=proc_env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=CODESYS_SCRIPT_TIMEOUT_SEC,
|
||||
check=False,
|
||||
)
|
||||
parsed_result: Dict[str, Any] | None = None
|
||||
if result_file.exists():
|
||||
try:
|
||||
parsed_result = json.loads(result_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
parsed_result = {"raw_result_text": result_file.read_text(encoding="utf-8", errors="ignore")}
|
||||
|
||||
return {
|
||||
"command": cmd,
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
"script_result": parsed_result,
|
||||
"payload_file": str(payload_file),
|
||||
"result_file": str(result_file),
|
||||
}
|
||||
|
||||
|
||||
def _execute(job: Job) -> Dict[str, Any]:
|
||||
if job.dry_run:
|
||||
return {
|
||||
"dry_run": True,
|
||||
"action": job.action,
|
||||
"payload": job.payload,
|
||||
"message": "Dry-run accepted. No changes made.",
|
||||
}
|
||||
|
||||
script_name = _action_to_script(job.action)
|
||||
# Project operations use CODESYS ScriptEngine (no manual IDE opening required).
|
||||
codesys_actions = {
|
||||
"create_project",
|
||||
"read_project_inventory",
|
||||
"read_device_io",
|
||||
"write_pou",
|
||||
"write_io_mapping",
|
||||
"manage_device",
|
||||
"sync_project",
|
||||
"build_project",
|
||||
"download_to_device",
|
||||
}
|
||||
if job.action in codesys_actions:
|
||||
result = _run_script_codesys(script_name, job.payload)
|
||||
else:
|
||||
result = _run_script_python(script_name, job.payload)
|
||||
if result["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
f"{job.action} failed with exit_code={result['exit_code']} stderr={result['stderr']}"
|
||||
)
|
||||
if result.get("script_result"):
|
||||
return result["script_result"]
|
||||
return result
|
||||
|
||||
|
||||
def _worker() -> None:
|
||||
while True:
|
||||
job_id = job_queue.get()
|
||||
try:
|
||||
job = store.get(job_id)
|
||||
job.status = "running"
|
||||
job.started_at = time.time()
|
||||
store.update(job)
|
||||
|
||||
result = _execute(job)
|
||||
job.status = "succeeded"
|
||||
job.result = result
|
||||
job.finished_at = time.time()
|
||||
store.update(job)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
job = store.get(job_id)
|
||||
job.status = "failed"
|
||||
job.error = str(exc)
|
||||
job.finished_at = time.time()
|
||||
store.update(job)
|
||||
finally:
|
||||
job_queue.task_done()
|
||||
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "codesys-vm-agent",
|
||||
"version": "0.2.0",
|
||||
"scripts_dir": str(SCRIPTS_DIR),
|
||||
"runtime_dir": str(RUNTIME_DIR),
|
||||
"download_enabled": ALLOW_DOWNLOAD,
|
||||
"codesys_exe_configured": bool(CODESYS_EXE_PATH),
|
||||
"codesys_profile_configured": bool(CODESYS_PROFILE),
|
||||
"codesys_no_ui": CODESYS_NO_UI,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/jobs")
|
||||
def submit_job(req: SubmitJobRequest, x_api_token: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||
_check_auth(x_api_token)
|
||||
job = store.create(req.action, req.payload, req.dry_run)
|
||||
job_queue.put(job.id)
|
||||
return {"job_id": job.id, "status": job.status}
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}")
|
||||
def get_job(job_id: str, x_api_token: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||
_check_auth(x_api_token)
|
||||
try:
|
||||
job = store.get(job_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="Job not found") from exc
|
||||
return asdict(job)
|
||||
Reference in New Issue
Block a user