@dypai-ai/workflow-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/capability-catalog.json +126 -0
- package/fixtures/legacy-create-booking.yaml +40 -0
- package/package.json +40 -0
- package/src/adapters/adapters.test.ts +168 -0
- package/src/adapters/engineBinding.ts +35 -0
- package/src/adapters/flowDefinitionToIr.ts +141 -0
- package/src/adapters/irToWorkflowCodeV2.ts +293 -0
- package/src/adapters/legacyYamlToIr.ts +340 -0
- package/src/adapters/placeholderToRef.ts +74 -0
- package/src/adapters/refToLegacyPlaceholder.ts +33 -0
- package/src/adapters/sqlBuilders.ts +81 -0
- package/src/adapters/triggers.ts +86 -0
- package/src/adapters/types.ts +15 -0
- package/src/adapters/workflowCodeTypes.ts +45 -0
- package/src/capabilities/agentBrief.ts +42 -0
- package/src/capabilities/capabilities.test.ts +126 -0
- package/src/capabilities/capabilityRegistry.ts +112 -0
- package/src/capabilities/catalogSchema.ts +14 -0
- package/src/capabilities/fromCatalog.ts +30 -0
- package/src/capabilities/index.ts +35 -0
- package/src/capabilities/types.ts +57 -0
- package/src/fixtures/createBooking.flow.ts +64 -0
- package/src/fixtures/createBooking.ir.ts +103 -0
- package/src/fixtures/listBookings.ir.ts +61 -0
- package/src/index.ts +172 -0
- package/src/ir/refs.ts +103 -0
- package/src/ir/schema.ts +149 -0
- package/src/ir/sourceMap.ts +59 -0
- package/src/ir/types.ts +147 -0
- package/src/ir/validate.test.ts +181 -0
- package/src/ir/validate.ts +365 -0
- package/src/registry/defineNode.ts +19 -0
- package/src/registry/nodeRegistry.ts +87 -0
- package/src/registry/nodes/dypaiDb.ts +164 -0
- package/src/registry/nodes/dypaiEmail.ts +57 -0
- package/src/registry/nodes/dypaiFlow.ts +25 -0
- package/src/registry/nodes/legacyWorkflow.ts +27 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { serializeConfig } from "./refToLegacyPlaceholder";
|
|
2
|
+
import { buildEngineParameters } from "./engineBinding";
|
|
3
|
+
import { buildInsertSql, buildListSql, buildUpdateSql } from "./sqlBuilders";
|
|
4
|
+
import { responseMapToComposeFields } from "./refToLegacyPlaceholder";
|
|
5
|
+
import { triggersToEngineConfig } from "./triggers";
|
|
6
|
+
import { CapabilityRegistry } from "../capabilities/capabilityRegistry";
|
|
7
|
+
import type { CapabilityCatalog } from "../capabilities/types";
|
|
8
|
+
import type {
|
|
9
|
+
AdaptWorkflowCodeResult,
|
|
10
|
+
EngineWorkflowEdge,
|
|
11
|
+
EngineWorkflowNode,
|
|
12
|
+
WorkflowCodeV2,
|
|
13
|
+
} from "./workflowCodeTypes";
|
|
14
|
+
import type { IRDiagnostic, WorkflowIR, WorkflowStepIR } from "../ir/types";
|
|
15
|
+
import { withSourceLocation } from "../ir/sourceMap";
|
|
16
|
+
|
|
17
|
+
export type AdaptWorkflowCodeOptions = {
|
|
18
|
+
capabilityCatalog?: CapabilityCatalog;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const COMPOSE_STEP_ID = "__response";
|
|
22
|
+
|
|
23
|
+
function isFlowReturnStep(step: WorkflowStepIR): boolean {
|
|
24
|
+
return step.node === "dypai.flow" && step.operation === "return";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function adaptDatabaseStep(step: WorkflowStepIR): EngineWorkflowNode {
|
|
28
|
+
const config = step.config;
|
|
29
|
+
let query = "";
|
|
30
|
+
|
|
31
|
+
if (step.operation === "query") {
|
|
32
|
+
query = typeof config.sql === "string" ? config.sql.trim() : "";
|
|
33
|
+
} else if (step.operation === "insert") {
|
|
34
|
+
query = buildInsertSql({
|
|
35
|
+
table: String(config.table || ""),
|
|
36
|
+
values: (config.values as Record<string, unknown>) || {},
|
|
37
|
+
returning: Array.isArray(config.returning)
|
|
38
|
+
? config.returning.filter((item): item is string => typeof item === "string")
|
|
39
|
+
: undefined,
|
|
40
|
+
});
|
|
41
|
+
} else if (step.operation === "update") {
|
|
42
|
+
query = buildUpdateSql({
|
|
43
|
+
table: String(config.table || ""),
|
|
44
|
+
where: (config.where as Record<string, unknown>) || {},
|
|
45
|
+
set: (config.set as Record<string, unknown>) || {},
|
|
46
|
+
returning: Array.isArray(config.returning)
|
|
47
|
+
? config.returning.filter((item): item is string => typeof item === "string")
|
|
48
|
+
: undefined,
|
|
49
|
+
});
|
|
50
|
+
} else if (step.operation === "list") {
|
|
51
|
+
query = buildListSql({
|
|
52
|
+
table: String(config.table || ""),
|
|
53
|
+
where: config.where as Record<string, unknown> | undefined,
|
|
54
|
+
orderBy: typeof config.orderBy === "string" ? config.orderBy : undefined,
|
|
55
|
+
limit: config.limit,
|
|
56
|
+
offset: config.offset,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: step.id,
|
|
62
|
+
node_type: "dypai_database",
|
|
63
|
+
variable: step.id,
|
|
64
|
+
parameters: {
|
|
65
|
+
operation: "query",
|
|
66
|
+
query,
|
|
67
|
+
...(config.params ? { params: serializeConfig(config.params as Record<string, unknown>) } : {}),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function adaptEmailStep(step: WorkflowStepIR): EngineWorkflowNode {
|
|
73
|
+
const config = serializeConfig(step.config);
|
|
74
|
+
return {
|
|
75
|
+
id: step.id,
|
|
76
|
+
node_type: "resend",
|
|
77
|
+
variable: step.id,
|
|
78
|
+
parameters: {
|
|
79
|
+
to: config.to,
|
|
80
|
+
subject: config.subject,
|
|
81
|
+
body: config.body,
|
|
82
|
+
template: config.template,
|
|
83
|
+
variables: config.variables,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function adaptCapabilityStep(
|
|
89
|
+
step: WorkflowStepIR,
|
|
90
|
+
registry: CapabilityRegistry,
|
|
91
|
+
): EngineWorkflowNode | null {
|
|
92
|
+
if (step.node === "dypai.db") {
|
|
93
|
+
return adaptDatabaseStep(step);
|
|
94
|
+
}
|
|
95
|
+
if (step.node === "dypai.email") {
|
|
96
|
+
return adaptEmailStep(step);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resolved = registry.lookup(step.node, step.operation, step.version);
|
|
100
|
+
if (!resolved) return null;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: step.id,
|
|
104
|
+
node_type: resolved.operation.engine_binding.node_type,
|
|
105
|
+
variable: step.id,
|
|
106
|
+
parameters: buildEngineParameters(step.config, resolved.operation.engine_binding),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function adaptLegacyStep(step: WorkflowStepIR): EngineWorkflowNode {
|
|
111
|
+
const legacy = step.config.legacyNode;
|
|
112
|
+
const node = legacy && typeof legacy === "object" && !Array.isArray(legacy)
|
|
113
|
+
? legacy as Record<string, unknown>
|
|
114
|
+
: {};
|
|
115
|
+
const {
|
|
116
|
+
id = step.id,
|
|
117
|
+
type,
|
|
118
|
+
node_type,
|
|
119
|
+
variable,
|
|
120
|
+
return: returnFlag,
|
|
121
|
+
is_return,
|
|
122
|
+
parameters,
|
|
123
|
+
...inline
|
|
124
|
+
} = node;
|
|
125
|
+
const resolvedType = typeof type === "string" ? type : typeof node_type === "string" ? node_type : "unknown";
|
|
126
|
+
const params = parameters && typeof parameters === "object" && !Array.isArray(parameters)
|
|
127
|
+
? parameters as Record<string, unknown>
|
|
128
|
+
: inline;
|
|
129
|
+
return {
|
|
130
|
+
id: typeof id === "string" ? id : step.id,
|
|
131
|
+
node_type: resolvedType,
|
|
132
|
+
variable: typeof variable === "string" ? variable : step.id,
|
|
133
|
+
...(returnFlag === true || is_return === true ? { is_return: true } : {}),
|
|
134
|
+
parameters: serializeConfig(params as Record<string, unknown>),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildComposeNode(workflow: WorkflowIR): EngineWorkflowNode {
|
|
139
|
+
return {
|
|
140
|
+
id: COMPOSE_STEP_ID,
|
|
141
|
+
node_type: "set_fields",
|
|
142
|
+
variable: COMPOSE_STEP_ID,
|
|
143
|
+
is_return: true,
|
|
144
|
+
parameters: {
|
|
145
|
+
operation: "compose",
|
|
146
|
+
fields: responseMapToComposeFields(workflow.responseMap),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeEdges(
|
|
152
|
+
workflow: WorkflowIR,
|
|
153
|
+
dataStepIds: string[],
|
|
154
|
+
returnStepIds: string[],
|
|
155
|
+
): EngineWorkflowEdge[] {
|
|
156
|
+
const edges: EngineWorkflowEdge[] = [];
|
|
157
|
+
const declared = workflow.edges || [];
|
|
158
|
+
const dataSteps = new Set(dataStepIds);
|
|
159
|
+
const returnSteps = new Set(returnStepIds);
|
|
160
|
+
const emittedNodes = new Set([...dataStepIds, COMPOSE_STEP_ID]);
|
|
161
|
+
|
|
162
|
+
for (const edge of declared) {
|
|
163
|
+
if (edge.from === "$trigger") continue;
|
|
164
|
+
const source = returnSteps.has(edge.from) ? COMPOSE_STEP_ID : edge.from;
|
|
165
|
+
const target = returnSteps.has(edge.to) ? COMPOSE_STEP_ID : edge.to;
|
|
166
|
+
if (source === target) continue;
|
|
167
|
+
if (!emittedNodes.has(source) || !emittedNodes.has(target)) continue;
|
|
168
|
+
edges.push({ source, target });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const composeTargets = new Set(
|
|
172
|
+
edges.filter((edge) => edge.target === COMPOSE_STEP_ID).map((edge) => edge.source),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
for (const stepId of dataSteps) {
|
|
176
|
+
if (!composeTargets.has(stepId)) {
|
|
177
|
+
edges.push({ source: stepId, target: COMPOSE_STEP_ID });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
edges.sort((a, b) => `${a.source}:${a.target}`.localeCompare(`${b.source}:${b.target}`));
|
|
182
|
+
return edges;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateGeneratedWorkflowCode(workflowCode: WorkflowCodeV2): IRDiagnostic[] {
|
|
186
|
+
const diagnostics: IRDiagnostic[] = [];
|
|
187
|
+
for (const node of workflowCode.nodes) {
|
|
188
|
+
if (node.node_type === "dypai_database") {
|
|
189
|
+
const operation = node.parameters.operation;
|
|
190
|
+
const query = node.parameters.query;
|
|
191
|
+
if (operation === "mutation" && query) {
|
|
192
|
+
diagnostics.push({
|
|
193
|
+
severity: "error",
|
|
194
|
+
rule: "adapter_mutation_with_query",
|
|
195
|
+
message: `Generated node '${node.id}' contains invalid operation: mutation with query.`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (node.parameters.config) {
|
|
199
|
+
diagnostics.push({
|
|
200
|
+
severity: "error",
|
|
201
|
+
rule: "adapter_config_nesting",
|
|
202
|
+
message: `Generated node '${node.id}' nests parameters under config.`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (node.node_type === "set_fields" && node.is_return && node.parameters.operation !== "compose") {
|
|
207
|
+
diagnostics.push({
|
|
208
|
+
severity: "error",
|
|
209
|
+
rule: "adapter_return_not_compose",
|
|
210
|
+
message: `Generated return node '${node.id}' must use set_fields operation: compose.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return diagnostics;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function irToWorkflowCodeV2(
|
|
218
|
+
workflow: WorkflowIR,
|
|
219
|
+
options: AdaptWorkflowCodeOptions = {},
|
|
220
|
+
): AdaptWorkflowCodeResult {
|
|
221
|
+
const diagnostics: IRDiagnostic[] = [];
|
|
222
|
+
const capabilityRegistry = options.capabilityCatalog
|
|
223
|
+
? CapabilityRegistry.fromCatalog(options.capabilityCatalog)
|
|
224
|
+
: new CapabilityRegistry();
|
|
225
|
+
const dataSteps = workflow.steps.filter((step) => !isFlowReturnStep(step));
|
|
226
|
+
const returnStepIds = workflow.steps.filter(isFlowReturnStep).map((step) => step.id);
|
|
227
|
+
const nodes: EngineWorkflowNode[] = [];
|
|
228
|
+
|
|
229
|
+
for (const step of dataSteps) {
|
|
230
|
+
if (step.node === "legacy.workflow") {
|
|
231
|
+
nodes.push(adaptLegacyStep(step));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const adapted = adaptCapabilityStep(step, capabilityRegistry);
|
|
236
|
+
if (adapted) {
|
|
237
|
+
nodes.push(adapted);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
diagnostics.push(withSourceLocation({
|
|
242
|
+
severity: "error",
|
|
243
|
+
rule: "adapter_unsupported_node",
|
|
244
|
+
message: `No workflow_code adapter for ${step.node}.${step.operation}@v${step.version}.`,
|
|
245
|
+
fixHint: capabilityRegistry.getGroup(step.node)
|
|
246
|
+
? `Available operations: ${capabilityRegistry.getGroup(step.node)!.operations.map((item) => item.operation).sort().join(", ")}`
|
|
247
|
+
: "Load capability catalog or use legacy.workflow for unsupported runtime nodes.",
|
|
248
|
+
path: `steps.${step.id}`,
|
|
249
|
+
}, step.source));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
nodes.push(buildComposeNode(workflow));
|
|
253
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
254
|
+
|
|
255
|
+
const workflowCode: WorkflowCodeV2 = {
|
|
256
|
+
schema_version: "2.0",
|
|
257
|
+
nodes,
|
|
258
|
+
edges: normalizeEdges(workflow, dataSteps.map((step) => step.id), returnStepIds),
|
|
259
|
+
execution_config: triggersToEngineConfig(workflow.trigger, workflow.auth),
|
|
260
|
+
metadata: {
|
|
261
|
+
ir_name: workflow.name,
|
|
262
|
+
ir_version: workflow.version,
|
|
263
|
+
...(options.capabilityCatalog?.content_hash
|
|
264
|
+
? { capability_catalog_hash: options.capabilityCatalog.content_hash }
|
|
265
|
+
: {}),
|
|
266
|
+
...(options.capabilityCatalog?.engine_version
|
|
267
|
+
? { capability_engine_version: options.capabilityCatalog.engine_version }
|
|
268
|
+
: {}),
|
|
269
|
+
...(workflow.response.cardinality ? { response_cardinality: workflow.response.cardinality } : {}),
|
|
270
|
+
...(workflow.source ? { source: { kind: workflow.source.kind, file: workflow.source.file } } : {}),
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
diagnostics.push(...validateGeneratedWorkflowCode(workflowCode));
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
ok: !diagnostics.some((item) => item.severity === "error"),
|
|
278
|
+
value: workflowCode,
|
|
279
|
+
diagnostics,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function stableStringifyWorkflowCode(workflowCode: WorkflowCodeV2): string {
|
|
284
|
+
return JSON.stringify(workflowCode, (_key, value) => {
|
|
285
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
286
|
+
return Object.keys(value as Record<string, unknown>).sort().reduce((acc, key) => {
|
|
287
|
+
acc[key] = (value as Record<string, unknown>)[key];
|
|
288
|
+
return acc;
|
|
289
|
+
}, {} as Record<string, unknown>);
|
|
290
|
+
}
|
|
291
|
+
return value;
|
|
292
|
+
}, 2);
|
|
293
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import { composeFieldsToResponseMap } from "./placeholderToRef";
|
|
3
|
+
import {
|
|
4
|
+
authFromLegacyTrigger,
|
|
5
|
+
normalizeHttpMethod,
|
|
6
|
+
responseCardinalityFromLegacy,
|
|
7
|
+
triggerFromLegacyDoc,
|
|
8
|
+
} from "./triggers";
|
|
9
|
+
import type { ImportYamlResult } from "./workflowCodeTypes";
|
|
10
|
+
import { isRecord, schemaPropertyNames } from "../ir/schema";
|
|
11
|
+
import { withSourceLocation } from "../ir/sourceMap";
|
|
12
|
+
import type {
|
|
13
|
+
IRDiagnostic,
|
|
14
|
+
JsonSchema,
|
|
15
|
+
RefIR,
|
|
16
|
+
SourceLocation,
|
|
17
|
+
WorkflowIR,
|
|
18
|
+
WorkflowStepIR,
|
|
19
|
+
} from "../ir/types";
|
|
20
|
+
import { WORKFLOW_IR_VERSION } from "../ir/types";
|
|
21
|
+
|
|
22
|
+
function nodeSource(file: string | undefined, index: number, stepId?: string): SourceLocation {
|
|
23
|
+
return {
|
|
24
|
+
file,
|
|
25
|
+
yamlPath: `workflow.nodes[${index}]`,
|
|
26
|
+
stepId,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function liftStartTriggerNodes(workflow: Record<string, unknown>) {
|
|
31
|
+
const allNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(isRecord) : [];
|
|
32
|
+
const allEdges = Array.isArray(workflow.edges) ? workflow.edges.filter(isRecord) : [];
|
|
33
|
+
const startNodes = allNodes.filter((node) => (node.type ?? node.node_type) === "start_trigger");
|
|
34
|
+
if (!startNodes.length) {
|
|
35
|
+
return { nodes: allNodes, edges: allEdges, inferredTrigger: null as Record<string, unknown> | null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const startIds = new Set(startNodes.map((node) => String(node.id || "")));
|
|
39
|
+
const nodes = allNodes.filter((node) => !startIds.has(String(node.id || "")));
|
|
40
|
+
const edges = allEdges.filter((edge) => (
|
|
41
|
+
!startIds.has(String(edge.from ?? edge.source ?? ""))
|
|
42
|
+
&& !startIds.has(String(edge.to ?? edge.target ?? ""))
|
|
43
|
+
));
|
|
44
|
+
|
|
45
|
+
const first = startNodes[0];
|
|
46
|
+
const params = isRecord(first.parameters) ? first.parameters : first;
|
|
47
|
+
const triggerType = params.trigger_type || params.triggerType || "http_api";
|
|
48
|
+
let inferredTrigger: Record<string, unknown>;
|
|
49
|
+
switch (triggerType) {
|
|
50
|
+
case "webhook":
|
|
51
|
+
inferredTrigger = { webhook: params.path ? { path: params.path } : {} };
|
|
52
|
+
break;
|
|
53
|
+
case "schedule":
|
|
54
|
+
inferredTrigger = {
|
|
55
|
+
schedule: {
|
|
56
|
+
...(params.cron ? { cron: params.cron } : {}),
|
|
57
|
+
...(params.timezone ? { timezone: params.timezone } : {}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
inferredTrigger = { http_api: { auth_mode: params.auth_mode || params.mode || "jwt" } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { nodes, edges, inferredTrigger };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function importDatabaseNode(
|
|
69
|
+
node: Record<string, unknown>,
|
|
70
|
+
source: SourceLocation,
|
|
71
|
+
diagnostics: IRDiagnostic[],
|
|
72
|
+
): WorkflowStepIR {
|
|
73
|
+
const id = String(node.id || "database");
|
|
74
|
+
const operation = typeof node.operation === "string" ? node.operation : "";
|
|
75
|
+
const query = typeof node.query === "string" ? node.query.trim() : "";
|
|
76
|
+
|
|
77
|
+
if (operation === "mutation" && query) {
|
|
78
|
+
diagnostics.push(withSourceLocation({
|
|
79
|
+
severity: "error",
|
|
80
|
+
rule: "legacy_mutation_with_query",
|
|
81
|
+
message: `Legacy node '${id}' uses operation: mutation with query. Importing as dypai.db.query for compatibility review.`,
|
|
82
|
+
fixHint: "Change YAML to operation: query or use declarative mutation fields without query.",
|
|
83
|
+
}, source));
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
node: "dypai.db",
|
|
87
|
+
operation: "query",
|
|
88
|
+
version: 1,
|
|
89
|
+
config: { sql: query },
|
|
90
|
+
source,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (operation === "query" || operation === "custom_query" || query) {
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
node: "dypai.db",
|
|
98
|
+
operation: "query",
|
|
99
|
+
version: 1,
|
|
100
|
+
config: {
|
|
101
|
+
sql: query,
|
|
102
|
+
...(isRecord(node.params) ? { params: node.params } : {}),
|
|
103
|
+
},
|
|
104
|
+
source,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (operation === "mutation") {
|
|
109
|
+
if (node.insert) {
|
|
110
|
+
return {
|
|
111
|
+
id,
|
|
112
|
+
node: "dypai.db",
|
|
113
|
+
operation: "insert",
|
|
114
|
+
version: 1,
|
|
115
|
+
config: {
|
|
116
|
+
table: String(node.table || ""),
|
|
117
|
+
values: isRecord(node.insert) ? node.insert : {},
|
|
118
|
+
...(Array.isArray(node.returning) ? { returning: node.returning } : {}),
|
|
119
|
+
},
|
|
120
|
+
source,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (node.update) {
|
|
124
|
+
return {
|
|
125
|
+
id,
|
|
126
|
+
node: "dypai.db",
|
|
127
|
+
operation: "update",
|
|
128
|
+
version: 1,
|
|
129
|
+
config: {
|
|
130
|
+
table: String(node.table || ""),
|
|
131
|
+
set: isRecord(node.update) ? node.update : {},
|
|
132
|
+
where: isRecord(node.where) ? node.where : {},
|
|
133
|
+
...(Array.isArray(node.returning) ? { returning: node.returning } : {}),
|
|
134
|
+
},
|
|
135
|
+
source,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
diagnostics.push(withSourceLocation({
|
|
141
|
+
severity: "warn",
|
|
142
|
+
rule: "legacy_database_unmapped",
|
|
143
|
+
message: `Legacy database node '${id}' was preserved as legacy.workflow.`,
|
|
144
|
+
fixHint: "Prefer operation: query or declarative dypai.db operations in WorkflowIR.",
|
|
145
|
+
}, source));
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
node: "legacy.workflow",
|
|
150
|
+
operation: "node",
|
|
151
|
+
version: 1,
|
|
152
|
+
config: { legacyNode: node },
|
|
153
|
+
source,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function responseMapFromOutputSchema(
|
|
158
|
+
outputSchema: JsonSchema,
|
|
159
|
+
stepId: string,
|
|
160
|
+
): Record<string, RefIR> {
|
|
161
|
+
const properties = schemaPropertyNames(outputSchema);
|
|
162
|
+
const responseMap: Record<string, RefIR> = {};
|
|
163
|
+
for (const key of properties) {
|
|
164
|
+
responseMap[key] = { kind: "step", stepId, path: [key] };
|
|
165
|
+
}
|
|
166
|
+
return responseMap;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function importLegacyNode(
|
|
170
|
+
node: Record<string, unknown>,
|
|
171
|
+
index: number,
|
|
172
|
+
file: string | undefined,
|
|
173
|
+
diagnostics: IRDiagnostic[],
|
|
174
|
+
): { step?: WorkflowStepIR; returnMap?: Record<string, RefIR>; isReturn?: boolean } {
|
|
175
|
+
const type = String(node.type ?? node.node_type ?? "");
|
|
176
|
+
const id = String(node.id || `step_${index}`);
|
|
177
|
+
const source = nodeSource(file, index, id);
|
|
178
|
+
const isReturn = node.return === true || node.is_return === true;
|
|
179
|
+
|
|
180
|
+
if (type === "dypai_database") {
|
|
181
|
+
return { step: importDatabaseNode(node, source, diagnostics), isReturn };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (type === "set_fields" && isReturn && node.operation === "compose" && isRecord(node.fields)) {
|
|
185
|
+
return {
|
|
186
|
+
step: {
|
|
187
|
+
id,
|
|
188
|
+
node: "dypai.flow",
|
|
189
|
+
operation: "return",
|
|
190
|
+
version: 1,
|
|
191
|
+
config: {},
|
|
192
|
+
return: true,
|
|
193
|
+
source,
|
|
194
|
+
},
|
|
195
|
+
returnMap: composeFieldsToResponseMap(node.fields),
|
|
196
|
+
isReturn: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (type === "set_fields" && isReturn) {
|
|
201
|
+
diagnostics.push(withSourceLocation({
|
|
202
|
+
severity: "warn",
|
|
203
|
+
rule: "legacy_return_not_compose",
|
|
204
|
+
message: `Legacy set_fields node '${id}' uses return without compose.`,
|
|
205
|
+
fixHint: "Use operation: compose for endpoint responses.",
|
|
206
|
+
}, source));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
step: {
|
|
211
|
+
id,
|
|
212
|
+
node: "legacy.workflow",
|
|
213
|
+
operation: "node",
|
|
214
|
+
version: 1,
|
|
215
|
+
config: { legacyNode: node },
|
|
216
|
+
source,
|
|
217
|
+
...(isReturn ? { return: true } : {}),
|
|
218
|
+
},
|
|
219
|
+
isReturn,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function legacyYamlDocumentToIr(
|
|
224
|
+
doc: Record<string, unknown>,
|
|
225
|
+
options: { file?: string } = {},
|
|
226
|
+
): ImportYamlResult {
|
|
227
|
+
const diagnostics: IRDiagnostic[] = [];
|
|
228
|
+
const file = options.file;
|
|
229
|
+
|
|
230
|
+
const name = typeof doc.name === "string" && doc.name.trim()
|
|
231
|
+
? doc.name.trim()
|
|
232
|
+
: file?.split("/").pop()?.replace(/\.(ya?ml|json)$/i, "") || "endpoint";
|
|
233
|
+
const method = normalizeHttpMethod(doc.method);
|
|
234
|
+
const workflowRaw = isRecord(doc.workflow) ? doc.workflow : {};
|
|
235
|
+
const lifted = liftStartTriggerNodes(workflowRaw);
|
|
236
|
+
const triggerValue = doc.trigger || lifted.inferredTrigger || { http_api: { auth_mode: "jwt" } };
|
|
237
|
+
const allowedRoles = Array.isArray(doc.allowed_roles)
|
|
238
|
+
? doc.allowed_roles.filter((item): item is string => typeof item === "string")
|
|
239
|
+
: undefined;
|
|
240
|
+
|
|
241
|
+
const steps: WorkflowStepIR[] = [];
|
|
242
|
+
let responseMap: Record<string, RefIR> = {};
|
|
243
|
+
let sawFlowReturn = false;
|
|
244
|
+
|
|
245
|
+
lifted.nodes.forEach((node, index) => {
|
|
246
|
+
if (!isRecord(node)) return;
|
|
247
|
+
const imported = importLegacyNode(node, index, file, diagnostics);
|
|
248
|
+
if (imported.step) steps.push(imported.step);
|
|
249
|
+
if (imported.returnMap) {
|
|
250
|
+
responseMap = imported.returnMap;
|
|
251
|
+
sawFlowReturn = true;
|
|
252
|
+
} else if (imported.isReturn && imported.step?.node === "dypai.db") {
|
|
253
|
+
const output = isRecord(doc.output) ? doc.output as JsonSchema : { type: "object", properties: {} };
|
|
254
|
+
responseMap = responseMapFromOutputSchema(output, imported.step.id);
|
|
255
|
+
sawFlowReturn = true;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!sawFlowReturn) {
|
|
260
|
+
steps.push({
|
|
261
|
+
id: "return",
|
|
262
|
+
node: "dypai.flow",
|
|
263
|
+
operation: "return",
|
|
264
|
+
version: 1,
|
|
265
|
+
config: {},
|
|
266
|
+
return: true,
|
|
267
|
+
source: { file, yamlPath: "workflow.response" },
|
|
268
|
+
});
|
|
269
|
+
const output = isRecord(doc.output) ? doc.output as JsonSchema : { type: "object", properties: {} };
|
|
270
|
+
responseMap = responseMapFromOutputSchema(output, steps[0]?.id || "return");
|
|
271
|
+
} else if (!steps.some((step) => step.node === "dypai.flow" && step.operation === "return")) {
|
|
272
|
+
steps.push({
|
|
273
|
+
id: "return",
|
|
274
|
+
node: "dypai.flow",
|
|
275
|
+
operation: "return",
|
|
276
|
+
version: 1,
|
|
277
|
+
config: {},
|
|
278
|
+
return: true,
|
|
279
|
+
source: { file, yamlPath: "workflow.response" },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const edges = lifted.edges
|
|
284
|
+
.map((edge) => ({
|
|
285
|
+
from: String(edge.from ?? edge.source ?? ""),
|
|
286
|
+
to: String(edge.to ?? edge.target ?? ""),
|
|
287
|
+
}))
|
|
288
|
+
.filter((edge) => edge.from && edge.to);
|
|
289
|
+
|
|
290
|
+
const workflow: WorkflowIR = {
|
|
291
|
+
version: WORKFLOW_IR_VERSION,
|
|
292
|
+
name,
|
|
293
|
+
source: file ? { kind: "legacy-yaml", file } : undefined,
|
|
294
|
+
trigger: triggerFromLegacyDoc(triggerValue, method),
|
|
295
|
+
auth: authFromLegacyTrigger(
|
|
296
|
+
isRecord(triggerValue) ? triggerValue : {},
|
|
297
|
+
allowedRoles,
|
|
298
|
+
),
|
|
299
|
+
inputSchema: isRecord(doc.input) ? doc.input as JsonSchema : { type: "object", properties: {} },
|
|
300
|
+
outputSchema: isRecord(doc.output) ? doc.output as JsonSchema : { type: "object", properties: {} },
|
|
301
|
+
response: {
|
|
302
|
+
cardinality: responseCardinalityFromLegacy(doc.response_cardinality),
|
|
303
|
+
},
|
|
304
|
+
steps,
|
|
305
|
+
edges: edges.length ? edges : undefined,
|
|
306
|
+
responseMap,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
ok: !diagnostics.some((item) => item.severity === "error"),
|
|
311
|
+
value: workflow,
|
|
312
|
+
diagnostics,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function legacyYamlToIr(yamlText: string, options: { file?: string } = {}): ImportYamlResult {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = parseYaml(yamlText);
|
|
319
|
+
if (!isRecord(parsed)) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
diagnostics: [{
|
|
323
|
+
severity: "error",
|
|
324
|
+
rule: "legacy_yaml_invalid_root",
|
|
325
|
+
message: "Legacy endpoint YAML root must be an object.",
|
|
326
|
+
}],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return legacyYamlDocumentToIr(parsed, options);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
diagnostics: [{
|
|
334
|
+
severity: "error",
|
|
335
|
+
rule: "legacy_yaml_parse_error",
|
|
336
|
+
message: error instanceof Error ? error.message : "Failed to parse legacy YAML.",
|
|
337
|
+
}],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RefIR } from "../ir/types";
|
|
2
|
+
|
|
3
|
+
const INPUT_REF = /^\$\{\s*input\.([a-zA-Z0-9_.[\]]+)\s*\}$/;
|
|
4
|
+
const CURRENT_USER_REF = /^\$\{\s*current_user_id\s*\}$/;
|
|
5
|
+
const VARS_REF = /^\$\{\s*vars\.([a-zA-Z0-9_]+)((?:\[\d+\])?(?:\.[a-zA-Z0-9_[\]]+)*)\s*\}$/;
|
|
6
|
+
const ENV_REF = /^\$\{\s*env\.([A-Z0-9_]+)\s*\}$/;
|
|
7
|
+
|
|
8
|
+
function splitRefPath(path: string): string[] {
|
|
9
|
+
const parts: string[] = [];
|
|
10
|
+
const regex = /([a-zA-Z0-9_]+)|\[(\d+)\]/g;
|
|
11
|
+
let match: RegExpExecArray | null;
|
|
12
|
+
while ((match = regex.exec(path)) !== null) {
|
|
13
|
+
if (match[1]) parts.push(match[1]);
|
|
14
|
+
if (match[2]) parts.push(match[2]);
|
|
15
|
+
}
|
|
16
|
+
return parts;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function placeholderToRef(value: unknown): RefIR | unknown {
|
|
20
|
+
if (typeof value !== "string") return value;
|
|
21
|
+
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (CURRENT_USER_REF.test(trimmed)) return { kind: "currentUserId" };
|
|
24
|
+
|
|
25
|
+
const inputMatch = trimmed.match(INPUT_REF);
|
|
26
|
+
if (inputMatch) {
|
|
27
|
+
return { kind: "input", path: splitRefPath(inputMatch[1]) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const varsMatch = trimmed.match(VARS_REF);
|
|
31
|
+
if (varsMatch) {
|
|
32
|
+
const stepId = varsMatch[1];
|
|
33
|
+
const tail = varsMatch[2]?.replace(/^\./, "").trim();
|
|
34
|
+
return {
|
|
35
|
+
kind: "step",
|
|
36
|
+
stepId,
|
|
37
|
+
path: tail ? splitRefPath(tail) : undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const envMatch = trimmed.match(ENV_REF);
|
|
42
|
+
if (envMatch) return { kind: "env", name: envMatch[1] };
|
|
43
|
+
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function mapObjectPlaceholdersToRefs(value: unknown): unknown {
|
|
48
|
+
const ref = placeholderToRef(value);
|
|
49
|
+
if (ref !== value) return ref;
|
|
50
|
+
if (Array.isArray(value)) return value.map((item) => mapObjectPlaceholdersToRefs(item));
|
|
51
|
+
if (value && typeof value === "object") {
|
|
52
|
+
const out: Record<string, unknown> = {};
|
|
53
|
+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
54
|
+
out[key] = mapObjectPlaceholdersToRefs(nested);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function composeFieldsToResponseMap(
|
|
62
|
+
fields: Record<string, unknown>,
|
|
63
|
+
): Record<string, RefIR> {
|
|
64
|
+
const responseMap: Record<string, RefIR> = {};
|
|
65
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
66
|
+
const mapped = mapObjectPlaceholdersToRefs(value);
|
|
67
|
+
if (mapped && typeof mapped === "object" && !Array.isArray(mapped) && "kind" in mapped) {
|
|
68
|
+
responseMap[key] = mapped as RefIR;
|
|
69
|
+
} else if (typeof mapped === "string" || typeof mapped === "number" || typeof mapped === "boolean") {
|
|
70
|
+
responseMap[key] = { kind: "literal", value: mapped };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return responseMap;
|
|
74
|
+
}
|