@synap-core/workspace-templates 0.9.2 → 0.9.4
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 +202 -31
- package/dist/index.js +782 -19
- 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 +94 -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 +186 -8
- package/src/types.ts +111 -32
- package/src/validate-flow.drift.test.ts +83 -0
- package/src/validate-flow.ts +555 -0
- package/src/validate-onboarding.test.ts +88 -0
- package/src/validate.test.ts +121 -0
- package/src/validate.ts +247 -12
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { validateTemplate } from "./validate.js";
|
|
3
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
4
|
+
|
|
5
|
+
function templateWithOnboarding(onboarding: unknown): WorkspaceYaml {
|
|
6
|
+
return {
|
|
7
|
+
meta: {
|
|
8
|
+
slug: "onboarding-validation",
|
|
9
|
+
name: "Onboarding validation",
|
|
10
|
+
description: "Validation fixture",
|
|
11
|
+
icon: "circle",
|
|
12
|
+
color: "blue",
|
|
13
|
+
},
|
|
14
|
+
workspace: {
|
|
15
|
+
name: "Onboarding validation",
|
|
16
|
+
description: "Validation fixture",
|
|
17
|
+
},
|
|
18
|
+
profiles: [
|
|
19
|
+
{
|
|
20
|
+
slug: "record",
|
|
21
|
+
displayName: "Record",
|
|
22
|
+
properties: [],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
onboarding,
|
|
26
|
+
} as WorkspaceYaml;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("validateTemplate onboarding contract", () => {
|
|
30
|
+
it("accepts a complete domain recipe", () => {
|
|
31
|
+
const result = validateTemplate(
|
|
32
|
+
templateWithOnboarding({
|
|
33
|
+
goal: "Create the first useful record.",
|
|
34
|
+
framing: "Act as a domain expert.",
|
|
35
|
+
expertise: {
|
|
36
|
+
starters: ["Begin with the live work."],
|
|
37
|
+
blindSpots: ["Do not collect records without an owner."],
|
|
38
|
+
bar: "Every record has a clear next step.",
|
|
39
|
+
},
|
|
40
|
+
collect: [
|
|
41
|
+
{
|
|
42
|
+
profileSlug: "record",
|
|
43
|
+
what: "The records currently in motion.",
|
|
44
|
+
cardinality: "few",
|
|
45
|
+
keyFields: ["title"],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
openingQuestions: ["What is currently in motion?"],
|
|
49
|
+
doneWhen: "At least one useful record exists.",
|
|
50
|
+
}),
|
|
51
|
+
{}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(result, JSON.stringify(result.errors)).toEqual({
|
|
55
|
+
ok: true,
|
|
56
|
+
errors: [],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects empty expertise prose and collection instructions", () => {
|
|
61
|
+
const result = validateTemplate(
|
|
62
|
+
templateWithOnboarding({
|
|
63
|
+
goal: "Set up this workspace.",
|
|
64
|
+
framing: "Act as a domain expert.",
|
|
65
|
+
expertise: {
|
|
66
|
+
starters: [""],
|
|
67
|
+
blindSpots: [" "],
|
|
68
|
+
bar: "",
|
|
69
|
+
},
|
|
70
|
+
collect: [{ profileSlug: "record", what: "", keyFields: [""] }],
|
|
71
|
+
openingQuestions: [""],
|
|
72
|
+
doneWhen: "",
|
|
73
|
+
}),
|
|
74
|
+
{}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(result.ok).toBe(false);
|
|
78
|
+
expect(result.errors.map((error) => error.rule)).toEqual(
|
|
79
|
+
expect.arrayContaining([
|
|
80
|
+
"onboarding-expertise-invalid",
|
|
81
|
+
"onboarding-collect-what-empty",
|
|
82
|
+
"onboarding-collect-key-fields-invalid",
|
|
83
|
+
"onboarding-opening-questions-invalid",
|
|
84
|
+
"onboarding-done-when-invalid",
|
|
85
|
+
])
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
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
|
+
});
|
package/src/validate.ts
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* • `scripts/check-converters.mjs` — injects the real converters, exits non-zero.
|
|
15
15
|
* • CP / pod / CLI (from dist) — import `validateTemplate` + `SYSTEM_PROFILES`.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* Node's type-stripping BEFORE `tsup` builds `dist/`. A static
|
|
17
|
+
* NO RUNTIME IMPORT THAT FAILS AT STRIP-TIME. `build.mjs` loads this file
|
|
18
|
+
* directly under Node's type-stripping BEFORE `tsup` builds `dist/`. A static
|
|
19
19
|
* `import { toWorkspaceDefinition } from "./define.js"` would fail to resolve at
|
|
20
20
|
* that moment (define → templates.js does not exist pre-build, and Node's
|
|
21
21
|
* type-stripper does not remap a `.js` specifier to its `.ts` source). So the
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* YAML/bento rules run everywhere; the converter-parity rules run wherever the
|
|
24
24
|
* caller can supply the compiled converters (check-converters.mjs, from dist).
|
|
25
25
|
* `import type` lines are fully erased by the type-stripper, so they are safe.
|
|
26
|
+
*
|
|
27
|
+
* THE ONE RUNTIME IMPORT: `./validate-flow.ts` (the pure automation-flow
|
|
28
|
+
* validator). It is imported with the `.ts` extension SPECIFICALLY so it resolves
|
|
29
|
+
* under Node's type-stripper at build.mjs time (a `.js` specifier would NOT — see
|
|
30
|
+
* above), and it has NO generated dependency (unlike `./define.js` → templates)
|
|
31
|
+
* so it is always present. It is DB-free (only `import type` internally), so it
|
|
32
|
+
* needs no injection: the flow-node contract check runs for every caller
|
|
33
|
+
* (build / check-converters / CLI / CP) automatically.
|
|
26
34
|
*/
|
|
27
35
|
|
|
28
36
|
import type {
|
|
@@ -31,6 +39,9 @@ import type {
|
|
|
31
39
|
TemplateProfile,
|
|
32
40
|
} from "./types.js";
|
|
33
41
|
|
|
42
|
+
// eslint-disable-next-line import/extensions -- .ts extension is intentional; see header.
|
|
43
|
+
import { validateFlowDefinition } from "./validate-flow.ts";
|
|
44
|
+
|
|
34
45
|
// ── Result shape ─────────────────────────────────────────────────────────────
|
|
35
46
|
|
|
36
47
|
export interface ValidationError {
|
|
@@ -263,7 +274,8 @@ export function validateTemplate(
|
|
|
263
274
|
if (!tpl.meta) add("meta-required", "meta", "Missing required field: meta");
|
|
264
275
|
else {
|
|
265
276
|
for (const f of REQUIRED_META_FIELDS) {
|
|
266
|
-
if (!tpl.meta[f])
|
|
277
|
+
if (!tpl.meta[f])
|
|
278
|
+
add("meta-field-required", `meta.${f}`, `meta.${f} is required`);
|
|
267
279
|
}
|
|
268
280
|
}
|
|
269
281
|
if (!tpl.workspace)
|
|
@@ -271,12 +283,88 @@ export function validateTemplate(
|
|
|
271
283
|
else {
|
|
272
284
|
for (const f of REQUIRED_WORKSPACE_FIELDS) {
|
|
273
285
|
if (!tpl.workspace[f])
|
|
274
|
-
add(
|
|
286
|
+
add(
|
|
287
|
+
"workspace-field-required",
|
|
288
|
+
`workspace.${f}`,
|
|
289
|
+
`workspace.${f} is required`
|
|
290
|
+
);
|
|
275
291
|
}
|
|
276
292
|
}
|
|
277
293
|
if (!tpl.profiles || tpl.profiles.length === 0) {
|
|
278
294
|
add("profiles-required", "profiles", "At least one profile is required");
|
|
279
295
|
}
|
|
296
|
+
if (
|
|
297
|
+
tpl.app &&
|
|
298
|
+
tpl.layout &&
|
|
299
|
+
Object.prototype.hasOwnProperty.call(tpl.layout, "primarySurface")
|
|
300
|
+
) {
|
|
301
|
+
add(
|
|
302
|
+
"primary-surface-authoring-conflict",
|
|
303
|
+
"layout.primarySurface",
|
|
304
|
+
"Declare either top-level app or layout.primarySurface, not both"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (tpl.app?.kind === "native" && !tpl.app.appId) {
|
|
308
|
+
add(
|
|
309
|
+
"app-native-id-required",
|
|
310
|
+
"app.appId",
|
|
311
|
+
"A native workspace app requires appId"
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (tpl.app?.kind === "url" && (!tpl.app.appId || !tpl.app.url)) {
|
|
315
|
+
add(
|
|
316
|
+
"app-url-identity-required",
|
|
317
|
+
"app",
|
|
318
|
+
"A hosted workspace app requires both appId and url"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (tpl.app?.kind === "stored" && !tpl.app.cellKey) {
|
|
322
|
+
add(
|
|
323
|
+
"app-cell-key-required",
|
|
324
|
+
"app.cellKey",
|
|
325
|
+
"A stored workspace app requires cellKey"
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const authoredPrimary = tpl.layout?.primarySurface;
|
|
329
|
+
if (authoredPrimary?.kind === "view" && !("viewId" in authoredPrimary)) {
|
|
330
|
+
const viewName = authoredPrimary.viewName;
|
|
331
|
+
const viewSlug = authoredPrimary.viewSlug;
|
|
332
|
+
if (!viewName && !viewSlug) {
|
|
333
|
+
add(
|
|
334
|
+
"primary-view-reference-required",
|
|
335
|
+
"layout.primarySurface",
|
|
336
|
+
"A view primary surface requires viewName or viewSlug"
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
const declaredViews = [
|
|
340
|
+
...(tpl.views ?? []).map((view) => ({
|
|
341
|
+
name: view.name,
|
|
342
|
+
slug: view.slug,
|
|
343
|
+
})),
|
|
344
|
+
...(tpl.bento?.viewName
|
|
345
|
+
? [{ name: tpl.bento.viewName, slug: undefined }]
|
|
346
|
+
: []),
|
|
347
|
+
];
|
|
348
|
+
const matches = declaredViews.filter(
|
|
349
|
+
(view) =>
|
|
350
|
+
(!viewName || view.name === viewName) &&
|
|
351
|
+
(!viewSlug || view.slug === viewSlug)
|
|
352
|
+
);
|
|
353
|
+
if (matches.length === 0) {
|
|
354
|
+
add(
|
|
355
|
+
"primary-view-reference-unknown",
|
|
356
|
+
"layout.primarySurface",
|
|
357
|
+
`Primary surface references an unknown view (${viewName ?? viewSlug})`
|
|
358
|
+
);
|
|
359
|
+
} else if (matches.length > 1) {
|
|
360
|
+
add(
|
|
361
|
+
"primary-view-reference-ambiguous",
|
|
362
|
+
"layout.primarySurface",
|
|
363
|
+
`Primary surface view reference matches ${matches.length} authored views; add a unique viewSlug`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
280
368
|
|
|
281
369
|
// ── Referential integrity: build the known-profile predicate ──────────────
|
|
282
370
|
const baseProfileSlugs = collectBaseProfileSlugs(tpl, resolveDepFrom(opts));
|
|
@@ -349,7 +437,110 @@ export function validateTemplate(
|
|
|
349
437
|
}
|
|
350
438
|
}
|
|
351
439
|
|
|
352
|
-
|
|
440
|
+
const onboarding = tpl.onboarding;
|
|
441
|
+
if (onboarding) {
|
|
442
|
+
if (typeof onboarding.goal !== "string" || onboarding.goal.trim() === "") {
|
|
443
|
+
add(
|
|
444
|
+
"onboarding-goal-empty",
|
|
445
|
+
"onboarding.goal",
|
|
446
|
+
"onboarding.goal must be a non-empty string"
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
typeof onboarding.framing !== "string" ||
|
|
451
|
+
onboarding.framing.trim() === ""
|
|
452
|
+
) {
|
|
453
|
+
add(
|
|
454
|
+
"onboarding-framing-empty",
|
|
455
|
+
"onboarding.framing",
|
|
456
|
+
"onboarding.framing must be a non-empty string"
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (!Array.isArray(onboarding.collect) || onboarding.collect.length === 0) {
|
|
460
|
+
add(
|
|
461
|
+
"onboarding-collect-empty",
|
|
462
|
+
"onboarding.collect",
|
|
463
|
+
"onboarding.collect must contain at least one structured target"
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const expertise = onboarding.expertise;
|
|
468
|
+
for (const [field, values] of [
|
|
469
|
+
["starters", expertise?.starters],
|
|
470
|
+
["blindSpots", expertise?.blindSpots],
|
|
471
|
+
] as const) {
|
|
472
|
+
if (
|
|
473
|
+
values !== undefined &&
|
|
474
|
+
(!Array.isArray(values) ||
|
|
475
|
+
values.some(
|
|
476
|
+
(value) => typeof value !== "string" || value.trim() === ""
|
|
477
|
+
))
|
|
478
|
+
) {
|
|
479
|
+
add(
|
|
480
|
+
"onboarding-expertise-invalid",
|
|
481
|
+
`onboarding.expertise.${field}`,
|
|
482
|
+
`onboarding.expertise.${field} must contain only non-empty strings`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
expertise?.bar !== undefined &&
|
|
488
|
+
(typeof expertise.bar !== "string" || expertise.bar.trim() === "")
|
|
489
|
+
) {
|
|
490
|
+
add(
|
|
491
|
+
"onboarding-expertise-invalid",
|
|
492
|
+
"onboarding.expertise.bar",
|
|
493
|
+
"onboarding.expertise.bar must be a non-empty string"
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
onboarding.openingQuestions !== undefined &&
|
|
499
|
+
(!Array.isArray(onboarding.openingQuestions) ||
|
|
500
|
+
onboarding.openingQuestions.some(
|
|
501
|
+
(question) => typeof question !== "string" || question.trim() === ""
|
|
502
|
+
))
|
|
503
|
+
) {
|
|
504
|
+
add(
|
|
505
|
+
"onboarding-opening-questions-invalid",
|
|
506
|
+
"onboarding.openingQuestions",
|
|
507
|
+
"onboarding.openingQuestions must contain only non-empty strings"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (
|
|
511
|
+
onboarding.doneWhen !== undefined &&
|
|
512
|
+
(typeof onboarding.doneWhen !== "string" ||
|
|
513
|
+
onboarding.doneWhen.trim() === "")
|
|
514
|
+
) {
|
|
515
|
+
add(
|
|
516
|
+
"onboarding-done-when-invalid",
|
|
517
|
+
"onboarding.doneWhen",
|
|
518
|
+
"onboarding.doneWhen must be a non-empty string"
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (const c of onboarding?.collect || []) {
|
|
524
|
+
if (typeof c.what !== "string" || c.what.trim() === "") {
|
|
525
|
+
add(
|
|
526
|
+
"onboarding-collect-what-empty",
|
|
527
|
+
`onboarding.collect.${c.profileSlug}.what`,
|
|
528
|
+
`onboarding.collect target "${c.profileSlug}" must describe what to capture`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (
|
|
532
|
+
c.keyFields !== undefined &&
|
|
533
|
+
(!Array.isArray(c.keyFields) ||
|
|
534
|
+
c.keyFields.some(
|
|
535
|
+
(field) => typeof field !== "string" || field.trim() === ""
|
|
536
|
+
))
|
|
537
|
+
) {
|
|
538
|
+
add(
|
|
539
|
+
"onboarding-collect-key-fields-invalid",
|
|
540
|
+
`onboarding.collect.${c.profileSlug}.keyFields`,
|
|
541
|
+
`onboarding.collect target "${c.profileSlug}" keyFields must contain only non-empty strings`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
353
544
|
if (!known(c.profileSlug))
|
|
354
545
|
add(
|
|
355
546
|
"onboarding-ref-unknown",
|
|
@@ -371,11 +562,39 @@ export function validateTemplate(
|
|
|
371
562
|
// SILENTLY at runtime.
|
|
372
563
|
for (const a of tpl.automations || []) {
|
|
373
564
|
const refs: Array<{ where: string; slug: string }> = [];
|
|
374
|
-
collectProfileSlugRefs(
|
|
565
|
+
collectProfileSlugRefs(
|
|
566
|
+
a.triggerConfig,
|
|
567
|
+
`automation "${a.name}".triggerConfig`,
|
|
568
|
+
refs
|
|
569
|
+
);
|
|
375
570
|
collectProfileSlugRefs(a.flow, `automation "${a.name}".flow`, refs);
|
|
376
571
|
for (const r of refs)
|
|
377
572
|
if (!known(r.slug))
|
|
378
|
-
add(
|
|
573
|
+
add(
|
|
574
|
+
"automation-ref-unknown",
|
|
575
|
+
r.where,
|
|
576
|
+
`${r.where} references unknown profile "${r.slug}"`
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
// Node-contract validation. Previously validate.ts checked automations for
|
|
580
|
+
// profileSlug refs ONLY, so a bogus node type / dangling edge / cycle / typo'd
|
|
581
|
+
// channelType / verb-less capability node passed the template gate green and
|
|
582
|
+
// then SILENTLY never fired at runtime (validateFlowDefinition only ran
|
|
583
|
+
// server-side at install). Run the SAME contract validator here, at author
|
|
584
|
+
// time. Catalog-existence checks are resolver-gated and skipped (no catalog at
|
|
585
|
+
// author time); the structural + per-node contract checks always run.
|
|
586
|
+
const flow = a.flow ?? (a as { flowDefinition?: unknown }).flowDefinition;
|
|
587
|
+
if (flow != null) {
|
|
588
|
+
const { errors: flowErrors } = validateFlowDefinition(flow);
|
|
589
|
+
for (const fe of flowErrors) {
|
|
590
|
+
const loc = fe.nodeId
|
|
591
|
+
? `automation "${a.name}".flow.node "${fe.nodeId}"`
|
|
592
|
+
: fe.edgeId
|
|
593
|
+
? `automation "${a.name}".flow.edge "${fe.edgeId}"`
|
|
594
|
+
: `automation "${a.name}".flow`;
|
|
595
|
+
add(`automation-flow-${fe.code}`, loc, `${loc}: ${fe.message}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
379
598
|
}
|
|
380
599
|
for (const pb of tpl.playbooks || []) {
|
|
381
600
|
const ps = pb.subjectProfile?.profileSlug;
|
|
@@ -426,7 +645,9 @@ export function validateTemplate(
|
|
|
426
645
|
// its base's workspace. The additive reconcile OVERWRITES workspace-level
|
|
427
646
|
// settings, so an overlay MUST set workspace.subtype to its compose-base's slug
|
|
428
647
|
// and MUST OMIT workspace.visibility — else it clobbers the base's identity.
|
|
429
|
-
const composeDeps = (tpl.dependencies || []).filter(
|
|
648
|
+
const composeDeps = (tpl.dependencies || []).filter(
|
|
649
|
+
(d) => d.relation === "compose"
|
|
650
|
+
);
|
|
430
651
|
for (const dep of composeDeps) {
|
|
431
652
|
if (tpl.workspace?.visibility !== undefined) {
|
|
432
653
|
add(
|
|
@@ -448,7 +669,9 @@ export function validateTemplate(
|
|
|
448
669
|
const blocks: TemplateBentoBlock[] = tpl.bento?.blocks ?? [];
|
|
449
670
|
const authoredWidgets = blocks.filter((b) => b.kind === "widget");
|
|
450
671
|
const authoredViews = blocks.filter((b) => b.kind === "view");
|
|
451
|
-
const unknownKind = blocks.filter(
|
|
672
|
+
const unknownKind = blocks.filter(
|
|
673
|
+
(b) => b.kind !== "widget" && b.kind !== "view"
|
|
674
|
+
);
|
|
452
675
|
if (unknownKind.length) {
|
|
453
676
|
add(
|
|
454
677
|
"bento-unknown-kind",
|
|
@@ -485,8 +708,18 @@ export function validateTemplate(
|
|
|
485
708
|
const expectW = authoredWidgets.length - badWidgets.length;
|
|
486
709
|
const expectV = authoredViews.length - badViews.length;
|
|
487
710
|
|
|
488
|
-
const doors: Array<
|
|
489
|
-
[
|
|
711
|
+
const doors: Array<
|
|
712
|
+
[
|
|
713
|
+
string,
|
|
714
|
+
Array<{ widgetType?: unknown }>,
|
|
715
|
+
Array<{ viewName?: unknown; kind?: unknown }>,
|
|
716
|
+
]
|
|
717
|
+
> = [
|
|
718
|
+
[
|
|
719
|
+
"tRPC createFromDefinition",
|
|
720
|
+
definition.bentoLayout ?? [],
|
|
721
|
+
definition.bentoViewBlocks ?? [],
|
|
722
|
+
],
|
|
490
723
|
["CP packages/apply", pkg.bentoLayout ?? [], pkg.bentoViewBlocks ?? []],
|
|
491
724
|
];
|
|
492
725
|
for (const [door, layout, viewBlocks] of doors) {
|
|
@@ -497,7 +730,9 @@ export function validateTemplate(
|
|
|
497
730
|
`[${door}]: authored ${expectW} widget/${expectV} view → got ${layout.length} bentoLayout/${viewBlocks.length} bentoViewBlocks`
|
|
498
731
|
);
|
|
499
732
|
}
|
|
500
|
-
const layoutBad = layout.filter(
|
|
733
|
+
const layoutBad = layout.filter(
|
|
734
|
+
(b) => typeof b.widgetType !== "string"
|
|
735
|
+
);
|
|
501
736
|
const viewsBad = viewBlocks.filter(
|
|
502
737
|
(b) => typeof b.viewName !== "string" || b.kind !== "view"
|
|
503
738
|
);
|