@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/dist/index.d.ts +95 -11
- package/dist/index.js +2018 -467
- 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/business-developer.template.test.ts +79 -10
- package/src/business-developer.yaml +61 -39
- 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 +8 -0
- package/src/life-os.yaml +6 -1
- package/src/operations.yaml +270 -0
- package/src/outreach-comms.yaml +196 -0
- package/src/project-management.yaml +6 -1
- package/src/templates.ts +1659 -373
- package/src/types.ts +66 -0
- package/src/validate-flow.ts +186 -7
- package/src/validate.ts +26 -2
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
|
|
package/src/validate-flow.ts
CHANGED
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
* `schema/automations.ts`.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
// ── Contract constants (mirror
|
|
39
|
+
// ── Contract constants (mirror schema/automations.ts — keep in sync) ──────────
|
|
40
40
|
|
|
41
|
-
/** The
|
|
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`,
|
|
85
|
-
* `channelEntityRef` (an entity-id expression → that entity's internal
|
|
86
|
-
* OR a `channelType` from this set, OR NOTHING (the executor defaults a
|
|
87
|
-
* channel_message to the automation's own run channel). A `channelType`
|
|
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
|
-
|
|
294
|
-
|
|
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 &&
|