mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
100 lines
3.4 KiB
Python
100 lines
3.4 KiB
Python
"""Python-side bridge to the Node TS validator.
|
|
|
|
Spawns `node api/mcp_server/ts_validator/src/index.ts` as a short-lived
|
|
subprocess per call, streams a JSON request on stdin, reads a JSON
|
|
response from stdout. The validator never executes LLM code — it either
|
|
emits TypeScript from a workflow JSON (`generate`) or parses LLM-authored
|
|
TS back into a workflow JSON via AST walking (`parse`).
|
|
|
|
The subprocess startup cost is ~100-200ms per call. Fine for MCP tool
|
|
rates; if it ever matters, the validator can be promoted to a long-lived
|
|
worker over a unix socket without changing this interface.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from api.services.workflow.dto import EdgeDataDTO
|
|
from api.services.workflow.node_specs import all_specs
|
|
|
|
_VALIDATOR_ENTRY = Path(__file__).resolve().parent / "ts_validator" / "src" / "index.ts"
|
|
|
|
|
|
class TsBridgeError(Exception):
|
|
"""The Node subprocess failed before producing a JSON response."""
|
|
|
|
|
|
def _specs_payload() -> list[dict[str, Any]]:
|
|
return [s.model_dump(mode="json") for s in all_specs()]
|
|
|
|
|
|
def _edge_field_names() -> list[str]:
|
|
return list(EdgeDataDTO.model_fields.keys())
|
|
|
|
|
|
async def _invoke(request: dict[str, Any]) -> dict[str, Any]:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"node",
|
|
str(_VALIDATOR_ENTRY),
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await proc.communicate(json.dumps(request).encode("utf-8"))
|
|
if proc.returncode != 0 and not stdout:
|
|
raise TsBridgeError(
|
|
f"ts_validator exited {proc.returncode}: "
|
|
f"{stderr.decode('utf-8', errors='replace')}"
|
|
)
|
|
try:
|
|
return json.loads(stdout.decode("utf-8"))
|
|
except json.JSONDecodeError as e:
|
|
raise TsBridgeError(
|
|
f"ts_validator emitted non-JSON: {stdout!r} (stderr: {stderr!r})"
|
|
) from e
|
|
|
|
|
|
async def generate_code(workflow: dict[str, Any], *, workflow_name: str = "") -> str:
|
|
"""Emit SDK TypeScript source from a workflow JSON payload.
|
|
|
|
Raises `TsBridgeError` if the validator can't produce code (unknown
|
|
node type, dangling edge reference, etc.) — these are bugs at the
|
|
caller layer, not user input, so we fail loudly.
|
|
"""
|
|
result = await _invoke(
|
|
{
|
|
"command": "generate",
|
|
"workflow": workflow,
|
|
"specs": _specs_payload(),
|
|
"edgeFieldNames": _edge_field_names(),
|
|
"workflowName": workflow_name,
|
|
}
|
|
)
|
|
if not result.get("ok"):
|
|
errs = result.get("errors") or [{"message": "unknown failure"}]
|
|
raise TsBridgeError(
|
|
"generate_code failed: " + "; ".join(e.get("message", "") for e in errs)
|
|
)
|
|
return result["code"]
|
|
|
|
|
|
async def parse_code(code: str) -> dict[str, Any]:
|
|
"""Parse LLM-authored TS back into a workflow JSON.
|
|
|
|
Returns the raw validator response — `{"ok": True, "workflow": {...}}`
|
|
on success, `{"ok": False, "stage": "parse" | "validate", "errors": [...]}`
|
|
on author-side failure. Author-side failures are surfaced to the LLM
|
|
verbatim so it can iterate; callers should not re-wrap them.
|
|
"""
|
|
return await _invoke(
|
|
{
|
|
"command": "parse",
|
|
"code": code,
|
|
"specs": _specs_payload(),
|
|
"edgeFieldNames": _edge_field_names(),
|
|
}
|
|
)
|