@synap-core/workspace-templates 0.9.1 → 0.9.3
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/dist/index.d.ts +170 -8
- package/dist/index.js +643 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/business-developer.yaml +24 -6
- package/src/content-os.yaml +19 -0
- package/src/crm.yaml +28 -2
- package/src/define.primary-surface.test.ts +183 -0
- package/src/define.ts +142 -19
- package/src/index.ts +26 -3
- package/src/marketing.yaml +19 -0
- package/src/templates.ts +126 -8
- package/src/types.ts +74 -5
- package/src/validate-flow.drift.test.ts +83 -0
- package/src/validate-flow.ts +555 -0
- package/src/validate.test.ts +121 -0
- package/src/validate.ts +133 -10
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-contract validation for automation flow definitions.
|
|
3
|
+
*
|
|
4
|
+
* The persist doors and the install/apply doors validate flow SHAPE only
|
|
5
|
+
* (`{ nodes: [], edges: [] }`). A semantically-broken flow — a `channel_message`
|
|
6
|
+
* output with a typo'd `channelType`, a `capability` node with no `verbId`, an
|
|
7
|
+
* edge to a nonexistent node, a cycle, an unknown `node.type` — passes the shape
|
|
8
|
+
* check and only fails at RUNTIME (automation-executor.ts throws mid-run). For a
|
|
9
|
+
* template automation this is worse: it validates green at author time and then
|
|
10
|
+
* SILENTLY never fires on a live pod.
|
|
11
|
+
*
|
|
12
|
+
* `validateFlowDefinition` closes that gap: a PURE, synchronous, DB-free function
|
|
13
|
+
* that rejects malformed flows with actionable per-node errors. It is called by
|
|
14
|
+
* `validateTemplate` (this package) so `synap market validate` / the build gate /
|
|
15
|
+
* the CP publish door all run the same contract check, and by the pod's persist
|
|
16
|
+
* doors.
|
|
17
|
+
*
|
|
18
|
+
* ── SSOT-IN-WAITING — temporary duplicate ────────────────────────────────────
|
|
19
|
+
* This is a PURE copy of synap-backend's
|
|
20
|
+
* `packages/api/src/services/automations/validate-flow.ts` (the current runtime
|
|
21
|
+
* SSOT, which carries the `@synap/database` COMPILE-TIME drift guard). This copy
|
|
22
|
+
* exists so `validateTemplate` — which runs in THIS published, backend-independent
|
|
23
|
+
* package (CLI `synap market validate` / build gate / CP publish) — can catch
|
|
24
|
+
* broken automation flows at AUTHOR time. It is intentionally free of any
|
|
25
|
+
* `@synap/database` import (this package depends only on zod + js-yaml), so the
|
|
26
|
+
* compile-time drift guard cannot live here; it stays in backend.
|
|
27
|
+
*
|
|
28
|
+
* The two copies are kept byte-identical (logic) by `validate-flow.drift.test.ts`
|
|
29
|
+
* in this package, which reads BOTH files and fails if they diverge.
|
|
30
|
+
*
|
|
31
|
+
* CUTOVER (collapse to ONE SSOT): publish `@synap-core/workspace-templates`, bump
|
|
32
|
+
* synap-backend (`^0.7.1`) and CP (`^0.9.2`) deps to that version, then replace
|
|
33
|
+
* backend's validate-flow.ts body with a re-export FROM HERE (keeping backend's
|
|
34
|
+
* `AssertExact` guard binding these arrays to the `@synap/database` unions).
|
|
35
|
+
* Until then: keep the constant arrays below identical to `@synap/database`
|
|
36
|
+
* `schema/automations.ts`.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ── Contract constants (mirror @synap/database schema/automations.ts) ─────────
|
|
40
|
+
|
|
41
|
+
/** The 21 node types — `AutomationNodeBase.type` union (schema/automations.ts). */
|
|
42
|
+
export const FLOW_NODE_TYPES = [
|
|
43
|
+
"trigger",
|
|
44
|
+
"command",
|
|
45
|
+
"condition",
|
|
46
|
+
"delay",
|
|
47
|
+
"output",
|
|
48
|
+
"loop",
|
|
49
|
+
"transform",
|
|
50
|
+
"fetch",
|
|
51
|
+
"query",
|
|
52
|
+
"entity_read",
|
|
53
|
+
"related_entities",
|
|
54
|
+
"guard",
|
|
55
|
+
"compute",
|
|
56
|
+
"select",
|
|
57
|
+
"claim",
|
|
58
|
+
"messages_query",
|
|
59
|
+
"switch",
|
|
60
|
+
"skill",
|
|
61
|
+
"capability",
|
|
62
|
+
"sub_automation",
|
|
63
|
+
"playbook_run",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
/** The 11 output types — `OutputNodeDef.data.outputType` (schema/automations.ts). */
|
|
67
|
+
export const FLOW_OUTPUT_TYPES = [
|
|
68
|
+
"notification",
|
|
69
|
+
"entity_create",
|
|
70
|
+
"entity_update",
|
|
71
|
+
"facet_attach",
|
|
72
|
+
"facet_update",
|
|
73
|
+
"facet_detach",
|
|
74
|
+
"relation_create",
|
|
75
|
+
"webhook",
|
|
76
|
+
"channel_message",
|
|
77
|
+
"session_update",
|
|
78
|
+
"set_state",
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The known `config.channelType` values for a `channel_message` output. Mirrors
|
|
83
|
+
* the executor's channel_message resolution (automation-executor.ts). The output
|
|
84
|
+
* channel is CONTEXT-DERIVED — a channel_message is valid with `channelId`, OR
|
|
85
|
+
* `channelEntityRef` (an entity-id expression → that entity's internal channel),
|
|
86
|
+
* OR a `channelType` from this set, OR NOTHING (the executor defaults a targetless
|
|
87
|
+
* channel_message to the automation's own run channel). A `channelType` that is
|
|
88
|
+
* PRESENT but not one of these is still a typo → still an error.
|
|
89
|
+
*/
|
|
90
|
+
export const CHANNEL_MESSAGE_CHANNEL_TYPES = [
|
|
91
|
+
"personal_thread",
|
|
92
|
+
"proactive",
|
|
93
|
+
"subjectEntity",
|
|
94
|
+
] as const;
|
|
95
|
+
|
|
96
|
+
const NODE_TYPE_SET = new Set<string>(FLOW_NODE_TYPES);
|
|
97
|
+
const OUTPUT_TYPE_SET = new Set<string>(FLOW_OUTPUT_TYPES);
|
|
98
|
+
const CHANNEL_TYPE_SET = new Set<string>(CHANNEL_MESSAGE_CHANNEL_TYPES);
|
|
99
|
+
|
|
100
|
+
// ── Result / error types ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export interface FlowValidationError {
|
|
103
|
+
/** Node the error is about (absent for graph-level / edge-level errors). */
|
|
104
|
+
nodeId?: string;
|
|
105
|
+
/** Edge the error is about (for dangling-reference / bad-handle errors). */
|
|
106
|
+
edgeId?: string;
|
|
107
|
+
/** Stable machine code — see the `code` literals below. */
|
|
108
|
+
code: string;
|
|
109
|
+
/** Human-actionable message naming what is wrong and how to fix it. */
|
|
110
|
+
message: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface FlowValidationResult {
|
|
114
|
+
valid: boolean;
|
|
115
|
+
errors: FlowValidationError[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Optional catalog resolvers. When provided, existence checks run; when omitted,
|
|
120
|
+
* those checks are SKIPPED (structural + contract checks always run). Kept
|
|
121
|
+
* synchronous so the whole function stays pure — the caller pre-loads whatever
|
|
122
|
+
* it needs and passes predicates.
|
|
123
|
+
*/
|
|
124
|
+
export interface FlowValidationResolvers {
|
|
125
|
+
/** Does this capability verb (skill name) exist in the workspace catalog? */
|
|
126
|
+
verbExists?: (verbId: string) => boolean;
|
|
127
|
+
/** Does the referenced skill exist (by id OR by name)? */
|
|
128
|
+
skillExists?: (ref: { skillId?: string; skillName?: string }) => boolean;
|
|
129
|
+
/** Does the referenced playbook exist? */
|
|
130
|
+
playbookExists?: (ref: {
|
|
131
|
+
playbookId?: string;
|
|
132
|
+
playbookName?: string;
|
|
133
|
+
}) => boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Loose input shapes (the flow arrives as untyped JSON at the door) ──────────
|
|
137
|
+
|
|
138
|
+
interface LooseNode {
|
|
139
|
+
id?: unknown;
|
|
140
|
+
type?: unknown;
|
|
141
|
+
data?: unknown;
|
|
142
|
+
}
|
|
143
|
+
interface LooseEdge {
|
|
144
|
+
id?: unknown;
|
|
145
|
+
source?: unknown;
|
|
146
|
+
target?: unknown;
|
|
147
|
+
sourceHandle?: unknown;
|
|
148
|
+
}
|
|
149
|
+
interface LooseFlow {
|
|
150
|
+
nodes?: unknown;
|
|
151
|
+
edges?: unknown;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
155
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
156
|
+
}
|
|
157
|
+
function nonEmptyString(v: unknown): v is string {
|
|
158
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── The validator ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Validate an automation flow definition against the node contract.
|
|
165
|
+
* Pure and synchronous. `resolvers` gate the catalog-existence checks only.
|
|
166
|
+
*/
|
|
167
|
+
export function validateFlowDefinition(
|
|
168
|
+
flow: unknown,
|
|
169
|
+
resolvers?: FlowValidationResolvers
|
|
170
|
+
): FlowValidationResult {
|
|
171
|
+
const errors: FlowValidationError[] = [];
|
|
172
|
+
|
|
173
|
+
// ── Shape ───────────────────────────────────────────────────────────────────
|
|
174
|
+
if (!isRecord(flow)) {
|
|
175
|
+
return {
|
|
176
|
+
valid: false,
|
|
177
|
+
errors: [
|
|
178
|
+
{
|
|
179
|
+
code: "flow_not_object",
|
|
180
|
+
message:
|
|
181
|
+
"flowDefinition must be an object with `nodes` and `edges` arrays.",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const f = flow as LooseFlow;
|
|
187
|
+
if (!Array.isArray(f.nodes)) {
|
|
188
|
+
errors.push({
|
|
189
|
+
code: "nodes_not_array",
|
|
190
|
+
message: "flowDefinition.nodes must be an array.",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (!Array.isArray(f.edges)) {
|
|
194
|
+
errors.push({
|
|
195
|
+
code: "edges_not_array",
|
|
196
|
+
message: "flowDefinition.edges must be an array.",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// Can't do node/edge checks without the arrays.
|
|
200
|
+
if (errors.length > 0) return { valid: false, errors };
|
|
201
|
+
|
|
202
|
+
const nodes = (f.nodes as unknown[]).map((n) =>
|
|
203
|
+
isRecord(n) ? n : {}
|
|
204
|
+
) as LooseNode[];
|
|
205
|
+
const edges = (f.edges as unknown[]).map((e) =>
|
|
206
|
+
isRecord(e) ? e : {}
|
|
207
|
+
) as LooseEdge[];
|
|
208
|
+
|
|
209
|
+
// ── Node ids: present, string, unique ─────────────────────────────────────────
|
|
210
|
+
const nodeIds = new Set<string>();
|
|
211
|
+
const seenIds = new Set<string>();
|
|
212
|
+
nodes.forEach((node, idx) => {
|
|
213
|
+
if (!nonEmptyString(node.id)) {
|
|
214
|
+
errors.push({
|
|
215
|
+
code: "node_missing_id",
|
|
216
|
+
message: `Node at index ${idx} is missing a non-empty string \`id\`.`,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (seenIds.has(node.id)) {
|
|
221
|
+
errors.push({
|
|
222
|
+
nodeId: node.id,
|
|
223
|
+
code: "duplicate_node_id",
|
|
224
|
+
message: `Duplicate node id "${node.id}" — every node id must be unique.`,
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
seenIds.add(node.id);
|
|
229
|
+
nodeIds.add(node.id);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── Per-node type + contract ──────────────────────────────────────────────────
|
|
233
|
+
const nodesById = new Map<string, LooseNode>();
|
|
234
|
+
for (const node of nodes) {
|
|
235
|
+
if (nonEmptyString(node.id)) nodesById.set(node.id, node);
|
|
236
|
+
|
|
237
|
+
const nodeId = nonEmptyString(node.id) ? node.id : undefined;
|
|
238
|
+
|
|
239
|
+
// node.type ∈ the union
|
|
240
|
+
if (!nonEmptyString(node.type) || !NODE_TYPE_SET.has(node.type)) {
|
|
241
|
+
errors.push({
|
|
242
|
+
nodeId,
|
|
243
|
+
code: "unknown_node_type",
|
|
244
|
+
message: `Node ${nodeId ? `"${nodeId}"` : `at index ?`} has ${
|
|
245
|
+
nonEmptyString(node.type) ? `unknown type "${node.type}"` : "no type"
|
|
246
|
+
}. Must be one of: ${FLOW_NODE_TYPES.join(", ")}.`,
|
|
247
|
+
});
|
|
248
|
+
continue; // contract checks below are type-specific
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const type = node.type;
|
|
252
|
+
const data = isRecord(node.data) ? node.data : {};
|
|
253
|
+
|
|
254
|
+
switch (type) {
|
|
255
|
+
case "capability": {
|
|
256
|
+
if (!nonEmptyString(data.verbId)) {
|
|
257
|
+
errors.push({
|
|
258
|
+
nodeId,
|
|
259
|
+
code: "capability_missing_verbId",
|
|
260
|
+
message: `Capability node "${nodeId}" has no \`verbId\`. Pick a verb on the selected tool.`,
|
|
261
|
+
});
|
|
262
|
+
} else if (
|
|
263
|
+
resolvers?.verbExists &&
|
|
264
|
+
!resolvers.verbExists(data.verbId)
|
|
265
|
+
) {
|
|
266
|
+
errors.push({
|
|
267
|
+
nodeId,
|
|
268
|
+
code: "capability_unknown_verbId",
|
|
269
|
+
message: `Capability node "${nodeId}" references verb "${data.verbId}", which is not in the capability catalog.`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case "skill": {
|
|
276
|
+
// Accept `skillId` OR `skillName` — the template-friendly form (mirrors
|
|
277
|
+
// playbook_run's id-OR-name): a capability seeds a skill + an automation
|
|
278
|
+
// that references it by its stable name, since the runtime id isn't known
|
|
279
|
+
// at author time. The create door resolves skillName → skillId pre-insert.
|
|
280
|
+
const skillId = nonEmptyString(data.skillId) ? data.skillId : undefined;
|
|
281
|
+
const skillName = nonEmptyString(data.skillName)
|
|
282
|
+
? data.skillName
|
|
283
|
+
: undefined;
|
|
284
|
+
if (!skillId && !skillName) {
|
|
285
|
+
errors.push({
|
|
286
|
+
nodeId,
|
|
287
|
+
code: "skill_missing_ref",
|
|
288
|
+
message: `Skill node "${nodeId}" has neither \`skillId\` nor \`skillName\`. One is required.`,
|
|
289
|
+
});
|
|
290
|
+
} else if (
|
|
291
|
+
resolvers?.skillExists &&
|
|
292
|
+
!resolvers.skillExists({ skillId, skillName })
|
|
293
|
+
) {
|
|
294
|
+
errors.push({
|
|
295
|
+
nodeId,
|
|
296
|
+
code: "skill_unknown_ref",
|
|
297
|
+
message: `Skill node "${nodeId}" references a skill (${
|
|
298
|
+
skillId ? `id "${skillId}"` : `name "${skillName}"`
|
|
299
|
+
}) that does not exist.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case "playbook_run": {
|
|
306
|
+
const playbookId = nonEmptyString(data.playbookId)
|
|
307
|
+
? data.playbookId
|
|
308
|
+
: undefined;
|
|
309
|
+
const playbookName = nonEmptyString(data.playbookName)
|
|
310
|
+
? data.playbookName
|
|
311
|
+
: undefined;
|
|
312
|
+
if (!playbookId && !playbookName) {
|
|
313
|
+
errors.push({
|
|
314
|
+
nodeId,
|
|
315
|
+
code: "playbook_run_missing_ref",
|
|
316
|
+
message: `Playbook-run node "${nodeId}" has neither \`playbookId\` nor \`playbookName\`. One is required.`,
|
|
317
|
+
});
|
|
318
|
+
} else if (
|
|
319
|
+
resolvers?.playbookExists &&
|
|
320
|
+
!resolvers.playbookExists({ playbookId, playbookName })
|
|
321
|
+
) {
|
|
322
|
+
errors.push({
|
|
323
|
+
nodeId,
|
|
324
|
+
code: "playbook_run_unknown_ref",
|
|
325
|
+
message: `Playbook-run node "${nodeId}" references a playbook (${
|
|
326
|
+
playbookId ? `id "${playbookId}"` : `name "${playbookName}"`
|
|
327
|
+
}) that does not exist.`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case "output": {
|
|
334
|
+
const outputType = data.outputType;
|
|
335
|
+
if (!nonEmptyString(outputType) || !OUTPUT_TYPE_SET.has(outputType)) {
|
|
336
|
+
errors.push({
|
|
337
|
+
nodeId,
|
|
338
|
+
code: "output_bad_outputType",
|
|
339
|
+
message: `Output node "${nodeId}" has ${
|
|
340
|
+
nonEmptyString(outputType)
|
|
341
|
+
? `unknown outputType "${outputType}"`
|
|
342
|
+
: "no outputType"
|
|
343
|
+
}. Must be one of: ${FLOW_OUTPUT_TYPES.join(", ")}.`,
|
|
344
|
+
});
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
// channel_message target is CONTEXT-DERIVED: valid with channelId,
|
|
348
|
+
// channelEntityRef, a known channelType, OR nothing (executor defaults a
|
|
349
|
+
// targetless channel_message to the automation's own run channel). Only a
|
|
350
|
+
// channelType that is PRESENT but unknown is an error (a typo'd type).
|
|
351
|
+
if (outputType === "channel_message") {
|
|
352
|
+
const config = isRecord(data.config) ? data.config : {};
|
|
353
|
+
const channelType = config.channelType;
|
|
354
|
+
if (channelType !== undefined && channelType !== null) {
|
|
355
|
+
if (
|
|
356
|
+
!nonEmptyString(channelType) ||
|
|
357
|
+
!CHANNEL_TYPE_SET.has(channelType)
|
|
358
|
+
) {
|
|
359
|
+
errors.push({
|
|
360
|
+
nodeId,
|
|
361
|
+
code: "channel_message_unknown_channelType",
|
|
362
|
+
message: `Channel-message output "${nodeId}" has unknown channelType ${
|
|
363
|
+
nonEmptyString(channelType)
|
|
364
|
+
? `"${channelType}"`
|
|
365
|
+
: JSON.stringify(channelType)
|
|
366
|
+
}. Use one of: ${CHANNEL_MESSAGE_CHANNEL_TYPES.join(
|
|
367
|
+
", "
|
|
368
|
+
)}, or set \`config.channelEntityRef\`/\`config.channelId\`, or omit the target to post to the automation's own run channel.`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case "loop": {
|
|
377
|
+
if (!nonEmptyString(data.iteratorExpression)) {
|
|
378
|
+
errors.push({
|
|
379
|
+
nodeId,
|
|
380
|
+
code: "loop_missing_iterator",
|
|
381
|
+
message: `Loop node "${nodeId}" has no \`iteratorExpression\` (the items to iterate, e.g. "steps.query.output.entities").`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case "condition": {
|
|
388
|
+
if (!nonEmptyString(data.expression)) {
|
|
389
|
+
errors.push({
|
|
390
|
+
nodeId,
|
|
391
|
+
code: "condition_missing_expression",
|
|
392
|
+
message: `Condition node "${nodeId}" has no \`expression\` (the predicate to evaluate).`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
case "switch": {
|
|
399
|
+
if (!nonEmptyString(data.expression)) {
|
|
400
|
+
errors.push({
|
|
401
|
+
nodeId,
|
|
402
|
+
code: "switch_missing_expression",
|
|
403
|
+
message: `Switch node "${nodeId}" has no \`expression\` (the value to match against cases).`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (!Array.isArray(data.cases) || data.cases.length === 0) {
|
|
407
|
+
errors.push({
|
|
408
|
+
nodeId,
|
|
409
|
+
code: "switch_missing_cases",
|
|
410
|
+
message: `Switch node "${nodeId}" has no \`cases\`. Add at least one case with a \`value\`.`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Edges: source/target reference existing nodes ─────────────────────────────
|
|
419
|
+
edges.forEach((edge, idx) => {
|
|
420
|
+
const edgeId = nonEmptyString(edge.id) ? edge.id : undefined;
|
|
421
|
+
const label = edgeId ? `"${edgeId}"` : `at index ${idx}`;
|
|
422
|
+
if (!nonEmptyString(edge.source) || !nodeIds.has(edge.source)) {
|
|
423
|
+
errors.push({
|
|
424
|
+
edgeId,
|
|
425
|
+
code: "edge_bad_source",
|
|
426
|
+
message: `Edge ${label} has ${
|
|
427
|
+
nonEmptyString(edge.source)
|
|
428
|
+
? `source "${edge.source}" which is not a node in this flow`
|
|
429
|
+
: "no source node"
|
|
430
|
+
}.`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (!nonEmptyString(edge.target) || !nodeIds.has(edge.target)) {
|
|
434
|
+
errors.push({
|
|
435
|
+
edgeId,
|
|
436
|
+
code: "edge_bad_target",
|
|
437
|
+
message: `Edge ${label} has ${
|
|
438
|
+
nonEmptyString(edge.target)
|
|
439
|
+
? `target "${edge.target}" which is not a node in this flow`
|
|
440
|
+
: "no target node"
|
|
441
|
+
}.`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ── Edge handles: condition → yes/no, switch → a declared case value ──────────
|
|
447
|
+
for (const edge of edges) {
|
|
448
|
+
if (!nonEmptyString(edge.source) || !nodesById.has(edge.source)) continue;
|
|
449
|
+
if (edge.sourceHandle === undefined || edge.sourceHandle === null) continue;
|
|
450
|
+
if (!nonEmptyString(edge.sourceHandle)) continue;
|
|
451
|
+
const src = nodesById.get(edge.source)!;
|
|
452
|
+
const edgeId = nonEmptyString(edge.id) ? edge.id : undefined;
|
|
453
|
+
if (src.type === "condition") {
|
|
454
|
+
if (edge.sourceHandle !== "yes" && edge.sourceHandle !== "no") {
|
|
455
|
+
errors.push({
|
|
456
|
+
edgeId,
|
|
457
|
+
nodeId: edge.source,
|
|
458
|
+
code: "condition_bad_handle",
|
|
459
|
+
message: `Edge from condition "${edge.source}" has sourceHandle "${edge.sourceHandle}" — must be "yes" or "no".`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
} else if (src.type === "switch") {
|
|
463
|
+
const data = isRecord(src.data) ? src.data : {};
|
|
464
|
+
const caseValues = Array.isArray(data.cases)
|
|
465
|
+
? data.cases
|
|
466
|
+
.map((c) => (isRecord(c) ? c.value : undefined))
|
|
467
|
+
.filter((v): v is string => typeof v === "string")
|
|
468
|
+
: [];
|
|
469
|
+
if (!caseValues.includes(edge.sourceHandle)) {
|
|
470
|
+
errors.push({
|
|
471
|
+
edgeId,
|
|
472
|
+
nodeId: edge.source,
|
|
473
|
+
code: "switch_bad_handle",
|
|
474
|
+
message: `Edge from switch "${edge.source}" has sourceHandle "${edge.sourceHandle}", which matches no declared case value (${
|
|
475
|
+
caseValues.length ? caseValues.join(", ") : "none declared"
|
|
476
|
+
}).`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Acyclic (Kahn's algorithm — mirrors topoSort in automation-executor.ts) ───
|
|
483
|
+
// Only run when edges reference real nodes; dangling edges are already reported
|
|
484
|
+
// and would corrupt the in-degree bookkeeping.
|
|
485
|
+
const cyclic = detectCycle(nodeIds, edges);
|
|
486
|
+
if (cyclic.length > 0) {
|
|
487
|
+
errors.push({
|
|
488
|
+
code: "flow_has_cycle",
|
|
489
|
+
message: `Flow has a cycle — these nodes cannot be ordered and would never run: ${cyclic.join(
|
|
490
|
+
", "
|
|
491
|
+
)}. Remove the back-edge(s) so the graph is acyclic.`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return { valid: errors.length === 0, errors };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Return the set of node ids that participate in a cycle (empty if acyclic).
|
|
500
|
+
* Kahn's algorithm — the same ordering the executor's `topoSort` relies on; a
|
|
501
|
+
* cycle is exactly the nodes whose in-degree never reaches zero. Edges whose
|
|
502
|
+
* endpoints aren't real nodes are ignored (those are reported separately).
|
|
503
|
+
*/
|
|
504
|
+
function detectCycle(nodeIds: Set<string>, edges: LooseEdge[]): string[] {
|
|
505
|
+
const inDegree = new Map<string, number>();
|
|
506
|
+
const adjacency = new Map<string, string[]>();
|
|
507
|
+
for (const id of nodeIds) {
|
|
508
|
+
inDegree.set(id, 0);
|
|
509
|
+
adjacency.set(id, []);
|
|
510
|
+
}
|
|
511
|
+
for (const edge of edges) {
|
|
512
|
+
const source = nonEmptyString(edge.source) ? edge.source : undefined;
|
|
513
|
+
const target = nonEmptyString(edge.target) ? edge.target : undefined;
|
|
514
|
+
if (!source || !target) continue;
|
|
515
|
+
if (!nodeIds.has(source) || !nodeIds.has(target)) continue;
|
|
516
|
+
adjacency.get(source)!.push(target);
|
|
517
|
+
inDegree.set(target, (inDegree.get(target) ?? 0) + 1);
|
|
518
|
+
}
|
|
519
|
+
const queue: string[] = [];
|
|
520
|
+
for (const [id, degree] of inDegree) if (degree === 0) queue.push(id);
|
|
521
|
+
let visited = 0;
|
|
522
|
+
while (queue.length > 0) {
|
|
523
|
+
const id = queue.shift()!;
|
|
524
|
+
visited++;
|
|
525
|
+
for (const target of adjacency.get(id) ?? []) {
|
|
526
|
+
const next = (inDegree.get(target) ?? 1) - 1;
|
|
527
|
+
inDegree.set(target, next);
|
|
528
|
+
if (next === 0) queue.push(target);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (visited === nodeIds.size) return [];
|
|
532
|
+
// The remaining nodes (in-degree > 0 after Kahn drain) are the cycle members.
|
|
533
|
+
const cyclic: string[] = [];
|
|
534
|
+
for (const [id, degree] of inDegree) if (degree > 0) cyclic.push(id);
|
|
535
|
+
return cyclic;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Convenience helper for the persist doors: run the validator and, if invalid,
|
|
540
|
+
* return a single joined actionable message (else null). Keeps the door code a
|
|
541
|
+
* one-liner and the message format consistent across create/update.
|
|
542
|
+
*/
|
|
543
|
+
export function flowValidationErrorMessage(
|
|
544
|
+
flow: unknown,
|
|
545
|
+
resolvers?: FlowValidationResolvers
|
|
546
|
+
): string | null {
|
|
547
|
+
const result = validateFlowDefinition(flow, resolvers);
|
|
548
|
+
if (result.valid) return null;
|
|
549
|
+
return (
|
|
550
|
+
"Invalid automation flow: " +
|
|
551
|
+
result.errors
|
|
552
|
+
.map((e) => (e.nodeId ? `[${e.nodeId}] ${e.message}` : e.message))
|
|
553
|
+
.join(" ")
|
|
554
|
+
);
|
|
555
|
+
}
|
package/src/validate.test.ts
CHANGED
|
@@ -79,3 +79,124 @@ describe("validateTemplate — clean template", () => {
|
|
|
79
79
|
expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
// Regression: before wiring `validateFlowDefinition` into validateTemplate, a
|
|
84
|
+
// template automation with a broken FLOW (bogus node type / dangling edge / cycle
|
|
85
|
+
// / typo'd channelType) passed the template gate green and then silently never
|
|
86
|
+
// fired on a live pod — the flow contract only ran server-side at install.
|
|
87
|
+
describe("validateTemplate — automation flow node contract", () => {
|
|
88
|
+
const withAutomation = (flow: unknown): WorkspaceYaml =>
|
|
89
|
+
({
|
|
90
|
+
meta: {
|
|
91
|
+
slug: "flowy",
|
|
92
|
+
name: "Flowy",
|
|
93
|
+
description: "d",
|
|
94
|
+
icon: "x",
|
|
95
|
+
color: "#000000",
|
|
96
|
+
},
|
|
97
|
+
workspace: { name: "Flowy", description: "d" },
|
|
98
|
+
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
99
|
+
automations: [
|
|
100
|
+
{ name: "on-create", triggerType: "event", triggerConfig: {}, flow },
|
|
101
|
+
],
|
|
102
|
+
}) as unknown as WorkspaceYaml;
|
|
103
|
+
|
|
104
|
+
it("FAILS on an unknown node type (was green before)", () => {
|
|
105
|
+
const result = validateTemplate(
|
|
106
|
+
withAutomation({
|
|
107
|
+
nodes: [
|
|
108
|
+
{ id: "t1", type: "trigger", data: {} },
|
|
109
|
+
{ id: "x1", type: "not_a_real_node", data: {} },
|
|
110
|
+
],
|
|
111
|
+
edges: [{ id: "e1", source: "t1", target: "x1" }],
|
|
112
|
+
}),
|
|
113
|
+
{}
|
|
114
|
+
);
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
const err = result.errors.find(
|
|
117
|
+
(e) => e.rule === "automation-flow-unknown_node_type"
|
|
118
|
+
);
|
|
119
|
+
expect(err, JSON.stringify(result.errors)).toBeDefined();
|
|
120
|
+
expect(err!.message).toContain("not_a_real_node");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("FAILS on a dangling edge (target references no node)", () => {
|
|
124
|
+
const result = validateTemplate(
|
|
125
|
+
withAutomation({
|
|
126
|
+
nodes: [{ id: "t1", type: "trigger", data: {} }],
|
|
127
|
+
edges: [{ id: "e1", source: "t1", target: "ghost" }],
|
|
128
|
+
}),
|
|
129
|
+
{}
|
|
130
|
+
);
|
|
131
|
+
expect(result.ok).toBe(false);
|
|
132
|
+
const err = result.errors.find(
|
|
133
|
+
(e) => e.rule === "automation-flow-edge_bad_target"
|
|
134
|
+
);
|
|
135
|
+
expect(err, JSON.stringify(result.errors)).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("FAILS on a capability node with no verbId", () => {
|
|
139
|
+
const result = validateTemplate(
|
|
140
|
+
withAutomation({
|
|
141
|
+
nodes: [
|
|
142
|
+
{ id: "t1", type: "trigger", data: {} },
|
|
143
|
+
{ id: "c1", type: "capability", data: {} },
|
|
144
|
+
],
|
|
145
|
+
edges: [{ id: "e1", source: "t1", target: "c1" }],
|
|
146
|
+
}),
|
|
147
|
+
{}
|
|
148
|
+
);
|
|
149
|
+
expect(result.ok).toBe(false);
|
|
150
|
+
expect(
|
|
151
|
+
result.errors.find(
|
|
152
|
+
(e) => e.rule === "automation-flow-capability_missing_verbId"
|
|
153
|
+
),
|
|
154
|
+
JSON.stringify(result.errors)
|
|
155
|
+
).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("FAILS on a cycle", () => {
|
|
159
|
+
const result = validateTemplate(
|
|
160
|
+
withAutomation({
|
|
161
|
+
nodes: [
|
|
162
|
+
{ id: "a", type: "transform", data: { expression: "{{x}}" } },
|
|
163
|
+
{ id: "b", type: "transform", data: { expression: "{{y}}" } },
|
|
164
|
+
],
|
|
165
|
+
edges: [
|
|
166
|
+
{ id: "e1", source: "a", target: "b" },
|
|
167
|
+
{ id: "e2", source: "b", target: "a" },
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
{}
|
|
171
|
+
);
|
|
172
|
+
expect(result.ok).toBe(false);
|
|
173
|
+
expect(
|
|
174
|
+
result.errors.find((e) => e.rule === "automation-flow-flow_has_cycle"),
|
|
175
|
+
JSON.stringify(result.errors)
|
|
176
|
+
).toBeDefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("PASSES a well-formed flow (trigger → notification output)", () => {
|
|
180
|
+
const result = validateTemplate(
|
|
181
|
+
withAutomation({
|
|
182
|
+
nodes: [
|
|
183
|
+
{ id: "t1", type: "trigger", data: { triggerType: "event" } },
|
|
184
|
+
{
|
|
185
|
+
id: "o1",
|
|
186
|
+
type: "output",
|
|
187
|
+
data: {
|
|
188
|
+
outputType: "notification",
|
|
189
|
+
config: { title: "Hi", body: "{{trigger.payload.data.title}}" },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
edges: [{ id: "e1", source: "t1", target: "o1" }],
|
|
194
|
+
}),
|
|
195
|
+
{}
|
|
196
|
+
);
|
|
197
|
+
expect(result, JSON.stringify(result.errors)).toEqual({
|
|
198
|
+
ok: true,
|
|
199
|
+
errors: [],
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|