@synap-core/workspace-templates 0.9.3 → 0.10.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/dist/index.d.ts +137 -44
- package/dist/index.js +1816 -128
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/default-relation-defs.json +208 -0
- package/src/__fixtures__/report-automation-flow.json +393 -0
- package/src/agent-fleet.yaml +6 -1
- package/src/base.template.test.ts +160 -0
- package/src/base.yaml +839 -0
- package/src/business-developer.template.test.ts +79 -10
- package/src/business-developer.yaml +61 -39
- package/src/concept-guard.test.ts +47 -31
- package/src/concept-guard.ts +23 -19
- package/src/content-os.yaml +22 -0
- package/src/corpus-flow-validity.test.ts +130 -0
- package/src/crm.yaml +125 -8
- package/src/define.primary-surface.test.ts +2 -4
- package/src/define.ts +28 -0
- package/src/ecosystem.yaml +14 -0
- package/src/enterprise-os.yaml +1 -1
- package/src/foundation.yaml +8 -0
- package/src/grants.yaml +8 -0
- package/src/life-os.yaml +6 -1
- package/src/operations.yaml +270 -0
- package/src/outreach-comms.yaml +196 -0
- package/src/project-management.yaml +6 -1
- package/src/templates.ts +1717 -371
- package/src/types.ts +113 -37
- package/src/validate-flow.ts +186 -7
- package/src/validate-onboarding.test.ts +88 -0
- package/src/validate.ts +148 -12
package/src/types.ts
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
// App Reference — what opens as first screen when entering this workspace
|
|
11
11
|
// ============================================================================
|
|
12
12
|
|
|
13
|
-
export type TemplateAppKind =
|
|
13
|
+
export type TemplateAppKind = "native" | "stored" | "url";
|
|
14
14
|
|
|
15
15
|
export interface TemplateAppRef {
|
|
16
16
|
kind: TemplateAppKind;
|
|
17
17
|
/** For kind='native': the appId registered in the browser manifest */
|
|
18
18
|
appId?: string;
|
|
19
19
|
/** For kind='native': how the platform renders the app */
|
|
20
|
-
rendererType?:
|
|
20
|
+
rendererType?: "native" | "external" | "iframe-srcdoc";
|
|
21
21
|
/** For kind='stored': the cell key registered in the cell registry */
|
|
22
22
|
cellKey?: string;
|
|
23
23
|
/** For kind='stored': default props passed to the cell */
|
|
@@ -42,28 +42,38 @@ export type WorkspaceJsonValue =
|
|
|
42
42
|
|
|
43
43
|
export type WorkspacePrimarySurface =
|
|
44
44
|
| {
|
|
45
|
-
kind:
|
|
45
|
+
kind: "app";
|
|
46
46
|
appId: string;
|
|
47
|
-
rendererType:
|
|
47
|
+
rendererType: "native";
|
|
48
48
|
title?: string;
|
|
49
49
|
props?: Record<string, WorkspaceJsonValue>;
|
|
50
50
|
}
|
|
51
51
|
| {
|
|
52
|
-
kind:
|
|
52
|
+
kind: "app";
|
|
53
53
|
appId: string;
|
|
54
|
-
rendererType:
|
|
54
|
+
rendererType: "external";
|
|
55
55
|
url: string;
|
|
56
56
|
title?: string;
|
|
57
57
|
props?: Record<string, WorkspaceJsonValue>;
|
|
58
58
|
}
|
|
59
59
|
| {
|
|
60
|
-
kind:
|
|
60
|
+
kind: "cell";
|
|
61
61
|
cellKey: string;
|
|
62
62
|
title?: string;
|
|
63
63
|
props?: Record<string, WorkspaceJsonValue>;
|
|
64
64
|
}
|
|
65
65
|
| {
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Ordinary website. Unlike an external `app`, this has no trusted app
|
|
68
|
+
* identity and is always opened through the Browser's unprivileged URL
|
|
69
|
+
* surface.
|
|
70
|
+
*/
|
|
71
|
+
kind: "url";
|
|
72
|
+
url: string;
|
|
73
|
+
title?: string;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
kind: "view";
|
|
67
77
|
viewId: string;
|
|
68
78
|
title?: string;
|
|
69
79
|
};
|
|
@@ -75,13 +85,13 @@ export type WorkspacePrimarySurface =
|
|
|
75
85
|
*/
|
|
76
86
|
export type WorkspacePrimarySurfaceViewReference =
|
|
77
87
|
| {
|
|
78
|
-
kind:
|
|
88
|
+
kind: "view";
|
|
79
89
|
viewName: string;
|
|
80
90
|
viewSlug?: string;
|
|
81
91
|
title?: string;
|
|
82
92
|
}
|
|
83
93
|
| {
|
|
84
|
-
kind:
|
|
94
|
+
kind: "view";
|
|
85
95
|
viewName?: string;
|
|
86
96
|
viewSlug: string;
|
|
87
97
|
title?: string;
|
|
@@ -134,11 +144,11 @@ export interface TemplateProfile {
|
|
|
134
144
|
* `entity_scope` of an existing profile (a divergence is reported as an
|
|
135
145
|
* advisory `scopeConflict` and the live row is left alone).
|
|
136
146
|
*/
|
|
137
|
-
entityScope?:
|
|
147
|
+
entityScope?: "pod" | "workspace";
|
|
138
148
|
/** Semantic slug for system profiles. */
|
|
139
149
|
semanticSlug?: string;
|
|
140
150
|
/** Kind vs role profile. Omit for default ('kind'). */
|
|
141
|
-
profileKind?:
|
|
151
|
+
profileKind?: "kind" | "role";
|
|
142
152
|
/** For profileKind='role': which base-kind profile slugs this role can attach to. */
|
|
143
153
|
applicableKinds?: string[];
|
|
144
154
|
properties: TemplateProperty[];
|
|
@@ -164,6 +174,29 @@ export interface TemplateEntityLink {
|
|
|
164
174
|
label?: string;
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
/**
|
|
178
|
+
* A domain-level relation TYPE definition — a `relation_defs` row, the workspace's
|
|
179
|
+
* relation vocabulary. Distinct from {@link TemplateEntityLink}, which is a
|
|
180
|
+
* profile→profile EDGE (and only carries `type` + `label`). A relation def stands
|
|
181
|
+
* alone: it has no source/target profile pairing and it carries the rich metadata
|
|
182
|
+
* (`description`, `isDirectional`, `uiHints`) that the entity-link path drops.
|
|
183
|
+
*
|
|
184
|
+
* Mirrors `DefaultRelationDef` in synap-backend
|
|
185
|
+
* `packages/database/src/utils/default-relation-defs.ts` VERBATIM — the door
|
|
186
|
+
* find-or-creates a `relation_defs` row from each entry (idempotent by
|
|
187
|
+
* `slug` within the workspace), which is `ensureDefaultRelationDefs`'s body.
|
|
188
|
+
*/
|
|
189
|
+
export interface TemplateRelationDef {
|
|
190
|
+
slug: string;
|
|
191
|
+
displayName: string;
|
|
192
|
+
description?: string;
|
|
193
|
+
isDirectional?: boolean;
|
|
194
|
+
uiHints?: {
|
|
195
|
+
category?: "workflow" | "social" | "reference" | "hierarchy";
|
|
196
|
+
inverseLabel?: string;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
167
200
|
export interface TemplateSeedEntity {
|
|
168
201
|
profileSlug: string;
|
|
169
202
|
title: string;
|
|
@@ -201,22 +234,22 @@ export interface TemplateDisplayTemplate {
|
|
|
201
234
|
// ============================================================================
|
|
202
235
|
|
|
203
236
|
export interface TemplateSidebarSurface {
|
|
204
|
-
kind:
|
|
237
|
+
kind: "cell" | "view" | "entity" | "document" | "channel" | "app" | "url";
|
|
205
238
|
cellKey?: string;
|
|
206
239
|
viewId?: string;
|
|
207
240
|
viewName?: string;
|
|
208
241
|
appId?: string;
|
|
209
242
|
url?: string;
|
|
210
|
-
rendererType?:
|
|
211
|
-
placement?:
|
|
212
|
-
displayMode?:
|
|
243
|
+
rendererType?: "native" | "external" | "iframe-srcdoc";
|
|
244
|
+
placement?: "main" | "side" | "floating" | "modal" | "popover" | "embed";
|
|
245
|
+
displayMode?: "compact" | "medium" | "full";
|
|
213
246
|
props?: Record<string, unknown>;
|
|
214
247
|
title?: string;
|
|
215
248
|
meta?: Record<string, unknown>;
|
|
216
249
|
}
|
|
217
250
|
|
|
218
251
|
export interface TemplateSidebarItem {
|
|
219
|
-
kind:
|
|
252
|
+
kind: "app" | "view" | "profile" | "external" | "cell";
|
|
220
253
|
appId?: string;
|
|
221
254
|
viewName?: string;
|
|
222
255
|
viewId?: string;
|
|
@@ -253,7 +286,7 @@ export interface TemplateLayoutConfig {
|
|
|
253
286
|
// ============================================================================
|
|
254
287
|
|
|
255
288
|
export interface TemplateBentoBlock {
|
|
256
|
-
kind:
|
|
289
|
+
kind: "view" | "widget";
|
|
257
290
|
viewName?: string;
|
|
258
291
|
widgetType?: string;
|
|
259
292
|
pos: { x: number; y: number; w: number; h: number };
|
|
@@ -272,7 +305,7 @@ export interface TemplateProfileEntityBentoTemplates {
|
|
|
272
305
|
// ============================================================================
|
|
273
306
|
|
|
274
307
|
/** Mirrors `WorkspaceSourceRole` in synap-backend `schema/workspaces.ts`. */
|
|
275
|
-
export type TemplateSourceRole =
|
|
308
|
+
export type TemplateSourceRole = "provider" | "consumer" | "provider-consumer";
|
|
276
309
|
|
|
277
310
|
/** Mirrors `WorkspaceDefaultSource` in synap-backend `schema/workspaces.ts`. */
|
|
278
311
|
export interface TemplateDefaultSource {
|
|
@@ -302,7 +335,7 @@ export interface TemplateOnboardingCollectTarget {
|
|
|
302
335
|
/** Human description of what to capture for this target. */
|
|
303
336
|
what: string;
|
|
304
337
|
/** Roughly how many to expect — guides interview depth. */
|
|
305
|
-
cardinality?:
|
|
338
|
+
cardinality?: "one" | "few" | "several";
|
|
306
339
|
/** Key fields the agent should make sure to fill. */
|
|
307
340
|
keyFields?: string[];
|
|
308
341
|
}
|
|
@@ -357,7 +390,7 @@ export interface TemplateOnboarding {
|
|
|
357
390
|
|
|
358
391
|
export interface TemplatePlaybookParam {
|
|
359
392
|
name: string;
|
|
360
|
-
type:
|
|
393
|
+
type: "text" | "number" | "entity" | "choice" | "boolean";
|
|
361
394
|
label?: string;
|
|
362
395
|
required?: boolean;
|
|
363
396
|
defaultValue?: unknown;
|
|
@@ -368,12 +401,12 @@ export interface TemplatePlaybook {
|
|
|
368
401
|
description?: string;
|
|
369
402
|
goalTemplate: string;
|
|
370
403
|
params?: TemplatePlaybookParam[];
|
|
371
|
-
executor?:
|
|
372
|
-
inputStrategy?:
|
|
404
|
+
executor?: "is-agent" | "external-agent" | "hybrid";
|
|
405
|
+
inputStrategy?: "none" | "static" | "rotating" | "query";
|
|
373
406
|
channelSpec?: {
|
|
374
|
-
type:
|
|
407
|
+
type: "GROUP" | "AGENT_COLLAB" | "THREAD";
|
|
375
408
|
members?: string[];
|
|
376
|
-
aiReactionMode?:
|
|
409
|
+
aiReactionMode?: "on_mention" | "always" | "never";
|
|
377
410
|
};
|
|
378
411
|
/**
|
|
379
412
|
* A cron schedule that maintains ONE backing cron automation (a single
|
|
@@ -399,7 +432,7 @@ export interface TemplatePlaybook {
|
|
|
399
432
|
* captured entity of this kind can auto-launch the playbook as a session.
|
|
400
433
|
*/
|
|
401
434
|
subjectProfile?: { profileSlug: string; filter?: Record<string, unknown> };
|
|
402
|
-
status?:
|
|
435
|
+
status?: "draft" | "active" | "paused";
|
|
403
436
|
}
|
|
404
437
|
|
|
405
438
|
// ============================================================================
|
|
@@ -429,10 +462,38 @@ export interface TemplateAutomation {
|
|
|
429
462
|
key?: string;
|
|
430
463
|
name: string;
|
|
431
464
|
description?: string;
|
|
432
|
-
triggerType:
|
|
465
|
+
triggerType: "event" | "cron" | "webhook" | "manual";
|
|
433
466
|
triggerConfig?: Record<string, unknown>;
|
|
434
467
|
flow: { nodes: unknown[]; edges: unknown[]; precondition?: string };
|
|
435
|
-
status?:
|
|
468
|
+
status?: "draft" | "active" | "paused";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ============================================================================
|
|
472
|
+
// Commands — workspace-scoped intelligence commands seeded with the workspace
|
|
473
|
+
//
|
|
474
|
+
// A prompt-template command (the "Default Command Pack") the workspace exposes
|
|
475
|
+
// in its command surface. Mirrors `DefaultCommandDef` in synap-backend
|
|
476
|
+
// `packages/database/src/utils/ensure-default-commands.ts` — the door
|
|
477
|
+
// find-or-creates an `intelligence_commands` row from each entry, idempotent by
|
|
478
|
+
// `title` within the workspace (`ensureDefaultCommands`'s body generalized).
|
|
479
|
+
// ============================================================================
|
|
480
|
+
|
|
481
|
+
export interface TemplateCommandDerivedInput {
|
|
482
|
+
name: string;
|
|
483
|
+
label?: string;
|
|
484
|
+
/** Mirrors `DerivedInput.type` (text | number | entity | view | choice). */
|
|
485
|
+
type?: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export interface TemplateCommand {
|
|
489
|
+
title: string;
|
|
490
|
+
promptTemplate: string;
|
|
491
|
+
derivedInputs?: TemplateCommandDerivedInput[];
|
|
492
|
+
/** text | proposal | view */
|
|
493
|
+
outputMode?: string;
|
|
494
|
+
canCreateViews?: boolean;
|
|
495
|
+
/** read_only | propose_writes */
|
|
496
|
+
permissionsProfile?: string;
|
|
436
497
|
}
|
|
437
498
|
|
|
438
499
|
// ============================================================================
|
|
@@ -464,9 +525,9 @@ export interface TemplateActionPlacement {
|
|
|
464
525
|
/** Entity kind whose detail surface this action appears on. */
|
|
465
526
|
profileSlug: string;
|
|
466
527
|
/** Where the action renders. Only "entity-detail" is consumed today. */
|
|
467
|
-
surface:
|
|
528
|
+
surface: "entity-detail" | string;
|
|
468
529
|
/** What the action does when clicked. */
|
|
469
|
-
kind:
|
|
530
|
+
kind: "capability" | "playbook" | "automation";
|
|
470
531
|
/**
|
|
471
532
|
* Reference to the thing to run: a capability verb key, or a playbook /
|
|
472
533
|
* automation NAME (resolved to a row id at apply time).
|
|
@@ -509,7 +570,7 @@ export interface TemplateIntegration {
|
|
|
509
570
|
/** Override default params with {{var}} interpolation. */
|
|
510
571
|
params?: Record<string, string>;
|
|
511
572
|
/** "apply" (create+update) or "install" (create only). */
|
|
512
|
-
mode?:
|
|
573
|
+
mode?: "apply" | "install";
|
|
513
574
|
}
|
|
514
575
|
|
|
515
576
|
// ============================================================================
|
|
@@ -583,10 +644,10 @@ export interface TemplateExtendsRef {
|
|
|
583
644
|
* 3. Once no payload carries `'automation'`, drop it from the wire enums and this tuple.
|
|
584
645
|
*/
|
|
585
646
|
export const PACKAGE_DEPENDENCY_KINDS = [
|
|
586
|
-
|
|
587
|
-
|
|
647
|
+
"workspace",
|
|
648
|
+
"capability",
|
|
588
649
|
/** @deprecated Retired by CP 0049 in favour of `workflow`. See note above. */
|
|
589
|
-
|
|
650
|
+
"automation",
|
|
590
651
|
] as const;
|
|
591
652
|
|
|
592
653
|
/** What a `TemplateDependency` points at. Derived — never re-declare the list. */
|
|
@@ -610,7 +671,7 @@ export interface TemplateDependency {
|
|
|
610
671
|
* - `'require'` (default): the dependency must be present on the pod as its
|
|
611
672
|
* own artifact, but this template does not merge into it.
|
|
612
673
|
*/
|
|
613
|
-
relation?:
|
|
674
|
+
relation?: "compose" | "require";
|
|
614
675
|
/**
|
|
615
676
|
* Short human reason shown in the install-time "this also installs…" prompt.
|
|
616
677
|
*/
|
|
@@ -654,7 +715,7 @@ export interface WorkspaceYaml {
|
|
|
654
715
|
* Workspace classifier — consumed by createFromDefinition (settings.workspaceType,
|
|
655
716
|
* creator-role + agent-workspace behavior). Mirrors the backend union.
|
|
656
717
|
*/
|
|
657
|
-
workspaceType?:
|
|
718
|
+
workspaceType?: "personal" | "agent" | "project" | "operational";
|
|
658
719
|
visibility?: string;
|
|
659
720
|
/** Capabilities this workspace provides (e.g. "brand.library", "content.pipeline") */
|
|
660
721
|
capabilities?: string[];
|
|
@@ -684,6 +745,21 @@ export interface WorkspaceYaml {
|
|
|
684
745
|
/** Schema-level links between entity types */
|
|
685
746
|
entityLinks?: TemplateEntityLink[];
|
|
686
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Domain-level relation TYPE vocabulary seeded into the workspace's
|
|
750
|
+
* `relation_defs`. Distinct from `entityLinks` (profile→profile edges): a
|
|
751
|
+
* relation def stands alone and carries `description`/`isDirectional`/`uiHints`.
|
|
752
|
+
* The `base` template carries the universal defaults. See {@link TemplateRelationDef}.
|
|
753
|
+
*/
|
|
754
|
+
relationDefs?: TemplateRelationDef[];
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Workspace-scoped intelligence commands (prompt-template "Default Command
|
|
758
|
+
* Pack") seeded with the workspace. The `base` template carries the universal
|
|
759
|
+
* pack. See {@link TemplateCommand}.
|
|
760
|
+
*/
|
|
761
|
+
commands?: TemplateCommand[];
|
|
762
|
+
|
|
687
763
|
/** Initial data to seed on workspace creation */
|
|
688
764
|
seedEntities?: TemplateSeedEntity[];
|
|
689
765
|
|
|
@@ -751,7 +827,7 @@ export interface WorkspaceYaml {
|
|
|
751
827
|
|
|
752
828
|
export interface ResolvedWorkspaceTemplate {
|
|
753
829
|
meta: TemplateMeta;
|
|
754
|
-
definition: WorkspaceYaml[
|
|
830
|
+
definition: WorkspaceYaml["workspace"] & {
|
|
755
831
|
profiles: TemplateProfile[];
|
|
756
832
|
views: TemplateView[];
|
|
757
833
|
entityLinks: TemplateEntityLink[];
|
package/src/validate-flow.ts
CHANGED
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
* `schema/automations.ts`.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
// ── Contract constants (mirror
|
|
39
|
+
// ── Contract constants (mirror schema/automations.ts — keep in sync) ──────────
|
|
40
40
|
|
|
41
|
-
/** The
|
|
41
|
+
/** The 23 node types — `AutomationNodeBase.type` union (schema/automations.ts). */
|
|
42
42
|
export const FLOW_NODE_TYPES = [
|
|
43
43
|
"trigger",
|
|
44
44
|
"command",
|
|
@@ -56,6 +56,8 @@ export const FLOW_NODE_TYPES = [
|
|
|
56
56
|
"select",
|
|
57
57
|
"claim",
|
|
58
58
|
"messages_query",
|
|
59
|
+
"runs_query",
|
|
60
|
+
"proposals_query",
|
|
59
61
|
"switch",
|
|
60
62
|
"skill",
|
|
61
63
|
"capability",
|
|
@@ -81,22 +83,73 @@ export const FLOW_OUTPUT_TYPES = [
|
|
|
81
83
|
/**
|
|
82
84
|
* The known `config.channelType` values for a `channel_message` output. Mirrors
|
|
83
85
|
* the executor's channel_message resolution (automation-executor.ts). The output
|
|
84
|
-
* channel is CONTEXT-DERIVED — a channel_message is valid with `channelId`,
|
|
85
|
-
* `channelEntityRef` (an entity-id expression → that entity's internal
|
|
86
|
-
* OR a `channelType` from this set, OR NOTHING (the executor defaults a
|
|
87
|
-
* channel_message to the automation's own run channel). A `channelType`
|
|
88
|
-
* PRESENT but not one of these is still a typo → still an error.
|
|
86
|
+
* channel is now CONTEXT-DERIVED — a channel_message is valid with `channelId`,
|
|
87
|
+
* OR `channelEntityRef` (an entity-id expression → that entity's internal
|
|
88
|
+
* channel), OR a `channelType` from this set, OR NOTHING (the executor defaults a
|
|
89
|
+
* targetless channel_message to the automation's own run channel). A `channelType`
|
|
90
|
+
* that is PRESENT but not one of these is still a typo → still an error.
|
|
89
91
|
*/
|
|
92
|
+
// The executor's channel_message resolution (automation-executor.ts) is the SSOT
|
|
93
|
+
// for these values; config is an untyped Record so there is no compile-time
|
|
94
|
+
// drift guard here — keep this array in sync with the executor by hand.
|
|
90
95
|
export const CHANNEL_MESSAGE_CHANNEL_TYPES = [
|
|
91
96
|
"personal_thread",
|
|
92
97
|
"proactive",
|
|
93
98
|
"subjectEntity",
|
|
94
99
|
] as const;
|
|
95
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Node types a `loop` may dispatch PER ITEM. Mirrors `LOOP_BODY_NODE_TYPES` in
|
|
103
|
+
* `packages/jobs/src/workers/automation-executor.ts` (the runtime SSOT — the
|
|
104
|
+
* `switch (childNode.type)` inside the loop body). A loop OWNS the contiguous
|
|
105
|
+
* chain of these reachable from it; traversal STOPS at any other type
|
|
106
|
+
* (switch / delay / nested loop / sub_automation / entity_read / …), which runs
|
|
107
|
+
* ONCE in the main topological pass instead.
|
|
108
|
+
*
|
|
109
|
+
* Hand-mirrored: `@synap/api` does not depend on `@synap/jobs`, so there is no
|
|
110
|
+
* compile-time drift guard here — keep this array in sync with the executor by
|
|
111
|
+
* hand (same convention as CHANNEL_MESSAGE_CHANNEL_TYPES above).
|
|
112
|
+
*/
|
|
113
|
+
export const LOOP_BODY_NODE_TYPES = [
|
|
114
|
+
"command",
|
|
115
|
+
"output",
|
|
116
|
+
"playbook_run",
|
|
117
|
+
"messages_query",
|
|
118
|
+
"runs_query",
|
|
119
|
+
"proposals_query",
|
|
120
|
+
"query",
|
|
121
|
+
"fetch",
|
|
122
|
+
"transform",
|
|
123
|
+
"condition",
|
|
124
|
+
"skill",
|
|
125
|
+
"capability",
|
|
126
|
+
] as const;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a loop actually PUTS in scope for its body: `context.loop = { item, index }`
|
|
130
|
+
* (automation-executor.ts, `case "loop"`). Any other `{{loop.<x>}}` segment
|
|
131
|
+
* resolves to undefined at runtime.
|
|
132
|
+
*/
|
|
133
|
+
export const LOOP_PROVIDED_BINDINGS = ["item", "index"] as const;
|
|
134
|
+
|
|
96
135
|
const NODE_TYPE_SET = new Set<string>(FLOW_NODE_TYPES);
|
|
136
|
+
const LOOP_BODY_TYPE_SET = new Set<string>(LOOP_BODY_NODE_TYPES);
|
|
137
|
+
const LOOP_BINDING_SET = new Set<string>(LOOP_PROVIDED_BINDINGS);
|
|
97
138
|
const OUTPUT_TYPE_SET = new Set<string>(FLOW_OUTPUT_TYPES);
|
|
98
139
|
const CHANNEL_TYPE_SET = new Set<string>(CHANNEL_MESSAGE_CHANNEL_TYPES);
|
|
99
140
|
|
|
141
|
+
// ── Drift guard — NOT compile-time IN THIS COPY ───────────────────────────────
|
|
142
|
+
// The arrays above are hand-mirrored from TS *union types* in the schema. The
|
|
143
|
+
// BACKEND copy carries an `AssertExact` binding that fails `tsc` on drift; THIS
|
|
144
|
+
// package has no `@synap/database` dependency, so that guard cannot live here.
|
|
145
|
+
// Do not assume tsc protects you when editing these arrays — what protects this
|
|
146
|
+
// copy is `validate-flow.drift.test.ts`, which strips comments and therefore
|
|
147
|
+
// could never have caught this paragraph being wrong (it was, until 2026-07-31).
|
|
148
|
+
//
|
|
149
|
+
// ⚠️ If you re-generate this file FROM the backend copy, re-apply this block and
|
|
150
|
+
// de-duplicate the header above: a naive strip of the `AssertExact` CODE leaves
|
|
151
|
+
// its comment behind, asserting a guard that does not exist here.
|
|
152
|
+
|
|
100
153
|
// ── Result / error types ──────────────────────────────────────────────────────
|
|
101
154
|
|
|
102
155
|
export interface FlowValidationError {
|
|
@@ -479,6 +532,54 @@ export function validateFlowDefinition(
|
|
|
479
532
|
}
|
|
480
533
|
}
|
|
481
534
|
|
|
535
|
+
// ── Loop bindings: `{{loop.*}}` only inside a loop body, only real paths ──────
|
|
536
|
+
// `context.loop` exists ONLY while a loop dispatches its body and is deleted
|
|
537
|
+
// right after, so a node OUTSIDE every loop body that reads `{{loop.item.id}}`
|
|
538
|
+
// resolves it to `undefined` at runtime — a Zod "expected string, received
|
|
539
|
+
// undefined" at best, a junk entity/relation written with an empty id at worst.
|
|
540
|
+
// The ownership walk below is the SAME rule the executor applies
|
|
541
|
+
// (`computeLoopBodyNodeIds`), so a node behind a boundary type (switch, delay,
|
|
542
|
+
// nested loop, …) or with no edge from the loop at all is correctly reported:
|
|
543
|
+
// those are exactly the shapes that leak into the main pass.
|
|
544
|
+
const loopBodyNodeIds = new Set<string>();
|
|
545
|
+
for (const node of nodes) {
|
|
546
|
+
if (!nonEmptyString(node.id) || node.type !== "loop") continue;
|
|
547
|
+
for (const id of collectLoopBody(node.id, nodesById, edges)) {
|
|
548
|
+
loopBodyNodeIds.add(id);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
for (const node of nodes) {
|
|
552
|
+
if (!nonEmptyString(node.id)) continue;
|
|
553
|
+
const refs = collectLoopBindingRefs(node.data);
|
|
554
|
+
if (refs.length === 0) continue;
|
|
555
|
+
if (!loopBodyNodeIds.has(node.id)) {
|
|
556
|
+
errors.push({
|
|
557
|
+
nodeId: node.id,
|
|
558
|
+
code: "loop_ref_outside_loop_body",
|
|
559
|
+
message: `Node "${node.id}" references ${refs
|
|
560
|
+
.map((r) => `\`{{loop.${r}...}}\``)
|
|
561
|
+
.join(
|
|
562
|
+
", "
|
|
563
|
+
)} but is not inside a loop body, so there is no item to bind — it would run once with an empty value. Connect it downstream of a loop node with no switch/delay/nested-loop/sub_automation node in between.`,
|
|
564
|
+
});
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const unknown = refs.filter((r) => !LOOP_BINDING_SET.has(r));
|
|
568
|
+
if (unknown.length > 0) {
|
|
569
|
+
errors.push({
|
|
570
|
+
nodeId: node.id,
|
|
571
|
+
code: "loop_ref_unknown_path",
|
|
572
|
+
message: `Node "${node.id}" references ${unknown
|
|
573
|
+
.map((r) => `\`{{loop.${r}}}\``)
|
|
574
|
+
.join(
|
|
575
|
+
", "
|
|
576
|
+
)}, which a loop does not provide. A loop binds only: ${LOOP_PROVIDED_BINDINGS.map(
|
|
577
|
+
(b) => `\`loop.${b}\``
|
|
578
|
+
).join(", ")}.`,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
482
583
|
// ── Acyclic (Kahn's algorithm — mirrors topoSort in automation-executor.ts) ───
|
|
483
584
|
// Only run when edges reference real nodes; dangling edges are already reported
|
|
484
585
|
// and would corrupt the in-degree bookkeeping.
|
|
@@ -495,6 +596,84 @@ export function validateFlowDefinition(
|
|
|
495
596
|
return { valid: errors.length === 0, errors };
|
|
496
597
|
}
|
|
497
598
|
|
|
599
|
+
/**
|
|
600
|
+
* The node ids a loop OWNS as its per-item body — the contiguous chain of
|
|
601
|
+
* LOOP_BODY_NODE_TYPES nodes reachable from it, stopping at any other type.
|
|
602
|
+
* Mirrors `computeLoopBodyNodeIds` in automation-executor.ts exactly (all
|
|
603
|
+
* out-edges, handle-agnostic); the `seen` set makes a cyclic graph terminate
|
|
604
|
+
* (cycles are reported separately).
|
|
605
|
+
*/
|
|
606
|
+
function collectLoopBody(
|
|
607
|
+
loopNodeId: string,
|
|
608
|
+
nodesById: Map<string, LooseNode>,
|
|
609
|
+
edges: LooseEdge[]
|
|
610
|
+
): Set<string> {
|
|
611
|
+
const body = new Set<string>();
|
|
612
|
+
const stack: string[] = [];
|
|
613
|
+
for (const edge of edges) {
|
|
614
|
+
if (edge.source === loopNodeId && nonEmptyString(edge.target))
|
|
615
|
+
stack.push(edge.target);
|
|
616
|
+
}
|
|
617
|
+
while (stack.length > 0) {
|
|
618
|
+
const id = stack.pop()!;
|
|
619
|
+
if (body.has(id)) continue;
|
|
620
|
+
const node = nodesById.get(id);
|
|
621
|
+
if (
|
|
622
|
+
!node ||
|
|
623
|
+
!nonEmptyString(node.type) ||
|
|
624
|
+
!LOOP_BODY_TYPE_SET.has(node.type)
|
|
625
|
+
)
|
|
626
|
+
continue; // boundary — runs once in the main pass, not per item
|
|
627
|
+
body.add(id);
|
|
628
|
+
for (const edge of edges) {
|
|
629
|
+
if (edge.source === id && nonEmptyString(edge.target))
|
|
630
|
+
stack.push(edge.target);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return body;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The FIRST path segment of every `{{loop.<segment>…}}` reference anywhere in a
|
|
638
|
+
* node's `data` (deep: objects + arrays), deduped. Mirrors
|
|
639
|
+
* `collectLoopBindingRefs` in automation-executor.ts — including the PRE-PIPE
|
|
640
|
+
* restriction: an array pipe argument (`{{x | map:{{loop.item.id}}}}`) binds
|
|
641
|
+
* `loop` per item inside the RESOLVER, a scope that is valid outside any loop
|
|
642
|
+
* node, so it must not be reported.
|
|
643
|
+
*/
|
|
644
|
+
function collectLoopBindingRefs(data: unknown): string[] {
|
|
645
|
+
const found = new Set<string>();
|
|
646
|
+
const walk = (v: unknown): void => {
|
|
647
|
+
if (typeof v === "string") {
|
|
648
|
+
const head = v.split("|")[0];
|
|
649
|
+
const re = /\{\{\s*loop\.([A-Za-z0-9_$]+)/g;
|
|
650
|
+
let m: RegExpExecArray | null;
|
|
651
|
+
let matched = false;
|
|
652
|
+
while ((m = re.exec(head)) !== null) {
|
|
653
|
+
found.add(m[1]);
|
|
654
|
+
matched = true;
|
|
655
|
+
}
|
|
656
|
+
// The executor's own detector is the LOOSER `/\{\{\s*loop\./` — it fires on
|
|
657
|
+
// `{{loop.}}` or `{{loop.-x}}`, which this stricter segment pattern does
|
|
658
|
+
// not. Without this fallback a malformed reference passes save-time
|
|
659
|
+
// validation and then throws at RUN time, which is exactly the class of
|
|
660
|
+
// failure the loop rules were added to eliminate. Report it under the
|
|
661
|
+
// empty segment so it surfaces as `loop_ref_unknown_path`.
|
|
662
|
+
if (!matched && /\{\{\s*loop\./.test(head)) found.add("");
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (Array.isArray(v)) {
|
|
666
|
+
for (const x of v) walk(x);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (isRecord(v)) {
|
|
670
|
+
for (const x of Object.values(v)) walk(x);
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
walk(data);
|
|
674
|
+
return [...found];
|
|
675
|
+
}
|
|
676
|
+
|
|
498
677
|
/**
|
|
499
678
|
* Return the set of node ids that participate in a cycle (empty if acyclic).
|
|
500
679
|
* Kahn's algorithm — the same ordering the executor's `topoSort` relies on; a
|
|
@@ -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
|
+
});
|