feat: refactor node spec and add mcp tools (#244)

* refactor: carve out extraction panel

* refactor: create spec versions for node types

* refactor: create a GenericNode and remove custom nodes

* feat: add python and typescript sdk

* add dograh sdk

* fix: fetch draft workflow definition over published one

* fix: fix routes of SDKs to use code gen

* chore: remove doclink dependency to reduce image size

* chore: format files

* chore: bump pipecat

* feat: let mcp fetch archived workflows on demand

* chore: fix tests

* feat: add sdk documentation

* chore: change banner and add badge
This commit is contained in:
Abhishek 2026-04-21 07:56:16 +05:30 committed by GitHub
parent 0a61ef295f
commit 00a1a22b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 14355 additions and 3554 deletions

View file

@ -0,0 +1,3 @@
from api.mcp_server.server import mcp
__all__ = ["mcp"]

46
api/mcp_server/auth.py Normal file
View file

@ -0,0 +1,46 @@
from fastapi import HTTPException
from fastmcp.server.dependencies import get_http_headers
from opentelemetry import trace
from api.db.models import UserModel
from api.services.auth.depends import _handle_api_key_auth
async def authenticate_mcp_request() -> UserModel:
"""Resolve the authenticated Dograh user for an MCP tool invocation.
Accepts either `X-API-Key: <key>` or `Authorization: Bearer <key>`,
reusing the API-key flow from `api.services.auth.depends`.
Tags the currently-active OTel span with the resolved organization
and user identifiers. `_OrgRoutingExporter` reads `dograh.org_id`
at export time to dispatch the span to the right Langfuse project;
the `langfuse.user.id` / `langfuse.session.id` attributes make the
span filterable in the Langfuse UI.
"""
headers = get_http_headers()
api_key = headers.get("x-api-key")
if not api_key:
auth = headers.get("authorization", "")
if auth.lower().startswith("bearer "):
api_key = auth.split(" ", 1)[1].strip()
if not api_key:
raise HTTPException(
status_code=401,
detail="Missing API key — send X-API-Key or Authorization: Bearer <key>",
)
user = await _handle_api_key_auth(api_key)
span = trace.get_current_span()
if span.is_recording():
org_id = user.selected_organization_id
# Intentionally NOT `dograh.org_id` — that attribute triggers the
# per-org Langfuse routing for pipeline spans, and MCP traffic
# should land in the default (developer-facing) project only.
# Exposed under `mcp.org_id` for Langfuse UI filtering without
# affecting the router.
span.set_attribute("mcp.org_id", str(org_id))
span.set_attribute("mcp.user_id", str(user.id))
span.set_attribute("langfuse.user.id", str(user.id))
return user

View file

@ -0,0 +1,124 @@
"""Top-level orchestration guide surfaced to every MCP session.
Sent to the client via `FastMCP(instructions=...)` the client bakes
this into its system prompt, so every LLM session sees it before the
first tool call. Prefer procedural orchestration here (call order, error
handling, hard constraints). Design-level per-field guidance belongs in
each `PropertySpec.llm_hint`; it flows out through `get_node_type` and
doesn't need to be repeated here.
Extend based on real LLM failures every bullet below ideally maps to a
mistake the system has seen at least once.
"""
DOGRAH_MCP_INSTRUCTIONS = """\
You build and edit Dograh voice-AI workflows by emitting TypeScript that
uses the `@dograh/sdk` package. Workflows are stored as JSON; this server
projects them to TypeScript for editing and parses them back on save.
## Call order
1. `list_workflows` locate the target workflow.
2. `get_workflow_code(workflow_id)` fetch the current source (draft if
one exists, otherwise published).
3. (optional) `list_node_types` / `get_node_type(name)` consult before
adding or editing a node type whose fields aren't already visible in
the current code.
4. Mutate the code in place. Preserve existing nodes, edges, and variable
names unless the task requires removing or renaming them.
5. `save_workflow(workflow_id, code)` persist as a new draft. The
published version is untouched.
## Allowed source shape
The parser is AST-only and rejects anything outside this grammar. At the
top level, only three statement forms are accepted:
import ... from "..."; // any import
const <var> = <initializer>; // bindings (see below)
wf.edge(<src>, <tgt>, { label, condition }); // bare edge calls
`<initializer>` is one of:
new Workflow({ name: "..." })
wf.addTyped(<factory>({ ...fields }) [, { position: [x, y] }])
wf.add({ type: "<nodeType>", ...fields [, position: [x, y]] })
No functions, arrow fns, loops, conditionals, ternaries, spreads,
destructuring, template interpolation, `export`, or `.map`/`.forEach`.
Data-position values must be plain literals (strings, numbers, booleans,
null, arrays/objects of same). A single `new Workflow(...)` per file
the `name` you pass there is the workflow's display name and is applied
on save (renames propagate immediately; definition changes go to draft).
## Adding edges — explicit syntax
wf.edge(source, target, { label: "...", condition: "..." });
Rules:
- `source` and `target` are the **bare variable identifiers** bound by
`wf.addTyped(...)` / `wf.add(...)` not strings, not `.id`, not inline
factories. Both must be declared earlier in the file.
- `label` is a short tag (4 words) shown in call logs to identify the
branch: `"qualified"`, `"wrap up"`, `"retry"`.
- `condition` is a full natural-language predicate the runtime evaluates
against the live conversation: `"caller confirmed interest in a demo"`,
not `"interested"`. Condition clarity determines routing accuracy.
- Both fields are required and must be non-empty strings.
- Edges are directional; emit one `wf.edge(...)` per outgoing branch.
- Place all edges after all node bindings; group by source node.
Example:
const greet = wf.addTyped(startCall({ name: "Greet", prompt: "Hi!" }));
const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." }));
wf.edge(greet, done, {
label: "wrap up",
condition: "user acknowledged the greeting and is ready to end"
});
## Hard graph constraints
- Exactly one `startCall` node per workflow; no incoming edges.
- `endCall` nodes have no outgoing edges.
- `globalNode` has no incoming or outgoing edges; its prompt is prepended
to every other node's prompt at runtime when that node sets
`add_global_prompt=true`.
- Every non-global node must be reachable from `startCall`.
## Iterating on errors
`save_workflow` returns one of:
- `parse_error` Disallowed construct (see grammar above) or
malformed TypeScript.
- `validation_error` Node data failed spec validation (unknown field,
missing required, wrong type, bad `options` value).
- `graph_validation` Structural rule broken (missing startCall,
unreachable node, edge to/from wrong node type).
- `bridge_error` Internal retry once, then surface to the user.
Every error carries `line` and `column`. Fix at that location and
resubmit the **complete source** this tool does not accept patches.
## Field conventions
- `data.name` is the canonical identifier. Pick a descriptive name
(`"Qualify Budget"`, not `"Node1"`) the generated code uses it as
the variable name and call logs reference it.
- Reference fields take UUIDs, not human names:
`tool_refs`, `document_refs` from `list_tools`, `list_documents`
`credential_ref` from `list_credentials`
`recording_ref` from `list_recordings`
- `mention_textarea` fields (prompts, greetings, etc.) accept
`{{template_variables}}` values resolved at runtime from
`pre_call_fetch`, caller context, or earlier extraction passes.
## Style
- Prefer `wf.addTyped(factory({ ... }))` over `wf.add({ type, ... })`.
- Only include fields whose values differ from the spec default the
parser re-applies defaults on save, so extras are noise.
- Omit `position`; the server reconciles positions against the previous
saved workflow and lays out new nodes automatically.
- Add nodes in call-flow order (start intermediate end) so the
generated code reads top-to-bottom, with all edges after all nodes.
"""

11
api/mcp_server/server.py Normal file
View file

@ -0,0 +1,11 @@
from fastmcp import FastMCP
from api.mcp_server.instructions import DOGRAH_MCP_INSTRUCTIONS
mcp = FastMCP("dograh", instructions=DOGRAH_MCP_INSTRUCTIONS)
from api.mcp_server.tools import catalog as _catalog # noqa: E402, F401
from api.mcp_server.tools import get_workflow_code as _get_workflow_code # noqa: E402, F401
from api.mcp_server.tools import node_types as _node_types # noqa: E402, F401
from api.mcp_server.tools import save_workflow as _save_workflow # noqa: E402, F401
from api.mcp_server.tools import workflows as _workflows # noqa: E402, F401

View file

View file

@ -0,0 +1,113 @@
"""MCP discovery tools for the reference catalogs.
Node properties of type `tool_refs`, `document_refs`, `recording_ref`, and
`credential_ref` carry UUIDs that resolve against these catalogs. LLMs must
list the catalog before populating those fields with real UUIDs.
"""
from api.db import db_client
from api.mcp_server.auth import authenticate_mcp_request
from api.mcp_server.server import mcp
from api.mcp_server.tracing import traced_tool
@mcp.tool
@traced_tool
async def list_tools(status: str | None = "active") -> list[dict]:
"""List tools the agent can invoke during a call.
Returns each tool's `tool_uuid` (use this in node `tool_uuids` properties),
`name`, `description`, and `category`. Pass `status=None` to include
archived tools.
"""
user = await authenticate_mcp_request()
tools = await db_client.get_tools_for_organization(
organization_id=user.selected_organization_id,
status=status,
)
return [
{
"tool_uuid": t.tool_uuid,
"name": t.name,
"description": t.description or "",
"category": t.category,
}
for t in tools
]
@mcp.tool
@traced_tool
async def list_documents() -> list[dict]:
"""List knowledge-base documents the agent can reference during a call.
Returns each document's `document_uuid` (use this in node
`document_uuids` properties), `filename`, and `processing_status`.
"""
user = await authenticate_mcp_request()
documents = await db_client.get_documents_for_organization(
organization_id=user.selected_organization_id,
)
return [
{
"document_uuid": d.document_uuid,
"filename": d.filename,
"processing_status": d.processing_status,
"total_chunks": d.total_chunks,
}
for d in documents
]
@mcp.tool
@traced_tool
async def list_credentials() -> list[dict]:
"""List external credentials available for webhook auth and pre-call fetch.
Returns each credential's `credential_uuid` (use this in node
`credential_uuid` / `pre_call_fetch_credential_uuid` properties), `name`,
`description`, and `credential_type`.
"""
user = await authenticate_mcp_request()
credentials = await db_client.get_credentials_for_organization(
organization_id=user.selected_organization_id,
)
return [
{
"credential_uuid": c.credential_uuid,
"name": c.name,
"description": c.description or "",
"credential_type": c.credential_type,
}
for c in credentials
]
@mcp.tool
@traced_tool
async def list_recordings(workflow_id: int | None = None) -> list[dict]:
"""List pre-recorded audio files available for greetings and edge
transition speech.
Returns each recording's `recording_id` (use this in
`greeting_recording_id` / `transition_speech_recording_id` properties),
`transcript`, and TTS metadata. Pass `workflow_id` to filter to one
workflow's recordings.
"""
user = await authenticate_mcp_request()
recordings = await db_client.get_recordings(
organization_id=user.selected_organization_id,
workflow_id=workflow_id,
)
return [
{
"id": r.id,
"recording_id": r.recording_id,
"workflow_id": r.workflow_id,
"transcript": r.transcript,
"tts_provider": r.tts_provider,
"tts_model": r.tts_model,
"tts_voice_id": r.tts_voice_id,
}
for r in recordings
]

View file

@ -0,0 +1,71 @@
"""MCP tool that returns a workflow as SDK TypeScript code.
Companion to `save_workflow`: the LLM calls `get_workflow_code` to see
the current state of a workflow as editable code, mutates it, and calls
`save_workflow` with the new code. Storage stays JSON; the TS form is
an ephemeral projection for the LLM edit loop.
Selection priority: latest draft latest published legacy
`workflow.workflow_definition`. That matches the UI's "whichever is the
working copy" behavior so the LLM sees what a human editor would see.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from api.db import db_client
from api.mcp_server.auth import authenticate_mcp_request
from api.mcp_server.server import mcp
from api.mcp_server.tracing import traced_tool
from api.mcp_server.ts_bridge import TsBridgeError, generate_code
@mcp.tool
@traced_tool
async def get_workflow_code(workflow_id: int) -> dict[str, Any]:
"""Return the workflow as SDK TypeScript code the LLM can edit.
Output shape:
{"code": "<TS source>", "workflow_id": int, "version": "draft" | "published" | "legacy"}
The LLM edits `code`, then calls `save_workflow(workflow_id, code)`.
"""
user = await authenticate_mcp_request()
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
# Draft wins over published — editing a draft is the normal flow.
# `current_definition` (is_current=True) is the published row, so we
# fetch the draft explicitly. If the latest draft was just published,
# no draft row exists and we fall through to `released_definition`.
draft = await db_client.get_draft_version(workflow_id)
released = workflow.released_definition
if draft is not None and draft.workflow_json:
payload = draft.workflow_json
source = "draft"
elif released is not None and released.workflow_json:
payload = released.workflow_json
source = "published"
else:
payload = workflow.workflow_definition or {}
source = "legacy"
try:
code = await generate_code(payload, workflow_name=workflow.name or "")
except TsBridgeError as e:
raise HTTPException(status_code=500, detail=f"Failed to generate code: {e}")
return {
"workflow_id": workflow_id,
"name": workflow.name or "",
"version": source,
"code": code,
}

View file

@ -0,0 +1,57 @@
"""MCP discovery tools for node specifications.
LLMs call these tools first to learn the available node-type catalog and
each node's property schema before composing or modifying a workflow.
"""
from fastapi import HTTPException
from api.mcp_server.auth import authenticate_mcp_request
from api.mcp_server.server import mcp
from api.mcp_server.tracing import traced_tool
from api.services.workflow.node_specs import SPEC_VERSION, all_specs, get_spec
@mcp.tool
@traced_tool
async def list_node_types() -> dict:
"""List every available node type with a brief summary.
Use this first to discover what nodes exist, then call `get_node_type`
for the full schema of any node you intend to use.
Returns:
A dict with `spec_version` (pin against this in any generated workflow
code) and `node_types` (list of {name, display_name, description,
category}).
"""
await authenticate_mcp_request()
return {
"spec_version": SPEC_VERSION,
"node_types": [
{
"name": spec.name,
"display_name": spec.display_name,
"description": spec.description,
"category": spec.category.value,
}
for spec in all_specs()
],
}
@mcp.tool
@traced_tool
async def get_node_type(name: str) -> dict:
"""Fetch the full schema for a node type, including every property's
type, default, conditional visibility rules, and LLM-readable
description, plus worked examples.
Use the property `description` and the `examples` list to understand
semantics types alone are not enough.
"""
await authenticate_mcp_request()
spec = get_spec(name)
if spec is None:
raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}")
return spec.model_dump(mode="json")

View file

@ -0,0 +1,168 @@
"""MCP tool that accepts LLM-authored SDK TypeScript and saves it as a draft.
Execution flow:
1. Parse via the Node TS validator AST-only, never executes the code.
Returns either a workflow JSON or per-location parse/validate errors.
2. Pydantic validation via `ReactFlowDTO.model_validate` (defence in
depth; the parser is already spec-driven, but the DTO layer is the
authoritative wire-format gate).
3. Graph validation via `WorkflowGraph`.
4. Save as a new draft via `db_client.save_workflow_draft` the
published version stays intact, so edits are rollback-safe.
Error codes surfaced to the LLM:
parse_error TS parse failed or a disallowed construct was used
validation_error node data failed spec validation (unknown field,
missing required, wrong type, option out of range)
schema_validation ReactFlowDTO Pydantic rejection (rare; parser bug)
graph_validation semantic graph rule broken (e.g. no start node)
bridge_error Node subprocess failed before returning JSON
All LLM-facing errors include file:line:column where available so the
LLM can correct its code directly.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from loguru import logger
from pydantic import ValidationError as PydanticValidationError
from api.db import db_client
from api.mcp_server.auth import authenticate_mcp_request
from api.mcp_server.server import mcp
from api.mcp_server.tracing import traced_tool
from api.mcp_server.ts_bridge import TsBridgeError, parse_code
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.layout import reconcile_positions
from api.services.workflow.workflow import WorkflowGraph
async def _previous_workflow_json(workflow: Any) -> dict[str, Any] | None:
"""Same selection priority as `get_workflow_code` — the version the
LLM saw is the version we reconcile against.
`current_definition` (is_current=True) is the published row, so the
draft must be fetched explicitly. If no draft exists (e.g. the last
draft was just published), fall through to `released_definition`.
"""
draft = await db_client.get_draft_version(workflow.id)
if draft is not None and draft.workflow_json:
return draft.workflow_json
released = workflow.released_definition
if released is not None and released.workflow_json:
return released.workflow_json
return workflow.workflow_definition or None
def _error_result(code: str, message: str, **extra: Any) -> dict[str, Any]:
return {"saved": False, "error_code": code, "error": message, **extra}
def _format_errors(errors: list[dict[str, Any]]) -> str:
parts: list[str] = []
for e in errors:
loc = ""
line = e.get("line")
col = e.get("column")
if line is not None:
loc = f" (line {line}" + (f", col {col}" if col is not None else "") + ")"
parts.append(f"{e.get('message', '')}{loc}")
return "\n".join(parts)
@mcp.tool
@traced_tool
async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]:
"""Parse SDK TypeScript and save the resulting workflow as a draft.
`code` is TypeScript source using `@dograh/sdk`. Fetch the current
code first via `get_workflow_code(workflow_id)`, edit it, then pass
the full updated source here.
Example code:
import { Workflow } from "@dograh/sdk";
import { startCall, endCall } from "@dograh/sdk/typed";
const wf = new Workflow({ name: "lead_qualification" });
const greeting = wf.addTyped(startCall({ name: "Greeting", prompt: "Hi!" }));
const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." }));
wf.edge(greeting, done, { label: "done", condition: "conversation complete" });
On success the draft version is saved; the published version is
untouched.
"""
user = await authenticate_mcp_request()
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
# 1. Parse + spec-validate via the Node TS validator.
try:
parsed = await parse_code(code)
except TsBridgeError as e:
logger.warning(f"ts_bridge failure: {e}")
return _error_result("bridge_error", str(e))
if not parsed.get("ok"):
stage = parsed.get("stage", "parse")
errs = parsed.get("errors") or []
code_key = "parse_error" if stage == "parse" else "validation_error"
return _error_result(code_key, _format_errors(errs), errors=errs)
payload = parsed["workflow"]
new_name = (parsed.get("workflowName") or "").strip()
# 1b. Reconcile node positions against the previously-stored workflow.
# The parser drops positions by design (LLMs don't place nodes well);
# here we fill them back in from what was there before, and pick
# approximate placements for newly-introduced nodes.
payload = reconcile_positions(payload, await _previous_workflow_json(workflow))
# 2. Pydantic shape check (defence in depth — parser is spec-driven).
try:
dto = ReactFlowDTO.model_validate(payload)
except PydanticValidationError as e:
return _error_result("schema_validation", str(e))
# 3. Graph-level semantic validation (start-node count, edge shape).
try:
WorkflowGraph(dto)
except (ValueError, Exception) as e: # WorkflowGraph raises ValueError
return _error_result("graph_validation", str(e))
# 4a. If the `new Workflow({ name })` in the edited source differs from
# the stored name, rename the workflow. Name is a workflow-level field
# (not versioned), so this takes effect immediately.
name_changed = bool(new_name) and new_name != workflow.name
if name_changed:
await db_client.update_workflow(
workflow_id=workflow_id,
name=new_name,
workflow_definition=None,
template_context_variables=None,
workflow_configurations=None,
organization_id=user.selected_organization_id,
)
# 4b. Save as a new draft (existing published version stays intact).
draft = await db_client.save_workflow_draft(
workflow_id=workflow_id,
workflow_definition=payload,
)
return {
"saved": True,
"workflow_id": workflow_id,
"version_number": draft.version_number,
"status": draft.status,
"node_count": len(payload["nodes"]),
"edge_count": len(payload["edges"]),
"name": new_name or workflow.name,
"renamed": name_changed,
}

View file

@ -0,0 +1,53 @@
from fastapi import HTTPException
from api.db import db_client
from api.mcp_server.auth import authenticate_mcp_request
from api.mcp_server.server import mcp
from api.mcp_server.tracing import traced_tool
@mcp.tool
@traced_tool
async def list_workflows(status: str | None = "active") -> list[dict]:
"""List agents (workflows) in the caller's organization.
Returns id, name, status, and created_at for each agent. Use
`get_workflow` to fetch a single agent's full definition. Defaults
to active agents; pass `status="archived"` to list archived agents,
or `status=None` to list all.
"""
user = await authenticate_mcp_request()
workflows = await db_client.get_all_workflows_for_listing(
organization_id=user.selected_organization_id,
status=status,
)
return [
{
"id": w.id,
"name": w.name,
"status": w.status,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in workflows
]
@mcp.tool
@traced_tool
async def get_workflow(workflow_id: int) -> dict:
"""Fetch a single agent by id, including its current published definition."""
user = await authenticate_mcp_request()
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
current = workflow.current_definition
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"definition": current.workflow_json if current else None,
"version_number": current.version_number if current else None,
}

87
api/mcp_server/tracing.py Normal file
View file

@ -0,0 +1,87 @@
"""OTel tracing for MCP tool invocations.
The project-wide tracing setup in
`api/services/pipecat/tracing_config.py` already routes spans to
per-organization Langfuse projects based on the `dograh.org_id` span
attribute. This module plugs MCP tool calls into that pipeline:
@mcp.tool
@traced_tool
async def my_tool(...): ...
Each decorated invocation produces one span named `mcp.<tool_name>` with
Langfuse-rendered input/output. Organization and user attributes are
stamped separately by `authenticate_mcp_request` when it runs inside
the tool body the decorator's span is the `current_span` at that
point, so the attributes land on the right span and the router export
dispatches to the correct Langfuse project.
"""
from __future__ import annotations
import json
from functools import wraps
from typing import Any, Awaitable, Callable, TypeVar
from opentelemetry import trace
from opentelemetry.context import Context
from opentelemetry.trace import Status, StatusCode
R = TypeVar("R")
_TRACER = trace.get_tracer("dograh.mcp")
# Langfuse truncates long payloads anyway; cap here to keep span size
# bounded. Tune up if you find tool outputs consistently clipped.
_MAX_ATTR_LEN = 8000
def _safe_json(value: Any) -> str:
try:
return json.dumps(value, default=str, ensure_ascii=False)
except Exception: # noqa: BLE001
return str(value)
def traced_tool(fn: Callable[..., Awaitable[R]]) -> Callable[..., Awaitable[R]]:
"""Wrap an MCP tool so each invocation produces a span.
Captures tool name, input kwargs, output, and exceptions. Stacks
below `@mcp.tool` so FastMCP sees the wrapped function when
introspecting the tool schema (`functools.wraps` preserves the
signature the framework reads).
"""
@wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> R:
# Each MCP tool call is its own root trace. Passing an empty
# `Context()` severs the inherited parent so the span doesn't
# graft onto whatever other trace happens to be active (e.g.
# the FastAPI request span, or a client-propagated context).
# One trace per tool invocation makes Langfuse diffing and
# per-org filtering clean.
with _TRACER.start_as_current_span(
f"mcp.{fn.__name__}",
context=Context(),
) as span:
span.set_attribute("mcp.tool.name", fn.__name__)
# Explicit trace-name override so the Langfuse UI shows
# `mcp.<tool>` at the top of the trace instead of whatever
# the framework happens to name the root span.
span.set_attribute("langfuse.trace.name", f"mcp.{fn.__name__}")
span.set_attribute(
"langfuse.observation.input",
_safe_json(kwargs)[:_MAX_ATTR_LEN],
)
try:
result = await fn(*args, **kwargs)
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
span.set_attribute(
"langfuse.observation.output",
_safe_json(result)[:_MAX_ATTR_LEN],
)
return result
return wrapper

View file

@ -0,0 +1,93 @@
"""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.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()]
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(),
"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(),
}
)

View file

@ -0,0 +1 @@
node_modules/

View file

@ -0,0 +1,31 @@
{
"name": "dograh-ts-validator",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dograh-ts-validator",
"version": "0.0.0",
"dependencies": {
"typescript": "^5.6.0"
},
"engines": {
"node": ">=22.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View file

@ -0,0 +1,13 @@
{
"name": "dograh-ts-validator",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Node helper invoked by the Python MCP server. Converts workflow JSON to SDK TypeScript code (generate) and parses LLM-authored TS back into a validated workflow JSON (parse). Runs as a short-lived subprocess over stdin/stdout.",
"dependencies": {
"typescript": "^5.6.0"
},
"engines": {
"node": ">=22.6"
}
}

View file

@ -0,0 +1,304 @@
// JSON → TypeScript source. Emits flat code the LLM can read and edit:
// imports, a `Workflow` construction, one `addTyped` per node, one `edge`
// per edge. Variable names are derived from `data.name` (falling back to
// the node id) and deduplicated so the AST round-trips back through
// `parse.ts` into the same workflow JSON.
import type {
GenerateResult,
NodeSpec,
PropertySpec,
WireWorkflow,
} from "./types.ts";
export function generateCode(
workflow: WireWorkflow,
specs: NodeSpec[],
opts: { workflowName?: string } = {},
): GenerateResult {
const specByName = new Map(specs.map((s) => [s.name, s]));
// Catch unknown node types up-front — otherwise we'd emit an import
// line for a factory that doesn't exist.
for (const n of workflow.nodes) {
if (!specByName.has(n.type)) {
return {
ok: false,
errors: [
{
message: `Unknown node type in workflow: "${n.type}"`,
},
],
};
}
}
const factoryNames = [
...new Set(workflow.nodes.map((n) => n.type)),
].sort();
const nodeVarById = new Map<string, string>();
const usedNames = new Set<string>();
const lines: string[] = [];
lines.push(`import { Workflow } from "@dograh/sdk";`);
if (factoryNames.length > 0) {
lines.push(
`import { ${factoryNames.join(", ")} } from "@dograh/sdk/typed";`,
);
}
lines.push("");
const wfName = opts.workflowName ?? "";
lines.push(
`const wf = new Workflow(${renderObject({ name: wfName }, 0)});`,
);
lines.push("");
for (const node of workflow.nodes) {
const varName = pickVarName(node, usedNames);
nodeVarById.set(node.id, varName);
const spec = specByName.get(node.type)!;
// Strip legacy/UI-state fields the spec doesn't know about
// (e.g. `invalid`, `selected`, `dragging`, `is_start`,
// `validationMessage`). They accumulated in stored workflow
// data before the parser enforced spec validation, and are
// pure noise from the LLM's perspective — dropping them keeps
// the editing surface clean and avoids a pointless save-time
// rejection round-trip.
const knownOnly = stripUnknown(node.data, spec);
const data = stripDefaults(knownOnly, spec);
const factoryArg = renderObject(data, 0);
// Positions are intentionally omitted — LLMs don't place nodes
// sensibly, so we let a downstream auto-layout pass (future
// enhancement) assign coordinates on save. Existing positions
// in the DB are preserved by `parse.ts` defaulting to {0,0}
// and the save path leaving pre-existing node positions alone.
lines.push(
`const ${varName} = wf.addTyped(${node.type}(${factoryArg}));`,
);
}
if (workflow.edges.length > 0) {
lines.push("");
}
for (const edge of workflow.edges) {
const src = nodeVarById.get(edge.source);
const tgt = nodeVarById.get(edge.target);
if (!src || !tgt) {
return {
ok: false,
errors: [
{
message:
`Edge ${edge.id} references unknown node ` +
`(source=${edge.source}, target=${edge.target}).`,
},
],
};
}
const cleanedEdge = pickEdgeFields(edge.data);
const edgeOpts = renderObject(cleanedEdge, 0);
lines.push(`wf.edge(${src}, ${tgt}, ${edgeOpts});`);
}
return { ok: true, code: lines.join("\n") + "\n" };
}
// ─── helpers ──────────────────────────────────────────────────────────
function pickVarName(
node: { id: string; data: Record<string, unknown> },
used: Set<string>,
): string {
const seed =
typeof node.data["name"] === "string" && node.data["name"].trim()
? (node.data["name"] as string)
: `node_${node.id}`;
const base = sanitizeIdentifier(seed);
let candidate = base;
let i = 2;
while (used.has(candidate) || RESERVED.has(candidate)) {
candidate = `${base}_${i++}`;
}
used.add(candidate);
return candidate;
}
function sanitizeIdentifier(raw: string): string {
const cleaned = raw
.trim()
.replace(/[^a-zA-Z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
if (!cleaned) return "node";
if (/^[0-9]/.test(cleaned)) return `n_${cleaned}`;
return cleaned;
}
const RESERVED = new Set([
"wf",
"const",
"let",
"var",
"new",
"function",
"class",
"import",
"export",
"return",
"if",
"else",
"for",
"while",
"do",
"switch",
"case",
"break",
"continue",
"default",
"throw",
"try",
"catch",
"finally",
"await",
"async",
"true",
"false",
"null",
"undefined",
"this",
"super",
"in",
"of",
"typeof",
"instanceof",
"delete",
"void",
"yield",
"Workflow",
]);
// Drop keys not declared in the spec. Handles nested `fixed_collection`
// rows by recursing through sub-property specs. Anything that isn't in
// the spec is legacy/UI state and should never reach the LLM.
function stripUnknown(
data: Record<string, unknown>,
spec: NodeSpec,
): Record<string, unknown> {
const known = new Map<string, PropertySpec>();
for (const p of spec.properties ?? []) known.set(p.name, p);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
const prop = known.get(k);
if (!prop) continue; // drop unknown
if (prop.type === "fixed_collection" && Array.isArray(v)) {
const rowSpec: NodeSpec = {
name: prop.name,
properties: prop.properties ?? [],
};
out[k] = v.map((row) =>
row && typeof row === "object" && !Array.isArray(row)
? stripUnknown(row as Record<string, unknown>, rowSpec)
: row,
);
} else {
out[k] = v;
}
}
return out;
}
// Edge schema is fixed (no NodeSpec for edges). Mirrors the allowed
// fields on `Workflow.edge(...)` in both SDKs.
const KNOWN_EDGE_FIELDS = new Set([
"label",
"condition",
"transition_speech",
"transition_speech_type",
"transition_speech_recording_id",
]);
function pickEdgeFields(
data: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
if (KNOWN_EDGE_FIELDS.has(k)) out[k] = v;
}
return out;
}
// Drop keys whose value equals the spec default — keeps emitted code tight.
function stripDefaults(
data: Record<string, unknown>,
spec: NodeSpec,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const defaults = new Map<string, unknown>();
for (const prop of spec.properties ?? []) {
if (prop.default !== undefined && prop.default !== null) {
defaults.set(prop.name, prop.default);
}
}
for (const [k, v] of Object.entries(data)) {
if (defaults.has(k) && deepEqual(defaults.get(k), v)) continue;
out[k] = v;
}
return out;
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a === null || b === null) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((el, i) => deepEqual(el, b[i]));
}
if (typeof a === "object" && typeof b === "object") {
const ak = Object.keys(a as object).sort();
const bk = Object.keys(b as object).sort();
if (ak.length !== bk.length) return false;
if (ak.some((k, i) => k !== bk[i])) return false;
return ak.every((k) =>
deepEqual(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k],
),
);
}
return false;
}
// Object renderer biased for readability — strings use single-line JSON,
// nested objects/arrays indent one level per depth.
function renderObject(obj: Record<string, unknown>, depth: number): string {
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
const pad = " ".repeat(depth + 1);
const closingPad = " ".repeat(depth);
const parts = keys.map((k) => {
const v = renderValue(obj[k], depth + 1);
return `${pad}${k}: ${v}`;
});
return `{\n${parts.join(",\n")},\n${closingPad}}`;
}
function renderValue(v: unknown, depth: number): string {
if (v === null || v === undefined) return "null";
if (typeof v === "string") return JSON.stringify(v);
if (typeof v === "number" || typeof v === "boolean") return String(v);
if (Array.isArray(v)) {
if (v.length === 0) return "[]";
const pad = " ".repeat(depth + 1);
const closingPad = " ".repeat(depth);
const items = v.map((el) => `${pad}${renderValue(el, depth + 1)}`);
return `[\n${items.join(",\n")},\n${closingPad}]`;
}
if (typeof v === "object") {
return renderObject(v as Record<string, unknown>, depth);
}
return JSON.stringify(v);
}

View file

@ -0,0 +1,74 @@
// Stdin/stdout dispatch. Reads a single JSON request, routes to
// generate or parse, writes a single JSON response. Exits 0 on request
// success (including validation failures — those are in the JSON), and
// exits 1 only on internal errors (bad input JSON, unhandled exception).
import { generateCode } from "./generate.ts";
import { parseCode } from "./parse.ts";
import type { NodeSpec, WireWorkflow } from "./types.ts";
interface GenerateRequest {
command: "generate";
workflow: WireWorkflow;
specs: NodeSpec[];
workflowName?: string;
}
interface ParseRequest {
command: "parse";
code: string;
specs: NodeSpec[];
}
type Request = GenerateRequest | ParseRequest;
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf-8");
}
function writeResult(payload: unknown): void {
process.stdout.write(JSON.stringify(payload));
}
async function main(): Promise<void> {
const input = await readStdin();
let req: Request;
try {
req = JSON.parse(input) as Request;
} catch (e) {
writeResult({
ok: false,
stage: "internal",
errors: [{ message: `Invalid JSON on stdin: ${(e as Error).message}` }],
});
process.exit(1);
}
if (req.command === "generate") {
writeResult(generateCode(req.workflow, req.specs, { workflowName: req.workflowName }));
return;
}
if (req.command === "parse") {
writeResult(parseCode(req.code, req.specs));
return;
}
writeResult({
ok: false,
stage: "internal",
errors: [{ message: `Unknown command: ${(req as { command?: unknown }).command}` }],
});
process.exit(1);
}
main().catch((err: unknown) => {
writeResult({
ok: false,
stage: "internal",
errors: [{ message: (err as Error).stack ?? String(err) }],
});
process.exit(1);
});

View file

@ -0,0 +1,612 @@
// TypeScript → workflow JSON.
//
// Parses LLM-authored SDK code with the TypeScript compiler, walks the
// AST statement by statement, and builds up a workflow JSON from the
// recognized SDK patterns:
//
// const wf = new Workflow({ name: "..." });
// const X = wf.addTyped(startCall({ ...fields }));
// const Y = wf.add({ type: "endCall", ...fields });
// wf.edge(X, Y, { label: "...", condition: "..." });
//
// No code is executed. Any top-level statement that doesn't match one
// of the recognized shapes is a parse error with a file:line:col pointer
// so the LLM can iterate. Node data is validated against the spec
// catalog before returning.
import ts from "typescript";
import type {
NodeSpec,
ParseErrorItem,
ParseResult,
PropertySpec,
WireEdge,
WireNode,
} from "./types.ts";
export function parseCode(code: string, specs: NodeSpec[]): ParseResult {
const specByName = new Map(specs.map((s) => [s.name, s]));
const sourceFile = ts.createSourceFile(
"workflow.ts",
code,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS,
);
const errors: ParseErrorItem[] = [];
const nodes: WireNode[] = [];
const edges: WireEdge[] = [];
const nodeRefs = new Map<string, WireNode>();
let workflowVar: string | null = null;
let workflowName = "";
let nextId = 1;
const addError = (node: ts.Node, message: string): void => {
const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart());
errors.push({
message,
line: pos.line + 1,
column: pos.character + 1,
});
};
for (const stmt of sourceFile.statements) {
if (ts.isImportDeclaration(stmt)) continue; // imports are harmless
if (
ts.isExportAssignment(stmt) ||
stmt.kind === ts.SyntaxKind.EmptyStatement
) {
continue;
}
// `const X = ...;` or `wf.edge(...);`
if (ts.isVariableStatement(stmt)) {
handleVariableStatement(stmt);
continue;
}
if (ts.isExpressionStatement(stmt)) {
handleExpressionStatement(stmt);
continue;
}
addError(
stmt,
`Only imports, \`const X = ...\` bindings, and \`wf.edge(...)\` calls are allowed at the top level. Found: ${ts.SyntaxKind[stmt.kind]}.`,
);
}
function handleVariableStatement(stmt: ts.VariableStatement): void {
const modifiers = ts.getModifiers(stmt);
if (modifiers && modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
addError(stmt, "`export` is not allowed on workflow bindings.");
return;
}
if ((stmt.declarationList.flags & ts.NodeFlags.Const) === 0) {
addError(stmt, "Use `const` for all bindings.");
return;
}
for (const decl of stmt.declarationList.declarations) {
if (!ts.isIdentifier(decl.name)) {
addError(decl, "Destructuring is not allowed — use a single identifier.");
continue;
}
if (!decl.initializer) {
addError(decl, "Bindings must have an initializer.");
continue;
}
const varName = decl.name.text;
handleBinding(varName, decl.initializer, decl);
}
}
function handleBinding(
varName: string,
initializer: ts.Expression,
origin: ts.Node,
): void {
const expr = unwrapAwait(initializer);
// `const wf = new Workflow({ name: "..." })`
if (ts.isNewExpression(expr)) {
if (!ts.isIdentifier(expr.expression) || expr.expression.text !== "Workflow") {
addError(origin, "Only `new Workflow(...)` is supported for object construction.");
return;
}
if (workflowVar) {
addError(origin, `A Workflow is already bound (as \`${workflowVar}\`). Only one Workflow is allowed.`);
return;
}
const args = expr.arguments ?? ts.factory.createNodeArray();
if (args.length > 0) {
const val = literalToJs(args[0]!, addError);
if (
val &&
typeof val === "object" &&
!Array.isArray(val) &&
typeof (val as Record<string, unknown>)["name"] === "string"
) {
workflowName = (val as Record<string, unknown>)["name"] as string;
}
}
workflowVar = varName;
return;
}
// `const X = wf.addTyped(factory({...}))` or `wf.add({ type: "...", ... })`
if (ts.isCallExpression(expr)) {
const call = expr;
const callee = call.expression;
// Must be `wf.XYZ(...)` — property access off the workflow var
if (
!ts.isPropertyAccessExpression(callee) ||
!ts.isIdentifier(callee.expression) ||
(workflowVar !== null && callee.expression.text !== workflowVar)
) {
addError(
origin,
`Expected \`${workflowVar ?? "wf"}.addTyped(...)\` or \`${workflowVar ?? "wf"}.add(...)\`.`,
);
return;
}
if (!workflowVar) {
addError(origin, "Workflow must be constructed before adding nodes.");
return;
}
const method = callee.name.text;
if (method === "addTyped") {
handleAddTyped(varName, call, origin);
} else if (method === "add") {
handleAddGeneric(varName, call, origin);
} else {
addError(
origin,
`Unsupported method \`${method}\`. Use \`addTyped\` or \`add\`.`,
);
}
return;
}
addError(
origin,
"Only `new Workflow(...)`, `wf.addTyped(...)`, and `wf.add(...)` are allowed as binding initializers.",
);
}
function handleAddTyped(
varName: string,
call: ts.CallExpression,
origin: ts.Node,
): void {
if (call.arguments.length < 1 || call.arguments.length > 2) {
addError(origin, "`addTyped` takes 1 or 2 arguments.");
return;
}
const inner = call.arguments[0]!;
if (!ts.isCallExpression(inner) || !ts.isIdentifier(inner.expression)) {
addError(
origin,
"`addTyped` must be called with a factory invocation, e.g. `addTyped(startCall({ ... }))`.",
);
return;
}
const factoryName = inner.expression.text;
if (!specByName.has(factoryName)) {
addError(
origin,
`Unknown node type: \`${factoryName}\`. Check the list of registered node types.`,
);
return;
}
const factoryArgs = inner.arguments;
let data: Record<string, unknown> = {};
if (factoryArgs.length > 0) {
const parsed = literalToJs(factoryArgs[0]!, addError);
if (parsed !== undefined) {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
addError(inner, "Factory argument must be an object literal.");
return;
}
data = parsed as Record<string, unknown>;
}
}
// Optional position arg
const position = extractPositionArg(call.arguments[1], addError);
bindNode(varName, factoryName, data, position, origin);
}
function handleAddGeneric(
varName: string,
call: ts.CallExpression,
origin: ts.Node,
): void {
if (call.arguments.length !== 1) {
addError(origin, "`add` takes exactly 1 object argument.");
return;
}
const parsed = literalToJs(call.arguments[0]!, addError);
if (parsed === undefined) return;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
addError(origin, "`add` argument must be an object literal.");
return;
}
const obj = parsed as Record<string, unknown>;
const typeValue = obj["type"];
if (typeof typeValue !== "string") {
addError(origin, "`add({ type, ... })` requires a string `type` field.");
return;
}
if (!specByName.has(typeValue)) {
addError(origin, `Unknown node type: \`${typeValue}\`.`);
return;
}
let position: { x: number; y: number } | undefined;
if (obj["position"] !== undefined) {
const p = obj["position"];
if (
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === "number" &&
typeof p[1] === "number"
) {
position = { x: p[0], y: p[1] };
} else {
addError(
origin,
"`position` must be a [x, y] tuple of numbers.",
);
return;
}
}
const { type: _ignored, position: _ignored2, ...rest } = obj;
bindNode(varName, typeValue, rest, position, origin);
}
function bindNode(
varName: string,
type: string,
data: Record<string, unknown>,
position: { x: number; y: number } | undefined,
origin: ts.Node,
): void {
if (nodeRefs.has(varName)) {
addError(origin, `Variable \`${varName}\` is already bound.`);
return;
}
const node: WireNode = {
id: String(nextId++),
type,
position: position ?? { x: 0, y: 0 },
data,
};
nodes.push(node);
nodeRefs.set(varName, node);
}
function handleExpressionStatement(stmt: ts.ExpressionStatement): void {
const expr = unwrapAwait(stmt.expression);
if (!ts.isCallExpression(expr)) {
addError(stmt, "Only `wf.edge(...)` calls are allowed as bare statements.");
return;
}
const callee = expr.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
!ts.isIdentifier(callee.expression) ||
(workflowVar !== null && callee.expression.text !== workflowVar) ||
callee.name.text !== "edge"
) {
addError(stmt, "Only `wf.edge(source, target, opts)` is allowed as a bare statement.");
return;
}
if (expr.arguments.length !== 3) {
addError(stmt, "`edge` takes exactly 3 arguments: (source, target, opts).");
return;
}
const [srcArg, tgtArg, optsArg] = expr.arguments;
if (!ts.isIdentifier(srcArg!) || !ts.isIdentifier(tgtArg!)) {
addError(stmt, "`edge` source and target must be variable identifiers bound by `addTyped`/`add`.");
return;
}
const src = nodeRefs.get(srcArg.text);
const tgt = nodeRefs.get(tgtArg.text);
if (!src) {
addError(srcArg, `Unknown node variable: \`${srcArg.text}\`.`);
return;
}
if (!tgt) {
addError(tgtArg, `Unknown node variable: \`${tgtArg.text}\`.`);
return;
}
const opts = literalToJs(optsArg!, addError);
if (opts === undefined) return;
if (typeof opts !== "object" || opts === null || Array.isArray(opts)) {
addError(stmt, "`edge` options must be an object literal.");
return;
}
const optsObj = opts as Record<string, unknown>;
if (typeof optsObj["label"] !== "string" || (optsObj["label"] as string).trim() === "") {
addError(stmt, "`edge` requires a non-empty `label` string.");
return;
}
if (typeof optsObj["condition"] !== "string" || (optsObj["condition"] as string).trim() === "") {
addError(stmt, "`edge` requires a non-empty `condition` string.");
return;
}
edges.push({
id: `${src.id}-${tgt.id}`,
source: src.id,
target: tgt.id,
data: optsObj,
});
}
// ── terminate early on parse errors ──────────────────────────────
if (errors.length > 0) {
return { ok: false, stage: "parse", errors };
}
if (!workflowVar) {
return {
ok: false,
stage: "parse",
errors: [
{
message:
"No Workflow construction found. Expected `const wf = new Workflow({ name: \"...\" });`.",
},
],
};
}
// ── spec-driven node validation ─────────────────────────────────
const validationErrors: ParseErrorItem[] = [];
for (const node of nodes) {
const spec = specByName.get(node.type)!;
const validated = validateNodeData(
spec,
node.data,
(msg) => validationErrors.push({ message: `[${node.type}] ${msg}` }),
);
if (validated !== null) node.data = validated;
}
if (validationErrors.length > 0) {
return { ok: false, stage: "validate", errors: validationErrors };
}
return {
ok: true,
workflow: {
nodes,
edges,
viewport: { x: 0, y: 0, zoom: 1 },
},
workflowName,
};
}
// ─── helpers ──────────────────────────────────────────────────────────
function unwrapAwait(expr: ts.Expression): ts.Expression {
return ts.isAwaitExpression(expr) ? expr.expression : expr;
}
function extractPositionArg(
arg: ts.Expression | undefined,
addError: (n: ts.Node, m: string) => void,
): { x: number; y: number } | undefined {
if (!arg) return undefined;
const parsed = literalToJs(arg, addError);
if (parsed === undefined || parsed === null) return undefined;
if (
typeof parsed === "object" &&
!Array.isArray(parsed) &&
Array.isArray((parsed as Record<string, unknown>)["position"])
) {
const p = (parsed as Record<string, unknown>)["position"] as unknown[];
if (p.length === 2 && typeof p[0] === "number" && typeof p[1] === "number") {
return { x: p[0], y: p[1] };
}
}
addError(arg, "Optional second arg must be `{ position: [x, y] }`.");
return undefined;
}
// Convert an expression to a plain JS value. Accepts: string, number,
// boolean, null, undefined (→ undefined), array/object literals of the
// same. Rejects any expression with runtime semantics (identifiers other
// than `true/false/null/undefined`, function calls, arrow fns, etc.).
function literalToJs(
expr: ts.Expression,
addError: (n: ts.Node, m: string) => void,
): unknown {
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
return expr.text;
}
if (ts.isNumericLiteral(expr)) return Number(expr.text);
if (expr.kind === ts.SyntaxKind.TrueKeyword) return true;
if (expr.kind === ts.SyntaxKind.FalseKeyword) return false;
if (expr.kind === ts.SyntaxKind.NullKeyword) return null;
if (ts.isIdentifier(expr) && expr.text === "undefined") return undefined;
if (ts.isPrefixUnaryExpression(expr)) {
if (expr.operator === ts.SyntaxKind.MinusToken) {
const inner = literalToJs(expr.operand, addError);
if (typeof inner === "number") return -inner;
}
if (expr.operator === ts.SyntaxKind.PlusToken) {
const inner = literalToJs(expr.operand, addError);
if (typeof inner === "number") return inner;
}
addError(expr, "Unsupported unary operator; only numeric negation is allowed.");
return undefined;
}
if (ts.isArrayLiteralExpression(expr)) {
const out: unknown[] = [];
for (const el of expr.elements) {
if (el.kind === ts.SyntaxKind.OmittedExpression) {
addError(el, "Sparse arrays are not allowed.");
return undefined;
}
if (ts.isSpreadElement(el)) {
addError(el, "Spread elements are not allowed in array literals.");
return undefined;
}
const v = literalToJs(el, addError);
if (v === undefined && el.kind !== ts.SyntaxKind.Identifier) {
return undefined;
}
out.push(v);
}
return out;
}
if (ts.isObjectLiteralExpression(expr)) {
const out: Record<string, unknown> = {};
for (const prop of expr.properties) {
if (!ts.isPropertyAssignment(prop)) {
addError(prop, "Only plain `key: value` properties are allowed (no methods, shorthand, spread, or computed keys).");
return undefined;
}
let key: string;
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
key = prop.name.text;
} else {
addError(prop.name, "Property keys must be identifiers or string literals.");
return undefined;
}
const val = literalToJs(prop.initializer, addError);
if (val === undefined && prop.initializer.kind !== ts.SyntaxKind.Identifier) {
// treat explicit `undefined` as omission
continue;
}
out[key] = val;
}
return out;
}
if (ts.isTemplateExpression(expr)) {
addError(expr, "Template literals with interpolation are not allowed — use plain strings.");
return undefined;
}
addError(expr, `Unsupported expression (${ts.SyntaxKind[expr.kind]}). Only literals are allowed in data positions.`);
return undefined;
}
// Spec-driven validation, mirrors the shape of
// `sdk/python/src/dograh_sdk/_validation.py` but lightweight — applies
// defaults for missing optionals, catches unknown keys, enforces `options`
// membership, and type-shapes the scalar and `fixed_collection` cases.
function validateNodeData(
spec: NodeSpec,
data: Record<string, unknown>,
addError: (message: string) => void,
): Record<string, unknown> | null {
const out: Record<string, unknown> = {};
const known = new Map<string, PropertySpec>();
for (const p of spec.properties ?? []) known.set(p.name, p);
for (const key of Object.keys(data)) {
if (!known.has(key)) {
addError(`Unknown field: \`${key}\`.`);
return null;
}
}
for (const [key, prop] of known) {
if (key in data) {
out[key] = data[key];
} else if (prop.default !== undefined && prop.default !== null) {
out[key] = prop.default;
} else if (prop.required) {
addError(`Missing required field: \`${key}\`.`);
return null;
}
}
for (const [key, prop] of known) {
if (!(key in out)) continue;
const value = out[key];
const err = checkPropertyShape(prop, value);
if (err) {
addError(`Field \`${key}\`: ${err}`);
return null;
}
}
return out;
}
function checkPropertyShape(prop: PropertySpec, value: unknown): string | null {
switch (prop.type) {
case "string":
case "mention_textarea":
case "url":
case "recording_ref":
case "credential_ref":
if (typeof value !== "string") return `expected string, got ${jsTypeOf(value)}.`;
return null;
case "number":
if (typeof value !== "number") return `expected number, got ${jsTypeOf(value)}.`;
return null;
case "boolean":
if (typeof value !== "boolean") return `expected boolean, got ${jsTypeOf(value)}.`;
return null;
case "tool_refs":
case "document_refs":
case "multi_options":
if (!Array.isArray(value)) return `expected array, got ${jsTypeOf(value)}.`;
for (const el of value) {
if (prop.type === "multi_options") {
if (!isInOptions(prop, el)) {
return `value \`${JSON.stringify(el)}\` is not in the allowed options.`;
}
} else if (typeof el !== "string") {
return `array elements must be strings.`;
}
}
return null;
case "options":
if (!isInOptions(prop, value)) {
return `value \`${JSON.stringify(value)}\` is not in the allowed options.`;
}
return null;
case "json":
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return `expected JSON object, got ${jsTypeOf(value)}.`;
}
return null;
case "fixed_collection":
if (!Array.isArray(value)) return `expected array of rows, got ${jsTypeOf(value)}.`;
for (let i = 0; i < value.length; i++) {
const row = value[i];
if (typeof row !== "object" || row === null || Array.isArray(row)) {
return `row ${i}: expected object, got ${jsTypeOf(row)}.`;
}
for (const sub of prop.properties ?? []) {
const subVal = (row as Record<string, unknown>)[sub.name];
if (subVal === undefined) {
if (sub.required && (sub.default === undefined || sub.default === null)) {
return `row ${i}: missing required field \`${sub.name}\`.`;
}
continue;
}
const subErr = checkPropertyShape(sub, subVal);
if (subErr) return `row ${i}, \`${sub.name}\`: ${subErr}`;
}
}
return null;
default:
return null; // Unknown types pass — forward compat.
}
}
function isInOptions(prop: PropertySpec, value: unknown): boolean {
if (!prop.options) return true;
return prop.options.some((o) => o.value === value);
}
function jsTypeOf(v: unknown): string {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
return typeof v;
}

View file

@ -0,0 +1,57 @@
// Shared shapes used by both generate and parse. Mirror the `ReactFlowDTO`
// wire format on the Python side (`api/services/workflow/dto.py`) and the
// node-spec JSON served by `/api/v1/node-types` / dumped by
// `python -m api.services.workflow.node_specs`.
export interface PropertyOption {
value: string | number | boolean;
label: string;
}
export interface PropertySpec {
name: string;
type: string;
required?: boolean;
default?: unknown;
options?: PropertyOption[];
properties?: PropertySpec[];
}
export interface NodeSpec {
name: string;
properties: PropertySpec[];
}
export interface WireNode {
id: string;
type: string;
position: { x: number; y: number };
data: Record<string, unknown>;
}
export interface WireEdge {
id: string;
source: string;
target: string;
data: Record<string, unknown>;
}
export interface WireWorkflow {
nodes: WireNode[];
edges: WireEdge[];
viewport: { x: number; y: number; zoom: number };
}
export interface ParseErrorItem {
message: string;
line?: number;
column?: number;
}
export type GenerateResult =
| { ok: true; code: string }
| { ok: false; errors: ParseErrorItem[] };
export type ParseResult =
| { ok: true; workflow: WireWorkflow; workflowName: string }
| { ok: false; stage: "parse" | "validate"; errors: ParseErrorItem[] };

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}