@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.9

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.
Files changed (95) hide show
  1. package/LICENSE +227 -128
  2. package/dist/analytics/analytics.cjs +18 -3
  3. package/dist/analytics/analytics.d.cts +1 -1
  4. package/dist/analytics/analytics.d.ts +1 -1
  5. package/dist/analytics/analytics.js +3 -2
  6. package/dist/analytics/log-build.cjs +70 -34
  7. package/dist/analytics/log-build.js +5 -4
  8. package/dist/analytics/telemetry.cjs +42 -0
  9. package/dist/analytics/telemetry.d.cts +20 -0
  10. package/dist/analytics/telemetry.d.ts +20 -0
  11. package/dist/analytics/telemetry.js +8 -0
  12. package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
  13. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  14. package/dist/chunk-CPW6G62V.js +17 -0
  15. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  16. package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
  17. package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
  18. package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
  19. package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +207 -123
  23. package/dist/eventcatalog.config.d.cts +55 -0
  24. package/dist/eventcatalog.config.d.ts +55 -0
  25. package/dist/eventcatalog.js +39 -19
  26. package/dist/features.cjs +8 -2
  27. package/dist/features.d.cts +3 -1
  28. package/dist/features.d.ts +3 -1
  29. package/dist/features.js +5 -3
  30. package/dist/federation/federate.js +4 -4
  31. package/dist/generate.cjs +1 -1
  32. package/dist/generate.js +3 -3
  33. package/dist/utils/cli-logger.cjs +1 -1
  34. package/dist/utils/cli-logger.js +2 -2
  35. package/dist/utils/license-status.cjs +70 -44
  36. package/dist/utils/license-status.d.cts +14 -5
  37. package/dist/utils/license-status.d.ts +14 -5
  38. package/dist/utils/license-status.js +1 -1
  39. package/eventcatalog/integrations/studio-server.mjs +15 -3
  40. package/eventcatalog/src/components/Header.astro +3 -3
  41. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  42. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  43. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  44. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  45. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  46. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  47. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  48. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  49. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  50. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  51. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  52. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  53. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  54. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  55. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  56. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  57. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  58. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  59. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  60. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  61. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  62. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  63. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  64. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  65. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  66. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  67. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  68. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  69. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  70. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  71. package/eventcatalog/src/features/studio/edges.ts +2 -0
  72. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  73. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  74. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  75. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  76. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  77. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  78. package/eventcatalog/src/features/studio/identity.ts +35 -0
  79. package/eventcatalog/src/features/studio/layout.ts +7 -4
  80. package/eventcatalog/src/features/studio/levels.ts +138 -72
  81. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  82. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  83. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  84. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  85. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  86. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  87. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  88. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  89. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  90. package/eventcatalog/src/features/studio/studio.css +184 -0
  91. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  92. package/eventcatalog/src/utils/feature.ts +7 -3
  93. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  94. package/package.json +3 -3
  95. package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
@@ -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 { findStudioRuntime } from '@features/studio/server/runtime';
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() && findStudioRuntime() ? CANVAS_TOOL_NAMES : []), ...extendedToolNames],
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
- fitGroupToChildren,
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
- for (let id = node.parentId; id; id = readNodes(doc).get(id)?.parentId) fitGroupToChildren(doc, id);
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 }: { nodeId: string; name?: string; summary?: string; x?: number; y?: number }
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) => updateCanvasNode(doc, { nodeId, name, summary }));
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 { buildContainer, canBeContainer, catalogNodeData, containerTypeFor, getCatalogLink, indexRelations } from './catalog';
7
- import { getAbsolutePosition, sizeOf } from './grouping';
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 !== 'note' && { name: resource.name }),
117
+ ...(!isWrittenOnCanvas(node.type) && { name: resource.name }),
103
118
  ...(resource.summary ? { summary: resource.summary } : {}),
104
- ...(node.type === 'note' ? { text: node.data.text } : {}),
105
- ...(link ? { catalogResource: link.key.replace(':', '/'), version: link.version } : {}),
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.type === 'note'
209
- ? { ...definition.createData(), text: spec.summary ?? spec.name ?? '' }
210
- : updateResource(definition.type, definition.createData(), {
211
- ...(spec.name && { name: spec.name }),
212
- ...(spec.summary && { summary: spec.summary }),
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
- const positions = placeNodes([...lookup.values()], toPlace, [...links.values()]);
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
- /** Renames or re-describes a node. Catalog resources keep their catalog name and summary. */
275
- export const updateCanvasNode = (doc: Y.Doc, { nodeId, name, summary }: { nodeId: string; name?: string; summary?: string }) =>
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 === 'note'
314
+ isWrittenOnCanvas(node.type)
282
315
  ? { ...data, text: summary ?? name ?? data.text }
283
- : updateResource(node.type, data, { ...(name !== undefined && { name }), ...(summary !== undefined && { summary }) })
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 = { name: string; color: string; agent?: boolean };
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
- export const resizeNode = (doc: Y.Doc, id: string, size: { width: number; height: number }) => {
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) nodes.set(id, { ...node, width: size.width, height: size.height });
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
- export const fitGroupToChildren = (doc: Y.Doc, groupId: string) =>
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
- for (const id of idsOf(service.data.writesTo)) relate(serviceKey, keyOf('containers', id));
148
- for (const id of idsOf(service.data.readsFrom)) relate(keyOf('containers', id), serviceKey);
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 };