@synap-core/workspace-templates 0.9.4 → 0.10.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/src/types.ts CHANGED
@@ -174,6 +174,29 @@ export interface TemplateEntityLink {
174
174
  label?: string;
175
175
  }
176
176
 
177
+ /**
178
+ * A domain-level relation TYPE definition — a `relation_defs` row, the workspace's
179
+ * relation vocabulary. Distinct from {@link TemplateEntityLink}, which is a
180
+ * profile→profile EDGE (and only carries `type` + `label`). A relation def stands
181
+ * alone: it has no source/target profile pairing and it carries the rich metadata
182
+ * (`description`, `isDirectional`, `uiHints`) that the entity-link path drops.
183
+ *
184
+ * Mirrors `DefaultRelationDef` in synap-backend
185
+ * `packages/database/src/utils/default-relation-defs.ts` VERBATIM — the door
186
+ * find-or-creates a `relation_defs` row from each entry (idempotent by
187
+ * `slug` within the workspace), which is `ensureDefaultRelationDefs`'s body.
188
+ */
189
+ export interface TemplateRelationDef {
190
+ slug: string;
191
+ displayName: string;
192
+ description?: string;
193
+ isDirectional?: boolean;
194
+ uiHints?: {
195
+ category?: "workflow" | "social" | "reference" | "hierarchy";
196
+ inverseLabel?: string;
197
+ };
198
+ }
199
+
177
200
  export interface TemplateSeedEntity {
178
201
  profileSlug: string;
179
202
  title: string;
@@ -445,6 +468,34 @@ export interface TemplateAutomation {
445
468
  status?: "draft" | "active" | "paused";
446
469
  }
447
470
 
471
+ // ============================================================================
472
+ // Commands — workspace-scoped intelligence commands seeded with the workspace
473
+ //
474
+ // A prompt-template command (the "Default Command Pack") the workspace exposes
475
+ // in its command surface. Mirrors `DefaultCommandDef` in synap-backend
476
+ // `packages/database/src/utils/ensure-default-commands.ts` — the door
477
+ // find-or-creates an `intelligence_commands` row from each entry, idempotent by
478
+ // `title` within the workspace (`ensureDefaultCommands`'s body generalized).
479
+ // ============================================================================
480
+
481
+ export interface TemplateCommandDerivedInput {
482
+ name: string;
483
+ label?: string;
484
+ /** Mirrors `DerivedInput.type` (text | number | entity | view | choice). */
485
+ type?: string;
486
+ }
487
+
488
+ export interface TemplateCommand {
489
+ title: string;
490
+ promptTemplate: string;
491
+ derivedInputs?: TemplateCommandDerivedInput[];
492
+ /** text | proposal | view */
493
+ outputMode?: string;
494
+ canCreateViews?: boolean;
495
+ /** read_only | propose_writes */
496
+ permissionsProfile?: string;
497
+ }
498
+
448
499
  // ============================================================================
449
500
  // Action placements — template-declarable actions on an entity-detail surface
450
501
  //
@@ -694,6 +745,21 @@ export interface WorkspaceYaml {
694
745
  /** Schema-level links between entity types */
695
746
  entityLinks?: TemplateEntityLink[];
696
747
 
748
+ /**
749
+ * Domain-level relation TYPE vocabulary seeded into the workspace's
750
+ * `relation_defs`. Distinct from `entityLinks` (profile→profile edges): a
751
+ * relation def stands alone and carries `description`/`isDirectional`/`uiHints`.
752
+ * The `base` template carries the universal defaults. See {@link TemplateRelationDef}.
753
+ */
754
+ relationDefs?: TemplateRelationDef[];
755
+
756
+ /**
757
+ * Workspace-scoped intelligence commands (prompt-template "Default Command
758
+ * Pack") seeded with the workspace. The `base` template carries the universal
759
+ * pack. See {@link TemplateCommand}.
760
+ */
761
+ commands?: TemplateCommand[];
762
+
697
763
  /** Initial data to seed on workspace creation */
698
764
  seedEntities?: TemplateSeedEntity[];
699
765
 
@@ -36,9 +36,9 @@
36
36
  * `schema/automations.ts`.
37
37
  */
38
38
 
39
- // ── Contract constants (mirror @synap/database schema/automations.ts) ─────────
39
+ // ── Contract constants (mirror schema/automations.ts — keep in sync) ──────────
40
40
 
41
- /** The 21 node types — `AutomationNodeBase.type` union (schema/automations.ts). */
41
+ /** The 23 node types — `AutomationNodeBase.type` union (schema/automations.ts). */
42
42
  export const FLOW_NODE_TYPES = [
43
43
  "trigger",
44
44
  "command",
@@ -56,6 +56,8 @@ export const FLOW_NODE_TYPES = [
56
56
  "select",
57
57
  "claim",
58
58
  "messages_query",
59
+ "runs_query",
60
+ "proposals_query",
59
61
  "switch",
60
62
  "skill",
61
63
  "capability",
@@ -81,22 +83,73 @@ export const FLOW_OUTPUT_TYPES = [
81
83
  /**
82
84
  * The known `config.channelType` values for a `channel_message` output. Mirrors
83
85
  * the executor's channel_message resolution (automation-executor.ts). The output
84
- * channel is CONTEXT-DERIVED — a channel_message is valid with `channelId`, OR
85
- * `channelEntityRef` (an entity-id expression → that entity's internal channel),
86
- * OR a `channelType` from this set, OR NOTHING (the executor defaults a targetless
87
- * channel_message to the automation's own run channel). A `channelType` that is
88
- * PRESENT but not one of these is still a typo → still an error.
86
+ * channel is now CONTEXT-DERIVED — a channel_message is valid with `channelId`,
87
+ * OR `channelEntityRef` (an entity-id expression → that entity's internal
88
+ * channel), OR a `channelType` from this set, OR NOTHING (the executor defaults a
89
+ * targetless channel_message to the automation's own run channel). A `channelType`
90
+ * that is PRESENT but not one of these is still a typo → still an error.
89
91
  */
92
+ // The executor's channel_message resolution (automation-executor.ts) is the SSOT
93
+ // for these values; config is an untyped Record so there is no compile-time
94
+ // drift guard here — keep this array in sync with the executor by hand.
90
95
  export const CHANNEL_MESSAGE_CHANNEL_TYPES = [
91
96
  "personal_thread",
92
97
  "proactive",
93
98
  "subjectEntity",
94
99
  ] as const;
95
100
 
101
+ /**
102
+ * Node types a `loop` may dispatch PER ITEM. Mirrors `LOOP_BODY_NODE_TYPES` in
103
+ * `packages/jobs/src/workers/automation-executor.ts` (the runtime SSOT — the
104
+ * `switch (childNode.type)` inside the loop body). A loop OWNS the contiguous
105
+ * chain of these reachable from it; traversal STOPS at any other type
106
+ * (switch / delay / nested loop / sub_automation / entity_read / …), which runs
107
+ * ONCE in the main topological pass instead.
108
+ *
109
+ * Hand-mirrored: `@synap/api` does not depend on `@synap/jobs`, so there is no
110
+ * compile-time drift guard here — keep this array in sync with the executor by
111
+ * hand (same convention as CHANNEL_MESSAGE_CHANNEL_TYPES above).
112
+ */
113
+ export const LOOP_BODY_NODE_TYPES = [
114
+ "command",
115
+ "output",
116
+ "playbook_run",
117
+ "messages_query",
118
+ "runs_query",
119
+ "proposals_query",
120
+ "query",
121
+ "fetch",
122
+ "transform",
123
+ "condition",
124
+ "skill",
125
+ "capability",
126
+ ] as const;
127
+
128
+ /**
129
+ * What a loop actually PUTS in scope for its body: `context.loop = { item, index }`
130
+ * (automation-executor.ts, `case "loop"`). Any other `{{loop.<x>}}` segment
131
+ * resolves to undefined at runtime.
132
+ */
133
+ export const LOOP_PROVIDED_BINDINGS = ["item", "index"] as const;
134
+
96
135
  const NODE_TYPE_SET = new Set<string>(FLOW_NODE_TYPES);
136
+ const LOOP_BODY_TYPE_SET = new Set<string>(LOOP_BODY_NODE_TYPES);
137
+ const LOOP_BINDING_SET = new Set<string>(LOOP_PROVIDED_BINDINGS);
97
138
  const OUTPUT_TYPE_SET = new Set<string>(FLOW_OUTPUT_TYPES);
98
139
  const CHANNEL_TYPE_SET = new Set<string>(CHANNEL_MESSAGE_CHANNEL_TYPES);
99
140
 
141
+ // ── Drift guard — NOT compile-time IN THIS COPY ───────────────────────────────
142
+ // The arrays above are hand-mirrored from TS *union types* in the schema. The
143
+ // BACKEND copy carries an `AssertExact` binding that fails `tsc` on drift; THIS
144
+ // package has no `@synap/database` dependency, so that guard cannot live here.
145
+ // Do not assume tsc protects you when editing these arrays — what protects this
146
+ // copy is `validate-flow.drift.test.ts`, which strips comments and therefore
147
+ // could never have caught this paragraph being wrong (it was, until 2026-07-31).
148
+ //
149
+ // ⚠️ If you re-generate this file FROM the backend copy, re-apply this block and
150
+ // de-duplicate the header above: a naive strip of the `AssertExact` CODE leaves
151
+ // its comment behind, asserting a guard that does not exist here.
152
+
100
153
  // ── Result / error types ──────────────────────────────────────────────────────
101
154
 
102
155
  export interface FlowValidationError {
@@ -479,6 +532,54 @@ export function validateFlowDefinition(
479
532
  }
480
533
  }
481
534
 
535
+ // ── Loop bindings: `{{loop.*}}` only inside a loop body, only real paths ──────
536
+ // `context.loop` exists ONLY while a loop dispatches its body and is deleted
537
+ // right after, so a node OUTSIDE every loop body that reads `{{loop.item.id}}`
538
+ // resolves it to `undefined` at runtime — a Zod "expected string, received
539
+ // undefined" at best, a junk entity/relation written with an empty id at worst.
540
+ // The ownership walk below is the SAME rule the executor applies
541
+ // (`computeLoopBodyNodeIds`), so a node behind a boundary type (switch, delay,
542
+ // nested loop, …) or with no edge from the loop at all is correctly reported:
543
+ // those are exactly the shapes that leak into the main pass.
544
+ const loopBodyNodeIds = new Set<string>();
545
+ for (const node of nodes) {
546
+ if (!nonEmptyString(node.id) || node.type !== "loop") continue;
547
+ for (const id of collectLoopBody(node.id, nodesById, edges)) {
548
+ loopBodyNodeIds.add(id);
549
+ }
550
+ }
551
+ for (const node of nodes) {
552
+ if (!nonEmptyString(node.id)) continue;
553
+ const refs = collectLoopBindingRefs(node.data);
554
+ if (refs.length === 0) continue;
555
+ if (!loopBodyNodeIds.has(node.id)) {
556
+ errors.push({
557
+ nodeId: node.id,
558
+ code: "loop_ref_outside_loop_body",
559
+ message: `Node "${node.id}" references ${refs
560
+ .map((r) => `\`{{loop.${r}...}}\``)
561
+ .join(
562
+ ", "
563
+ )} but is not inside a loop body, so there is no item to bind — it would run once with an empty value. Connect it downstream of a loop node with no switch/delay/nested-loop/sub_automation node in between.`,
564
+ });
565
+ continue;
566
+ }
567
+ const unknown = refs.filter((r) => !LOOP_BINDING_SET.has(r));
568
+ if (unknown.length > 0) {
569
+ errors.push({
570
+ nodeId: node.id,
571
+ code: "loop_ref_unknown_path",
572
+ message: `Node "${node.id}" references ${unknown
573
+ .map((r) => `\`{{loop.${r}}}\``)
574
+ .join(
575
+ ", "
576
+ )}, which a loop does not provide. A loop binds only: ${LOOP_PROVIDED_BINDINGS.map(
577
+ (b) => `\`loop.${b}\``
578
+ ).join(", ")}.`,
579
+ });
580
+ }
581
+ }
582
+
482
583
  // ── Acyclic (Kahn's algorithm — mirrors topoSort in automation-executor.ts) ───
483
584
  // Only run when edges reference real nodes; dangling edges are already reported
484
585
  // and would corrupt the in-degree bookkeeping.
@@ -495,6 +596,84 @@ export function validateFlowDefinition(
495
596
  return { valid: errors.length === 0, errors };
496
597
  }
497
598
 
599
+ /**
600
+ * The node ids a loop OWNS as its per-item body — the contiguous chain of
601
+ * LOOP_BODY_NODE_TYPES nodes reachable from it, stopping at any other type.
602
+ * Mirrors `computeLoopBodyNodeIds` in automation-executor.ts exactly (all
603
+ * out-edges, handle-agnostic); the `seen` set makes a cyclic graph terminate
604
+ * (cycles are reported separately).
605
+ */
606
+ function collectLoopBody(
607
+ loopNodeId: string,
608
+ nodesById: Map<string, LooseNode>,
609
+ edges: LooseEdge[]
610
+ ): Set<string> {
611
+ const body = new Set<string>();
612
+ const stack: string[] = [];
613
+ for (const edge of edges) {
614
+ if (edge.source === loopNodeId && nonEmptyString(edge.target))
615
+ stack.push(edge.target);
616
+ }
617
+ while (stack.length > 0) {
618
+ const id = stack.pop()!;
619
+ if (body.has(id)) continue;
620
+ const node = nodesById.get(id);
621
+ if (
622
+ !node ||
623
+ !nonEmptyString(node.type) ||
624
+ !LOOP_BODY_TYPE_SET.has(node.type)
625
+ )
626
+ continue; // boundary — runs once in the main pass, not per item
627
+ body.add(id);
628
+ for (const edge of edges) {
629
+ if (edge.source === id && nonEmptyString(edge.target))
630
+ stack.push(edge.target);
631
+ }
632
+ }
633
+ return body;
634
+ }
635
+
636
+ /**
637
+ * The FIRST path segment of every `{{loop.<segment>…}}` reference anywhere in a
638
+ * node's `data` (deep: objects + arrays), deduped. Mirrors
639
+ * `collectLoopBindingRefs` in automation-executor.ts — including the PRE-PIPE
640
+ * restriction: an array pipe argument (`{{x | map:{{loop.item.id}}}}`) binds
641
+ * `loop` per item inside the RESOLVER, a scope that is valid outside any loop
642
+ * node, so it must not be reported.
643
+ */
644
+ function collectLoopBindingRefs(data: unknown): string[] {
645
+ const found = new Set<string>();
646
+ const walk = (v: unknown): void => {
647
+ if (typeof v === "string") {
648
+ const head = v.split("|")[0];
649
+ const re = /\{\{\s*loop\.([A-Za-z0-9_$]+)/g;
650
+ let m: RegExpExecArray | null;
651
+ let matched = false;
652
+ while ((m = re.exec(head)) !== null) {
653
+ found.add(m[1]);
654
+ matched = true;
655
+ }
656
+ // The executor's own detector is the LOOSER `/\{\{\s*loop\./` — it fires on
657
+ // `{{loop.}}` or `{{loop.-x}}`, which this stricter segment pattern does
658
+ // not. Without this fallback a malformed reference passes save-time
659
+ // validation and then throws at RUN time, which is exactly the class of
660
+ // failure the loop rules were added to eliminate. Report it under the
661
+ // empty segment so it surfaces as `loop_ref_unknown_path`.
662
+ if (!matched && /\{\{\s*loop\./.test(head)) found.add("");
663
+ return;
664
+ }
665
+ if (Array.isArray(v)) {
666
+ for (const x of v) walk(x);
667
+ return;
668
+ }
669
+ if (isRecord(v)) {
670
+ for (const x of Object.values(v)) walk(x);
671
+ }
672
+ };
673
+ walk(data);
674
+ return [...found];
675
+ }
676
+
498
677
  /**
499
678
  * Return the set of node ids that participate in a cycle (empty if acyclic).
500
679
  * Kahn's algorithm — the same ordering the executor's `topoSort` relies on; a
package/src/validate.ts CHANGED
@@ -152,6 +152,11 @@ export const SYSTEM_PROFILES = new Set([
152
152
  "research",
153
153
  "knowledge",
154
154
  "user_observation",
155
+ // Generated artifact — a workspace-scoped pod system profile
156
+ // (`ensure-system-profiles.ts`) written by the `base` report automation. It
157
+ // was simply missing from this hand-maintained list; the flow's
158
+ // `create-report` node references it, so referential integrity needs it here.
159
+ "report",
155
160
  ]);
156
161
 
157
162
  // ── Dependency-vocabulary closure (compose vs require semantics) ─────────────
@@ -290,8 +295,27 @@ export function validateTemplate(
290
295
  );
291
296
  }
292
297
  }
293
- if (!tpl.profiles || tpl.profiles.length === 0) {
294
- add("profiles-required", "profiles", "At least one profile is required");
298
+ // A template earns validity by declaring a data-schema profile OR by carrying
299
+ // operational defaults (relation vocabulary / commands / automations). The
300
+ // `base` template is the latter: pod-wide operational config with no
301
+ // data-schema profile of its own (its report automation writes the `report`
302
+ // system profile and reads other system profiles, none declared here). Without
303
+ // this carve-out a legitimate operational-defaults template fails the gate,
304
+ // while a data template that forgot its profiles still does (it carries no
305
+ // operational config either).
306
+ const carriesOperationalDefaults =
307
+ (tpl.relationDefs && tpl.relationDefs.length > 0) ||
308
+ (tpl.commands && tpl.commands.length > 0) ||
309
+ (tpl.automations && tpl.automations.length > 0);
310
+ if (
311
+ (!tpl.profiles || tpl.profiles.length === 0) &&
312
+ !carriesOperationalDefaults
313
+ ) {
314
+ add(
315
+ "profiles-required",
316
+ "profiles",
317
+ "At least one profile is required (or operational defaults: relationDefs / commands / automations)"
318
+ );
295
319
  }
296
320
  if (
297
321
  tpl.app &&