@synap-core/workspace-templates 0.5.0 → 0.6.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.
package/src/define.ts CHANGED
@@ -10,8 +10,106 @@
10
10
  * shape never drifts per call-site.
11
11
  */
12
12
 
13
- import type { WorkspaceYaml } from './types.js';
14
- import { getWorkspaceTemplate } from './templates.js';
13
+ import type { TemplateBentoBlock, WorkspaceYaml } from "./types.js";
14
+ import { getWorkspaceTemplate } from "./templates.js";
15
+
16
+ /** A widget block as the wire expects it: `bentoLayout[]`, keyed by `widgetType`. */
17
+ export interface BentoWidgetBlockWire {
18
+ widgetType: string;
19
+ pos: TemplateBentoBlock['pos'];
20
+ config?: Record<string, unknown>;
21
+ }
22
+
23
+ /** A view block as the wire expects it: `bentoViewBlocks[]`, keyed by `viewName`. */
24
+ export interface BentoViewBlockWire {
25
+ kind: 'view';
26
+ viewName: string;
27
+ pos: TemplateBentoBlock['pos'];
28
+ overrides?: Record<string, unknown>;
29
+ }
30
+
31
+ export interface SplitBentoBlocks {
32
+ bentoLayout: BentoWidgetBlockWire[];
33
+ bentoViewBlocks: BentoViewBlockWire[];
34
+ }
35
+
36
+ /**
37
+ * Split the authored `bento.blocks` DISCRIMINATED UNION into the TWO wire
38
+ * fields the pod expects. This is the whole point: `TemplateBentoBlock` is one
39
+ * array discriminated by `kind`, but the pod does NOT read `kind` — it splits by
40
+ * FIELD (`create-workspace-from-definition.ts`):
41
+ *
42
+ * - `bentoLayout` → mapped as widgets, reads `widget.widgetType`
43
+ * - `bentoViewBlocks` → filtered against `viewMap[vb.viewSlug ?? vb.viewName]`,
44
+ * unknown entries DROPPED with a logger.warn
45
+ *
46
+ * Dumping the whole union into either single field silently mis-routes the other
47
+ * half — which is exactly what both converters used to do:
48
+ * - `toWorkspaceDefinition` sent all blocks as `bentoViewBlocks`, so widgets
49
+ * (no `viewName`) hit `viewMap[undefined]` and were filtered out.
50
+ * - `toPackageDefinition` sent all blocks as `bentoLayout`, so view blocks
51
+ * (no `widgetType`) became widgets with `widgetType: undefined`.
52
+ * Net: nobody had ever seen an authored bento render as authored.
53
+ *
54
+ * Blocks are emitted in the EXACT shape each sink declares (the tRPC
55
+ * `createFromDefinition` zod is strict: `bentoLayout` requires `widgetType`,
56
+ * `bentoViewBlocks` requires `kind: 'view'` + `viewName`), so a widget's
57
+ * `overrides` and a view's `config` are not smuggled into the wrong sink.
58
+ * Authored order is preserved WITHIN each kind; the pod concatenates
59
+ * `[...widgetBlocks, ...viewBlocks]`, so cross-kind order is the pod's to define.
60
+ *
61
+ * `kind` is INFERRED from field presence when absent. `TemplateBentoBlock.kind`
62
+ * is a required field in TypeScript, but the generated `templates.ts` casts the
63
+ * parsed YAML `as unknown as Record<string, WorkspaceYaml>` — so the compiler
64
+ * never checks the authored data against the type, and `agent-fleet.yaml`
65
+ * authored 8 widget blocks with no `kind:` at all (bare `widgetType`). Routing
66
+ * strictly on `kind` would silently DROP those — the exact failure mode this
67
+ * function exists to end. Inferring by field mirrors what the pod itself does
68
+ * (it splits by field and never reads `kind`), so the seam is total: a block
69
+ * that CAN be materialized always is. `scripts/check-converters.mjs` is the
70
+ * strict half — it fails the build if an authored block omits `kind` — so this
71
+ * leniency is a safety net, not a place for drift to hide.
72
+ */
73
+ function resolveBlockKind(block: TemplateBentoBlock): 'widget' | 'view' | null {
74
+ if (block.kind === 'widget' || block.kind === 'view') return block.kind;
75
+ if (block.widgetType) return 'widget';
76
+ if (block.viewName) return 'view';
77
+ return null;
78
+ }
79
+
80
+ export function splitBentoBlocks(
81
+ blocks: TemplateBentoBlock[] | undefined
82
+ ): SplitBentoBlocks {
83
+ const bentoLayout: BentoWidgetBlockWire[] = [];
84
+ const bentoViewBlocks: BentoViewBlockWire[] = [];
85
+
86
+ for (const block of blocks ?? []) {
87
+ const kind = resolveBlockKind(block);
88
+ if (kind === 'widget') {
89
+ // A widget block without `widgetType` cannot be rendered — the pod would
90
+ // create a `widgetType: undefined` block. Drop it loudly rather than ship
91
+ // a broken block; the build-time guard asserts this never happens in the
92
+ // authored templates.
93
+ if (!block.widgetType) continue;
94
+ bentoLayout.push({
95
+ widgetType: block.widgetType,
96
+ pos: block.pos,
97
+ ...(block.config ? { config: block.config } : {}),
98
+ });
99
+ } else if (kind === 'view') {
100
+ // Same reasoning: the pod filters unknown/absent view names anyway.
101
+ if (!block.viewName) continue;
102
+ bentoViewBlocks.push({
103
+ kind: 'view',
104
+ viewName: block.viewName,
105
+ pos: block.pos,
106
+ ...(block.overrides ? { overrides: block.overrides } : {}),
107
+ });
108
+ }
109
+ }
110
+
111
+ return { bentoLayout, bentoViewBlocks };
112
+ }
15
113
 
16
114
  /** The flat definition object accepted by createFromDefinition. */
17
115
  export interface WorkspaceDefinitionInput {
@@ -26,7 +124,16 @@ export interface WorkspaceDefinitionInput {
26
124
  displayTemplates?: WorkspaceYaml['displayTemplates'];
27
125
  layoutConfig: WorkspaceYaml['layout'] | undefined;
28
126
  bentoViewName?: string;
29
- bentoViewBlocks?: NonNullable<WorkspaceYaml['bento']>['blocks'];
127
+ /**
128
+ * WIDGET half of the authored bento union. The door declares this field
129
+ * (`workspaces.createFromDefinition` zod) and the pod maps it as widgets via
130
+ * `widget.widgetType`. It was missing from this interface entirely, which is
131
+ * why `toWorkspaceDefinition` had nowhere to put widgets and dumped the whole
132
+ * union into `bentoViewBlocks` — where every widget was silently filtered out.
133
+ */
134
+ bentoLayout?: BentoWidgetBlockWire[];
135
+ /** VIEW half of the authored bento union — resolved against the viewMap. */
136
+ bentoViewBlocks?: BentoViewBlockWire[];
30
137
  profileEntityBentoTemplates?: WorkspaceYaml['profileEntityBentoTemplates'];
31
138
  // Workspace-level metadata (flattened from the `workspace` block)
32
139
  workspaceSubtype?: string;
@@ -37,6 +144,15 @@ export interface WorkspaceDefinitionInput {
37
144
  defaultSources?: NonNullable<WorkspaceYaml['workspace']>['defaultSources'];
38
145
  /** Per-workspace onboarding context — written to settings.onboarding. */
39
146
  onboarding?: WorkspaceYaml['onboarding'];
147
+ /**
148
+ * Template-composition dependencies. MUST be forwarded: the backend resolves
149
+ * these BEFORE materializing this workspace (`workspaces.createFromDefinition`
150
+ * → `materializeWorkspaceCore` Step 0 → `resolvePackageDependencies`), which
151
+ * is what installs a `require`d base FIRST and makes shared-profile base
152
+ * seeding deterministic. Dropping this field silently de-orders provisioning
153
+ * on the tRPC/UI door — see the note in `toWorkspaceDefinition`.
154
+ */
155
+ dependencies?: WorkspaceYaml['dependencies'];
40
156
  }
41
157
 
42
158
  export interface ToDefinitionResult {
@@ -68,6 +184,10 @@ export function toWorkspaceDefinition(
68
184
  const name = nameOverride ?? ws.name;
69
185
  const proposalId = ws.proposalId ?? `${tpl.meta.slug}-v1`;
70
186
 
187
+ // The authored `bento.blocks` union must be ROUTED, not forwarded: the pod
188
+ // splits by field, not by `kind`. See `splitBentoBlocks`.
189
+ const bento = splitBentoBlocks(tpl.bento?.blocks);
190
+
71
191
  const definition: WorkspaceDefinitionInput = {
72
192
  workspaceName: name,
73
193
  description: ws.description,
@@ -80,7 +200,8 @@ export function toWorkspaceDefinition(
80
200
  displayTemplates: tpl.displayTemplates,
81
201
  layoutConfig: tpl.layout,
82
202
  bentoViewName: tpl.bento?.viewName,
83
- bentoViewBlocks: tpl.bento?.blocks,
203
+ bentoLayout: bento.bentoLayout,
204
+ bentoViewBlocks: bento.bentoViewBlocks,
84
205
  profileEntityBentoTemplates: tpl.profileEntityBentoTemplates,
85
206
  workspaceSubtype: ws.subtype,
86
207
  workspaceType: ws.workspaceType,
@@ -89,11 +210,106 @@ export function toWorkspaceDefinition(
89
210
  sourceRoles: ws.sourceRoles,
90
211
  defaultSources: ws.defaultSources,
91
212
  onboarding: tpl.onboarding,
213
+ // Template-composition dependencies — forwarded so the tRPC/UI door gets the
214
+ // SAME deps-first ordering the CLI/Hub door gets via `toPackageDefinition`.
215
+ // `workspaces.createFromDefinition` declares `dependencies` in its zod input
216
+ // and branches on `input.definition.dependencies?.length` to run
217
+ // `materializeWorkspaceCore` (Step 0 = `resolvePackageDependencies`) BEFORE
218
+ // creating this workspace. Omitting it here made that branch dead on every
219
+ // frontend call-site, so a `require: foundation` dep was silently dropped and
220
+ // shared-profile bases were seeded by whichever template happened to apply
221
+ // first. This function is documented as "the single canonical seam" — it only
222
+ // is one if it forwards the fields the wire shape declares.
223
+ dependencies: tpl.dependencies,
92
224
  };
93
225
 
94
226
  return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug };
95
227
  }
96
228
 
229
+ // ─── Exhaustiveness ledger: toWorkspaceDefinition ────────────────────────────
230
+ // `WorkspaceYaml` is read by two converters and nothing forced either to be
231
+ // total — which is how `dependencies` was silently dropped here for months.
232
+ // Every key of `WorkspaceYaml` must be classified below as either FORWARDED
233
+ // onto the wire or explicitly DROPPED with a reason. Adding a field to
234
+ // `WorkspaceYaml` FAILS THE BUILD until it is classified, forcing the author
235
+ // to make the forward-or-drop decision instead of defaulting to silent loss.
236
+ //
237
+ // ⚠️ KNOW WHAT THIS LEDGER DOES NOT CATCH. It is FIELD-PRESENCE-exhaustive, not
238
+ // SEMANTICS-exhaustive. It asks "is every key classified?" — not "does the value
239
+ // reach the right sink?". `bento` was classified as forwarded, and it WAS
240
+ // forwarded: the whole discriminated union was dumped into ONE field on each
241
+ // door (`bentoViewBlocks` here, `bentoLayout` in `toPackageDefinition`). The
242
+ // ledger passed while widgets were filtered out on one door and views became
243
+ // `widgetType: undefined` widgets on the other. A ledger entry is a CLAIM about
244
+ // routing; only a runtime assertion can check the claim.
245
+ // → The counterpart guard for that class is `scripts/check-converters.mjs`,
246
+ // which RUNS both converters over all templates and asserts the per-kind
247
+ // block counts survive the split. Any future union-shaped field (one authored
248
+ // array fanning out to N wire fields) needs an assertion there too — do not
249
+ // let a ledger comment stand in for one.
250
+
251
+ /** Keys `toWorkspaceDefinition` reads and carries onto the wire. */
252
+ type WorkspaceDefinitionForwarded =
253
+ | 'meta' // → proposalId (meta.slug) + templateId; not a wire field itself
254
+ | 'workspace' // flattened into workspaceName/description/workspaceType/…
255
+ | 'profiles'
256
+ | 'views'
257
+ | 'entityLinks'
258
+ | 'seedEntities'
259
+ | 'suggestedRelations'
260
+ | 'displayTemplates'
261
+ | 'layout' // → layoutConfig
262
+ | 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
263
+ | 'profileEntityBentoTemplates'
264
+ | 'onboarding'
265
+ | 'dependencies';
266
+
267
+ /**
268
+ * Keys deliberately NOT forwarded by `toWorkspaceDefinition`. Each needs a
269
+ * one-line WHY. "The door has no such field" = forwarding is a no-op; anything
270
+ * else is a real gap and should say so.
271
+ */
272
+ type WorkspaceDefinitionDropped =
273
+ // UI-only: `workspaces.createFromDefinition` has no `app` field. Consumers
274
+ // read `template.app` straight off the YAML to pick the first screen, so this
275
+ // never belonged on the wire.
276
+ | 'app'
277
+ // The door DOES accept `extends`, but no template declares it today (0/21).
278
+ // Forwarding is a no-op until one does — wire it then.
279
+ | 'extends'
280
+ // The door DOES accept `automations`, but no template declares it today
281
+ // (0/21), and its zod shape ({trigger,action:{playbook_run}}) is not
282
+ // `TemplateAutomation`. Needs a mapper if a template ever uses it.
283
+ // The browser install path installs automations via its own createAutomation door.
284
+ | 'automations'
285
+ // The door has no `integrations` field. Its `tools` field is a DIFFERENT
286
+ // shape ({name,kind,inputSchema} vs {templateKey,params,mode}). Capability
287
+ // templates are seeded via the packages/apply path, not createFromDefinition
288
+ // — see the note on `TemplateIntegration` in types.ts. Forwarding = no-op.
289
+ | 'integrations'
290
+ // REAL GAP (agent-fleet, project-management declare playbooks). The door
291
+ // accepts `playbooks`, but the shapes disagree: template param `type` allows
292
+ // 'text'|'entity'|'choice', the tRPC enum accepts only string|number|boolean
293
+ // (and defaultValue↔default, grants: string[]↔{kind,ref}[]). Raw forwarding =
294
+ // zod BAD_REQUEST. Needs a param mapper — separate wave, not a silent drop.
295
+ | 'playbooks';
296
+
297
+ type UnclassifiedWorkspaceDefinitionKey = Exclude<
298
+ keyof WorkspaceYaml,
299
+ WorkspaceDefinitionForwarded | WorkspaceDefinitionDropped
300
+ >;
301
+
302
+ // If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
303
+ // `toWorkspaceDefinition` and add it to `WorkspaceDefinitionForwarded`, or add
304
+ // it to `WorkspaceDefinitionDropped` with a one-line WHY.
305
+ const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [never]
306
+ ? true
307
+ : [
308
+ 'UNCLASSIFIED YAML FIELD — forward it or add it to WorkspaceDefinitionDropped:',
309
+ UnclassifiedWorkspaceDefinitionKey,
310
+ ] = true;
311
+ void _workspaceDefinitionLedger;
312
+
97
313
  /**
98
314
  * The unified `PackageDefinition` shape consumed by `POST /api/hub/packages/apply`
99
315
  * (CLI `synap launch`) and the CP package registry seeder. It composes the
@@ -125,13 +341,25 @@ export interface PackageDefinitionOutput {
125
341
  suggestedRelations?: WorkspaceYaml['suggestedRelations'];
126
342
  displayTemplates?: WorkspaceYaml['displayTemplates'];
127
343
  entityLinks?: WorkspaceYaml['entityLinks'];
128
- bentoLayout?: NonNullable<WorkspaceYaml['bento']>['blocks'];
344
+ /** WIDGET half of the authored bento union — mapped by the pod via `widgetType`. */
345
+ bentoLayout?: BentoWidgetBlockWire[];
346
+ /**
347
+ * VIEW half of the authored bento union. Widgets go to `bentoLayout`, views
348
+ * here — the pod splits by field (create-workspace-from-definition.ts:1373
349
+ * reads `bentoViewBlocks`), so both wire fields must be carried. The apply
350
+ * door's `PackageApplySchema` now declares this field (it is a plain
351
+ * `z.object`, so an undeclared field would be silently stripped) — the inbound
352
+ * seam is closed to match this outbound emit.
353
+ */
354
+ bentoViewBlocks?: BentoViewBlockWire[];
129
355
  bentoViewName?: string;
130
356
  profileEntityBentoTemplates?: WorkspaceYaml['profileEntityBentoTemplates'];
131
357
  layoutConfig?: WorkspaceYaml['layout'];
132
358
  capabilities?: WorkspaceYaml['integrations'];
133
359
  playbooks?: WorkspaceYaml['playbooks'];
134
360
  onboarding?: WorkspaceYaml['onboarding'];
361
+ /** Template-composition dependencies — resolved by the apply endpoint. */
362
+ dependencies?: WorkspaceYaml['dependencies'];
135
363
  }
136
364
 
137
365
  /**
@@ -151,6 +379,9 @@ export function toPackageDefinition(
151
379
  }
152
380
 
153
381
  const ws = tpl.workspace;
382
+ // Same routing as the tRPC door — the union is split by `kind`, never dumped
383
+ // whole into one field. See `splitBentoBlocks`.
384
+ const bento = splitBentoBlocks(tpl.bento?.blocks);
154
385
  return {
155
386
  _meta: {
156
387
  slug: tpl.meta.slug,
@@ -173,12 +404,67 @@ export function toPackageDefinition(
173
404
  suggestedRelations: tpl.suggestedRelations,
174
405
  displayTemplates: tpl.displayTemplates,
175
406
  entityLinks: tpl.entityLinks,
176
- bentoLayout: tpl.bento?.blocks,
407
+ bentoLayout: bento.bentoLayout,
408
+ bentoViewBlocks: bento.bentoViewBlocks,
177
409
  bentoViewName: tpl.bento?.viewName,
178
410
  profileEntityBentoTemplates: tpl.profileEntityBentoTemplates,
179
411
  layoutConfig: tpl.layout,
180
412
  capabilities: tpl.integrations,
181
413
  playbooks: tpl.playbooks,
182
414
  onboarding: tpl.onboarding,
415
+ dependencies: tpl.dependencies,
183
416
  };
184
417
  }
418
+
419
+ // ─── Exhaustiveness ledger: toPackageDefinition ──────────────────────────────
420
+ // Same forcing function as above, for the OTHER converter. These two are NOT
421
+ // merged on purpose: they target different endpoints with different contracts
422
+ // (`PackageDefinition` is a superset with renames — seedEntities→suggestedEntities,
423
+ // bento.blocks→bentoLayout, integrations→capabilities, plus `_meta`). Keeping
424
+ // two converters is fine; keeping either one lossy-by-accident is not.
425
+
426
+ /** Keys `toPackageDefinition` reads and carries onto the wire. */
427
+ type PackageDefinitionForwarded =
428
+ | 'meta' // → _meta
429
+ | 'workspace' // flattened into workspaceName/description/workspaceType/…
430
+ | 'profiles'
431
+ | 'views'
432
+ | 'seedEntities' // → suggestedEntities
433
+ | 'suggestedRelations'
434
+ | 'displayTemplates'
435
+ | 'entityLinks'
436
+ | 'bento' // → bentoViewName + bentoLayout (widgets) + bentoViewBlocks (views)
437
+ | 'profileEntityBentoTemplates'
438
+ | 'layout' // → layoutConfig
439
+ | 'integrations' // → capabilities
440
+ | 'playbooks'
441
+ | 'onboarding'
442
+ | 'dependencies';
443
+
444
+ /** Keys deliberately NOT forwarded by `toPackageDefinition`. */
445
+ type PackageDefinitionDropped =
446
+ // UI-only, same as the definition door: `PackageDefinition` has no `app`.
447
+ | 'app'
448
+ // REAL GAP: backend `PackageDefinition` DOES declare `extends`
449
+ // (package-definition.ts:262) and `automations` (:268), but
450
+ // `PackageDefinitionOutput` omits both. No template declares either today
451
+ // (0/21), so this is latent, not live — wire them when a template first uses
452
+ // one.
453
+ | 'extends'
454
+ | 'automations';
455
+
456
+ type UnclassifiedPackageDefinitionKey = Exclude<
457
+ keyof WorkspaceYaml,
458
+ PackageDefinitionForwarded | PackageDefinitionDropped
459
+ >;
460
+
461
+ // If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
462
+ // `toPackageDefinition` and add it to `PackageDefinitionForwarded`, or add it
463
+ // to `PackageDefinitionDropped` with a one-line WHY.
464
+ const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [never]
465
+ ? true
466
+ : [
467
+ 'UNCLASSIFIED YAML FIELD — forward it or add it to PackageDefinitionDropped:',
468
+ UnclassifiedPackageDefinitionKey,
469
+ ] = true;
470
+ void _packageDefinitionLedger;
@@ -115,6 +115,9 @@ profiles:
115
115
  color: "#8B5CF6"
116
116
  description: "External services, tools, and providers used by the Synap platform."
117
117
  scope: WORKSPACE
118
+ profileKind: role
119
+ applicableKinds:
120
+ - company
118
121
  properties:
119
122
  - slug: vendor-type
120
123
  label: Vendor Type