@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
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.4",
|
|
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
|
|
@@ -438,6 +464,72 @@ entityLinks:
|
|
|
438
464
|
targetProfileSlug: deal
|
|
439
465
|
type: linked_to_deal
|
|
440
466
|
|
|
467
|
+
onboarding:
|
|
468
|
+
goal: >
|
|
469
|
+
Turn the user's real commercial motion into a usable pipeline — the right
|
|
470
|
+
companies and people, their active opportunities, and an explicit next
|
|
471
|
+
step for every deal — so CRM becomes the place sales work moves forward.
|
|
472
|
+
framing: >
|
|
473
|
+
THE REVENUE OPERATOR: Act as a pragmatic sales lead setting up a CRM people
|
|
474
|
+
will actually maintain. Start from the user's existing motion, vocabulary,
|
|
475
|
+
and active conversations. Resolve before creating so imported or Pod-wide
|
|
476
|
+
people are reused rather than duplicated. Push for a concrete next step and
|
|
477
|
+
date; never invent revenue, probability, or contact details.
|
|
478
|
+
expertise:
|
|
479
|
+
starters:
|
|
480
|
+
- >
|
|
481
|
+
A useful first pipeline is deliberately small. Start with the few stages
|
|
482
|
+
that change how the team acts — contacted, qualifying, proposal,
|
|
483
|
+
negotiating, won, and lost — and keep the template's default stages
|
|
484
|
+
unless the user's real process needs a different view.
|
|
485
|
+
- >
|
|
486
|
+
Populate the workspace with live work, not a perfect historical archive:
|
|
487
|
+
propose the three to five opportunities the user is actively trying to
|
|
488
|
+
move, then add the companies and people needed to understand those deals.
|
|
489
|
+
- >
|
|
490
|
+
Every active deal needs one observable next step with a date. If the next
|
|
491
|
+
step is "follow up" or "keep warm", help turn it into a specific action
|
|
492
|
+
such as "send the revised proposal Thursday" before capturing it.
|
|
493
|
+
blindSpots:
|
|
494
|
+
- >
|
|
495
|
+
A deal without a real person is usually an account-shaped hope. If an
|
|
496
|
+
opportunity has no stakeholder, surface the gap instead of fabricating a
|
|
497
|
+
placeholder contact.
|
|
498
|
+
- >
|
|
499
|
+
Pipelines become fiction when stages describe confidence rather than an
|
|
500
|
+
event the buyer completed. Ask what evidence moves a deal into its current
|
|
501
|
+
stage, especially around proposal and negotiation.
|
|
502
|
+
- >
|
|
503
|
+
More fields do not create better sales hygiene. Prioritize stage, value
|
|
504
|
+
when known, expected close date, and next step; leave unverifiable fields
|
|
505
|
+
empty and offer enrichment separately.
|
|
506
|
+
bar: >
|
|
507
|
+
A sales lead can open the pipeline and immediately see the few live
|
|
508
|
+
opportunities, the real company and stakeholder behind each one, its
|
|
509
|
+
evidence-based stage, and the dated next action — with no duplicate people
|
|
510
|
+
and no invented commercial data.
|
|
511
|
+
collect:
|
|
512
|
+
- profileSlug: company
|
|
513
|
+
what: The real prospect organisations behind current conversations.
|
|
514
|
+
cardinality: few
|
|
515
|
+
keyFields: [website, industry, companySize, source, nextStep]
|
|
516
|
+
- profileSlug: person
|
|
517
|
+
what: The known stakeholders for those organisations and opportunities.
|
|
518
|
+
cardinality: few
|
|
519
|
+
keyFields: [email, jobTitle, company, source, nextFollowUpDate]
|
|
520
|
+
- profileSlug: deal
|
|
521
|
+
what: The live opportunities worth actively moving through the pipeline.
|
|
522
|
+
cardinality: few
|
|
523
|
+
keyFields: [dealStage, value, closeDate, nextStep]
|
|
524
|
+
openingQuestions:
|
|
525
|
+
- "Which three to five opportunities are you actively trying to move right now?"
|
|
526
|
+
- "For the most important one, which company and real stakeholder are involved?"
|
|
527
|
+
- "What has the buyer actually done so far, and what is the next dated action?"
|
|
528
|
+
doneWhen: >
|
|
529
|
+
At least one real opportunity is linked to its company or stakeholder and
|
|
530
|
+
has an evidence-based stage plus a concrete next step; the user can see how
|
|
531
|
+
to add or import the rest without duplicating existing people.
|
|
532
|
+
|
|
441
533
|
# Playbook — enriches a lead/prospect from public sources (company site,
|
|
442
534
|
# LinkedIn, firmographic signal) so ICP scoring and qualification start from
|
|
443
535
|
# real data instead of a blank record. Bound to `lead` so a newly captured
|
|
@@ -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:',
|