@eventcatalog/core 5.7.1 → 5.7.4

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 (43) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-GRC67WHC.js → chunk-GOIDVIMH.js} +1 -1
  6. package/dist/{chunk-HZ3XZKUT.js → chunk-L2PFSX4L.js} +1 -1
  7. package/dist/{chunk-36XVRD36.js → chunk-PLTHZFTW.js} +1 -1
  8. package/dist/{chunk-CPF5WE3R.js → chunk-PXYVJK54.js} +1 -1
  9. package/dist/{chunk-AXWTR2FV.js → chunk-RUG7AIT5.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/01-what-is-eventcatalog-assistant.md +8 -0
  13. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/02-configuration.md +24 -0
  14. package/dist/docs/development/studio/01-introduction.md +2 -1
  15. package/dist/docs/development/studio/04-collaborate-on-a-canvas.md +10 -2
  16. package/dist/docs/development/studio/05-connect-your-agent.md +4 -0
  17. package/dist/docs/development/studio/07-reference.md +40 -7
  18. package/dist/docs/development/studio/draw-with-ai.md +96 -0
  19. package/dist/eventcatalog.cjs +1 -1
  20. package/dist/eventcatalog.js +5 -5
  21. package/dist/generate.cjs +1 -1
  22. package/dist/generate.js +3 -3
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +113 -8
  26. package/eventcatalog/src/components/ChatPanel/panel-width.ts +28 -0
  27. package/eventcatalog/src/components/MDX/Steps/Step.astro +4 -2
  28. package/eventcatalog/src/components/SchemaExplorer/DiffViewer.tsx +2 -103
  29. package/eventcatalog/src/components/SchemaExplorer/diff-dark-styles.ts +104 -0
  30. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +71 -71
  31. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
  32. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +25 -3
  33. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  34. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
  35. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_ChangelogPage.astro +297 -0
  36. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.spec.ts +101 -0
  37. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +76 -47
  38. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +4 -168
  39. package/eventcatalog/src/pages/docs/services/[id]/[version]/changelog/index.astro +14 -0
  40. package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +3 -3
  41. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +112 -28
  42. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +24 -10
  43. package/package.json +4 -4
@@ -1,176 +1,12 @@
1
1
  ---
2
- import Footer from '@layouts/Footer.astro';
3
- import { getChangeLogs } from '@utils/collections/changelogs';
4
- import { getChangelogResourceBadge } from '@utils/changelog-resource-badge';
5
- import { render, getEntry } from 'astro:content';
6
- import mdxComponents from '@components/MDX/components';
7
- import 'diff2html/bundles/css/diff2html.min.css';
8
-
9
- import { buildUrl } from '@utils/url-builder';
10
- import { getPreviousVersion } from '@utils/collections/util';
11
- import { getDiffsForCurrentAndPreviousVersion } from '@utils/file-diffs';
12
- import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
13
- import Badge from '@components/Badge.astro';
14
- import { ClientRouter } from 'astro:transitions';
15
-
2
+ import ChangelogPage from './_ChangelogPage.astro';
16
3
  import { Page } from './_index.data';
17
4
 
18
5
  export const prerender = Page.prerender;
19
6
  export const getStaticPaths = Page.getStaticPaths;
20
7
 
21
- // Get data
22
- const props = await Page.getData(Astro);
23
- if (!props) return Astro.rewrite('/404');
24
-
25
- let collectionItem = props;
26
- const logs = await getChangeLogs(props);
27
-
28
- const { data } = props;
29
- const latestVersion = data.latestVersion;
30
-
31
- const renderedLogs = logs.map(async (log) => {
32
- const logEntry = await getEntry('changelogs', log.id);
33
- const { Content } = await render(logEntry as any);
34
- return {
35
- Content,
36
- ...log,
37
- };
38
- });
39
-
40
- const logsToRender = await Promise.all(renderedLogs);
41
-
42
- const logListPromise = logsToRender.map(async (log, index) => {
43
- const currentLogVersion = log.data.version;
44
- const previousLogVersion = log.data.version ? getPreviousVersion(log.data.version, data.versions || []) : '';
45
- return {
46
- id: log.id,
47
- url: buildUrl(`/docs/${props.collection}/${props.data.id}`),
48
- isLatest: log.data.version === latestVersion,
49
- version: log.data.version,
50
- createdAt: log.data.createdAt,
51
- badges: log.data.badges || [],
52
- Content: log.Content,
53
- diffs:
54
- currentLogVersion && previousLogVersion
55
- ? await getDiffsForCurrentAndPreviousVersion(
56
- currentLogVersion,
57
- previousLogVersion,
58
- collectionItem.data.id,
59
- props.allCollectionItems
60
- )
61
- : [],
62
- };
63
- });
64
-
65
- const logList = await Promise.all(logListPromise);
66
-
67
- const badges = [getChangelogResourceBadge(props.collection)].filter((badge) => badge != null);
8
+ const changelogOwner = await Page.getData(Astro);
9
+ if (!changelogOwner) return Astro.rewrite('/404');
68
10
  ---
69
11
 
70
- <VerticalSideBarLayout title="ChangeLog">
71
- <main class="w-full md:pt-4 px-8 overflow-y-auto h-full max-w-[95%]">
72
- <nav aria-label="Breadcrumb" class="flex">
73
- <ol role="list" class="flex items-center space-x-2">
74
- <li>
75
- <div>
76
- <a
77
- href={buildUrl(`/docs/${props.collection}/${props.data.id}/${props.data.version}`)}
78
- class="text-sm font-medium text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]"
79
- >
80
- {props.data.name}
81
- </a>
82
- </div>
83
- </li>
84
- <li>
85
- <div class="flex items-center">
86
- <svg
87
- fill="currentColor"
88
- viewBox="0 0 20 20"
89
- aria-hidden="true"
90
- class="h-5 w-5 flex-shrink-0 text-[rgb(var(--ec-page-border))]"
91
- >
92
- <path d="M5.555 17.776l8-16 .894.448-8 16-.894-.448z"></path>
93
- </svg>
94
- <a
95
- href={buildUrl(`/docs/${props.collection}/${props.data.id}/${props.data.version}`)}
96
- class="text-sm font-medium text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]"
97
- >
98
- Changelog
99
- </a>
100
- </div>
101
- </li>
102
- </ol>
103
- </nav>
104
- <div class="border-b border-[rgb(var(--ec-page-border))] flex justify-between items-start py-4 w-full">
105
- <div>
106
- <h2 class="text-2xl md:text-4xl font-bold text-[rgb(var(--ec-page-text))]">{props.data.name} (Changelog)</h2>
107
- <h2 class="text-lg pt-2 text-[rgb(var(--ec-page-text-muted))] font-light">{props.data.summary}</h2>
108
- {
109
- badges.length > 0 && (
110
- <div class="flex flex-wrap py-2 pt-4">
111
- {badges.map((badge) => (
112
- <Badge badge={badge} className="mr-2 px-2 py-1 rounded-md" />
113
- ))}
114
- </div>
115
- )
116
- }
117
- </div>
118
- </div>
119
- {
120
- logList.length === 0 && (
121
- <div class="py-4 text-[rgb(var(--ec-page-text-muted))] prose prose-md">
122
- <p>No changelogs found.</p>
123
- </div>
124
- )
125
- }
126
- <div class="flow-root py-8 w-full">
127
- <ul role="list" class="-mb-8">
128
- {
129
- logList.map((log, index) => (
130
- <li>
131
- <div class="relative pb-8">
132
- {index !== logList.length - 1 ? (
133
- <span aria-hidden="true" class="absolute left-6 top-4 -ml-px h-full w-0.5 bg-[rgb(var(--ec-page-border))]" />
134
- ) : null}
135
- <div class="relative flex space-x-3">
136
- <div>
137
- <a
138
- href={buildUrl(`${log.url}/${log.version}`)}
139
- class="bg-primary hover:bg-primary/80 text-white flex h-8 w-14 items-center justify-center rounded-full ring-8 ring-[rgb(var(--ec-page-bg))]"
140
- >
141
- {log.version}
142
- </a>
143
- </div>
144
- </div>
145
- <div class="pl-[70px] py-2 -mt-10">
146
- {log.createdAt && (
147
- <div class="pb-2">
148
- <h3 class="text-2xl text-[rgb(var(--ec-page-text))] font-bold">
149
- <span>
150
- {log.createdAt.toISOString().split('T')[0]} {`${log.isLatest ? '(latest)' : ''}`}
151
- </span>
152
- </h3>
153
- </div>
154
- )}
155
- {log.badges && (
156
- <div class="flex flex-wrap">
157
- {log.badges.filter(Boolean).map((badge: any) => (
158
- <Badge badge={badge} className="mr-2 px-2 py-1 rounded-md" />
159
- ))}
160
- </div>
161
- )}
162
- <div class="prose prose-md max-w-none! py-2">
163
- <log.Content components={mdxComponents(props)} />
164
- </div>
165
- {log.diffs && log.diffs.map((diff) => <div class="bg-white rounded-lg p-2 my-2" set:html={diff} />)}
166
- </div>
167
- </div>
168
- </li>
169
- ))
170
- }
171
- </ul>
172
- </div>
173
- <Footer />
174
- </main>
175
- <ClientRouter />
176
- </VerticalSideBarLayout>
12
+ <ChangelogPage changelogOwner={changelogOwner} />
@@ -0,0 +1,14 @@
1
+ ---
2
+ // Services need their own Changelog route: /docs/services/[id]/[docType]/[docId] outranks
3
+ // the generic /docs/[type]/[id]/[version]/changelog route and would catch these URLs.
4
+ import ChangelogPage from '../../../../[type]/[id]/[version]/changelog/_ChangelogPage.astro';
5
+ import { ServicePage } from '../../../../[type]/[id]/[version]/changelog/_index.data';
6
+
7
+ export const prerender = ServicePage.prerender;
8
+ export const getStaticPaths = ServicePage.getStaticPaths;
9
+
10
+ const changelogOwner = await ServicePage.getData(Astro);
11
+ if (!changelogOwner) return Astro.rewrite('/404');
12
+ ---
13
+
14
+ <ChangelogPage changelogOwner={changelogOwner} />
@@ -2,11 +2,11 @@
2
2
  import LatestVersionRedirect from '@components/LatestVersionRedirect.astro';
3
3
  import { isChangelogEnabled, isSSR } from '@utils/feature';
4
4
  import { buildUrl } from '@utils/url-builder';
5
- import { Page as VersionedPage } from '../../../[type]/[id]/[version]/changelog/_index.data';
6
- import { getLatestServicePaths, getLatestServiceVersion } from '../../_latest-version-route';
5
+ import { getLatestServiceChangelogPaths } from '../../../[type]/[id]/[version]/changelog/_index.data';
6
+ import { getLatestServiceVersion } from '../../_latest-version-route';
7
7
 
8
8
  export const prerender = !isSSR();
9
- export const getStaticPaths = async () => getLatestServicePaths(await VersionedPage.getStaticPaths());
9
+ export const getStaticPaths = getLatestServiceChangelogPaths;
10
10
 
11
11
  const { id } = Astro.params;
12
12
  const version = Astro.props.redirectVersion ?? (id ? await getLatestServiceVersion(id) : undefined);
@@ -4,6 +4,7 @@ import { getNodesAndEdges as getServiceNodesAndEdges } from './services-node-gra
4
4
  import { getNodesAndEdgesForDomainSystems as getDomainContextNodesAndEdges } from './system-context-node-graph';
5
5
  import { getServices } from '@utils/collections/services';
6
6
  import { getAgents } from '@utils/collections/agents';
7
+ import { versionMatches } from '@utils/collections/util';
7
8
  import { createTriggerMessageNode } from './message-node-graph';
8
9
  import {
9
10
  createEdge,
@@ -97,14 +98,16 @@ const createDomainCard = (domain: any, { parentId, subdomain = false }: { parent
97
98
  * systems is shown as a card. Messages a (sub)domain sends or receives itself
98
99
  * (`domainMessages`: by message, the boxes drawn with it) fold into it, as a
99
100
  * system's do into the system, when only one does (more than one, they connect
100
- * through the message instead, rather than to each other).
101
+ * through the message instead, rather than to each other). `receivedBy` has
102
+ * what the services other domains stand for receive, by the node shown for them.
101
103
  */
102
104
  export const collapseSystems = (
103
105
  graph: Graph,
104
106
  systems: any[],
105
107
  contextNodes: Map<string, any>,
106
108
  subdomains: Map<string, any>,
107
- domainMessages: Map<string, Set<string>> = new Map()
109
+ domainMessages: Map<string, Set<string>> = new Map(),
110
+ receivedBy: Map<string, any[]> = new Map()
108
111
  ): Graph => {
109
112
  const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
110
113
  const systemsByGroupId = new Map<string, any>(systems.map((system: any) => [systemGroupId(system), system]));
@@ -123,11 +126,32 @@ export const collapseSystems = (
123
126
  cards.set(groupId, createDomainCard(subdomain, { parentId: nodesById.get(groupId)?.parentId, subdomain: true }));
124
127
  });
125
128
 
129
+ const sendersOf = new Map<string, string[]>();
130
+ graph.edges.forEach((edge) => sendersOf.set(edge.target, [...(sendersOf.get(edge.target) ?? []), edge.source]));
131
+ const carries = (nodeId: string) => nodesById.has(nodeId) && isCarrier(nodesById.get(nodeId));
132
+ const isMessage = (nodeId: string) => MESSAGE_TYPES.has(nodesById.get(nodeId)?.type);
133
+ const isChannel = (nodeId: string) => nodesById.get(nodeId)?.type === 'channels';
134
+
135
+ // A message is shown as what sends it, not where it's drawn, which can be with
136
+ // one of its consumers. Sent from more than one, it's left as it is (connecting
137
+ // each sender to what receives it, rather than one to the rest). None when nothing sends it
138
+ const senderHomes = new Map<string, string | undefined>();
139
+ const senderHome = (nodeId: string): string | undefined => {
140
+ if (!senderHomes.has(nodeId)) {
141
+ const senders = new Set((sendersOf.get(nodeId) ?? []).filter((sender) => !carries(sender)).map(representative));
142
+ const [sender] = [...senders];
143
+ senderHomes.set(nodeId, senders.size > 1 ? nodeId : sender);
144
+ }
145
+ return senderHomes.get(nodeId);
146
+ };
147
+
126
148
  // The node something is shown as: its system, the card of the subdomain it's
127
149
  // in, the (sub)domain it's in, or itself
128
150
  const representative = (nodeId: string): string => {
129
151
  const boxes = domainMessages.get(nodeId);
130
152
  if (boxes?.size === 1) return representative([...boxes][0]);
153
+ const home = isMessage(nodeId) ? senderHome(nodeId) : undefined;
154
+ if (home) return home;
131
155
  for (let node = nodesById.get(nodeId); node; node = node.parentId ? nodesById.get(node.parentId) : undefined) {
132
156
  const system: any = systemsByGroupId.get(node.id);
133
157
  if (system) return generateIdForNode(system);
@@ -149,6 +173,37 @@ export const collapseSystems = (
149
173
  );
150
174
  const shownNodes = new Map([...otherNodes, ...systemNodes, ...cards.values()].map((node) => [node.id, node]));
151
175
 
176
+ // The messages sent through a channel (and the channels before it)
177
+ const messagesThrough = (channelId: string, seen = new Set<string>()): string[] => {
178
+ if (seen.has(channelId)) return [];
179
+ seen.add(channelId);
180
+ return (sendersOf.get(channelId) ?? []).flatMap((sender) =>
181
+ isMessage(sender) ? [sender] : isChannel(sender) ? messagesThrough(sender, seen) : []
182
+ );
183
+ };
184
+ // Whether a node (or the services another domain stands for) says it receives a
185
+ // message: its id, and its version (or range, or latest)
186
+ const receives = (nodeId: string, messageId: string) => {
187
+ const data = nodesById.get(nodeId)?.data;
188
+ const message = nodesById.get(messageId)?.data?.message;
189
+ return ((data?.service ?? data?.agent)?.receives ?? receivedBy.get(nodeId) ?? []).some((pointer: any) => {
190
+ const { id, version } = pointer.data ?? pointer;
191
+ return id === message?.id && versionMatches(message?.version ?? '', version ?? 'latest');
192
+ });
193
+ };
194
+
195
+ // Where an edge is from and the messages it carries. A channel is one node for
196
+ // all its messages, so an edge out of one is from each message sent through it
197
+ // that its target receives (all of them when that isn't known), as those
198
+ // messages are shown. Edges into a channel are shown by the edges out of it
199
+ const edgeSources = (edge: any): { from: string; messages: string[] }[] => {
200
+ if (isChannel(edge.target) && carries(edge.source)) return [];
201
+ const through = isChannel(edge.source) ? messagesThrough(edge.source) : [];
202
+ if (through.length === 0) return [{ from: edge.source, messages: [edge.source, edge.target].filter(isMessage) }];
203
+ const sent = through.filter((messageId) => receives(edge.target, messageId));
204
+ return (sent.length > 0 ? sent : through).map((messageId) => ({ from: messageId, messages: [messageId] }));
205
+ };
206
+
152
207
  // Edges between systems / actors / domains: context relationships as they
153
208
  // are, edges to nodes kept as their first label, the rest labelled with the
154
209
  // messages they carry
@@ -156,28 +211,30 @@ export const collapseSystems = (
156
211
  string,
157
212
  { source: string; target: string; messages: Map<string, string>; relationship?: any; label?: string; crossDomain?: boolean }
158
213
  >();
159
- graph.edges.forEach((edge) => {
160
- const source = representative(edge.source);
161
- const target = representative(edge.target);
162
- if (
163
- source === target ||
164
- !shownNodes.has(source) ||
165
- !shownNodes.has(target) ||
166
- // One shown node inside another (e.g. a system in its domain)
167
- isInside(shownNodes, source, target) ||
168
- isInside(shownNodes, target, source)
169
- )
170
- return;
171
- const key = `${source}->${target}`;
172
- const connection = connections.get(key) ?? { source, target, messages: new Map<string, string>() };
173
- if (edge.id.startsWith(CONTEXT_EDGE_PREFIX)) connection.relationship = edge;
174
- if (edge.data?.crossDomain) connection.crossDomain = true;
175
- [edge.source, edge.target].forEach((nodeId) => {
176
- const node = nodesById.get(nodeId);
177
- if (MESSAGE_TYPES.has(node?.type)) connection.messages.set(node.data?.message?.name ?? nodeId, node.type);
178
- });
179
- connections.set(key, connection);
180
- });
214
+ graph.edges.forEach((edge) =>
215
+ edgeSources(edge).forEach(({ from, messages }) => {
216
+ const source = representative(from);
217
+ const target = representative(edge.target);
218
+ if (
219
+ source === target ||
220
+ !shownNodes.has(source) ||
221
+ !shownNodes.has(target) ||
222
+ // One shown node inside another (e.g. a system in its domain)
223
+ isInside(shownNodes, source, target) ||
224
+ isInside(shownNodes, target, source)
225
+ )
226
+ return;
227
+ const key = `${source}->${target}`;
228
+ const connection = connections.get(key) ?? { source, target, messages: new Map<string, string>() };
229
+ if (edge.id.startsWith(CONTEXT_EDGE_PREFIX)) connection.relationship = edge;
230
+ if (edge.data?.crossDomain) connection.crossDomain = true;
231
+ messages.forEach((messageId) => {
232
+ const node = nodesById.get(messageId);
233
+ connection.messages.set(node.data?.message?.name ?? messageId, node.type);
234
+ });
235
+ connections.set(key, connection);
236
+ })
237
+ );
181
238
 
182
239
  // A relationship already connects the pair (either way), so skip messages
183
240
  const related = new Set(
@@ -298,8 +355,10 @@ export const buildDomainGraph = ({
298
355
  ])
299
356
  );
300
357
  const edges: any[] = [];
301
- // Nodes replaced by another (services in other domains become the domain)
358
+ // Nodes replaced by another (services in other domains become the domain),
359
+ // and what each of them receives
302
360
  const replacedBy = new Map<string, string>();
361
+ const replacedReceives = new Map<string, any[]>();
303
362
 
304
363
  // Other domains are shown as a single card, with counts of what's in them. A
305
364
  // subdomain of another domain is shown inside a box for that domain (and
@@ -364,6 +423,7 @@ export const buildDomainGraph = ({
364
423
  const owner = SERVICE_TYPES.has(node.type) ? owners.get(node.id) : undefined;
365
424
  if (owner && !groupOfDomain.has(owner.domain.data.id)) {
366
425
  replacedBy.set(node.id, addOtherDomain(owner.domain));
426
+ replacedReceives.set(node.id, (node.data?.service ?? node.data?.agent)?.receives ?? []);
367
427
  return;
368
428
  }
369
429
  if (owner && owner.system) {
@@ -440,11 +500,17 @@ export const buildDomainGraph = ({
440
500
 
441
501
  const graphEdges = connectEdges(edges, new Map([...cardBoxes, ...replacedBy]), nodes);
442
502
  placeMessagesWithPublishers(nodes, graphEdges, new Set(domainMessages.keys()));
503
+ // What the services each other domain stands for receive, by the node shown for the domain
504
+ const receivedBy = new Map<string, any[]>();
505
+ replacedBy.forEach((nodeId, replaced) =>
506
+ receivedBy.set(nodeId, [...(receivedBy.get(nodeId) ?? []), ...(replacedReceives.get(replaced) ?? [])])
507
+ );
443
508
  return {
444
509
  nodes,
445
510
  edges: graphEdges,
446
511
  otherDomainIds: new Set([...replacedBy.values(), ...[...otherDomainIds].map((id) => cardBoxes.get(id) ?? id)]),
447
512
  domainMessages,
513
+ receivedBy,
448
514
  };
449
515
  };
450
516
 
@@ -609,7 +675,8 @@ const addDomainMessages = ({
609
675
  /**
610
676
  * Messages are placed in the first graph they appear in, which can be a
611
677
  * system consuming them. Moves each message into the system (or domain) of the
612
- * services publishing it, when they're all in the same one. Messages a domain
678
+ * services publishing it, when they're all in the same one, and each channel
679
+ * with the messages sent through it. Messages a domain
613
680
  * sends or receives itself (`kept`) stay outside its box, where its edge to them
614
681
  * is drawn.
615
682
  */
@@ -630,6 +697,22 @@ export const placeMessagesWithPublishers = (nodes: Map<string, any>, edges: any[
630
697
  nodes.set(messageId, { ...message, parentId, extent: undefined });
631
698
  }
632
699
  });
700
+
701
+ // Channels go with what sends through them (messages, or channels before them), when that's all in one place
702
+ const sendersOf = new Map<string, string[]>();
703
+ edges.forEach((edge) => sendersOf.set(edge.target, [...(sendersOf.get(edge.target) ?? []), edge.source]));
704
+ for (let moved = true; moved; ) {
705
+ moved = false;
706
+ nodes.forEach((channel, channelId) => {
707
+ if (channel.type !== 'channels' || kept.has(channelId)) return;
708
+ const groups = new Set((sendersOf.get(channelId) ?? []).map((sender) => nodes.get(sender)?.parentId));
709
+ const [parentId] = [...groups];
710
+ if (groups.size === 1 && parentId && channel.parentId !== parentId) {
711
+ nodes.set(channelId, { ...channel, parentId, extent: undefined });
712
+ moved = true;
713
+ }
714
+ });
715
+ }
633
716
  };
634
717
 
635
718
  /**
@@ -860,7 +943,7 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
860
943
  ),
861
944
  ]);
862
945
 
863
- const { nodes, edges, otherDomainIds, domainMessages } = buildDomainGraph({
946
+ const { nodes, edges, otherDomainIds, domainMessages, receivedBy } = buildDomainGraph({
864
947
  tree,
865
948
  owners,
866
949
  parentDomainOf,
@@ -888,7 +971,8 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
888
971
  systems.map(({ system }) => system),
889
972
  context.contextNodesById,
890
973
  new Map(tree.filter((entry) => entry.parentId).map((entry) => [entry.groupId, entry.domain])),
891
- domainMessages
974
+ domainMessages,
975
+ receivedBy
892
976
  );
893
977
  const overview = hideMessageNodes(collapsed.nodes, collapsed.edges);
894
978
 
@@ -126,17 +126,21 @@ export const getNodesAndEdges = async ({
126
126
 
127
127
  // Wrap the graph in one parent "system group" boundary node. Messages,
128
128
  // channels and data stores stay in it. With confineToMembers, only this
129
- // system's own services are children; other services are left outside.
129
+ // system's own services are children; other services (and their agent tools)
130
+ // are left outside.
131
+ const graphNodes = [...nodes.values()];
132
+ const graphEdges = [...edges.values()];
130
133
  const graph = {
131
134
  nodes:
132
135
  wrapInSystemGroup && nodes.size > 0
133
136
  ? wrapNodesInSystemGroup(
134
- [...nodes.values()],
137
+ graphNodes,
138
+ graphEdges,
135
139
  system,
136
140
  confineToMembers ? memberServiceNodeIds(system, serviceMap) : undefined
137
141
  )
138
- : [...nodes.values()],
139
- edges: [...edges.values()],
142
+ : graphNodes,
143
+ edges: graphEdges,
140
144
  };
141
145
 
142
146
  return layout ? layoutNodeGraph(graph) : graph;
@@ -167,11 +171,22 @@ const withoutGroupParent = (node: any) => {
167
171
  * Wraps a system's nodes in one parent `system-group` node (sized to fit them
168
172
  * when the graph is laid out), so they visibly belong to the system.
169
173
  * When `memberIds` is set, service and agent nodes that aren't members are
170
- * left outside the group.
174
+ * left outside the group, and so are the tools of agents left outside.
171
175
  */
172
- const wrapNodesInSystemGroup = (systemNodes: any[], system: any, memberIds?: Set<string>) => {
176
+ const wrapNodesInSystemGroup = (systemNodes: any[], systemEdges: any[], system: any, memberIds?: Set<string>) => {
173
177
  const groupId = `system-group-${system.data.id}-${system.data.version}`;
174
178
 
179
+ const outside = new Set<string>(
180
+ memberIds
181
+ ? systemNodes.filter((node) => SERVICE_NODE_TYPES.has(node.type) && !memberIds.has(node.id)).map((node) => node.id)
182
+ : []
183
+ );
184
+ // An agent's tools sit wherever the agent does
185
+ const toolIds = new Set(systemNodes.filter((node) => node.type === 'agentTool').map((node) => node.id));
186
+ for (const edge of systemEdges) {
187
+ if (outside.has(edge.source) && toolIds.has(edge.target)) outside.add(edge.target);
188
+ }
189
+
175
190
  const groupNode = {
176
191
  id: groupId,
177
192
  type: 'system-group',
@@ -188,9 +203,8 @@ const wrapNodesInSystemGroup = (systemNodes: any[], system: any, memberIds?: Set
188
203
  // Parent must come before its children in the array for React Flow
189
204
  return [
190
205
  groupNode,
191
- ...systemNodes.map((node) => {
192
- if (memberIds && SERVICE_NODE_TYPES.has(node.type) && !memberIds.has(node.id)) return withoutGroupParent(node);
193
- return { ...node, parentId: groupId, extent: 'parent' };
194
- }),
206
+ ...systemNodes.map((node) =>
207
+ outside.has(node.id) ? withoutGroupParent(node) : { ...node, parentId: groupId, extent: 'parent' }
208
+ ),
195
209
  ];
196
210
  };
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "BUSL-1.1",
9
9
  "type": "module",
10
- "version": "5.7.1",
10
+ "version": "5.7.4",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -142,10 +142,10 @@
142
142
  "yjs": "^13.6.33",
143
143
  "zod": "^4.6.6",
144
144
  "@eventcatalog/exporters": "0.1.4",
145
- "@eventcatalog/linter": "1.3.1",
146
145
  "@eventcatalog/fitness": "0.1.1",
147
- "@eventcatalog/sdk": "2.32.0",
148
- "@eventcatalog/visualiser": "^5.2.1"
146
+ "@eventcatalog/linter": "1.3.1",
147
+ "@eventcatalog/visualiser": "^5.2.2",
148
+ "@eventcatalog/sdk": "2.32.0"
149
149
  },
150
150
  "devDependencies": {
151
151
  "@astrojs/check": "^0.9.10",