@eventcatalog/core 5.0.0-beta.6 → 5.0.0-beta.8
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/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +3 -1
- package/dist/analytics/log-build.js +4 -4
- package/dist/{chunk-XMPFHLK4.js → chunk-4W55ZOOS.js} +2 -2
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/{chunk-QBP3BBSV.js → chunk-C6T2AAFR.js} +1 -1
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-56CD2NH6.js → chunk-PU25TOOJ.js} +1 -1
- package/dist/{chunk-L2IRFOE7.js → chunk-RDIZJ7UT.js} +1 -1
- package/dist/{chunk-DE3QHVJP.js → chunk-T4G3VVZ4.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +138 -90
- package/dist/eventcatalog.config.d.cts +47 -0
- package/dist/eventcatalog.config.d.ts +47 -0
- package/dist/eventcatalog.js +30 -11
- package/dist/features.cjs +8 -2
- package/dist/features.d.cts +3 -1
- package/dist/features.d.ts +3 -1
- package/dist/features.js +5 -3
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +70 -44
- package/dist/utils/license-status.d.cts +14 -5
- package/dist/utils/license-status.d.ts +14 -5
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/integrations/studio-server.mjs +15 -3
- package/eventcatalog/src/components/Header.astro +3 -3
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
- package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
- package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
- package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
- package/eventcatalog/src/features/studio/clipboard.ts +97 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
- package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
- package/eventcatalog/src/features/studio/components/icons.ts +6 -1
- package/eventcatalog/src/features/studio/components/status.ts +30 -1
- package/eventcatalog/src/features/studio/edges.ts +2 -0
- package/eventcatalog/src/features/studio/grouping.ts +95 -2
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
- package/eventcatalog/src/features/studio/identity.ts +35 -0
- package/eventcatalog/src/features/studio/layout.ts +7 -4
- package/eventcatalog/src/features/studio/levels.ts +138 -72
- package/eventcatalog/src/features/studio/node-types.ts +35 -3
- package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
- package/eventcatalog/src/features/studio/pages/index.astro +555 -2
- package/eventcatalog/src/features/studio/pages/new.astro +24 -0
- package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
- package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +110 -0
- package/eventcatalog/src/features/studio/studio.css +184 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
- package/eventcatalog/src/utils/feature.ts +7 -3
- package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
- package/package.json +4 -4
|
@@ -24,7 +24,7 @@ import { getMcpAppViewLoader, mcpAppNote, registerMcpApp } from './mcp-apps';
|
|
|
24
24
|
import { buildUrl } from '@utils/url-builder';
|
|
25
25
|
import { isCanvasEnabled } from '@utils/feature';
|
|
26
26
|
import { CANVAS_TOOL_NAMES, registerCanvasTools } from '@features/studio/server/canvas-mcp';
|
|
27
|
-
import {
|
|
27
|
+
import { startStudio } from '@features/studio/server/runtime';
|
|
28
28
|
|
|
29
29
|
const loadViewerView = getMcpAppViewLoader('viewer');
|
|
30
30
|
|
|
@@ -401,7 +401,7 @@ function createMcpServer(scope: McpScope | undefined, { catalogUrl, userAgent }:
|
|
|
401
401
|
if (resource.error !== undefined) return { error: resource.error };
|
|
402
402
|
|
|
403
403
|
const schemas = await tools.getSchemaForResource({ ...params, resourceVersion: resource.version });
|
|
404
|
-
if ('error' in schemas) return schemas;
|
|
404
|
+
if ('error' in schemas && schemas.error !== undefined) return { error: schemas.error };
|
|
405
405
|
|
|
406
406
|
return {
|
|
407
407
|
resource: {
|
|
@@ -974,7 +974,7 @@ const handleGetRequest = async (c: Context, kind?: McpScopeKind) => {
|
|
|
974
974
|
...(scope && { scope: scope.ref }),
|
|
975
975
|
tools: scope
|
|
976
976
|
? getScopedBuiltInTools(scope)
|
|
977
|
-
: [...globalBuiltInTools, ...(isCanvasEnabled()
|
|
977
|
+
: [...globalBuiltInTools, ...(isCanvasEnabled() ? CANVAS_TOOL_NAMES : []), ...extendedToolNames],
|
|
978
978
|
extendedTools: !scope && extendedToolNames.length > 0 ? extendedToolNames : undefined,
|
|
979
979
|
resources: getMcpResourceUris(scope),
|
|
980
980
|
});
|
|
@@ -993,6 +993,8 @@ const handleMcpRequest = async (c: Context, kind?: McpScopeKind) => {
|
|
|
993
993
|
}
|
|
994
994
|
|
|
995
995
|
const scope = await resolveRequestScope(c, kind);
|
|
996
|
+
// Canvas tools work on Studio's canvases: started (storage loaded) before they're listed
|
|
997
|
+
if (!scope && isCanvasEnabled()) await startStudio();
|
|
996
998
|
const server = createMcpServer(scope, {
|
|
997
999
|
catalogUrl: new URL(c.req.url).origin,
|
|
998
1000
|
userAgent: c.req.header('user-agent'),
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
createThread,
|
|
7
7
|
deleteEdges,
|
|
8
8
|
deleteNodes,
|
|
9
|
-
|
|
9
|
+
fitContainersAround,
|
|
10
10
|
getCanvasMaps,
|
|
11
11
|
moveNode,
|
|
12
12
|
resizeNode,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type CatalogIndex,
|
|
25
25
|
type EdgeSpecs,
|
|
26
26
|
type NodeSpecs,
|
|
27
|
+
type NodeUpdate,
|
|
27
28
|
} from './canvas-actions';
|
|
28
29
|
import { getRelatedEdges } from './catalog';
|
|
29
30
|
import { getAbsolutePosition, sizeOf } from './grouping';
|
|
@@ -191,7 +192,7 @@ export async function playAddToCanvas(
|
|
|
191
192
|
const node = { ...plannedNode, position: { x: canvasPosition.x - origin.x, y: canvasPosition.y - origin.y } };
|
|
192
193
|
addNodes(doc, [node]);
|
|
193
194
|
// The containers it's in grow to fit it
|
|
194
|
-
|
|
195
|
+
fitContainersAround(doc, node.id);
|
|
195
196
|
const related = resource
|
|
196
197
|
? getRelatedEdges(resource, node.id, [...onCanvas.values()], catalog.relationsByKey, node.type)
|
|
197
198
|
: [];
|
|
@@ -358,11 +359,13 @@ export async function playOnNode<T>(stage: AgentStage, nodeId: string, activity:
|
|
|
358
359
|
/** Updating a node, played out: point at it to rename or re-describe it, then drag it to its new centre (x/y) */
|
|
359
360
|
export async function playUpdateNode(
|
|
360
361
|
stage: AgentStage,
|
|
361
|
-
{ nodeId, name, summary, x, y }:
|
|
362
|
+
{ nodeId, name, summary, version, x, y }: NodeUpdate & { x?: number; y?: number }
|
|
362
363
|
) {
|
|
363
364
|
const missing = { error: `No node "${nodeId}" on the canvas` };
|
|
364
|
-
if (name !== undefined || summary !== undefined) {
|
|
365
|
-
const outcome = await playOnNode(stage, nodeId, 'Editing', (doc) =>
|
|
365
|
+
if (name !== undefined || summary !== undefined || version !== undefined) {
|
|
366
|
+
const outcome = await playOnNode(stage, nodeId, 'Editing', (doc) =>
|
|
367
|
+
updateCanvasNode(doc, { nodeId, name, summary, version })
|
|
368
|
+
);
|
|
366
369
|
if ('error' in outcome) return outcome;
|
|
367
370
|
}
|
|
368
371
|
if (x !== undefined || y !== undefined) {
|
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import type { Node, XYPosition } from '@xyflow/react';
|
|
1
|
+
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
2
|
import type * as Y from 'yjs';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { buildNode, getCanvasMaps, readCanvas, updateNodeData } from './canvas-doc';
|
|
4
|
+
import { buildNode, getCanvasMaps, getCanvasStatus, readCanvas, updateNodeData } from './canvas-doc';
|
|
5
5
|
import type { CatalogRelation, CatalogResource } from './catalog-resources';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
buildContainer,
|
|
8
|
+
canBeContainer,
|
|
9
|
+
catalogNodeData,
|
|
10
|
+
containerTypeFor,
|
|
11
|
+
getCatalogLink,
|
|
12
|
+
getRelatedEdges,
|
|
13
|
+
indexRelations,
|
|
14
|
+
} from './catalog';
|
|
15
|
+
import { findGroupAtPoint, getAbsolutePosition, sizeOf } from './grouping';
|
|
8
16
|
import { placeNodes, type Link } from './placement';
|
|
9
17
|
import {
|
|
18
|
+
canGoInContainer,
|
|
10
19
|
COMPONENT_TYPES,
|
|
11
20
|
getNodeDefinition,
|
|
12
21
|
getNodeLabel,
|
|
13
22
|
getNodeName,
|
|
23
|
+
getNoteLevel,
|
|
14
24
|
getResource,
|
|
15
25
|
isGroupType,
|
|
26
|
+
isOnLevel,
|
|
27
|
+
isVersioned,
|
|
28
|
+
isWrittenOnCanvas,
|
|
16
29
|
updateResource,
|
|
17
30
|
} from './node-types';
|
|
18
31
|
|
|
@@ -44,7 +57,8 @@ export const nodeSpecsSchema = z
|
|
|
44
57
|
.describe('A catalog resource to add (latest version)'),
|
|
45
58
|
type: z.enum(COMPONENT_TYPES).optional().describe('For a new component: what it is'),
|
|
46
59
|
name: z.string().optional().describe('For a new component: its name'),
|
|
47
|
-
summary: z.string().optional().describe('For a new component: what it does. For a note: its text'),
|
|
60
|
+
summary: z.string().optional().describe('For a new component: what it does. For a note or text: its text'),
|
|
61
|
+
version: z.string().optional().describe('For a new component: its version, e.g. 1.0.0 (defaults to 0.0.1)'),
|
|
48
62
|
container: z
|
|
49
63
|
.boolean()
|
|
50
64
|
.optional()
|
|
@@ -96,13 +110,17 @@ const lookupOf = (node: Node, lookup?: NodeLookup) => lookup ?? new Map([[node.i
|
|
|
96
110
|
export const describeNode = (node: Node, lookup?: NodeLookup) => {
|
|
97
111
|
const resource = getResource(node.type, node.data);
|
|
98
112
|
const link = getCatalogLink(node);
|
|
113
|
+
const version = link?.version ?? (typeof resource.version === 'string' ? resource.version : undefined);
|
|
99
114
|
return {
|
|
100
115
|
id: node.id,
|
|
101
116
|
type: getNodeLabel(node.type),
|
|
102
|
-
...(node.type
|
|
117
|
+
...(!isWrittenOnCanvas(node.type) && { name: resource.name }),
|
|
103
118
|
...(resource.summary ? { summary: resource.summary } : {}),
|
|
104
|
-
...(node.type
|
|
105
|
-
|
|
119
|
+
...(isWrittenOnCanvas(node.type) ? { text: node.data.text } : {}),
|
|
120
|
+
// Notes added on L1 or L2 are only shown there (people see the rest at L3)
|
|
121
|
+
...(getNoteLevel(node) !== undefined && getNoteLevel(node) !== 3 && { onLevel: `L${getNoteLevel(node)}` }),
|
|
122
|
+
...(link && { catalogResource: link.key.replace(':', '/') }),
|
|
123
|
+
...(version && { version }),
|
|
106
124
|
/** The node's centre on the canvas, like the x/y agents give */
|
|
107
125
|
position: (({ x, y }) => ({ x: Math.round(x), y: Math.round(y) }))(nodeCenter(node, lookup)),
|
|
108
126
|
size: sizeOf(node),
|
|
@@ -131,8 +149,17 @@ export const describeCanvas = (doc: Y.Doc) => {
|
|
|
131
149
|
const canvas = readCanvas(doc);
|
|
132
150
|
const lookup = new Map(canvas.nodes.map((node) => [node.id, node]));
|
|
133
151
|
const names = new Map(canvas.nodes.map((node) => [node.id, getNodeName(node.type, node.data, node.id)]));
|
|
152
|
+
const lastChange = canvas.meta.statusHistory?.at(-1);
|
|
134
153
|
return {
|
|
135
154
|
title: canvas.meta.title,
|
|
155
|
+
status: getCanvasStatus(canvas.meta),
|
|
156
|
+
...(lastChange && {
|
|
157
|
+
statusChanged: {
|
|
158
|
+
by: lastChange.by.name,
|
|
159
|
+
at: new Date(lastChange.at).toISOString(),
|
|
160
|
+
...(lastChange.note && { note: lastChange.note }),
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
136
163
|
nodes: canvas.nodes.map((node) => describeNode(node, lookup)),
|
|
137
164
|
connections: canvas.edges.map((edge) => ({
|
|
138
165
|
id: edge.id,
|
|
@@ -204,18 +231,20 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
|
|
|
204
231
|
errors.push(`Give each node a resource, or a type (one of ${COMPONENT_TYPES.join(', ')})`);
|
|
205
232
|
continue;
|
|
206
233
|
}
|
|
207
|
-
const data =
|
|
208
|
-
definition.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
234
|
+
const data = isWrittenOnCanvas(definition.type)
|
|
235
|
+
? { ...definition.createData(), text: spec.summary ?? spec.name ?? '' }
|
|
236
|
+
: updateResource(definition.type, definition.createData(), {
|
|
237
|
+
...(spec.name && { name: spec.name }),
|
|
238
|
+
...(spec.summary && { summary: spec.summary }),
|
|
239
|
+
...(spec.version && isVersioned(definition.type) && { version: spec.version }),
|
|
240
|
+
});
|
|
214
241
|
const node = buildNode(definition.type, data, centerOf(spec));
|
|
215
242
|
built = { node, name: String(getResource(node.type, node.data).name ?? definition.label) };
|
|
216
243
|
}
|
|
217
244
|
|
|
218
|
-
if (spec.inside) {
|
|
245
|
+
if (spec.inside && !canGoInContainer(built.node.type)) {
|
|
246
|
+
errors.push(`Notes aren't put in containers: "${spec.ref ?? built.name}" was put on the canvas instead`);
|
|
247
|
+
} else if (spec.inside) {
|
|
219
248
|
const parent = refs.get(spec.inside) ?? lookup.get(spec.inside);
|
|
220
249
|
if (parent && isGroupType(parent.type)) built.node = putInside(built.node, parent, spec);
|
|
221
250
|
else errors.push(`"${spec.inside}" is not a container on the canvas (a domain or system added as a container)`);
|
|
@@ -248,7 +277,9 @@ export const planNodes = (existing: Node[], specs: NodeSpecs, catalog: CatalogIn
|
|
|
248
277
|
}
|
|
249
278
|
}
|
|
250
279
|
|
|
251
|
-
|
|
280
|
+
// Placed around what's on the canvas people edit (notes on L1 and L2 aren't there)
|
|
281
|
+
const onCanvas = [...lookup.values()].filter((node) => isOnLevel(node, 3));
|
|
282
|
+
const positions = placeNodes(onCanvas, toPlace, [...links.values()]);
|
|
252
283
|
const canvasPositions = new Map(planned.map((entry) => [entry.node.id, positions.get(entry.node.id) ?? entry.canvasPosition]));
|
|
253
284
|
for (const entry of planned) {
|
|
254
285
|
const canvasPosition = positions.get(entry.node.id);
|
|
@@ -271,17 +302,113 @@ export const resolveEdgeRefs = (edges: EdgeSpecs, planned: PlannedNode[]) => {
|
|
|
271
302
|
return edges.map((edge) => ({ ...edge, from: refs.get(edge.from) ?? edge.from, to: refs.get(edge.to) ?? edge.to }));
|
|
272
303
|
};
|
|
273
304
|
|
|
274
|
-
|
|
275
|
-
|
|
305
|
+
export type NodeUpdate = { nodeId: string; name?: string; summary?: string; version?: string };
|
|
306
|
+
|
|
307
|
+
/** Renames, re-describes or re-versions a node. Catalog resources keep their catalog name, summary and version. */
|
|
308
|
+
export const updateCanvasNode = (doc: Y.Doc, { nodeId, name, summary, version }: NodeUpdate) =>
|
|
276
309
|
doc.transact(() => {
|
|
277
310
|
const node = getCanvasMaps(doc).nodes.get(nodeId);
|
|
278
311
|
if (!node) return { error: `No node "${nodeId}" on the canvas` };
|
|
279
312
|
if (!getCatalogLink(node)) {
|
|
280
313
|
updateNodeData(doc, nodeId, (data) =>
|
|
281
|
-
node.type
|
|
314
|
+
isWrittenOnCanvas(node.type)
|
|
282
315
|
? { ...data, text: summary ?? name ?? data.text }
|
|
283
|
-
: updateResource(node.type, data, {
|
|
316
|
+
: updateResource(node.type, data, {
|
|
317
|
+
...(name !== undefined && { name }),
|
|
318
|
+
...(summary !== undefined && { summary }),
|
|
319
|
+
...(version !== undefined && isVersioned(node.type) && { version }),
|
|
320
|
+
})
|
|
284
321
|
);
|
|
285
322
|
}
|
|
286
323
|
return { updated: nodeId };
|
|
287
324
|
});
|
|
325
|
+
|
|
326
|
+
// ---- A catalog resource with its connections ----
|
|
327
|
+
|
|
328
|
+
const MESSAGE_COLLECTIONS = new Set(['events', 'commands', 'queries']);
|
|
329
|
+
|
|
330
|
+
/** What a group of connections is: what a service sends, receives and stores, or which services send or receive a message */
|
|
331
|
+
export type ConnectionGroupId = 'receives' | 'sends' | 'dataStores' | 'senders' | 'receivers';
|
|
332
|
+
export type ConnectionGroup = { id: ConnectionGroupId; label: string; resources: CatalogResource[] };
|
|
333
|
+
|
|
334
|
+
const GROUP_LABELS: Record<ConnectionGroupId, string> = {
|
|
335
|
+
receives: 'Messages it receives',
|
|
336
|
+
sends: 'Messages it sends',
|
|
337
|
+
dataStores: 'Data stores it reads or writes',
|
|
338
|
+
senders: 'Services that send it',
|
|
339
|
+
receivers: 'Services that receive it',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** Which group a related resource is in, for a service or a message (or none: not brought with it) */
|
|
343
|
+
const groupOf = (resource: CatalogResource, other: CatalogResource, outgoing: boolean): ConnectionGroupId | undefined => {
|
|
344
|
+
if (resource.collection === 'services') {
|
|
345
|
+
if (other.collection === 'containers') return 'dataStores';
|
|
346
|
+
if (MESSAGE_COLLECTIONS.has(other.collection)) return outgoing ? 'sends' : 'receives';
|
|
347
|
+
}
|
|
348
|
+
if (MESSAGE_COLLECTIONS.has(resource.collection) && other.collection === 'services') return outgoing ? 'receivers' : 'senders';
|
|
349
|
+
return undefined;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What a catalog service or message connects to that isn't on the canvas yet, in groups (only the groups with
|
|
354
|
+
* something in them): a service's messages and data stores, or the services that send and receive a message
|
|
355
|
+
*/
|
|
356
|
+
export const getCatalogConnections = (
|
|
357
|
+
resource: CatalogResource,
|
|
358
|
+
catalog: CatalogIndex,
|
|
359
|
+
keysOnCanvas: ReadonlySet<string>
|
|
360
|
+
): ConnectionGroup[] => {
|
|
361
|
+
const groups = new Map<ConnectionGroupId, CatalogResource[]>();
|
|
362
|
+
const seen = new Set<string>();
|
|
363
|
+
for (const relation of catalog.relationsByKey.get(resource.key) ?? []) {
|
|
364
|
+
const outgoing = relation.source === resource.key;
|
|
365
|
+
const other = catalog.resourcesByKey.get(outgoing ? relation.target : relation.source);
|
|
366
|
+
if (!other || seen.has(other.key) || keysOnCanvas.has(other.key)) continue;
|
|
367
|
+
const group = groupOf(resource, other, outgoing);
|
|
368
|
+
if (!group) continue;
|
|
369
|
+
groups.set(group, [...(groups.get(group) ?? []), other]);
|
|
370
|
+
seen.add(other.key);
|
|
371
|
+
}
|
|
372
|
+
// In reading order: what comes in, then what goes out
|
|
373
|
+
return (['receives', 'senders', 'sends', 'receivers', 'dataStores'] as const).flatMap((id) => {
|
|
374
|
+
const resources = groups.get(id);
|
|
375
|
+
return resources ? [{ id, label: GROUP_LABELS[id], resources }] : [];
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A catalog resource centred on a canvas point (inside the container there, if there is one), with the resources
|
|
381
|
+
* it connects to placed around it like an architect would (what comes in before it, what goes out after it), and
|
|
382
|
+
* the connections between them and to what's already on the canvas. A service's data stores go in its container
|
|
383
|
+
* with it. Positions are relative to the containers they're in, as the canvas is now.
|
|
384
|
+
*/
|
|
385
|
+
export const planWithConnections = (
|
|
386
|
+
existing: Node[],
|
|
387
|
+
resource: CatalogResource,
|
|
388
|
+
center: XYPosition,
|
|
389
|
+
include: CatalogResource[],
|
|
390
|
+
catalog: CatalogIndex
|
|
391
|
+
): { nodes: Node[]; edges: Edge[] } => {
|
|
392
|
+
const container = findGroupAtPoint(center, existing);
|
|
393
|
+
const specOf = (entry: CatalogResource) => ({
|
|
394
|
+
resource: { collection: entry.collection as (typeof CATALOG_COLLECTIONS)[number], id: entry.id },
|
|
395
|
+
});
|
|
396
|
+
const { planned } = planNodes(
|
|
397
|
+
existing,
|
|
398
|
+
[
|
|
399
|
+
{ ...specOf(resource), x: center.x, y: center.y, ...(container && { inside: container.id }) },
|
|
400
|
+
...include.map((entry) => ({
|
|
401
|
+
...specOf(entry),
|
|
402
|
+
...(container && entry.collection === 'containers' && { inside: container.id }),
|
|
403
|
+
})),
|
|
404
|
+
],
|
|
405
|
+
catalog
|
|
406
|
+
);
|
|
407
|
+
const placed = [...existing];
|
|
408
|
+
const edges: Edge[] = [];
|
|
409
|
+
for (const { node, resource: entry } of planned) {
|
|
410
|
+
if (entry) edges.push(...getRelatedEdges(entry, node.id, placed, catalog.relationsByKey, node.type));
|
|
411
|
+
placed.push(node);
|
|
412
|
+
}
|
|
413
|
+
return { nodes: planned.map((entry) => entry.node), edges };
|
|
414
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
2
|
import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
|
|
3
|
-
import { createEdge } from './edges';
|
|
3
|
+
import { createEdge, getEdgeLabel } from './edges';
|
|
4
4
|
import { fitGroup, getAbsolutePosition, withDescendants, withParent } from './grouping';
|
|
5
5
|
import { getNodeDefinition, getNodeSize, isGroupType } from './node-types';
|
|
6
6
|
|
|
@@ -9,13 +9,28 @@ import { getNodeDefinition, getNodeSize, isGroupType } from './node-types';
|
|
|
9
9
|
* browser (people, WebMCP) and on the server (MCP tools), so humans and agents edit a canvas the same way.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
export type Author = {
|
|
12
|
+
export type Author = {
|
|
13
|
+
name: string;
|
|
14
|
+
color: string;
|
|
15
|
+
agent?: boolean;
|
|
16
|
+
/** A picture from their sign-in provider (SSO), shown over their initials */
|
|
17
|
+
picture?: string;
|
|
18
|
+
};
|
|
13
19
|
export type Message = { id: string; text: string; author: Author; createdAt: number };
|
|
14
20
|
/** Where a comment is pinned: a canvas position, or a node (and the offset from its top left) so it moves with it */
|
|
15
21
|
export type CommentAnchor = { position: XYPosition; nodeId?: string; offset?: XYPosition };
|
|
16
22
|
export type Thread = CommentAnchor & { id: string; author: Author; createdAt: number; resolved: boolean; messages: Message[] };
|
|
23
|
+
/** Where a canvas's design is: being worked on, up for review, agreed, or decided against */
|
|
24
|
+
export const CANVAS_STATUSES = ['draft', 'proposed', 'accepted', 'rejected'] as const;
|
|
25
|
+
export type CanvasStatus = (typeof CANVAS_STATUSES)[number];
|
|
26
|
+
/** A canvas's status being changed: by whom, when, and why (e.g. "Agreed in the architecture review") */
|
|
27
|
+
export type StatusChange = { status: CanvasStatus; by: Author; at: number; note?: string };
|
|
17
28
|
export type CanvasMeta = {
|
|
18
29
|
title?: string;
|
|
30
|
+
/** Draft until someone changes it */
|
|
31
|
+
status?: CanvasStatus;
|
|
32
|
+
/** Every status change, oldest first (the last few hundred) */
|
|
33
|
+
statusHistory?: StatusChange[];
|
|
19
34
|
createdAt?: number;
|
|
20
35
|
createdBy?: string;
|
|
21
36
|
/** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
|
|
@@ -59,7 +74,7 @@ export const buildNode = (type: string, data: Record<string, unknown>, center: X
|
|
|
59
74
|
type,
|
|
60
75
|
position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
|
|
61
76
|
data,
|
|
62
|
-
...(definition?.defaultSize ? size : {}),
|
|
77
|
+
...(definition?.defaultSize ? (definition.autoHeight ? { width: size.width } : size) : {}),
|
|
63
78
|
// Containers sit behind what's in them and the connections between them
|
|
64
79
|
...(isGroupType(type) && { zIndex: -1 }),
|
|
65
80
|
});
|
|
@@ -75,6 +90,56 @@ export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
|
|
|
75
90
|
Object.entries(meta).forEach(([key, value]) => (value === undefined ? map.delete(key) : map.set(key, value)));
|
|
76
91
|
});
|
|
77
92
|
|
|
93
|
+
// ---- Status ----
|
|
94
|
+
|
|
95
|
+
const MAX_STATUS_HISTORY = 200;
|
|
96
|
+
|
|
97
|
+
export const getCanvasStatus = (meta: CanvasMeta): CanvasStatus => meta.status ?? 'draft';
|
|
98
|
+
|
|
99
|
+
/** Changes a canvas's status (and records who did it). Returns false if it already had that status. */
|
|
100
|
+
export const setCanvasStatus = (doc: Y.Doc, status: CanvasStatus, by: Author, note?: string) => {
|
|
101
|
+
let changed = false;
|
|
102
|
+
doc.transact(() => {
|
|
103
|
+
const meta = readMeta(doc);
|
|
104
|
+
if (getCanvasStatus(meta) === status) return;
|
|
105
|
+
const change: StatusChange = { status, by, at: Date.now(), ...(note?.trim() && { note: note.trim() }) };
|
|
106
|
+
const map = getCanvasMaps(doc).meta;
|
|
107
|
+
map.set('status', status);
|
|
108
|
+
map.set('statusHistory', [...(meta.statusHistory ?? []), change].slice(-MAX_STATUS_HISTORY));
|
|
109
|
+
changed = true;
|
|
110
|
+
});
|
|
111
|
+
return changed;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Accepted and rejected canvases are decided: changing what's on them makes them a draft again */
|
|
115
|
+
export const isDecided = (status: CanvasStatus) => status === 'accepted' || status === 'rejected';
|
|
116
|
+
|
|
117
|
+
/** A decided canvas whose nodes or edges were changed goes back to draft, recorded as changed by `by` */
|
|
118
|
+
export const reopenIfDecided = (doc: Y.Doc, by: Author) => {
|
|
119
|
+
const status = getCanvasStatus(readMeta(doc));
|
|
120
|
+
if (isDecided(status)) setCanvasStatus(doc, 'draft', by, `Changed after it was ${status}`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Runs `fn` (which may change the canvas), and if it changed nodes or edges, sends a decided canvas back to draft.
|
|
125
|
+
* Comments don't count: reviewing an accepted design keeps it accepted.
|
|
126
|
+
*/
|
|
127
|
+
export const reopeningIfEdited = <T>(doc: Y.Doc, by: Author, fn: () => T): T => {
|
|
128
|
+
let result!: T;
|
|
129
|
+
// In one transaction (the one it's already in, if it is): what it changed is known before it ends
|
|
130
|
+
doc.transact((transaction) => {
|
|
131
|
+
result = fn();
|
|
132
|
+
if (changesNodesOrEdges(transaction)) reopenIfDecided(doc, by);
|
|
133
|
+
});
|
|
134
|
+
return result;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Whether a transaction changed the canvas's nodes or edges (not just its comments or meta) */
|
|
138
|
+
export const changesNodesOrEdges = (transaction: Y.Transaction) => {
|
|
139
|
+
const { nodes, edges } = getCanvasMaps(transaction.doc);
|
|
140
|
+
return [...transaction.changed.keys()].some((type) => type === (nodes as unknown) || type === (edges as unknown));
|
|
141
|
+
};
|
|
142
|
+
|
|
78
143
|
// ---- Nodes and edges ----
|
|
79
144
|
|
|
80
145
|
export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
|
|
@@ -91,18 +156,38 @@ export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => {
|
|
|
91
156
|
return !!node;
|
|
92
157
|
};
|
|
93
158
|
|
|
94
|
-
|
|
159
|
+
/** Resize a node: both sides, or just its width or height (e.g. text, whose height follows what's in it) */
|
|
160
|
+
export const resizeNode = (
|
|
161
|
+
doc: Y.Doc,
|
|
162
|
+
id: string,
|
|
163
|
+
size: { width: number; height: number },
|
|
164
|
+
attributes: true | 'width' | 'height' = true
|
|
165
|
+
) => {
|
|
95
166
|
const { nodes } = getCanvasMaps(doc);
|
|
96
167
|
const node = nodes.get(id);
|
|
97
|
-
if (node)
|
|
168
|
+
if (node)
|
|
169
|
+
nodes.set(id, {
|
|
170
|
+
...node,
|
|
171
|
+
...(attributes !== 'height' && { width: size.width }),
|
|
172
|
+
...(attributes !== 'width' && { height: size.height }),
|
|
173
|
+
});
|
|
98
174
|
return !!node;
|
|
99
175
|
};
|
|
100
176
|
|
|
101
177
|
/** Grow a container to fit what's in it (moving them clear of its header if needed), never shrinking it */
|
|
102
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The sizes nodes are rendered at (in the browser), by id. The document doesn't have them, so without them
|
|
180
|
+
* containers are fitted to the size each type renders at at most.
|
|
181
|
+
*/
|
|
182
|
+
export type RenderedSizes = ReadonlyMap<string, { width: number; height: number }>;
|
|
183
|
+
|
|
184
|
+
const withRenderedSizes = (nodes: Node[], sizes?: RenderedSizes) =>
|
|
185
|
+
sizes ? nodes.map((node) => (sizes.has(node.id) ? { ...node, measured: sizes.get(node.id) } : node)) : nodes;
|
|
186
|
+
|
|
187
|
+
export const fitGroupToChildren = (doc: Y.Doc, groupId: string, sizes?: RenderedSizes) =>
|
|
103
188
|
doc.transact(() => {
|
|
104
189
|
const { nodes } = getCanvasMaps(doc);
|
|
105
|
-
const fit = fitGroup(groupId, Array.from(nodes.values()));
|
|
190
|
+
const fit = fitGroup(groupId, withRenderedSizes(Array.from(nodes.values()), sizes));
|
|
106
191
|
const group = nodes.get(groupId);
|
|
107
192
|
if (!fit || !group) return;
|
|
108
193
|
if (fit.shift.x || fit.shift.y) {
|
|
@@ -120,14 +205,21 @@ export const fitGroupToChildren = (doc: Y.Doc, groupId: string) =>
|
|
|
120
205
|
});
|
|
121
206
|
});
|
|
122
207
|
|
|
208
|
+
/** The containers a node is in grow to fit it (innermost first), e.g. after it's added or resized */
|
|
209
|
+
export const fitContainersAround = (doc: Y.Doc, nodeId: string, sizes?: RenderedSizes) =>
|
|
210
|
+
doc.transact(() => {
|
|
211
|
+
const { nodes } = getCanvasMaps(doc);
|
|
212
|
+
for (let id = nodes.get(nodeId)?.parentId; id; id = nodes.get(id)?.parentId) fitGroupToChildren(doc, id, sizes);
|
|
213
|
+
});
|
|
214
|
+
|
|
123
215
|
/** Put a node in a container (or take it out, without a parentId), keeping it where it is on the canvas */
|
|
124
|
-
export const setNodeParent = (doc: Y.Doc, nodeId: string, parentId: string | undefined) =>
|
|
216
|
+
export const setNodeParent = (doc: Y.Doc, nodeId: string, parentId: string | undefined, sizes?: RenderedSizes) =>
|
|
125
217
|
doc.transact(() => {
|
|
126
218
|
const { nodes } = getCanvasMaps(doc);
|
|
127
219
|
const node = nodes.get(nodeId);
|
|
128
220
|
if (!node || node.parentId === parentId) return false;
|
|
129
221
|
nodes.set(nodeId, toSharedNode(withParent(node, parentId, Array.from(nodes.values()))));
|
|
130
|
-
if (parentId) fitGroupToChildren(doc, parentId);
|
|
222
|
+
if (parentId) fitGroupToChildren(doc, parentId, sizes);
|
|
131
223
|
return true;
|
|
132
224
|
});
|
|
133
225
|
|
|
@@ -167,6 +259,35 @@ export const connectNodes = (
|
|
|
167
259
|
return created;
|
|
168
260
|
};
|
|
169
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Moves a connection to new ends (dragged to another node), keeping it the same connection. Its label follows
|
|
264
|
+
* the new ends if it was EventCatalog's wording, and is kept if someone wrote it.
|
|
265
|
+
*/
|
|
266
|
+
export const reconnectEdge = (doc: Y.Doc, edgeId: string, connection: Connection): Edge | { error: string } =>
|
|
267
|
+
doc.transact(() => {
|
|
268
|
+
const { nodes, edges } = getCanvasMaps(doc);
|
|
269
|
+
const edge = edges.get(edgeId);
|
|
270
|
+
const source = nodes.get(connection.source);
|
|
271
|
+
const target = nodes.get(connection.target);
|
|
272
|
+
if (!edge) return { error: `Connection "${edgeId}" is not on the canvas` };
|
|
273
|
+
if (!source || !target) return { error: 'Connections join two nodes on the canvas' };
|
|
274
|
+
if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
|
|
275
|
+
const duplicate = Array.from(edges.values()).find(
|
|
276
|
+
(other) => other.id !== edgeId && other.source === source.id && other.target === target.id
|
|
277
|
+
);
|
|
278
|
+
if (duplicate) return { error: 'Those nodes are already connected' };
|
|
279
|
+
|
|
280
|
+
const defaultLabel = getEdgeLabel(nodes.get(edge.source)?.type, nodes.get(edge.target)?.type);
|
|
281
|
+
const moved = createEdge(connection, source.type, target.type);
|
|
282
|
+
const reconnected: Edge = {
|
|
283
|
+
...moved,
|
|
284
|
+
id: edge.id,
|
|
285
|
+
...(edge.label !== undefined && edge.label !== defaultLabel && { label: edge.label }),
|
|
286
|
+
};
|
|
287
|
+
edges.set(edgeId, reconnected);
|
|
288
|
+
return reconnected;
|
|
289
|
+
});
|
|
290
|
+
|
|
170
291
|
export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
|
|
171
292
|
doc.transact(() => ids.forEach((id) => getCanvasMaps(doc).edges.delete(id)));
|
|
172
293
|
|
|
@@ -267,6 +388,14 @@ export const deleteThread = (doc: Y.Doc, threadId: string) => {
|
|
|
267
388
|
return exists;
|
|
268
389
|
};
|
|
269
390
|
|
|
391
|
+
/** Deletes comment threads in one change (one undo brings them all back) */
|
|
392
|
+
export const deleteThreads = (doc: Y.Doc, threadIds: string[]) =>
|
|
393
|
+
doc.transact(() => threadIds.forEach((id) => deleteThread(doc, id)));
|
|
394
|
+
|
|
395
|
+
/** Moves comment threads in one change, e.g. ones that moved with what was dragged */
|
|
396
|
+
export const moveThreads = (doc: Y.Doc, moves: { threadId: string; anchor: CommentAnchor }[]) =>
|
|
397
|
+
doc.transact(() => moves.forEach(({ threadId, anchor }) => moveThread(doc, threadId, anchor)));
|
|
398
|
+
|
|
270
399
|
// ---- Reading a whole canvas ----
|
|
271
400
|
|
|
272
401
|
export const readCanvas = (doc: Y.Doc) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Edge, Node } from '@xyflow/react';
|
|
2
|
+
import { getAbsolutePosition, sizeOf } from './grouping';
|
|
3
|
+
import { isGroupType } from './node-types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A canvas drawn small, for lists of canvases: each node as a box where it is on the canvas (containers as outlines
|
|
7
|
+
* behind what's in them) and each connection as a line between the boxes' middles. In the canvas's own coordinates,
|
|
8
|
+
* starting at 0,0, to draw in an SVG with a viewBox of its width and height.
|
|
9
|
+
*/
|
|
10
|
+
export type CanvasPreview = {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
shapes: { x: number; y: number; width: number; height: number; type: string; container: boolean }[];
|
|
14
|
+
lines: { x1: number; y1: number; x2: number; y2: number }[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Big canvases are drawn from their first few hundred nodes and connections: enough for a thumbnail */
|
|
18
|
+
const MAX_SHAPES = 300;
|
|
19
|
+
const MAX_LINES = 300;
|
|
20
|
+
|
|
21
|
+
export const getCanvasPreview = (nodes: Node[], edges: Edge[]): CanvasPreview | null => {
|
|
22
|
+
if (nodes.length === 0) return null;
|
|
23
|
+
const lookup = new Map(nodes.map((node) => [node.id, node]));
|
|
24
|
+
const boxes = new Map(
|
|
25
|
+
nodes.slice(0, MAX_SHAPES).map((node) => {
|
|
26
|
+
const { x, y } = getAbsolutePosition(node, lookup);
|
|
27
|
+
return [node.id, { x, y, ...sizeOf(node), type: node.type ?? 'node', container: isGroupType(node.type) }];
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
const all = [...boxes.values()];
|
|
31
|
+
const left = Math.min(...all.map((box) => box.x));
|
|
32
|
+
const top = Math.min(...all.map((box) => box.y));
|
|
33
|
+
const right = Math.max(...all.map((box) => box.x + box.width));
|
|
34
|
+
const bottom = Math.max(...all.map((box) => box.y + box.height));
|
|
35
|
+
const middle = (id: string) => {
|
|
36
|
+
const box = boxes.get(id);
|
|
37
|
+
return box && { x: box.x - left + box.width / 2, y: box.y - top + box.height / 2 };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
width: Math.max(right - left, 1),
|
|
42
|
+
height: Math.max(bottom - top, 1),
|
|
43
|
+
// Containers first, so what's in them is drawn over them
|
|
44
|
+
shapes: all
|
|
45
|
+
.map((box) => ({ ...box, x: box.x - left, y: box.y - top }))
|
|
46
|
+
.sort((a, b) => Number(b.container) - Number(a.container)),
|
|
47
|
+
lines: edges.slice(0, MAX_LINES).flatMap((edge) => {
|
|
48
|
+
const from = middle(edge.source);
|
|
49
|
+
const to = middle(edge.target);
|
|
50
|
+
return from && to ? [{ x1: from.x, y1: from.y, x2: to.x, y2: to.y }] : [];
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -144,8 +144,11 @@ export const getCatalogResources = async (): Promise<{ resources: CatalogResourc
|
|
|
144
144
|
const collection = messageCollection.get(id);
|
|
145
145
|
if (collection) relate(keyOf(collection, id), serviceKey);
|
|
146
146
|
}
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
// A data store it both writes to and reads from is one connection (like the visualiser), not two on top of each other
|
|
148
|
+
const reads = new Set(idsOf(service.data.readsFrom));
|
|
149
|
+
const writes = new Set(idsOf(service.data.writesTo));
|
|
150
|
+
for (const id of writes) relate(serviceKey, keyOf('containers', id), reads.has(id) ? 'reads and writes' : undefined);
|
|
151
|
+
for (const id of reads) if (!writes.has(id)) relate(keyOf('containers', id), serviceKey);
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
return { resources, relations };
|