@synap-core/workspace-templates 0.9.2 → 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/workspace-templates",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "pnpm@9.15.4",
|
|
6
6
|
"description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
|
|
@@ -86,14 +86,13 @@ profiles:
|
|
|
86
86
|
inputType: select
|
|
87
87
|
enumValues:
|
|
88
88
|
[
|
|
89
|
-
|
|
90
|
-
referral,
|
|
89
|
+
cold-outreach,
|
|
91
90
|
linkedin,
|
|
92
|
-
|
|
91
|
+
twitter,
|
|
92
|
+
landing-page,
|
|
93
|
+
inbound-call,
|
|
94
|
+
referral,
|
|
93
95
|
event,
|
|
94
|
-
inbound,
|
|
95
|
-
partner,
|
|
96
|
-
import,
|
|
97
96
|
other,
|
|
98
97
|
]
|
|
99
98
|
- slug: icpScore
|
|
@@ -352,6 +351,25 @@ profiles:
|
|
|
352
351
|
label: Notes
|
|
353
352
|
valueType: string
|
|
354
353
|
inputType: textarea
|
|
354
|
+
# `campaign-type` is the campaign's NATURE/phase, orthogonal to
|
|
355
|
+
# `campaign-channel` (the delivery medium). scoping/outreach/live have no
|
|
356
|
+
# channel equivalent, so folding them into `campaign-channel` would lose
|
|
357
|
+
# them — they get their own def. Kept verbatim across the three campaign
|
|
358
|
+
# declarations (marketing / content-os / here) so there is no drift.
|
|
359
|
+
- slug: campaign-type
|
|
360
|
+
label: Type
|
|
361
|
+
valueType: string
|
|
362
|
+
inputType: select
|
|
363
|
+
enumValues:
|
|
364
|
+
[scoping, outreach, live, partnership, event, other]
|
|
365
|
+
constraints: { defaultValue: outreach }
|
|
366
|
+
# `campaign-color` backs the per-campaign colour the CRM surfaces render.
|
|
367
|
+
# Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
|
|
368
|
+
- slug: campaign-color
|
|
369
|
+
label: Color
|
|
370
|
+
valueType: string
|
|
371
|
+
inputType: select
|
|
372
|
+
enumValues: [indigo, violet, amber, cyan, pink, teal]
|
|
355
373
|
|
|
356
374
|
entityLinks:
|
|
357
375
|
# The existing generic `linked_to_deal` relation remains the deal ↔
|
package/src/content-os.yaml
CHANGED
|
@@ -623,6 +623,25 @@ profiles:
|
|
|
623
623
|
label: Notes
|
|
624
624
|
valueType: string
|
|
625
625
|
inputType: textarea
|
|
626
|
+
# `campaign-type` is the campaign's NATURE/phase, orthogonal to
|
|
627
|
+
# `campaign-channel` (the delivery medium). scoping/outreach/live have no
|
|
628
|
+
# channel equivalent, so folding them into `campaign-channel` would lose
|
|
629
|
+
# them — they get their own def. Kept verbatim across the three campaign
|
|
630
|
+
# declarations (business-developer / marketing / here) so there is no drift.
|
|
631
|
+
- slug: campaign-type
|
|
632
|
+
label: Type
|
|
633
|
+
valueType: string
|
|
634
|
+
inputType: select
|
|
635
|
+
enumValues:
|
|
636
|
+
[scoping, outreach, live, partnership, event, other]
|
|
637
|
+
constraints: { defaultValue: outreach }
|
|
638
|
+
# `campaign-color` backs the per-campaign colour the CRM surfaces render.
|
|
639
|
+
# Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
|
|
640
|
+
- slug: campaign-color
|
|
641
|
+
label: Color
|
|
642
|
+
valueType: string
|
|
643
|
+
inputType: select
|
|
644
|
+
enumValues: [indigo, violet, amber, cyan, pink, teal]
|
|
626
645
|
|
|
627
646
|
# ── Sponsor (shared ecosystem role — SSOT is foundation.yaml) ─────────────
|
|
628
647
|
# `scope: shared` dedups to the ONE pod-wide `sponsor` role seeded by
|
package/src/crm.yaml
CHANGED
|
@@ -18,6 +18,14 @@ meta:
|
|
|
18
18
|
- contacts
|
|
19
19
|
isPublic: true
|
|
20
20
|
|
|
21
|
+
# Canonical hosted surface. Browser resolves auth from appId and launches this
|
|
22
|
+
# URL with non-secret workspace/Pod context; CRM establishes its own session.
|
|
23
|
+
app:
|
|
24
|
+
kind: url
|
|
25
|
+
appId: crm
|
|
26
|
+
url: https://crm.synap.live
|
|
27
|
+
title: CRM
|
|
28
|
+
|
|
21
29
|
# SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
|
|
22
30
|
# shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
|
|
23
31
|
# a shared profile's pod-wide BASE property body from whichever template
|
|
@@ -79,7 +87,16 @@ profiles:
|
|
|
79
87
|
- slug: source
|
|
80
88
|
label: Source
|
|
81
89
|
valueType: string
|
|
82
|
-
inputType:
|
|
90
|
+
inputType: select
|
|
91
|
+
enumValues:
|
|
92
|
+
- cold-outreach
|
|
93
|
+
- linkedin
|
|
94
|
+
- twitter
|
|
95
|
+
- landing-page
|
|
96
|
+
- inbound-call
|
|
97
|
+
- referral
|
|
98
|
+
- event
|
|
99
|
+
- other
|
|
83
100
|
- slug: icpScore
|
|
84
101
|
label: ICP score
|
|
85
102
|
valueType: number
|
|
@@ -141,7 +158,16 @@ profiles:
|
|
|
141
158
|
- slug: source
|
|
142
159
|
label: Source
|
|
143
160
|
valueType: string
|
|
144
|
-
inputType:
|
|
161
|
+
inputType: select
|
|
162
|
+
enumValues:
|
|
163
|
+
- cold-outreach
|
|
164
|
+
- linkedin
|
|
165
|
+
- twitter
|
|
166
|
+
- landing-page
|
|
167
|
+
- inbound-call
|
|
168
|
+
- referral
|
|
169
|
+
- event
|
|
170
|
+
- other
|
|
145
171
|
- slug: icpScore
|
|
146
172
|
label: ICP score
|
|
147
173
|
valueType: number
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
normalizeTemplateLayout,
|
|
4
|
+
toPackageDefinition,
|
|
5
|
+
toWorkspaceDefinition,
|
|
6
|
+
} from './define.js';
|
|
7
|
+
import type { WorkspaceYaml } from './types.js';
|
|
8
|
+
import { validateTemplate } from './validate.js';
|
|
9
|
+
|
|
10
|
+
function template(
|
|
11
|
+
overrides: Partial<Pick<WorkspaceYaml, 'app' | 'layout' | 'views' | 'bento'>>
|
|
12
|
+
): WorkspaceYaml {
|
|
13
|
+
return {
|
|
14
|
+
meta: {
|
|
15
|
+
slug: 'surface-contract-test',
|
|
16
|
+
name: 'Surface contract test',
|
|
17
|
+
description: 'Surface contract fixture',
|
|
18
|
+
icon: 'panels-top-left',
|
|
19
|
+
color: 'blue',
|
|
20
|
+
},
|
|
21
|
+
workspace: {
|
|
22
|
+
name: 'Surface contract test',
|
|
23
|
+
description: 'Surface contract fixture',
|
|
24
|
+
},
|
|
25
|
+
profiles: [{ slug: 'thing', displayName: 'Thing', properties: [] }],
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('workspace primary-surface conversion', () => {
|
|
31
|
+
it('normalizes an external app into both provisioning doors', () => {
|
|
32
|
+
const source = template({
|
|
33
|
+
app: {
|
|
34
|
+
kind: 'url',
|
|
35
|
+
appId: 'crm',
|
|
36
|
+
url: 'https://crm.synap.live',
|
|
37
|
+
title: 'CRM',
|
|
38
|
+
},
|
|
39
|
+
layout: { pinnedApps: ['data'] },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const expected = {
|
|
43
|
+
kind: 'app',
|
|
44
|
+
appId: 'crm',
|
|
45
|
+
rendererType: 'external',
|
|
46
|
+
url: 'https://crm.synap.live',
|
|
47
|
+
title: 'CRM',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
|
|
51
|
+
{
|
|
52
|
+
pinnedApps: ['data'],
|
|
53
|
+
primarySurface: expected,
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
expect(toPackageDefinition(source).layoutConfig).toMatchObject({
|
|
57
|
+
pinnedApps: ['data'],
|
|
58
|
+
primarySurface: expected,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('ships the generated CRM template as a hosted primary surface', () => {
|
|
63
|
+
expect(
|
|
64
|
+
toWorkspaceDefinition('crm').definition.layoutConfig?.primarySurface
|
|
65
|
+
).toEqual({
|
|
66
|
+
kind: 'app',
|
|
67
|
+
appId: 'crm',
|
|
68
|
+
rendererType: 'external',
|
|
69
|
+
url: 'https://crm.synap.live',
|
|
70
|
+
title: 'CRM',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps legacy defaultApp as a native compatibility input', () => {
|
|
75
|
+
expect(
|
|
76
|
+
normalizeTemplateLayout({ defaultApp: 'dashboard' }, undefined)
|
|
77
|
+
).toMatchObject({
|
|
78
|
+
defaultApp: 'dashboard',
|
|
79
|
+
primarySurface: {
|
|
80
|
+
kind: 'app',
|
|
81
|
+
appId: 'dashboard',
|
|
82
|
+
rendererType: 'native',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keeps an authored view name unresolved through both provisioning doors', () => {
|
|
88
|
+
const source = template({
|
|
89
|
+
views: [
|
|
90
|
+
{
|
|
91
|
+
name: 'Pipeline',
|
|
92
|
+
slug: 'pipeline',
|
|
93
|
+
type: 'kanban',
|
|
94
|
+
scopeProfileSlug: 'thing',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
layout: {
|
|
98
|
+
primarySurface: {
|
|
99
|
+
kind: 'view',
|
|
100
|
+
viewName: 'Pipeline',
|
|
101
|
+
viewSlug: 'pipeline',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
|
|
107
|
+
{
|
|
108
|
+
primarySurface: {
|
|
109
|
+
kind: 'view',
|
|
110
|
+
viewName: 'Pipeline',
|
|
111
|
+
viewSlug: 'pipeline',
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
expect(toPackageDefinition(source).layoutConfig).toMatchObject({
|
|
116
|
+
primarySurface: {
|
|
117
|
+
kind: 'view',
|
|
118
|
+
viewName: 'Pipeline',
|
|
119
|
+
viewSlug: 'pipeline',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
expect(validateTemplate(source).ok).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects an unknown authored primary view before provisioning', () => {
|
|
126
|
+
const source = template({
|
|
127
|
+
views: [{ name: 'Pipeline', type: 'kanban' }],
|
|
128
|
+
layout: {
|
|
129
|
+
primarySurface: { kind: 'view', viewName: 'Missing' },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(validateTemplate(source).errors).toContainEqual(
|
|
134
|
+
expect.objectContaining({ rule: 'primary-view-reference-unknown' })
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not activate historical entity-detail placeholders without identity', () => {
|
|
139
|
+
expect(
|
|
140
|
+
normalizeTemplateLayout(
|
|
141
|
+
{ defaultApp: null },
|
|
142
|
+
{ kind: 'stored', cellKey: 'entity-detail' }
|
|
143
|
+
)
|
|
144
|
+
).toMatchObject({
|
|
145
|
+
defaultApp: null,
|
|
146
|
+
primarySurface: null,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rejects two competing authoring sources', () => {
|
|
151
|
+
const source = template({
|
|
152
|
+
app: { kind: 'native', appId: 'dashboard' },
|
|
153
|
+
layout: {
|
|
154
|
+
primarySurface: {
|
|
155
|
+
kind: 'app',
|
|
156
|
+
appId: 'data',
|
|
157
|
+
rendererType: 'native',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(validateTemplate(source).errors).toContainEqual(
|
|
163
|
+
expect.objectContaining({ rule: 'primary-surface-authoring-conflict' })
|
|
164
|
+
);
|
|
165
|
+
expect(() => toWorkspaceDefinition(source)).toThrow(
|
|
166
|
+
/both top-level app and layout\.primarySurface/
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects a hosted app that cannot be authorized by application id', () => {
|
|
171
|
+
const source = template({
|
|
172
|
+
app: { kind: 'url', url: 'https://crm.synap.live' },
|
|
173
|
+
layout: {},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(validateTemplate(source).errors).toContainEqual(
|
|
177
|
+
expect.objectContaining({ rule: 'app-url-identity-required' })
|
|
178
|
+
);
|
|
179
|
+
expect(() => toWorkspaceDefinition(source)).toThrow(
|
|
180
|
+
/requires both appId and url/
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/define.ts
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* shape never drifts per call-site.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
14
|
-
|
|
13
|
+
import type {
|
|
14
|
+
TemplateAppRef, TemplateAutomation, TemplateBentoBlock,
|
|
15
|
+
TemplateLayoutConfig,
|
|
16
|
+
WorkspacePrimarySurface,
|
|
17
|
+
WorkspaceYaml, } from './types.js';
|
|
18
|
+
import { getWorkspaceTemplate } from './templates.js';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* A `TemplateAutomation` as the apply door expects it: `flowDefinition`, not
|
|
@@ -25,12 +29,12 @@ export interface PackageAutomationWire {
|
|
|
25
29
|
description?: string;
|
|
26
30
|
triggerType: TemplateAutomation['triggerType'];
|
|
27
31
|
triggerConfig?: Record<string, unknown>;
|
|
28
|
-
flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string };
|
|
32
|
+
flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string; };
|
|
29
33
|
status?: TemplateAutomation['status'];
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function toAutomationWire(
|
|
33
|
-
automations: TemplateAutomation[] | undefined
|
|
37
|
+
automations: TemplateAutomation[] | undefined
|
|
34
38
|
): PackageAutomationWire[] | undefined {
|
|
35
39
|
return automations?.map((a) => ({
|
|
36
40
|
key: a.key,
|
|
@@ -63,6 +67,122 @@ export interface SplitBentoBlocks {
|
|
|
63
67
|
bentoViewBlocks: BentoViewBlockWire[];
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
const hasOwn = <K extends PropertyKey>(
|
|
71
|
+
value: object,
|
|
72
|
+
key: K
|
|
73
|
+
): value is Record<K, unknown> =>
|
|
74
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert the legacy top-level `app:` authoring shorthand into the persisted
|
|
78
|
+
* workspace surface contract. The conversion is deliberately conservative:
|
|
79
|
+
* an external app without an application id cannot pass the host's
|
|
80
|
+
* application/origin authorization, and the historical `entity-detail`
|
|
81
|
+
* placeholders have no entity identity to render.
|
|
82
|
+
*/
|
|
83
|
+
function appRefToPrimarySurface(
|
|
84
|
+
app: TemplateAppRef | undefined
|
|
85
|
+
): WorkspacePrimarySurface | undefined {
|
|
86
|
+
if (!app) return undefined;
|
|
87
|
+
|
|
88
|
+
if (app.kind === 'native') {
|
|
89
|
+
if (!app.appId) {
|
|
90
|
+
throw new Error('Workspace template native app is missing appId');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
kind: 'app',
|
|
94
|
+
appId: app.appId,
|
|
95
|
+
rendererType: 'native',
|
|
96
|
+
...(app.title ? { title: app.title } : {}),
|
|
97
|
+
...(app.props ? { props: app.props } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (app.kind === 'url') {
|
|
102
|
+
if (!app.appId || !app.url) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'Workspace template URL app requires both appId and url'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: 'app',
|
|
109
|
+
appId: app.appId,
|
|
110
|
+
rendererType: 'external',
|
|
111
|
+
url: app.url,
|
|
112
|
+
...(app.title ? { title: app.title } : {}),
|
|
113
|
+
...(app.props ? { props: app.props } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (app.kind === 'stored') {
|
|
118
|
+
if (!app.cellKey) {
|
|
119
|
+
throw new Error('Workspace template stored app is missing cellKey');
|
|
120
|
+
}
|
|
121
|
+
// Twenty-two historical templates carry this inert placeholder without an
|
|
122
|
+
// entity id. Activating it would open an unusable detail cell on entry.
|
|
123
|
+
if (app.cellKey === 'entity-detail' && !app.props?.entityId) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
kind: 'cell',
|
|
128
|
+
cellKey: app.cellKey,
|
|
129
|
+
...(app.title ? { title: app.title } : {}),
|
|
130
|
+
...(app.props ? { props: app.props } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Produce the canonical persisted layout. A template may author either the
|
|
139
|
+
* top-level `app:` shorthand or an explicit `layout.primarySurface`, never both.
|
|
140
|
+
* Legacy `defaultApp` is the final compatibility fallback. Keeping the property
|
|
141
|
+
* absent preserves a live workspace value during reconciliation; an authored
|
|
142
|
+
* null is retained as an explicit clear.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeTemplateLayout(
|
|
145
|
+
layout: TemplateLayoutConfig | undefined,
|
|
146
|
+
app: TemplateAppRef | undefined
|
|
147
|
+
): TemplateLayoutConfig | undefined {
|
|
148
|
+
if (app && layout && hasOwn(layout, 'primarySurface')) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'Workspace template declares both top-level app and layout.primarySurface; choose one authoring source'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const primarySurface = (() => {
|
|
155
|
+
if (layout && hasOwn(layout, 'primarySurface')) {
|
|
156
|
+
return layout.primarySurface;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const fromApp = appRefToPrimarySurface(app);
|
|
160
|
+
if (fromApp) return fromApp;
|
|
161
|
+
|
|
162
|
+
if (layout && hasOwn(layout, 'defaultApp')) {
|
|
163
|
+
return layout.defaultApp
|
|
164
|
+
? {
|
|
165
|
+
kind: 'app' as const,
|
|
166
|
+
appId: layout.defaultApp,
|
|
167
|
+
rendererType: 'native' as const,
|
|
168
|
+
}
|
|
169
|
+
: null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return undefined;
|
|
173
|
+
})();
|
|
174
|
+
|
|
175
|
+
if (!layout && primarySurface === undefined) return undefined;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...layout,
|
|
179
|
+
...(primarySurface !== undefined ||
|
|
180
|
+
(layout && hasOwn(layout, 'primarySurface'))
|
|
181
|
+
? { primarySurface }
|
|
182
|
+
: {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
66
186
|
/**
|
|
67
187
|
* Split the authored `bento.blocks` DISCRIMINATED UNION into the TWO wire
|
|
68
188
|
* fields the pod expects. This is the whole point: `TemplateBentoBlock` is one
|
|
@@ -229,12 +349,14 @@ export interface ToDefinitionResult {
|
|
|
229
349
|
* or selected value string. Keep this conversion at the shared template seam
|
|
230
350
|
* so browser creation and package application cannot silently disagree.
|
|
231
351
|
*/
|
|
232
|
-
function toWorkspacePlaybooks(
|
|
352
|
+
function toWorkspacePlaybooks(
|
|
353
|
+
playbooks: WorkspaceYaml['playbooks']
|
|
354
|
+
): Array<Record<string, unknown>> | undefined {
|
|
233
355
|
return playbooks?.map((playbook) => ({
|
|
234
356
|
...playbook,
|
|
235
357
|
params: playbook.params?.map((param) => ({
|
|
236
358
|
name: param.name,
|
|
237
|
-
type: param.type ===
|
|
359
|
+
type: param.type === 'number' || param.type === 'boolean' ? param.type : 'string',
|
|
238
360
|
...(param.defaultValue !== undefined ? { default: param.defaultValue } : {}),
|
|
239
361
|
...(param.label
|
|
240
362
|
? { description: param.label }
|
|
@@ -249,7 +371,7 @@ function toWorkspacePlaybooks(playbooks: WorkspaceYaml["playbooks"]): Array<Reco
|
|
|
249
371
|
*/
|
|
250
372
|
export function toWorkspaceDefinition(
|
|
251
373
|
slugOrYaml: string | WorkspaceYaml,
|
|
252
|
-
nameOverride?: string
|
|
374
|
+
nameOverride?: string
|
|
253
375
|
): ToDefinitionResult {
|
|
254
376
|
const tpl: WorkspaceYaml | undefined =
|
|
255
377
|
typeof slugOrYaml === 'string' ? getWorkspaceTemplate(slugOrYaml) : slugOrYaml;
|
|
@@ -276,7 +398,7 @@ export function toWorkspaceDefinition(
|
|
|
276
398
|
seedEntities: tpl.seedEntities ?? [],
|
|
277
399
|
suggestedRelations: tpl.suggestedRelations,
|
|
278
400
|
displayTemplates: tpl.displayTemplates,
|
|
279
|
-
layoutConfig: tpl.layout,
|
|
401
|
+
layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
|
|
280
402
|
bentoViewName: tpl.bento?.viewName,
|
|
281
403
|
bentoLayout: bento.bentoLayout,
|
|
282
404
|
bentoViewBlocks: bento.bentoViewBlocks,
|
|
@@ -315,7 +437,7 @@ export function toWorkspaceDefinition(
|
|
|
315
437
|
dependencies: tpl.dependencies,
|
|
316
438
|
};
|
|
317
439
|
|
|
318
|
-
return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug };
|
|
440
|
+
return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug, };
|
|
319
441
|
}
|
|
320
442
|
|
|
321
443
|
// ─── Exhaustiveness ledger: toWorkspaceDefinition ────────────────────────────
|
|
@@ -366,14 +488,11 @@ type WorkspaceDefinitionForwarded =
|
|
|
366
488
|
* else is a real gap and should say so.
|
|
367
489
|
*/
|
|
368
490
|
type WorkspaceDefinitionDropped =
|
|
369
|
-
//
|
|
370
|
-
// read `template.app` straight off the YAML to pick the first screen, so this
|
|
371
|
-
// never belonged on the wire.
|
|
491
|
+
// Normalized into `layoutConfig.primarySurface`.
|
|
372
492
|
| 'app'
|
|
373
493
|
// The door DOES accept `extends`, but no template declares it today (0/21).
|
|
374
494
|
// Forwarding is a no-op until one does — wire it then.
|
|
375
|
-
| 'extends'
|
|
376
|
-
;
|
|
495
|
+
| 'extends';
|
|
377
496
|
|
|
378
497
|
type UnclassifiedWorkspaceDefinitionKey = Exclude<
|
|
379
498
|
keyof WorkspaceYaml,
|
|
@@ -383,7 +502,9 @@ type UnclassifiedWorkspaceDefinitionKey = Exclude<
|
|
|
383
502
|
// If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
|
|
384
503
|
// `toWorkspaceDefinition` and add it to `WorkspaceDefinitionForwarded`, or add
|
|
385
504
|
// it to `WorkspaceDefinitionDropped` with a one-line WHY.
|
|
386
|
-
const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [
|
|
505
|
+
const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [
|
|
506
|
+
never,
|
|
507
|
+
]
|
|
387
508
|
? true
|
|
388
509
|
: [
|
|
389
510
|
'UNCLASSIFIED YAML FIELD — forward it or add it to WorkspaceDefinitionDropped:',
|
|
@@ -462,7 +583,7 @@ export interface PackageDefinitionOutput {
|
|
|
462
583
|
* package instead of disk-stored JSON.
|
|
463
584
|
*/
|
|
464
585
|
export function toPackageDefinition(
|
|
465
|
-
slugOrYaml: string | WorkspaceYaml
|
|
586
|
+
slugOrYaml: string | WorkspaceYaml
|
|
466
587
|
): PackageDefinitionOutput {
|
|
467
588
|
const tpl: WorkspaceYaml | undefined =
|
|
468
589
|
typeof slugOrYaml === 'string' ? getWorkspaceTemplate(slugOrYaml) : slugOrYaml;
|
|
@@ -502,7 +623,7 @@ export function toPackageDefinition(
|
|
|
502
623
|
bentoViewBlocks: bento.bentoViewBlocks,
|
|
503
624
|
bentoViewName: tpl.bento?.viewName,
|
|
504
625
|
profileEntityBentoTemplates: tpl.profileEntityBentoTemplates,
|
|
505
|
-
layoutConfig: tpl.layout,
|
|
626
|
+
layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
|
|
506
627
|
capabilities: tpl.integrations,
|
|
507
628
|
playbooks: tpl.playbooks,
|
|
508
629
|
automations: toAutomationWire(tpl.automations),
|
|
@@ -541,7 +662,7 @@ type PackageDefinitionForwarded =
|
|
|
541
662
|
|
|
542
663
|
/** Keys deliberately NOT forwarded by `toPackageDefinition`. */
|
|
543
664
|
type PackageDefinitionDropped =
|
|
544
|
-
//
|
|
665
|
+
// Normalized into `layoutConfig.primarySurface`.
|
|
545
666
|
| 'app'
|
|
546
667
|
// REAL GAP: backend `PackageDefinition` DOES declare `extends`
|
|
547
668
|
// (package-definition.ts:262), but `PackageDefinitionOutput` omits it. No
|
|
@@ -557,7 +678,9 @@ type UnclassifiedPackageDefinitionKey = Exclude<
|
|
|
557
678
|
// If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
|
|
558
679
|
// `toPackageDefinition` and add it to `PackageDefinitionForwarded`, or add it
|
|
559
680
|
// to `PackageDefinitionDropped` with a one-line WHY.
|
|
560
|
-
const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [
|
|
681
|
+
const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [
|
|
682
|
+
never,
|
|
683
|
+
]
|
|
561
684
|
? true
|
|
562
685
|
: [
|
|
563
686
|
'UNCLASSIFIED YAML FIELD — forward it or add it to PackageDefinitionDropped:',
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* const template = getWorkspaceTemplate('brand-library');
|
|
12
12
|
* // template.workspace.name === 'Brand Library'
|
|
13
13
|
* // template.app.kind === 'native'
|
|
14
|
-
* // template.
|
|
14
|
+
* // template.app.appId === 'brandLibrary'
|
|
15
15
|
*
|
|
16
16
|
* YAML source files live in src/*.yaml — AI-friendly, commentable, mergeable.
|
|
17
17
|
* Run `pnpm build` to regenerate src/templates.ts from YAML.
|
|
@@ -22,6 +22,10 @@ export type {
|
|
|
22
22
|
TemplateMeta,
|
|
23
23
|
TemplateAppRef,
|
|
24
24
|
TemplateAppKind,
|
|
25
|
+
WorkspaceJsonValue,
|
|
26
|
+
WorkspacePrimarySurface,
|
|
27
|
+
WorkspacePrimarySurfaceViewReference,
|
|
28
|
+
TemplatePrimarySurface,
|
|
25
29
|
TemplateProfile,
|
|
26
30
|
TemplateProperty,
|
|
27
31
|
TemplateView,
|
|
@@ -86,8 +90,7 @@ export type {
|
|
|
86
90
|
} from "./catalog.js";
|
|
87
91
|
|
|
88
92
|
export {
|
|
89
|
-
layerTemplateGraph,
|
|
90
|
-
bundledEdgesOf,
|
|
93
|
+
layerTemplateGraph, bundledEdgesOf
|
|
91
94
|
} from "./composition-graph.js";
|
|
92
95
|
export type {
|
|
93
96
|
CompositionInstalledEntry,
|
|
@@ -138,10 +141,30 @@ export type {
|
|
|
138
141
|
TemplateConverters,
|
|
139
142
|
} from "./validate.js";
|
|
140
143
|
|
|
144
|
+
// The pure, DB-free automation-flow node-contract validator. `validateTemplate`
|
|
145
|
+
// runs it over every `automations[].flow` so broken flows (unknown node type,
|
|
146
|
+
// dangling edge, cycle, typo'd channelType, verb-less capability node) fail at
|
|
147
|
+
// author time instead of silently never firing on a live pod. Exported so the
|
|
148
|
+
// pod's persist doors + the (post-publish) backend re-export share this SSOT.
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
validateFlowDefinition,
|
|
152
|
+
flowValidationErrorMessage,
|
|
153
|
+
FLOW_NODE_TYPES,
|
|
154
|
+
FLOW_OUTPUT_TYPES,
|
|
155
|
+
CHANNEL_MESSAGE_CHANNEL_TYPES,
|
|
156
|
+
} from "./validate-flow.js";
|
|
157
|
+
export type {
|
|
158
|
+
FlowValidationError,
|
|
159
|
+
FlowValidationResult,
|
|
160
|
+
FlowValidationResolvers,
|
|
161
|
+
} from "./validate-flow.js";
|
|
162
|
+
|
|
141
163
|
export {
|
|
142
164
|
toWorkspaceDefinition,
|
|
143
165
|
toPackageDefinition,
|
|
144
166
|
splitBentoBlocks,
|
|
167
|
+
normalizeTemplateLayout,
|
|
145
168
|
} from "./define.js";
|
|
146
169
|
export type {
|
|
147
170
|
WorkspaceDefinitionInput,
|
package/src/marketing.yaml
CHANGED
|
@@ -92,6 +92,25 @@ profiles:
|
|
|
92
92
|
label: Notes
|
|
93
93
|
valueType: string
|
|
94
94
|
inputType: textarea
|
|
95
|
+
# `campaign-type` is the campaign's NATURE/phase, orthogonal to
|
|
96
|
+
# `campaign-channel` (the delivery medium). scoping/outreach/live have no
|
|
97
|
+
# channel equivalent, so folding them into `campaign-channel` would lose
|
|
98
|
+
# them — they get their own def. Kept verbatim across the three campaign
|
|
99
|
+
# declarations (business-developer / content-os / here) so there is no drift.
|
|
100
|
+
- slug: campaign-type
|
|
101
|
+
label: Type
|
|
102
|
+
valueType: string
|
|
103
|
+
inputType: select
|
|
104
|
+
enumValues:
|
|
105
|
+
[scoping, outreach, live, partnership, event, other]
|
|
106
|
+
constraints: { defaultValue: outreach }
|
|
107
|
+
# `campaign-color` backs the per-campaign colour the CRM surfaces render.
|
|
108
|
+
# Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
|
|
109
|
+
- slug: campaign-color
|
|
110
|
+
label: Color
|
|
111
|
+
valueType: string
|
|
112
|
+
inputType: select
|
|
113
|
+
enumValues: [indigo, violet, amber, cyan, pink, teal]
|
|
95
114
|
|
|
96
115
|
- slug: contact
|
|
97
116
|
displayName: Contact
|