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,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[] };