@synap-core/workspace-templates 0.9.4 → 0.11.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 +154 -45
- package/dist/index.js +2462 -543
- 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/blockchain-ecosystem.yaml +106 -0
- package/src/business-developer.template.test.ts +79 -10
- package/src/business-developer.yaml +61 -39
- package/src/catalog.ts +36 -0
- 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 +59 -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 +256 -1
- package/src/life-os.yaml +6 -1
- package/src/networking.yaml +14 -3
- package/src/operations.yaml +270 -0
- package/src/outreach-comms.yaml +196 -0
- package/src/project-management.yaml +6 -1
- package/src/templates.ts +1770 -129
- package/src/the-arch.yaml +4 -0
- package/src/types.ts +98 -34
- package/src/validate-flow.ts +186 -7
- package/src/validate.ts +26 -2
|
@@ -43,14 +43,25 @@ describe("concept guard — shipped corpus", () => {
|
|
|
43
43
|
).toEqual([]);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
it("
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
expect(
|
|
53
|
-
|
|
46
|
+
it("needs NO declared divergence — the CRM fork was resolved, not allowlisted", () => {
|
|
47
|
+
// The allowlist was born carrying `crm-lead|lead` + `client|crm-client`.
|
|
48
|
+
// business-developer.yaml now re-declares the SHARED `lead`/`client` roles
|
|
49
|
+
// and carries its business-development fields as workspace-overlay
|
|
50
|
+
// properties, so there is no fork left to declare. Rule 3 (stale-allowlist)
|
|
51
|
+
// is what forces this to stay true: a leftover entry fails CI.
|
|
52
|
+
expect(Object.keys(DECLARED_DIVERGENCES)).toEqual([]);
|
|
53
|
+
|
|
54
|
+
const bd = WORKSPACE_TEMPLATES["business-developer"]?.profiles ?? [];
|
|
55
|
+
expect(bd.map((p) => p.slug)).not.toContain("crm-lead");
|
|
56
|
+
expect(bd.map((p) => p.slug)).not.toContain("crm-client");
|
|
57
|
+
for (const slug of ["lead", "client"]) {
|
|
58
|
+
const reused = bd.find((p) => p.slug === slug);
|
|
59
|
+
expect(reused, `business-developer must reuse shared '${slug}'`).toBeDefined();
|
|
60
|
+
expect(reused?.scope).toBe("shared");
|
|
61
|
+
expect(reused?.profileKind).toBe("role");
|
|
62
|
+
// No alias: the shared slug IS the concept identity.
|
|
63
|
+
expect(reused?.semanticSlug).toBeUndefined();
|
|
64
|
+
}
|
|
54
65
|
});
|
|
55
66
|
});
|
|
56
67
|
|
|
@@ -71,33 +82,36 @@ describe("concept guard — fires on a real fork", () => {
|
|
|
71
82
|
"some-addon": [role("channel-partner", { scope: "WORKSPACE" })],
|
|
72
83
|
})
|
|
73
84
|
);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
78
|
-
expect(forks[0]!.rule).toBe("concept-collision");
|
|
79
|
-
expect(forks[0]!.concept).toBe("partner");
|
|
80
|
-
expect(forks[0]!.message).toContain("channel-partner");
|
|
85
|
+
expect(violations).toHaveLength(1);
|
|
86
|
+
expect(violations[0]!.rule).toBe("concept-collision");
|
|
87
|
+
expect(violations[0]!.concept).toBe("partner");
|
|
88
|
+
expect(violations[0]!.message).toContain("channel-partner");
|
|
81
89
|
});
|
|
82
90
|
|
|
83
91
|
it("suppresses exactly the pair an author declared — and nothing else", () => {
|
|
84
|
-
// The SAME shape as the test above, but with
|
|
92
|
+
// The SAME shape as the test above, but with an allowlist naming the pair.
|
|
85
93
|
// This is the whole safety property of the escape hatch: it is per-pair, not
|
|
86
|
-
// per-concept, so a THIRD `lead` spelling would still fail.
|
|
94
|
+
// per-concept, so a THIRD `lead` spelling would still fail. The allowlist is
|
|
95
|
+
// INJECTED because the shipped one is (healthily) empty — the guard must
|
|
96
|
+
// still behave correctly the next time someone genuinely needs an entry.
|
|
97
|
+
const allowlist = { "crm-lead|lead": "declared for this fixture" };
|
|
87
98
|
const declared = findConceptViolations(
|
|
88
99
|
corpus({
|
|
89
100
|
foundation: [role("lead", { scope: "shared" })],
|
|
90
101
|
"business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
|
|
91
|
-
})
|
|
102
|
+
}),
|
|
103
|
+
allowlist
|
|
92
104
|
);
|
|
93
105
|
expect(declared.filter((v) => v.rule === "concept-collision")).toEqual([]);
|
|
106
|
+
expect(declared.filter((v) => v.rule === "stale-allowlist")).toEqual([]);
|
|
94
107
|
|
|
95
108
|
const third = findConceptViolations(
|
|
96
109
|
corpus({
|
|
97
110
|
foundation: [role("lead", { scope: "shared" })],
|
|
98
111
|
"business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
|
|
99
112
|
"another-addon": [role("sales-lead", { scope: "WORKSPACE" })],
|
|
100
|
-
})
|
|
113
|
+
}),
|
|
114
|
+
allowlist
|
|
101
115
|
);
|
|
102
116
|
expect(third.filter((v) => v.rule === "concept-collision")).toHaveLength(2);
|
|
103
117
|
});
|
|
@@ -109,9 +123,7 @@ describe("concept guard — fires on a real fork", () => {
|
|
|
109
123
|
other: [role("pipeline-lead", { semanticSlug: "sales.lead" })],
|
|
110
124
|
})
|
|
111
125
|
);
|
|
112
|
-
expect(violations.
|
|
113
|
-
"concept-collision",
|
|
114
|
-
]);
|
|
126
|
+
expect(violations.map((v) => v.rule)).toEqual(["concept-collision"]);
|
|
115
127
|
});
|
|
116
128
|
|
|
117
129
|
it("fails when two aliased profiles disagree on shape", () => {
|
|
@@ -125,11 +137,15 @@ describe("concept guard — fires on a real fork", () => {
|
|
|
125
137
|
});
|
|
126
138
|
|
|
127
139
|
it("fails when an allowlist entry stops matching anything", () => {
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
// An entry whose collision no longer exists — exactly the state resolving a
|
|
141
|
+
// divergence leaves behind, and exactly what forced `crm-lead|lead` and
|
|
142
|
+
// `client|crm-client` to be DELETED rather than left as decoration.
|
|
143
|
+
const violations = findConceptViolations(corpus({}), {
|
|
144
|
+
"crm-lead|lead": "resolved, but the entry was left behind",
|
|
145
|
+
});
|
|
146
|
+
expect(violations).toEqual([
|
|
147
|
+
expect.objectContaining({ rule: "stale-allowlist", concept: "crm-lead|lead" }),
|
|
148
|
+
]);
|
|
133
149
|
});
|
|
134
150
|
});
|
|
135
151
|
|
|
@@ -142,7 +158,7 @@ describe("concept guard — stays green on legitimate patterns", () => {
|
|
|
142
158
|
marketing: [role("lead", { scope: "shared" })],
|
|
143
159
|
})
|
|
144
160
|
);
|
|
145
|
-
expect(violations
|
|
161
|
+
expect(violations).toEqual([]);
|
|
146
162
|
});
|
|
147
163
|
|
|
148
164
|
it("allows sibling roles authored in ONE template to share a noun", () => {
|
|
@@ -151,7 +167,7 @@ describe("concept guard — stays green on legitimate patterns", () => {
|
|
|
151
167
|
"blockchain-ecosystem": [role("marketing-agency"), role("paid-ads-agency")],
|
|
152
168
|
})
|
|
153
169
|
);
|
|
154
|
-
expect(violations
|
|
170
|
+
expect(violations).toEqual([]);
|
|
155
171
|
});
|
|
156
172
|
|
|
157
173
|
it("allows an identical concept reused under one semanticSlug", () => {
|
|
@@ -161,7 +177,7 @@ describe("concept guard — stays green on legitimate patterns", () => {
|
|
|
161
177
|
marketing: [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
|
|
162
178
|
})
|
|
163
179
|
);
|
|
164
|
-
expect(violations
|
|
180
|
+
expect(violations).toEqual([]);
|
|
165
181
|
});
|
|
166
182
|
|
|
167
183
|
it("does not police non-role profiles", () => {
|
|
@@ -171,6 +187,6 @@ describe("concept guard — stays green on legitimate patterns", () => {
|
|
|
171
187
|
b: [{ slug: "meeting-note", displayName: "Meeting Note", properties: [] }],
|
|
172
188
|
})
|
|
173
189
|
);
|
|
174
|
-
expect(violations
|
|
190
|
+
expect(violations).toEqual([]);
|
|
175
191
|
});
|
|
176
192
|
});
|
package/src/concept-guard.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Cross-template CONCEPT guard — catches vocabulary forks between templates.
|
|
3
3
|
*
|
|
4
4
|
* WHY THIS EXISTS (read before weakening or deleting it):
|
|
5
|
-
* `business-developer.yaml` introduced workspace-scoped
|
|
6
|
-
* for the same real-world concept that
|
|
7
|
-
* pod-wide shared `lead`/`client
|
|
5
|
+
* `business-developer.yaml` once introduced workspace-scoped
|
|
6
|
+
* `crm-lead`/`crm-client` for the same real-world concept that
|
|
7
|
+
* `foundation.yaml`/`crm.yaml` declare as pod-wide shared `lead`/`client` (that
|
|
8
|
+
* fork has since been collapsed back onto the shared slugs). Nothing failed at
|
|
9
|
+
* the time. Three reasons:
|
|
8
10
|
*
|
|
9
11
|
* 1. `build.mjs`'s `validate()` runs PER FILE, inside the read loop, so it
|
|
10
12
|
* structurally cannot see that another template already owns `lead`.
|
|
@@ -46,20 +48,15 @@ export interface ConceptViolation {
|
|
|
46
48
|
* list cannot rot into a graveyard.
|
|
47
49
|
*
|
|
48
50
|
* Key = the two colliding profile slugs, sorted, joined by "|".
|
|
51
|
+
*
|
|
52
|
+
* Currently EMPTY, and that is the healthy state. The two entries this list was
|
|
53
|
+
* born for — `crm-lead|lead` and `client|crm-client` — were RESOLVED rather
|
|
54
|
+
* than kept: business-developer.yaml now re-declares the shared `lead`/`client`
|
|
55
|
+
* roles and carries its business-development fields as workspace-overlay
|
|
56
|
+
* PROPERTIES. One concept, one slug, workspace-specific fields. Rule 3 below
|
|
57
|
+
* would have failed CI had the entries been left behind.
|
|
49
58
|
*/
|
|
50
|
-
export const DECLARED_DIVERGENCES: Record<string, string> = {
|
|
51
|
-
// Two-tier lead vocabulary, documented at business-developer.yaml:1-16.
|
|
52
|
-
// Foundation's `lead` is a SHARED pod-wide role: being a lead is true
|
|
53
|
-
// everywhere. `crm-lead` is a WORKSPACE-scoped role so a person can be a lead
|
|
54
|
-
// inside one CRM workspace without becoming a lead across the whole pod. The
|
|
55
|
-
// tiers are intentionally separate rows; `semanticSlug: crm.lead` records the
|
|
56
|
-
// kinship so the two are discoverable as one concept via getBySemanticSlug.
|
|
57
|
-
"crm-lead|lead":
|
|
58
|
-
"business-developer: workspace-tier lead, deliberately NOT pod-wide foundation `lead` (two-tier CRM design)",
|
|
59
|
-
// Same reasoning for the post-sale side of the same lifecycle.
|
|
60
|
-
"client|crm-client":
|
|
61
|
-
"business-developer: workspace-tier client, deliberately NOT pod-wide foundation `client` (two-tier CRM design)",
|
|
62
|
-
};
|
|
59
|
+
export const DECLARED_DIVERGENCES: Record<string, string> = {};
|
|
63
60
|
|
|
64
61
|
/**
|
|
65
62
|
* A profile's concept identity. `semanticSlug` is the ONE field that can say
|
|
@@ -105,7 +102,14 @@ interface ProfileRef {
|
|
|
105
102
|
}
|
|
106
103
|
|
|
107
104
|
export function findConceptViolations(
|
|
108
|
-
templates: Record<string, WorkspaceYaml> = WORKSPACE_TEMPLATES
|
|
105
|
+
templates: Record<string, WorkspaceYaml> = WORKSPACE_TEMPLATES,
|
|
106
|
+
/**
|
|
107
|
+
* The declared-divergence allowlist to enforce against. Defaults to the real
|
|
108
|
+
* one; injectable so the escape-hatch semantics (rule 1 suppression, rule 3
|
|
109
|
+
* staleness) stay testable now that the shipped list is empty — previously
|
|
110
|
+
* those tests could only be written because a live entry happened to exist.
|
|
111
|
+
*/
|
|
112
|
+
declaredDivergences: Record<string, string> = DECLARED_DIVERGENCES
|
|
109
113
|
): ConceptViolation[] {
|
|
110
114
|
const violations: ConceptViolation[] = [];
|
|
111
115
|
const all: ProfileRef[] = [];
|
|
@@ -143,7 +147,7 @@ export function findConceptViolations(
|
|
|
143
147
|
if (term !== conceptTerm(effectiveSemanticSlug(b.profile))) continue;
|
|
144
148
|
|
|
145
149
|
const key = pairKey(a.profile.slug, b.profile.slug);
|
|
146
|
-
if (key in
|
|
150
|
+
if (key in declaredDivergences) {
|
|
147
151
|
usedAllowlistKeys.add(key);
|
|
148
152
|
continue;
|
|
149
153
|
}
|
|
@@ -200,7 +204,7 @@ export function findConceptViolations(
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
// ── RULE 3 — the allowlist may not rot ─────────────────────────────────────
|
|
203
|
-
for (const key of Object.keys(
|
|
207
|
+
for (const key of Object.keys(declaredDivergences)) {
|
|
204
208
|
if (usedAllowlistKeys.has(key)) continue;
|
|
205
209
|
violations.push({
|
|
206
210
|
rule: "stale-allowlist",
|
package/src/content-os.yaml
CHANGED
|
@@ -826,6 +826,10 @@ views:
|
|
|
826
826
|
description: Your editorial calendar — every post on the day it publishes.
|
|
827
827
|
config:
|
|
828
828
|
dateField: properties.publish-date
|
|
829
|
+
# The scope lives in `scopeProfileIds` (UUIDs), so `query.profileSlug` is
|
|
830
|
+
# empty — set focusKind explicitly (a slug the adapter reads from `render`)
|
|
831
|
+
# so the backlog + empty-state frame for posts ("Unscheduled posts").
|
|
832
|
+
focusKind: post
|
|
829
833
|
- name: Pillars
|
|
830
834
|
slug: content-pillars
|
|
831
835
|
type: list
|
|
@@ -949,6 +953,24 @@ bento:
|
|
|
949
953
|
viewName: Content Calendar
|
|
950
954
|
pos: { x: 6, y: 5, w: 6, h: 5 }
|
|
951
955
|
|
|
956
|
+
# Per-profile entity-detail layouts (the "template-defined default" bento,
|
|
957
|
+
# entities.ts:3720). A `series` opens showing its ordered curriculum, a `pillar`
|
|
958
|
+
# showing everything made under it — the two crafted detail surfaces. Each is a
|
|
959
|
+
# generic header + properties PLUS a custom cell (registered in
|
|
960
|
+
# registerWorkspacePersonalizationCells; `widgetType` IS the cellKey). The custom
|
|
961
|
+
# widgets read the entity id from CellContext, the same way entity-header does.
|
|
962
|
+
profileEntityBentoTemplates:
|
|
963
|
+
series:
|
|
964
|
+
blocks:
|
|
965
|
+
- { id: series-eb-header, kind: widget, widgetType: entity-header, pos: { x: 0, y: 0, w: 12, h: 2 } }
|
|
966
|
+
- { id: series-eb-props, kind: widget, widgetType: entity-properties, pos: { x: 0, y: 2, w: 5, h: 8 } }
|
|
967
|
+
- { id: series-eb-curriculum, kind: widget, widgetType: series-curriculum, pos: { x: 5, y: 2, w: 7, h: 8 } }
|
|
968
|
+
pillar:
|
|
969
|
+
blocks:
|
|
970
|
+
- { id: pillar-eb-header, kind: widget, widgetType: entity-header, pos: { x: 0, y: 0, w: 12, h: 2 } }
|
|
971
|
+
- { id: pillar-eb-props, kind: widget, widgetType: entity-properties, pos: { x: 0, y: 2, w: 5, h: 8 } }
|
|
972
|
+
- { id: pillar-eb-chapter, kind: widget, widgetType: pillar-chapter, pos: { x: 5, y: 2, w: 7, h: 8 } }
|
|
973
|
+
|
|
952
974
|
# Editorial graph: connect posts to campaign / pillar / sponsor
|
|
953
975
|
entityLinks:
|
|
954
976
|
- sourceProfileSlug: post
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORPUS guard: every automation flow we SHIP must pass `validateFlowDefinition`.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists. `validateTemplate` has called `validateFlowDefinition`
|
|
5
|
+
* for a while (`validate.ts:588`), and the package had 83 passing tests — yet
|
|
6
|
+
* `operations.yaml`'s "Pick up handed-off clients" shipped with four write nodes
|
|
7
|
+
* orphaned outside their loop body, creating entities and relations with EMPTY
|
|
8
|
+
* ids on every run of every pod that installed it. The rule existed and the
|
|
9
|
+
* validator was wired; nothing ever ran the two together over the real corpus.
|
|
10
|
+
* A validator no test points at the shipped data is a validator that only ever
|
|
11
|
+
* checks fixtures.
|
|
12
|
+
*
|
|
13
|
+
* So this reads the SAME YAML the build reads (`readYamlTemplates`, the source
|
|
14
|
+
* the generator consumes — not `templates.ts`, which is generated output and
|
|
15
|
+
* could drift) and asserts the shipped flows are clean. It fails loudly with the
|
|
16
|
+
* per-node messages, because "which node, and why" is the whole value.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { test } from "vitest";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
25
|
+
import { validateFlowDefinition } from "./validate-flow.js";
|
|
26
|
+
|
|
27
|
+
interface ShippedAutomation {
|
|
28
|
+
readonly key?: string;
|
|
29
|
+
readonly name?: string;
|
|
30
|
+
readonly flow?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("corpus: every shipped automation flow passes validateFlowDefinition", () => {
|
|
34
|
+
const templates = readYamlTemplates();
|
|
35
|
+
const failures: string[] = [];
|
|
36
|
+
let flowsChecked = 0;
|
|
37
|
+
|
|
38
|
+
// `readYamlTemplates()` yields `{ file, slug, data }` — the parsed YAML is
|
|
39
|
+
// under `data`, NOT spread onto the entry.
|
|
40
|
+
for (const entry of templates) {
|
|
41
|
+
const { slug, data } = entry as {
|
|
42
|
+
slug?: string;
|
|
43
|
+
data?: { automations?: ShippedAutomation[] };
|
|
44
|
+
};
|
|
45
|
+
const automations = data?.automations;
|
|
46
|
+
if (!Array.isArray(automations)) continue;
|
|
47
|
+
|
|
48
|
+
for (const automation of automations) {
|
|
49
|
+
if (!automation?.flow) continue;
|
|
50
|
+
flowsChecked++;
|
|
51
|
+
const { valid, errors } = validateFlowDefinition(automation.flow);
|
|
52
|
+
if (valid) continue;
|
|
53
|
+
const label = automation.key ?? automation.name ?? "<unnamed>";
|
|
54
|
+
for (const error of errors) {
|
|
55
|
+
failures.push(
|
|
56
|
+
`${slug ?? "<template>"} › ${label} › ` +
|
|
57
|
+
`${error.nodeId ?? "-"} [${error.code}] ${error.message}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Guard the guard: if the corpus ever stops yielding flows (a rename in
|
|
64
|
+
// `readYamlTemplates`, a schema move), this test would "pass" while checking
|
|
65
|
+
// nothing — the exact failure mode it was written to prevent.
|
|
66
|
+
assert.ok(
|
|
67
|
+
flowsChecked > 0,
|
|
68
|
+
"no automation flows found in the shipped corpus — this guard is checking nothing"
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(
|
|
72
|
+
failures,
|
|
73
|
+
[],
|
|
74
|
+
`shipped automation flows failed validation:\n ${failures.join("\n ")}`
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── The SECOND corpus: Control-Plane capability seeds ────────────────────────
|
|
79
|
+
// The YAML sweep above was written after a shipped workspace template was found
|
|
80
|
+
// writing entities with empty ids. It did not catch the IDENTICAL defect in
|
|
81
|
+
// `client-comms-report.capability.json`, because that flow lives in a different
|
|
82
|
+
// repo, in JSON, and nothing pointed a validator at it. Same bug, same week,
|
|
83
|
+
// missed for the same reason: a corpus nobody sweeps.
|
|
84
|
+
//
|
|
85
|
+
// These seeds ship automations verbatim into user pods, so they are held to the
|
|
86
|
+
// same contract. Skips when the CP checkout is absent, like the drift guards.
|
|
87
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
88
|
+
const CP_SEEDS = join(
|
|
89
|
+
HERE,
|
|
90
|
+
"../../../../synap-control-plane-api/src/seeds/capability-templates",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
test("corpus: every CP capability seed's automation flow passes validateFlowDefinition", (t) => {
|
|
94
|
+
if (!existsSync(CP_SEEDS)) {
|
|
95
|
+
t.skip();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const failures: string[] = [];
|
|
99
|
+
let flowsChecked = 0;
|
|
100
|
+
|
|
101
|
+
for (const file of readdirSync(CP_SEEDS).filter((f) => f.endsWith(".json"))) {
|
|
102
|
+
const seed = JSON.parse(readFileSync(join(CP_SEEDS, file), "utf8")) as {
|
|
103
|
+
automations?: Array<{ key?: string; name?: string; flowDefinition?: unknown }>;
|
|
104
|
+
};
|
|
105
|
+
for (const automation of seed.automations ?? []) {
|
|
106
|
+
if (!automation?.flowDefinition) continue;
|
|
107
|
+
flowsChecked++;
|
|
108
|
+
const { valid, errors } = validateFlowDefinition(automation.flowDefinition);
|
|
109
|
+
if (valid) continue;
|
|
110
|
+
const label = automation.key ?? automation.name ?? "<unnamed>";
|
|
111
|
+
for (const error of errors) {
|
|
112
|
+
failures.push(
|
|
113
|
+
`${file} › ${label} › ${error.nodeId ?? "-"} [${error.code}] ${error.message}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Same self-guard as above: a rename in the seed shape must fail loudly
|
|
120
|
+
// rather than silently sweep nothing.
|
|
121
|
+
assert.ok(
|
|
122
|
+
flowsChecked > 0,
|
|
123
|
+
"no automation flows found in the CP capability seeds — this guard is checking nothing",
|
|
124
|
+
);
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
failures,
|
|
127
|
+
[],
|
|
128
|
+
`CP capability seed flows failed validation:\n ${failures.join("\n ")}`,
|
|
129
|
+
);
|
|
130
|
+
});
|
package/src/crm.yaml
CHANGED
|
@@ -18,13 +18,13 @@ meta:
|
|
|
18
18
|
- contacts
|
|
19
19
|
isPublic: true
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
# CRM is a website, so Browser opens it in its normal first-party web session.
|
|
22
|
+
# It deliberately does not use the SDK-hosted iframe/auth-broker path.
|
|
23
|
+
layout:
|
|
24
|
+
primarySurface:
|
|
25
|
+
kind: url
|
|
26
|
+
url: https://crm.synap.live
|
|
27
|
+
title: CRM
|
|
28
28
|
|
|
29
29
|
# SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
|
|
30
30
|
# shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
|
|
@@ -63,6 +63,12 @@ profiles:
|
|
|
63
63
|
icon: user
|
|
64
64
|
color: amber
|
|
65
65
|
description: A prospect, stakeholder, or contact.
|
|
66
|
+
# Pod-wide identity: `entityScope: pod` lands the ROWS with `workspace_id =
|
|
67
|
+
# NULL` (one person record visible from every workspace); the CRM lenses onto
|
|
68
|
+
# it via a role facet (lead/client). `person` is a pod-wide SYSTEM kind, so
|
|
69
|
+
# `scope` is omitted (no claim) — the applier reuses the system row and these
|
|
70
|
+
# CRM props land as this workspace's property OVERLAY.
|
|
71
|
+
entityScope: pod
|
|
66
72
|
properties:
|
|
67
73
|
- slug: email
|
|
68
74
|
label: Email
|
|
@@ -123,7 +129,13 @@ profiles:
|
|
|
123
129
|
icon: building-2
|
|
124
130
|
color: "#10B981"
|
|
125
131
|
description: An organisation being qualified or sold to.
|
|
126
|
-
|
|
132
|
+
# Pod-wide identity: `entityScope: pod` lands the ROWS with `workspace_id =
|
|
133
|
+
# NULL` (one company record visible from every workspace, auto-appearing on the
|
|
134
|
+
# Ecosystem graph), lensed onto this CRM by its role facet (lead/client).
|
|
135
|
+
# `company` is a pod-wide SYSTEM kind, so `scope` is omitted (no claim) — the
|
|
136
|
+
# applier reuses the system row and these CRM props land as an OVERLAY.
|
|
137
|
+
# (Was `scope: WORKSPACE`, which contradicted the pod-wide model.)
|
|
138
|
+
entityScope: pod
|
|
127
139
|
properties:
|
|
128
140
|
- slug: contextSummary
|
|
129
141
|
label: Context
|
|
@@ -336,6 +348,14 @@ profiles:
|
|
|
336
348
|
- healthy
|
|
337
349
|
- at_risk
|
|
338
350
|
- critical
|
|
351
|
+
- slug: driveLink
|
|
352
|
+
label: Drive folder
|
|
353
|
+
valueType: string
|
|
354
|
+
inputType: url
|
|
355
|
+
- slug: driveFolderId
|
|
356
|
+
label: Drive folder ID
|
|
357
|
+
valueType: string
|
|
358
|
+
inputType: text
|
|
339
359
|
|
|
340
360
|
# ── Partner (referral source + co-delivery collaborator) ──────────────────
|
|
341
361
|
- slug: partner
|
|
@@ -456,6 +476,21 @@ profiles:
|
|
|
456
476
|
valueType: string
|
|
457
477
|
inputType: textarea
|
|
458
478
|
|
|
479
|
+
# `linked_to_deal` is the GENERIC counterparty edge and stays the SSOT for "who
|
|
480
|
+
# is on this deal" — the handoff flow's `find-party` node reads exactly it and
|
|
481
|
+
# requires exactly one match, so nothing below may replace it.
|
|
482
|
+
#
|
|
483
|
+
# The two ROLE-QUALIFIED edges are additive: some deals are brokered, and the
|
|
484
|
+
# party on the client side is not the party on the partner side. `client` and
|
|
485
|
+
# `partner` are CO-ATTACHABLE facets on the same person/company (a facet is a
|
|
486
|
+
# hat, not an identity), so the side a party plays ON THIS DEAL cannot be read
|
|
487
|
+
# off its facets — the same company can be the client on one deal and the
|
|
488
|
+
# introducing partner on another. That is a property of the EDGE, which is why
|
|
489
|
+
# it is expressed as two relation types rather than promoted to a kind or
|
|
490
|
+
# inferred from a facet.
|
|
491
|
+
#
|
|
492
|
+
# Both are optional. A direct deal carries only `linked_to_deal` (+ optionally
|
|
493
|
+
# `client_on_deal` naming the same party); nothing requires a partner side.
|
|
459
494
|
entityLinks:
|
|
460
495
|
- sourceProfileSlug: person
|
|
461
496
|
targetProfileSlug: deal
|
|
@@ -463,6 +498,22 @@ entityLinks:
|
|
|
463
498
|
- sourceProfileSlug: company
|
|
464
499
|
targetProfileSlug: deal
|
|
465
500
|
type: linked_to_deal
|
|
501
|
+
- sourceProfileSlug: person
|
|
502
|
+
targetProfileSlug: deal
|
|
503
|
+
type: client_on_deal
|
|
504
|
+
label: Client side
|
|
505
|
+
- sourceProfileSlug: company
|
|
506
|
+
targetProfileSlug: deal
|
|
507
|
+
type: client_on_deal
|
|
508
|
+
label: Client side
|
|
509
|
+
- sourceProfileSlug: person
|
|
510
|
+
targetProfileSlug: deal
|
|
511
|
+
type: partner_on_deal
|
|
512
|
+
label: Partner side
|
|
513
|
+
- sourceProfileSlug: company
|
|
514
|
+
targetProfileSlug: deal
|
|
515
|
+
type: partner_on_deal
|
|
516
|
+
label: Partner side
|
|
466
517
|
|
|
467
518
|
onboarding:
|
|
468
519
|
goal: >
|
|
@@ -59,13 +59,11 @@ describe('workspace primary-surface conversion', () => {
|
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('ships the generated CRM template as
|
|
62
|
+
it('ships the generated CRM template as an ordinary website surface', () => {
|
|
63
63
|
expect(
|
|
64
64
|
toWorkspaceDefinition('crm').definition.layoutConfig?.primarySurface
|
|
65
65
|
).toEqual({
|
|
66
|
-
kind: '
|
|
67
|
-
appId: 'crm',
|
|
68
|
-
rendererType: 'external',
|
|
66
|
+
kind: 'url',
|
|
69
67
|
url: 'https://crm.synap.live',
|
|
70
68
|
title: 'CRM',
|
|
71
69
|
});
|
package/src/define.ts
CHANGED
|
@@ -272,6 +272,19 @@ export interface WorkspaceDefinitionInput {
|
|
|
272
272
|
seedEntities: NonNullable<WorkspaceYaml['seedEntities']>;
|
|
273
273
|
suggestedRelations?: WorkspaceYaml['suggestedRelations'];
|
|
274
274
|
displayTemplates?: WorkspaceYaml['displayTemplates'];
|
|
275
|
+
/**
|
|
276
|
+
* Domain-level relation TYPE vocabulary — the door find-or-creates a
|
|
277
|
+
* `relation_defs` row per entry (idempotent by slug within the workspace). NOT
|
|
278
|
+
* `entityLinks` (those are profile→profile edges and drop the rich metadata).
|
|
279
|
+
* Carried verbatim; the authored form IS the wire form.
|
|
280
|
+
*/
|
|
281
|
+
relationDefs?: WorkspaceYaml['relationDefs'];
|
|
282
|
+
/**
|
|
283
|
+
* Workspace intelligence commands — the door find-or-creates an
|
|
284
|
+
* `intelligence_commands` row per entry (idempotent by title). Carried
|
|
285
|
+
* verbatim; the authored form IS the wire form.
|
|
286
|
+
*/
|
|
287
|
+
commands?: WorkspaceYaml['commands'];
|
|
275
288
|
layoutConfig: WorkspaceYaml['layout'] | undefined;
|
|
276
289
|
bentoViewName?: string;
|
|
277
290
|
/**
|
|
@@ -398,6 +411,11 @@ export function toWorkspaceDefinition(
|
|
|
398
411
|
seedEntities: tpl.seedEntities ?? [],
|
|
399
412
|
suggestedRelations: tpl.suggestedRelations,
|
|
400
413
|
displayTemplates: tpl.displayTemplates,
|
|
414
|
+
// Relation-type vocabulary + intelligence commands — carried verbatim so the
|
|
415
|
+
// one reconcile door can seed the universal defaults (the `base` template)
|
|
416
|
+
// that three hardcoded seeders used to. Authored form IS the wire form.
|
|
417
|
+
relationDefs: tpl.relationDefs,
|
|
418
|
+
commands: tpl.commands,
|
|
401
419
|
layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
|
|
402
420
|
bentoViewName: tpl.bento?.viewName,
|
|
403
421
|
bentoLayout: bento.bentoLayout,
|
|
@@ -472,6 +490,8 @@ type WorkspaceDefinitionForwarded =
|
|
|
472
490
|
| 'seedEntities'
|
|
473
491
|
| 'suggestedRelations'
|
|
474
492
|
| 'displayTemplates'
|
|
493
|
+
| 'relationDefs' // → carried verbatim; door find-or-creates relation_defs rows
|
|
494
|
+
| 'commands' // → carried verbatim; door find-or-creates intelligence_commands rows
|
|
475
495
|
| 'layout' // → layoutConfig
|
|
476
496
|
| 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
|
|
477
497
|
| 'profileEntityBentoTemplates'
|
|
@@ -544,6 +564,10 @@ export interface PackageDefinitionOutput {
|
|
|
544
564
|
suggestedRelations?: WorkspaceYaml['suggestedRelations'];
|
|
545
565
|
displayTemplates?: WorkspaceYaml['displayTemplates'];
|
|
546
566
|
entityLinks?: WorkspaceYaml['entityLinks'];
|
|
567
|
+
/** Relation-type vocabulary — carried verbatim (authored form IS the wire form). */
|
|
568
|
+
relationDefs?: WorkspaceYaml['relationDefs'];
|
|
569
|
+
/** Intelligence commands — carried verbatim (authored form IS the wire form). */
|
|
570
|
+
commands?: WorkspaceYaml['commands'];
|
|
547
571
|
/** WIDGET half of the authored bento union — mapped by the pod via `widgetType`. */
|
|
548
572
|
bentoLayout?: BentoWidgetBlockWire[];
|
|
549
573
|
/**
|
|
@@ -619,6 +643,8 @@ export function toPackageDefinition(
|
|
|
619
643
|
suggestedRelations: tpl.suggestedRelations,
|
|
620
644
|
displayTemplates: tpl.displayTemplates,
|
|
621
645
|
entityLinks: tpl.entityLinks,
|
|
646
|
+
relationDefs: tpl.relationDefs,
|
|
647
|
+
commands: tpl.commands,
|
|
622
648
|
bentoLayout: bento.bentoLayout,
|
|
623
649
|
bentoViewBlocks: bento.bentoViewBlocks,
|
|
624
650
|
bentoViewName: tpl.bento?.viewName,
|
|
@@ -650,6 +676,8 @@ type PackageDefinitionForwarded =
|
|
|
650
676
|
| 'suggestedRelations'
|
|
651
677
|
| 'displayTemplates'
|
|
652
678
|
| 'entityLinks'
|
|
679
|
+
| 'relationDefs' // → carried verbatim
|
|
680
|
+
| 'commands' // → carried verbatim
|
|
653
681
|
| 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
|
|
654
682
|
| 'profileEntityBentoTemplates'
|
|
655
683
|
| 'layout' // → layoutConfig
|
package/src/ecosystem.yaml
CHANGED
|
@@ -51,6 +51,14 @@ profiles:
|
|
|
51
51
|
icon: user
|
|
52
52
|
color: amber
|
|
53
53
|
description: A person in the ecosystem — a founder, operator, or contact.
|
|
54
|
+
# Pod-wide identity. `person` is a pod-wide SYSTEM kind, so `entityScope: pod`
|
|
55
|
+
# is the load-bearing axis: the ROWS land with `workspace_id = NULL`, visible
|
|
56
|
+
# from every workspace (auto-appearing on the Ecosystem graph) and lensed onto
|
|
57
|
+
# a CRM only where it wears a role facet (lead/client/partner). `scope` is
|
|
58
|
+
# deliberately OMITTED: it is already a system profile (more pod-wide than
|
|
59
|
+
# `shared`), so any scope claim only forks the row-level SSOT (see
|
|
60
|
+
# shared-role-ssot.test.ts (c)); the applier reuses the existing system row.
|
|
61
|
+
entityScope: pod
|
|
54
62
|
properties:
|
|
55
63
|
- slug: email
|
|
56
64
|
label: Email
|
|
@@ -66,6 +74,12 @@ profiles:
|
|
|
66
74
|
icon: building-2
|
|
67
75
|
color: "#10B981"
|
|
68
76
|
description: A company in the ecosystem — a player you operate around.
|
|
77
|
+
# Pod-wide identity — same rationale as `person` above: `company` is a pod-wide
|
|
78
|
+
# SYSTEM kind, so `entityScope: pod` lands the ROWS with `workspace_id = NULL`
|
|
79
|
+
# (visible from every workspace, auto-appearing on the Ecosystem graph, lensed
|
|
80
|
+
# onto CRM only by its role facet). `scope` omitted so the applier reuses the
|
|
81
|
+
# existing system row without forking the row-level SSOT.
|
|
82
|
+
entityScope: pod
|
|
69
83
|
properties:
|
|
70
84
|
- slug: website
|
|
71
85
|
label: Website
|
package/src/enterprise-os.yaml
CHANGED
|
@@ -51,7 +51,7 @@ dependencies:
|
|
|
51
51
|
- slug: business-developer
|
|
52
52
|
kind: workspace
|
|
53
53
|
relation: require
|
|
54
|
-
reason: "The lead-first sales motion (
|
|
54
|
+
reason: "The lead-first sales motion (the business-development overlay on the pod-wide lead/client roles, commercial proposals, commission handoff) that the CRM app runs on. Composes onto the CRM workspace above — without it the CRM lens has the bare lead/client roles with no lifecycle or handoff fields and the app's pipeline is empty."
|
|
55
55
|
- slug: content-os
|
|
56
56
|
kind: workspace
|
|
57
57
|
relation: require
|
package/src/foundation.yaml
CHANGED
|
@@ -371,6 +371,14 @@ profiles:
|
|
|
371
371
|
- healthy
|
|
372
372
|
- at_risk
|
|
373
373
|
- critical
|
|
374
|
+
- slug: driveLink
|
|
375
|
+
label: Drive folder
|
|
376
|
+
valueType: string
|
|
377
|
+
inputType: url
|
|
378
|
+
- slug: driveFolderId
|
|
379
|
+
label: Drive folder ID
|
|
380
|
+
valueType: string
|
|
381
|
+
inputType: text
|
|
374
382
|
|
|
375
383
|
# ── Partner (referral source + co-delivery collaborator) ──────────────────
|
|
376
384
|
- slug: partner
|