@synap-core/workspace-templates 0.6.4 → 0.7.1

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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Unit tests for the `layerTemplateGraph` KEYSTONE engine.
3
+ * Run: `npx tsx --test src/composition-graph.test.ts`
4
+ *
5
+ * These test the SOURCE (`./composition-graph.js` → composition-graph.ts),
6
+ * never `dist/`, so no build is required and there is no stale-generated-
7
+ * output trap (same convention as `catalog.test.ts`).
8
+ */
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { layerTemplateGraph, type CompositionGraph } from "./composition-graph.js";
13
+ import type { TemplateDependency } from "./types.js";
14
+
15
+ /** Build a small `edgesOf` from a plain adjacency map, for test fixtures. */
16
+ function edgesFrom(map: Record<string, TemplateDependency[]>) {
17
+ return (slug: string): readonly TemplateDependency[] => map[slug] ?? [];
18
+ }
19
+
20
+ function flat(graph: CompositionGraph): string[] {
21
+ return graph.layers.flat().map((n) => n.slug);
22
+ }
23
+
24
+ // ── Longest-path: deeper occurrence wins (bedrock behavior) ────────────────
25
+ test("longest-path: a node required at depth 1 by one root and depth 3 by another lands at the deeper layer", () => {
26
+ // root-a -> shared (depth 1)
27
+ // root-b -> mid -> deep -> shared (depth 3)
28
+ const edgesOf = edgesFrom({
29
+ "root-a": [{ slug: "shared" }],
30
+ "root-b": [{ slug: "mid" }],
31
+ mid: [{ slug: "deep" }],
32
+ deep: [{ slug: "shared" }],
33
+ });
34
+
35
+ const graph = layerTemplateGraph({ roots: ["root-a", "root-b"], edgesOf });
36
+ const shared = graph.nodes.get("shared")!;
37
+ assert.equal(shared.layer, 3, "shared must sink to the DEEPER of its two reachable depths");
38
+ });
39
+
40
+ // ── Dedupe / fan-in ──────────────────────────────────────────────────────────
41
+ test("dedupe/fan-in: a node reachable from 6 parents appears ONCE with dependents.length === 6", () => {
42
+ const parents = Array.from({ length: 6 }, (_, i) => `parent-${i}`);
43
+ const edgesOf = edgesFrom({
44
+ root: parents.map((p) => ({ slug: p })),
45
+ ...Object.fromEntries(parents.map((p) => [p, [{ slug: "foundation" }]])),
46
+ });
47
+
48
+ const graph = layerTemplateGraph({ roots: ["root"], edgesOf });
49
+ const foundationNodes = [...graph.nodes.values()].filter((n) => n.slug === "foundation");
50
+ assert.equal(foundationNodes.length, 1, "foundation must appear exactly once in the node map");
51
+ assert.equal(foundationNodes[0]!.dependents.length, 6, "foundation must carry TRUE fan-in from all 6 parents");
52
+ });
53
+
54
+ // ── Multi-root ───────────────────────────────────────────────────────────────
55
+ test("multi-root: two roots both reaching a shared node → roots lists both", () => {
56
+ const edgesOf = edgesFrom({
57
+ "root-a": [{ slug: "shared" }],
58
+ "root-b": [{ slug: "shared" }],
59
+ });
60
+
61
+ const graph = layerTemplateGraph({ roots: ["root-a", "root-b"], edgesOf });
62
+ const shared = graph.nodes.get("shared")!;
63
+ assert.deepEqual(shared.roots, ["root-a", "root-b"]);
64
+ });
65
+
66
+ // ── Compose overlays ─────────────────────────────────────────────────────────
67
+ test("compose: a relation:compose dep appears in the base's overlays[] and NOT in layers", () => {
68
+ // `grants` composes onto `operations` — mirrors the real grants.yaml/operations.yaml pair.
69
+ const edgesOf = edgesFrom({
70
+ grants: [{ slug: "operations", relation: "compose", reason: "overlay" }],
71
+ });
72
+
73
+ const graph = layerTemplateGraph({ roots: ["grants"], edgesOf });
74
+
75
+ const operations = graph.nodes.get("operations")!;
76
+ assert.ok(operations, "operations (the base) must be a real node");
77
+ assert.deepEqual(
78
+ operations.overlays.map((o) => o.slug),
79
+ ["grants"],
80
+ "the compose source must be attached as an overlay on the base"
81
+ );
82
+
83
+ assert.equal(graph.nodes.has("grants"), false, "the compose SOURCE must never be its own node");
84
+ assert.ok(!flat(graph).includes("grants"), "the compose source must never appear in layers[][]");
85
+
86
+ const composeEdge = graph.edges.find((e) => e.from === "grants" && e.to === "operations");
87
+ assert.ok(composeEdge, "the compose edge must still be recorded in edges[]");
88
+ assert.equal(composeEdge!.relation, "compose");
89
+ });
90
+
91
+ // ── Ghost nodes ──────────────────────────────────────────────────────────────
92
+ test("ghost: an uninstalled dep is present with installed:false", () => {
93
+ const edgesOf = edgesFrom({
94
+ root: [{ slug: "not-installed-yet" }],
95
+ });
96
+
97
+ const graph = layerTemplateGraph({ roots: ["root"], edgesOf, installed: [] });
98
+ const ghost = graph.nodes.get("not-installed-yet")!;
99
+ assert.equal(ghost.installed, false);
100
+ assert.deepEqual(ghost.workspaceIds, []);
101
+ // The gap IS the information: it still occupies its layer.
102
+ assert.equal(graph.layers[ghost.layer]!.some((n) => n.slug === "not-installed-yet"), true);
103
+ });
104
+
105
+ // ── Cycles ───────────────────────────────────────────────────────────────────
106
+ test("cycle: A→B→A terminates and reports the broken edge in cycles[]", () => {
107
+ const edgesOf = edgesFrom({
108
+ A: [{ slug: "B" }],
109
+ B: [{ slug: "A" }],
110
+ });
111
+
112
+ const graph = layerTemplateGraph({ roots: ["A"], edgesOf });
113
+
114
+ assert.equal(graph.cycles.length, 1);
115
+ assert.deepEqual(graph.cycles[0], ["B", "A"]);
116
+
117
+ // Still terminates and produces a usable graph.
118
+ assert.ok(graph.nodes.has("A"));
119
+ assert.ok(graph.nodes.has("B"));
120
+ assert.equal(graph.nodes.get("A")!.layer, 0);
121
+ });
122
+
123
+ // ── edgesOf injection ─────────────────────────────────────────────────────────
124
+ test("edgesOf injection: deps supplied for a slug NOT in the bundled map are still walked", () => {
125
+ // "private-remote-pkg" does not exist in the bundled WORKSPACE_TEMPLATES map
126
+ // at all — this is the live bug rule 5 fixes: a caller-supplied edgesOf must
127
+ // still be able to walk it.
128
+ const edgesOf = edgesFrom({
129
+ "private-remote-pkg": [{ slug: "some-base", relation: "require" }],
130
+ });
131
+
132
+ const graph = layerTemplateGraph({ roots: ["private-remote-pkg"], edgesOf });
133
+ assert.ok(graph.nodes.has("some-base"), "a dependency declared only by the injected edgesOf must still be walked");
134
+ assert.equal(graph.nodes.get("some-base")!.layer, 1);
135
+ });
@@ -0,0 +1,298 @@
1
+ /**
2
+ * layerTemplateGraph — the KEYSTONE composition-graph engine.
3
+ * ============================================================
4
+ *
5
+ * Today the CLI (`synap-cli/src/commands/market.ts` `buildCompositionTrees`)
6
+ * renders template composition as a nested TREE: `buildDependencyNode`
7
+ * rebuilds a fresh `TreeNode` for every OCCURRENCE of a slug on every path, so
8
+ * a widely-required base (e.g. `foundation`) prints once per branch that
9
+ * reaches it (6× under a suite like `enterprise-os`). Its "shared" tag is only
10
+ * top-level-package granularity — there is no true fan-in count.
11
+ *
12
+ * This module replaces that with a LAYERED DAG: every node appears EXACTLY
13
+ * ONCE, positioned at its longest-path depth, with edges converging on it —
14
+ * so `foundation` sinks to the bedrock row with N inbound edges instead of
15
+ * being redrawn N times. It is the single source of truth for template
16
+ * composition; the CLI, browser dialogs, backend boot-ordering, and (later)
17
+ * the Atlas all consume it.
18
+ *
19
+ * PURE + TRANSPORT-AGNOSTIC (same discipline as `catalog.ts`): this is the
20
+ * ONLY module `synap-cli`, `browser`, `synap-control-plane-api`, and
21
+ * `synap-backend` can all resolve. No React, no dagre/d3, no fetch, no
22
+ * Node-only APIs. Pixel layout lives elsewhere (browser-only) — this module
23
+ * only computes topology (layers + edges + memoized nodes), never coordinates.
24
+ *
25
+ * CONSUMES drift, never recomputes it: `installed` / `updates` are read
26
+ * straight off `catalog.ts`'s `InstalledTemplate`-shaped rows and
27
+ * `computeTemplateUpdates()` output — this module does no semver comparison
28
+ * of its own.
29
+ */
30
+
31
+ import type { TemplateDependency } from "./types.js";
32
+ import { getWorkspaceTemplate } from "./templates.js";
33
+ import type { TemplateUpdateCheck } from "./catalog.js";
34
+
35
+ // ============================================================================
36
+ // Inputs
37
+ // ============================================================================
38
+
39
+ /**
40
+ * An installed template row, keyed by slug — the shape this engine needs to
41
+ * mark a node `installed` and populate `workspaceIds`.
42
+ *
43
+ * NOTE ON NAMING: `catalog.ts`'s `InstalledTemplate` is `{ slug, version }`
44
+ * only — it was built for `computeTemplateUpdates`'s narrower need (a version
45
+ * to diff) and carries no `workspaceId`. `CompositionNode.workspaceIds` is
46
+ * part of the ratified API and cannot be populated from that shape, so this
47
+ * is a REAL conflict, not a preference: this module declares its own,
48
+ * slightly richer row instead of importing `catalog.ts`'s `InstalledTemplate`
49
+ * under that name. Every field of `catalog.ts`'s `InstalledTemplate` is a
50
+ * subset of this one, so a caller building `InstalledTemplate[]` for
51
+ * `computeTemplateUpdates` can build ONE array of `CompositionInstalledEntry`
52
+ * and pass it to both doors.
53
+ */
54
+ export interface CompositionInstalledEntry {
55
+ slug: string;
56
+ workspaceId: string;
57
+ workspaceName?: string;
58
+ /** `settings.packageVersion`, when the pod stamped one. Omit when unknown. */
59
+ version?: string;
60
+ }
61
+
62
+ /** One compose-relation dependency, attached to the BASE node it overlays onto. */
63
+ export interface CompositionOverlay {
64
+ slug: string;
65
+ name: string;
66
+ installed: boolean;
67
+ }
68
+
69
+ /** One edge in the raw dependency graph, exactly as declared (kept even when broken by a cycle). */
70
+ export interface CompositionEdge {
71
+ from: string;
72
+ to: string;
73
+ relation: "compose" | "require";
74
+ depKind?: string;
75
+ }
76
+
77
+ export interface CompositionNode {
78
+ slug: string;
79
+ name: string;
80
+ /** Longest-path depth: `1 + max(layer of every node that depends on this one)`. Roots = 0, bedrock = max. */
81
+ layer: number;
82
+ /** TRUE fan-in — every slug (node or compose-only overlay source) that declares a dependency on this one. */
83
+ dependents: string[];
84
+ /** Which of the input `roots` transitively reach this node (self-inclusive for a root). */
85
+ roots: string[];
86
+ installed: boolean;
87
+ workspaceIds: string[];
88
+ installedVersion?: string;
89
+ latestVersion?: string | null;
90
+ updateAvailable: boolean;
91
+ noVersionInfo: boolean;
92
+ /** `compose`-relation deps that overlay onto THIS node (see rule 2 — the overlay source itself is never a layer node). */
93
+ overlays: CompositionOverlay[];
94
+ }
95
+
96
+ export interface CompositionGraph {
97
+ nodes: Map<string, CompositionNode>;
98
+ edges: CompositionEdge[];
99
+ /** `layers[0]` = roots … `layers[n]` = bedrock. Compose-overlay SOURCES never appear here (rule 2). */
100
+ layers: CompositionNode[][];
101
+ /** Broken back-edges `[from, to]` — surfaced, never silently swallowed (rule 4). */
102
+ cycles: string[][];
103
+ }
104
+
105
+ export interface LayerTemplateGraphInput {
106
+ /** Top-level slugs to walk from. Always land at `layer: 0`. */
107
+ roots: string[];
108
+ /** INJECTED edge source (rule 5) — never hardcoded to the bundled map. See `bundledEdgesOf`. */
109
+ edgesOf: (slug: string) => readonly TemplateDependency[];
110
+ nameOf?: (slug: string) => string | undefined;
111
+ installed?: CompositionInstalledEntry[];
112
+ updates?: TemplateUpdateCheck[];
113
+ }
114
+
115
+ /**
116
+ * Default `edgesOf` — reads `dependencies[]` off the BUNDLED template map
117
+ * only. This is what the current CLI walker is hardcoded to, which is
118
+ * precisely the live bug rule 5 fixes: a remote/private CP-only package's
119
+ * `definition.dependencies` are invisible to it, so its subtree silently
120
+ * renders empty. Callers with a remote catalog entry available should pass
121
+ * their OWN `edgesOf` that falls back to `entry.remote.definition.dependencies`
122
+ * (or similar) for slugs this default can't see.
123
+ */
124
+ export function bundledEdgesOf(slug: string): readonly TemplateDependency[] {
125
+ return getWorkspaceTemplate(slug)?.dependencies ?? [];
126
+ }
127
+
128
+ // ============================================================================
129
+ // The engine
130
+ // ============================================================================
131
+
132
+ export function layerTemplateGraph(input: LayerTemplateGraphInput): CompositionGraph {
133
+ const { roots, edgesOf } = input;
134
+ const nameOf = input.nameOf ?? (() => undefined);
135
+ const rootSet = new Set(roots);
136
+
137
+ const installedBySlug = new Map<string, CompositionInstalledEntry[]>();
138
+ for (const entry of input.installed ?? []) {
139
+ const list = installedBySlug.get(entry.slug);
140
+ if (list) list.push(entry);
141
+ else installedBySlug.set(entry.slug, [entry]);
142
+ }
143
+ const updatesBySlug = new Map((input.updates ?? []).map((u) => [u.slug, u]));
144
+
145
+ // ── Phase 1: discovery — walk every root, global memo (rule 1) ───────────
146
+ const edges: CompositionEdge[] = [];
147
+ const cycles: string[][] = [];
148
+ /** Every edge target/source touched, whether or not it ends up a layer node. */
149
+ const touched = new Set<string>(roots);
150
+ /** dependent -> Set(dependency) for EVERY edge (both relations) — reachability adjacency. */
151
+ const adjacency = new Map<string, Set<string>>();
152
+ /** dependency -> Set(dependent) for every NON-broken edge (both relations) — true fan-in + layering DP. */
153
+ const dependentsMap = new Map<string, Set<string>>();
154
+ /** Compose-relation edges, kept separately to build overlays + the node-exclusion set. */
155
+ const composeEdges: Array<{ from: string; to: string; depKind?: string }> = [];
156
+ /** A slug's `edgesOf` has already been walked — prevents re-expanding a shared node's children (rule 1). */
157
+ const discovered = new Set<string>();
158
+
159
+ function addAdjacency(from: string, to: string): void {
160
+ const set = adjacency.get(from);
161
+ if (set) set.add(to);
162
+ else adjacency.set(from, new Set([to]));
163
+ }
164
+
165
+ function addDependent(dependency: string, dependent: string): void {
166
+ const set = dependentsMap.get(dependency);
167
+ if (set) set.add(dependent);
168
+ else dependentsMap.set(dependency, new Set([dependent]));
169
+ }
170
+
171
+ function walk(slug: string, ancestors: Set<string>): void {
172
+ const deps = edgesOf(slug) ?? [];
173
+ for (const dep of deps) {
174
+ const relation: "compose" | "require" = dep.relation ?? "require";
175
+ touched.add(dep.slug);
176
+
177
+ if (ancestors.has(dep.slug)) {
178
+ // Broken back-edge — surfaced, not swallowed (rule 4). Still recorded
179
+ // in `edges` (the honest picture of what was declared), but excluded
180
+ // from `dependentsMap`/`adjacency` so it can't loop the DP/BFS below.
181
+ cycles.push([slug, dep.slug]);
182
+ edges.push({ from: slug, to: dep.slug, relation, depKind: dep.kind });
183
+ continue;
184
+ }
185
+
186
+ edges.push({ from: slug, to: dep.slug, relation, depKind: dep.kind });
187
+ addAdjacency(slug, dep.slug);
188
+ addDependent(dep.slug, slug);
189
+ if (relation === "compose") {
190
+ composeEdges.push({ from: slug, to: dep.slug, depKind: dep.kind });
191
+ }
192
+
193
+ if (!discovered.has(dep.slug)) {
194
+ discovered.add(dep.slug);
195
+ const nextAncestors = new Set(ancestors);
196
+ nextAncestors.add(slug);
197
+ walk(dep.slug, nextAncestors);
198
+ }
199
+ }
200
+ }
201
+
202
+ for (const root of roots) {
203
+ if (discovered.has(root)) continue;
204
+ discovered.add(root);
205
+ walk(root, new Set([root]));
206
+ }
207
+
208
+ // ── Phase 2: compose-relation sources are never layer nodes (rule 2) ─────
209
+ const composeSources = new Set(composeEdges.map((e) => e.from));
210
+ const overlaysByBase = new Map<string, CompositionOverlay[]>();
211
+ for (const e of composeEdges) {
212
+ const isInstalled = installedBySlug.has(e.from);
213
+ const overlay: CompositionOverlay = { slug: e.from, name: nameOf(e.from) ?? e.from, installed: isInstalled };
214
+ const list = overlaysByBase.get(e.to);
215
+ if (list) list.push(overlay);
216
+ else overlaysByBase.set(e.to, [overlay]);
217
+ }
218
+
219
+ // ── Phase 3: which touched slugs are real layer nodes ────────────────────
220
+ const nodeSlugs = [...touched].filter((s) => !composeSources.has(s)).sort();
221
+
222
+ // ── Phase 4: reachability — which input roots reach each slug ────────────
223
+ const rootsReaching = new Map<string, Set<string>>();
224
+ for (const root of roots) {
225
+ const visited = new Set<string>([root]);
226
+ const queue: string[] = [root];
227
+ const addReach = (s: string) => {
228
+ const set = rootsReaching.get(s);
229
+ if (set) set.add(root);
230
+ else rootsReaching.set(s, new Set([root]));
231
+ };
232
+ addReach(root);
233
+ while (queue.length) {
234
+ const cur = queue.shift()!;
235
+ for (const next of adjacency.get(cur) ?? []) {
236
+ if (visited.has(next)) continue;
237
+ visited.add(next);
238
+ addReach(next);
239
+ queue.push(next);
240
+ }
241
+ }
242
+ }
243
+
244
+ // ── Phase 5: longest-path layering (rule 1) ───────────────────────────────
245
+ // layer(n) = 1 + max(layer of every node that depends on n); roots = 0.
246
+ // Works for ANY slug (node or compose-only overlay source) so a compose
247
+ // source's OWN layer still feeds the DP for the base it composes onto —
248
+ // e.g. `grants` (excluded from `nodes`) still positions `operations` one
249
+ // layer below wherever `grants` itself sits.
250
+ const layerMemo = new Map<string, number>();
251
+ function layerOf(slug: string, stack: Set<string>): number {
252
+ if (rootSet.has(slug)) return 0;
253
+ const memoized = layerMemo.get(slug);
254
+ if (memoized !== undefined) return memoized;
255
+ if (stack.has(slug)) return 0; // defensive — cycle edges are already excluded from dependentsMap
256
+ stack.add(slug);
257
+ const dependents = dependentsMap.get(slug);
258
+ const layer = !dependents || dependents.size === 0
259
+ ? 0
260
+ : 1 + Math.max(...[...dependents].map((d) => layerOf(d, stack)));
261
+ stack.delete(slug);
262
+ layerMemo.set(slug, layer);
263
+ return layer;
264
+ }
265
+
266
+ // ── Phase 6: build nodes ──────────────────────────────────────────────────
267
+ const nodes = new Map<string, CompositionNode>();
268
+ for (const slug of nodeSlugs) {
269
+ const installedEntries = installedBySlug.get(slug) ?? [];
270
+ const workspaceIds = installedEntries.map((e) => e.workspaceId).filter(Boolean);
271
+ const installed = workspaceIds.length > 0;
272
+ const update = updatesBySlug.get(slug);
273
+ const installedVersion = update?.installedVersion || installedEntries[0]?.version;
274
+ const noVersionInfo = installed && (update === undefined || update.latestVersion === undefined);
275
+
276
+ nodes.set(slug, {
277
+ slug,
278
+ name: nameOf(slug) ?? slug,
279
+ layer: layerOf(slug, new Set()),
280
+ dependents: [...(dependentsMap.get(slug) ?? [])].sort(),
281
+ roots: [...(rootsReaching.get(slug) ?? [])].sort(),
282
+ installed,
283
+ workspaceIds,
284
+ installedVersion,
285
+ latestVersion: update?.latestVersion ?? null,
286
+ updateAvailable: update?.updateAvailable ?? false,
287
+ noVersionInfo,
288
+ overlays: overlaysByBase.get(slug) ?? [],
289
+ });
290
+ }
291
+
292
+ const maxLayer = Math.max(0, ...[...nodes.values()].map((n) => n.layer));
293
+ const layers: CompositionNode[][] = Array.from({ length: maxLayer + 1 }, () => []);
294
+ for (const node of nodes.values()) layers[node.layer].push(node);
295
+ for (const bucket of layers) bucket.sort((a, b) => a.slug.localeCompare(b.slug));
296
+
297
+ return { nodes, edges, layers, cycles };
298
+ }
package/src/crm.yaml CHANGED
@@ -1,8 +1,8 @@
1
1
  # CRM workspace template
2
2
  # The CRM owns pre-sale work only. A won deal is handed to the operating
3
3
  # workspace; this template deliberately does not provision client delivery,
4
- # campaigns, tasks, documents, or automations. It DOES seed funnel playbooks
5
- # (the action layer) so the pipeline is operable via sessions — see `playbooks:`.
4
+ # campaigns, tasks, documents, automations, or sales-specific playbooks. A
5
+ # use-case overlay (for example `business-developer`) may add those workflows.
6
6
 
7
7
  meta:
8
8
  slug: crm
@@ -17,6 +17,20 @@ meta:
17
17
  - contacts
18
18
  isPublic: true
19
19
 
20
+ # SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
21
+ # shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
22
+ # a shared profile's pod-wide BASE property body from whichever template
23
+ # creates it FIRST, and adds a REUSING template's extra props as workspace
24
+ # OVERLAYS. Requiring foundation makes the resolver install it first
25
+ # (deps-first), so the base is always foundation's SSOT body and never this
26
+ # template's. `require`, not `compose`: foundation is a real workspace, not an
27
+ # overlay.
28
+ dependencies:
29
+ - slug: foundation
30
+ kind: workspace
31
+ relation: require
32
+ reason: "Provides the pod-wide client/partner/lead/churned role vocabulary this workspace reuses."
33
+
20
34
  workspace:
21
35
  name: CRM
22
36
  description: A focused pre-sale workspace for contacts, companies, and deals.
@@ -407,67 +421,33 @@ entityLinks:
407
421
  targetProfileSlug: deal
408
422
  type: linked_to_deal
409
423
 
410
- # Playbooks — session templates seeded with the workspace. The CRM's action layer.
411
- # `subjectProfile` binds each playbook to a funnel subject so the Capture → Session
412
- # matcher (`playbooks.matchForEntity`, filtering `subject_profile->>'profileSlug'`)
413
- # can offer it when that subject is captured — a captured lead surfaces "Enrich this
414
- # lead" / "Qualify this lead"; a deal surfaces "Work this deal". The subject entity
415
- # is passed as the matching entity param and substituted into the goalTemplate.
416
- #
417
- # Two subjects are used: `lead` (a shared ROLE facet on a person/company — the same
418
- # facet-role subject pattern ecosystem.yaml uses for `competitor`) and `deal` (a
419
- # primary CRM kind). The matcher keys purely on the profileSlug string, so a role
420
- # facet and a kind are matched identically.
421
- #
422
- # No `grants:` on "Enrich this lead": enrichment tools (Apify/Unipile-LinkedIn) are
423
- # workspace-conditional and may not be granted, so the goalTemplate context-injects
424
- # the intent and the session agent uses whatever enrichment capability the workspace
425
- # actually has — mirroring how "Produce Content" (content-os) grants nothing and
426
- # leans on the workspace's tools, rather than hardcoding a tool that might be absent.
424
+ # Playbook — enriches a lead/prospect from public sources (company site,
425
+ # LinkedIn, firmographic signal) so ICP scoring and qualification start from
426
+ # real data instead of a blank record. Bound to `lead` so a newly captured
427
+ # lead can offer this as a session.
427
428
  playbooks:
428
- - name: Enrich this lead
429
- description: Take a thin, freshly-captured lead and fill it out — company, title, LinkedIn, and the relationships around it.
429
+ - name: Enrich Lead/Company
430
+ description: Enrich a lead or company from public sources — firmographics, company size, and recent signal — to sharpen ICP scoring and qualification.
430
431
  goalTemplate: >-
431
- Enrich this lead ({lead}). Starting from whatever is known, fill out the
432
- missing detail — company, job title, LinkedIn URL, location, and the people
433
- and companies around them — and record it on the lead. Draw on whatever
434
- enrichment capability this workspace has connected (e.g. an Apify or
435
- Unipile/LinkedIn tool); if none is available, enrich from the public web and
436
- what is already in the pod. Link the lead to its company and any related
437
- contacts, and note where information is still missing.
432
+ Enrich {target} using public sources. Scrape their website and LinkedIn
433
+ presence (via Apify) for firmographics: company size, industry, location,
434
+ and any recent signal (funding, hiring, product launches). Fill in the
435
+ missing profile fields (industry, companySize, location, icpScore) with
436
+ what you find, and note anything that changes qualification. Do not
437
+ fabricate — leave a field blank rather than guess.
438
438
  subjectProfile:
439
439
  profileSlug: lead
440
440
  params:
441
- - { name: lead, type: entity, label: Lead, required: true }
441
+ - { name: target, type: entity, label: Lead or company, required: true }
442
442
  executor: is-agent
443
+ grants:
444
+ - apify_run_actor
445
+ - entity.create
443
446
  status: active
444
447
 
445
- - name: Qualify this lead
446
- description: Assess a lead against the ICP, score the fit, and decide the next step — advance, nurture, or disqualify.
447
- goalTemplate: >-
448
- Qualify this lead ({lead}). Assess how well they fit our ideal customer
449
- profile — company, role, industry, size, and the signals of intent or need —
450
- and set an ICP score. Decide the next step: advance them toward a deal,
451
- keep nurturing, or disqualify. Write the reasoning and the decision back onto
452
- the lead (its stage, score, and next step) so the call is auditable.
453
- subjectProfile:
454
- profileSlug: lead
455
- params:
456
- - { name: lead, type: entity, label: Lead, required: true }
457
- executor: is-agent
458
- status: active
459
-
460
- - name: Work this deal
461
- description: Move a deal toward Won — read the current stage, find the blocker, and propose the next step and owner.
462
- goalTemplate: >-
463
- Work this deal ({deal}) toward Won. Assess where it stands — its current
464
- stage, value, and close date — and identify the single thing blocking it from
465
- advancing. Propose the concrete next step, who owns it, and by when, and
466
- update the deal's next step accordingly. If the stage should change, say so
467
- and why.
468
- subjectProfile:
469
- profileSlug: deal
470
- params:
471
- - { name: deal, type: entity, label: Deal, required: true }
472
- executor: is-agent
473
- status: active
448
+ # Apify powers the enrichment playbook (scrape public sources for a lead's
449
+ # firmographics). Composed pod-wide at provisioning via the existing
450
+ # integrations door; the user supplies the Apify API key once on install — a
451
+ # missing key fails gracefully per-skill, not the whole workspace.
452
+ integrations:
453
+ - templateKey: apify
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toWorkspaceDefinition } from "./define.js";
3
+ import type { WorkspaceYaml } from "./types.js";
4
+
5
+ const workflowTemplate: WorkspaceYaml = {
6
+ meta: {
7
+ slug: "workflow-contract-test",
8
+ name: "Workflow contract test",
9
+ description: "Test template",
10
+ icon: "workflow",
11
+ color: "blue",
12
+ },
13
+ workspace: { name: "Workflow contract test", description: "Test" },
14
+ profiles: [],
15
+ automations: [
16
+ {
17
+ name: "A manual workflow",
18
+ triggerType: "manual",
19
+ flow: {
20
+ nodes: [{ id: "trigger", type: "trigger" }],
21
+ edges: [],
22
+ precondition: "trigger.payload.ready === true",
23
+ },
24
+ },
25
+ ],
26
+ actionPlacements: [
27
+ {
28
+ profileSlug: "person",
29
+ surface: "entity-detail",
30
+ kind: "automation",
31
+ ref: "A manual workflow",
32
+ label: "Run workflow",
33
+ when: {
34
+ requiredFacetSlugs: ["lead"],
35
+ propertyEquals: { stage: "ready" },
36
+ },
37
+ confirmation: { title: "Continue?", confirmLabel: "Run" },
38
+ },
39
+ ],
40
+ };
41
+
42
+ describe("toWorkspaceDefinition flow automations", () => {
43
+ it("keeps graph workflows distinct from legacy loop automations", () => {
44
+ const { definition } = toWorkspaceDefinition(workflowTemplate);
45
+
46
+ expect(definition.flowAutomations).toEqual([
47
+ expect.objectContaining({
48
+ name: "A manual workflow",
49
+ triggerType: "manual",
50
+ flowDefinition: expect.objectContaining({
51
+ precondition: "trigger.payload.ready === true",
52
+ }),
53
+ }),
54
+ ]);
55
+ expect("automations" in definition).toBe(false);
56
+ });
57
+
58
+ it("preserves action applicability and confirmation policy", () => {
59
+ const { definition } = toWorkspaceDefinition(workflowTemplate);
60
+ expect(definition.actionPlacements?.[0]).toMatchObject({
61
+ when: { requiredFacetSlugs: ["lead"], propertyEquals: { stage: "ready" } },
62
+ confirmation: { title: "Continue?", confirmLabel: "Run" },
63
+ });
64
+ });
65
+ });