@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.
- package/LICENSE +227 -128
- package/dist/analytics/analytics.cjs +18 -3
- package/dist/analytics/analytics.d.cts +1 -1
- package/dist/analytics/analytics.d.ts +1 -1
- package/dist/analytics/analytics.js +3 -2
- package/dist/analytics/log-build.cjs +70 -34
- package/dist/analytics/log-build.js +5 -4
- package/dist/analytics/telemetry.cjs +42 -0
- package/dist/analytics/telemetry.d.cts +20 -0
- package/dist/analytics/telemetry.d.ts +20 -0
- package/dist/analytics/telemetry.js +8 -0
- package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
- package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
- package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
- package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +207 -123
- package/dist/eventcatalog.config.d.cts +55 -0
- package/dist/eventcatalog.config.d.ts +55 -0
- package/dist/eventcatalog.js +39 -19
- 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/federation/federate.js +4 -4
- 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 +3 -3
- package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
|
@@ -11,7 +11,17 @@ import {
|
|
|
11
11
|
CANVAS_VIEW_TOOL,
|
|
12
12
|
type CanvasPayload,
|
|
13
13
|
} from '@features/mcp/apps/canvas/shared';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CANVAS_STATUSES,
|
|
16
|
+
getCanvasMaps,
|
|
17
|
+
readCanvas,
|
|
18
|
+
readMeta,
|
|
19
|
+
reopeningIfEdited,
|
|
20
|
+
replyToThread,
|
|
21
|
+
setCanvasStatus,
|
|
22
|
+
setThreadResolved,
|
|
23
|
+
type Author,
|
|
24
|
+
} from '../canvas-doc';
|
|
15
25
|
import { ADD_TO_CANVAS_DESCRIPTION, describeCanvas, edgeSpecsSchema, indexCatalog, nodeSpecsSchema } from '../canvas-actions';
|
|
16
26
|
import { getCatalogResources } from '../catalog-resources';
|
|
17
27
|
import { getLayoutPositions } from '../layout';
|
|
@@ -76,6 +86,7 @@ export const CANVAS_TOOL_NAMES = [
|
|
|
76
86
|
'connectCanvasNodes',
|
|
77
87
|
'removeFromCanvas',
|
|
78
88
|
'layoutCanvas',
|
|
89
|
+
'setCanvasStatus',
|
|
79
90
|
'commentOnCanvas',
|
|
80
91
|
'replyToCanvasComment',
|
|
81
92
|
'resolveCanvasComment',
|
|
@@ -92,12 +103,8 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
92
103
|
const missing = (id: string) =>
|
|
93
104
|
text({ error: `No canvas "${id}". Use listCanvases to find one, or openCanvas to start one.` }, true);
|
|
94
105
|
|
|
95
|
-
const createCanvas =
|
|
96
|
-
|
|
97
|
-
const agent = getAgent(name, userAgent);
|
|
98
|
-
await runtime.withCanvas(id, (doc) => setMeta(doc, { title, createdAt: Date.now(), createdBy: agent.name }));
|
|
99
|
-
return id;
|
|
100
|
-
};
|
|
106
|
+
const createCanvas = (title: string, name?: string) =>
|
|
107
|
+
runtime.createCanvas({ title, createdBy: getAgent(name, userAgent).name });
|
|
101
108
|
|
|
102
109
|
/**
|
|
103
110
|
* Work on a canvas as an agent: changes are played out like a person would make them (see agent-choreography),
|
|
@@ -105,13 +112,15 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
105
112
|
*/
|
|
106
113
|
const asAgent = (id: string, name: string | undefined) => {
|
|
107
114
|
const agent = getAgent(name, userAgent);
|
|
115
|
+
const author = { ...agent, agent: true } as Author;
|
|
108
116
|
const stage: AgentStage = {
|
|
109
|
-
|
|
117
|
+
// An agent changing an accepted (or rejected) canvas makes it a draft again, like a person doing it
|
|
118
|
+
change: (fn) => runtime.withCanvas(id, (doc) => reopeningIfEdited(doc, author, () => fn(doc))),
|
|
110
119
|
present: (presence) => runtime.setAgentPresence(id, agent, presence),
|
|
111
120
|
lastPointer: runtime.getAgentPointer(id, agent),
|
|
112
121
|
sharesMoves: true,
|
|
113
122
|
};
|
|
114
|
-
return { stage, author
|
|
123
|
+
return { stage, author };
|
|
115
124
|
};
|
|
116
125
|
|
|
117
126
|
// ---- The canvas in the chat (MCP App) ----
|
|
@@ -168,9 +177,10 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
168
177
|
const canvases = runtime
|
|
169
178
|
.listCanvases()
|
|
170
179
|
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
|
|
171
|
-
.map(({ canvasId: id, title, people, nodeCount, openComments }) => ({
|
|
180
|
+
.map(({ canvasId: id, title, status, people, nodeCount, openComments }) => ({
|
|
172
181
|
canvasId: id,
|
|
173
182
|
title,
|
|
183
|
+
status,
|
|
174
184
|
people,
|
|
175
185
|
nodeCount,
|
|
176
186
|
openComments,
|
|
@@ -205,7 +215,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
205
215
|
title,
|
|
206
216
|
url: canvasUrl(id),
|
|
207
217
|
people: runtime.peopleOn(id),
|
|
208
|
-
note: 'Clients that support MCP Apps are showing the user this canvas, live. Work on it with them using the canvas tools (getCanvas, addToCanvas
|
|
218
|
+
note: 'Clients that support MCP Apps are showing the user this canvas, live. Work on it with them using the canvas tools (getCanvas, addToCanvas...): they see your changes as you make them. Talk with them in the conversation, not in canvas comments (only comment when they ask you to). Otherwise, share the url so they can open it.',
|
|
209
219
|
},
|
|
210
220
|
label: `the canvas ${title ?? id}`,
|
|
211
221
|
getPayload: async (): Promise<CanvasPayload> => payload,
|
|
@@ -301,6 +311,30 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
301
311
|
}
|
|
302
312
|
);
|
|
303
313
|
|
|
314
|
+
server.registerTool(
|
|
315
|
+
'setCanvasStatus',
|
|
316
|
+
{
|
|
317
|
+
title: 'Set a canvas status',
|
|
318
|
+
description: [
|
|
319
|
+
"Changes a canvas's status: draft (being worked on, where every canvas starts), proposed (ready for review), accepted (the agreed design) or rejected (decided against).",
|
|
320
|
+
'Only change it when the user asks you to. Changing the nodes or connections on an accepted or rejected canvas makes it a draft again.',
|
|
321
|
+
].join(' '),
|
|
322
|
+
inputSchema: z.object({
|
|
323
|
+
canvasId,
|
|
324
|
+
status: z.enum(CANVAS_STATUSES),
|
|
325
|
+
note: z.string().optional().describe('Why, e.g. "Agreed in the architecture review"'),
|
|
326
|
+
agentName,
|
|
327
|
+
}),
|
|
328
|
+
annotations: writes,
|
|
329
|
+
},
|
|
330
|
+
async ({ canvasId: id, status, note, agentName: name }) => {
|
|
331
|
+
if (!runtime.exists(id)) return missing(id);
|
|
332
|
+
const { author } = asAgent(id, name);
|
|
333
|
+
const changed = await runtime.withCanvas(id, (doc) => setCanvasStatus(doc, status, author, note));
|
|
334
|
+
return text({ canvasId: id, status, ...(!changed && { note: `The canvas was already ${status}` }) });
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
304
338
|
server.registerTool(
|
|
305
339
|
'addToCanvas',
|
|
306
340
|
{
|
|
@@ -322,12 +356,13 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
322
356
|
{
|
|
323
357
|
title: 'Update a node on a canvas',
|
|
324
358
|
description:
|
|
325
|
-
'Renames, re-describes or moves a node on a canvas (x/y: its new centre). Catalog resources keep their catalog name and
|
|
359
|
+
'Renames, re-describes, re-versions or moves a node on a canvas (x/y: its new centre). Catalog resources keep their catalog name, summary and version; change those in the catalog. For notes, summary is the note text.',
|
|
326
360
|
inputSchema: z.object({
|
|
327
361
|
canvasId,
|
|
328
362
|
nodeId: z.string(),
|
|
329
363
|
name: z.string().optional(),
|
|
330
364
|
summary: z.string().optional(),
|
|
365
|
+
version: z.string().optional().describe('e.g. 1.0.0'),
|
|
331
366
|
x: z.number().optional(),
|
|
332
367
|
y: z.number().optional(),
|
|
333
368
|
agentName,
|
|
@@ -401,7 +436,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
|
|
|
401
436
|
{
|
|
402
437
|
title: 'Comment on a canvas',
|
|
403
438
|
description:
|
|
404
|
-
|
|
439
|
+
"Starts a comment thread on a canvas, pinned to a node (nodeId) or a spot (x, y). Only use it when the user asks you to comment (e.g. to review the canvas or leave notes for others on it). Don't use comments to greet people, introduce yourself or ask the user questions: talk to them in the conversation.",
|
|
405
440
|
inputSchema: z.object({
|
|
406
441
|
canvasId,
|
|
407
442
|
text: z.string(),
|
|
@@ -4,8 +4,20 @@ import * as Y from 'yjs';
|
|
|
4
4
|
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
|
|
5
5
|
import type { XYPosition } from '@xyflow/react';
|
|
6
6
|
import type { AgentPresence } from '../agent-choreography';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
canvasDocumentName,
|
|
9
|
+
canvasIdFromDocumentName,
|
|
10
|
+
getCanvasMaps,
|
|
11
|
+
readMeta,
|
|
12
|
+
setMeta,
|
|
13
|
+
getCanvasStatus,
|
|
14
|
+
type CanvasStatus,
|
|
15
|
+
} from '../canvas-doc';
|
|
16
|
+
import { getCanvasPreview, type CanvasPreview } from '../canvas-preview';
|
|
8
17
|
import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
|
|
18
|
+
import config from '../../../utils/eventcatalog-config/source';
|
|
19
|
+
import { isAuthEnabled } from '../../../utils/feature';
|
|
20
|
+
import { createStorage, memoryStorage, type CanvasStorage, type StudioStorageConfig } from './storage';
|
|
9
21
|
|
|
10
22
|
export const STUDIO_SOCKET_PATH = '/_eventcatalog/studio';
|
|
11
23
|
const SOCKET_PATH_PATTERN = /\/_eventcatalog\/studio\/?$/;
|
|
@@ -17,47 +29,92 @@ export type AgentIdentity = { name: string; color: string };
|
|
|
17
29
|
export type CanvasSummary = {
|
|
18
30
|
canvasId: string;
|
|
19
31
|
title?: string;
|
|
32
|
+
status: CanvasStatus;
|
|
20
33
|
createdAt?: number;
|
|
34
|
+
/** Who started it (a person, or an agent) */
|
|
35
|
+
createdBy?: string;
|
|
36
|
+
/** When anyone (a person, or an agent) last changed it */
|
|
37
|
+
updatedAt?: number;
|
|
21
38
|
nodeCount: number;
|
|
22
39
|
edgeCount: number;
|
|
23
40
|
openComments: number;
|
|
24
41
|
people: string[];
|
|
42
|
+
/** The canvas drawn small (asked for with `withPreview`, e.g. for the canvases page) */
|
|
43
|
+
preview?: CanvasPreview | null;
|
|
25
44
|
};
|
|
26
45
|
|
|
27
46
|
/**
|
|
28
|
-
* What the collaboration server keeps for the life of the process: one Hocuspocus instance, and
|
|
29
|
-
* in memory (
|
|
47
|
+
* What the collaboration server keeps for the life of the process: one Hocuspocus instance, and every canvas's
|
|
48
|
+
* latest snapshot in memory (read from storage when the server starts, and written through to it each time a
|
|
49
|
+
* canvas is stored), so listing canvases and opening one never wait on storage.
|
|
30
50
|
*/
|
|
31
51
|
type RuntimeState = {
|
|
32
52
|
server: Server;
|
|
33
53
|
snapshots: Map<string, Uint8Array>;
|
|
54
|
+
/** When each canvas last changed (kept with it in storage) */
|
|
55
|
+
updatedAt: Map<string, number>;
|
|
34
56
|
agents: Map<string, { awareness: Awareness; expires: ReturnType<typeof setTimeout> }>;
|
|
35
57
|
attached: WeakSet<HttpServer>;
|
|
58
|
+
storage: CanvasStorage;
|
|
59
|
+
/** The storage config in use (to tell when it changes) */
|
|
60
|
+
storageKey?: string;
|
|
61
|
+
/** Settles once the stored canvases are in `snapshots` */
|
|
62
|
+
ready: Promise<void>;
|
|
63
|
+
/** Studio started (by startStudio): its storage loaded */
|
|
64
|
+
started?: Promise<void>;
|
|
65
|
+
savesOnExit: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Whether a collaboration connection comes from someone signed in, when sign-in is on. Given by the pages
|
|
68
|
+
* (see startStudio): Auth.js's config only loads in the app, not in the dev server's integration.
|
|
69
|
+
*/
|
|
70
|
+
isSignedIn?: (request: Request) => Promise<boolean>;
|
|
36
71
|
};
|
|
37
72
|
|
|
38
73
|
const createState = (): RuntimeState => {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
74
|
+
const state: RuntimeState = {
|
|
75
|
+
snapshots: new Map(),
|
|
76
|
+
updatedAt: new Map(),
|
|
42
77
|
agents: new Map(),
|
|
43
78
|
attached: new WeakSet(),
|
|
79
|
+
storage: memoryStorage(),
|
|
80
|
+
ready: Promise.resolve(),
|
|
81
|
+
savesOnExit: false,
|
|
44
82
|
server: new Server({
|
|
45
83
|
quiet: true,
|
|
84
|
+
// With sign-in on, only signed-in people can open a canvas (refused until the pages have said how to check)
|
|
85
|
+
onConnect: async ({ request }) => {
|
|
86
|
+
if (!isAuthEnabled()) return;
|
|
87
|
+
if (!(await state.isSignedIn?.(request).catch(() => false))) throw new Error('Sign in to open this canvas');
|
|
88
|
+
},
|
|
46
89
|
onLoadDocument: async ({ documentName, document }) => {
|
|
47
|
-
const snapshot = snapshots.get(documentName);
|
|
48
|
-
if (!snapshot) return;
|
|
49
90
|
// A hook that throws leaves the document half loaded in Hocuspocus (ueberdosis/hocuspocus#1156)
|
|
50
91
|
try {
|
|
51
|
-
|
|
92
|
+
await state.ready;
|
|
93
|
+
const snapshot = state.snapshots.get(documentName);
|
|
94
|
+
if (snapshot) Y.applyUpdate(document, snapshot);
|
|
52
95
|
} catch (error) {
|
|
53
96
|
console.error(`[studio] Could not load canvas ${documentName}, starting it empty`, error);
|
|
54
97
|
}
|
|
55
98
|
},
|
|
56
|
-
|
|
57
|
-
|
|
99
|
+
// Changes only: Hocuspocus starts listening once a canvas has loaded
|
|
100
|
+
onChange: async ({ documentName }) => {
|
|
101
|
+
state.updatedAt.set(documentName, Date.now());
|
|
58
102
|
},
|
|
103
|
+
onStoreDocument: async ({ documentName, document }) => saveSnapshot(state, documentName, document),
|
|
59
104
|
}),
|
|
60
105
|
};
|
|
106
|
+
return state;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Keeps a canvas's snapshot, and writes it to storage (failing to store it doesn't lose it from memory) */
|
|
110
|
+
const saveSnapshot = (state: RuntimeState, documentName: string, document: Y.Doc) => {
|
|
111
|
+
const snapshot = Y.encodeStateAsUpdate(document);
|
|
112
|
+
state.snapshots.set(documentName, snapshot);
|
|
113
|
+
try {
|
|
114
|
+
state.storage.save(documentName, snapshot, state.updatedAt.get(documentName));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`[studio] Could not store canvas ${documentName} (${state.storage.type} storage)`, error);
|
|
117
|
+
}
|
|
61
118
|
};
|
|
62
119
|
|
|
63
120
|
/**
|
|
@@ -80,6 +137,89 @@ class StudioRuntime {
|
|
|
80
137
|
return this.state.attached;
|
|
81
138
|
}
|
|
82
139
|
|
|
140
|
+
/** Where canvases are kept (memory: until the server restarts) */
|
|
141
|
+
get storage(): Pick<CanvasStorage, 'type' | 'location'> {
|
|
142
|
+
const { type, location } = this.state.storage;
|
|
143
|
+
return { type, location };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Keeps canvases in the storage configured (`studio.storage`, memory by default), and loads the ones
|
|
148
|
+
* stored there. Set once when the server starts; calling it again with the same config does nothing.
|
|
149
|
+
*/
|
|
150
|
+
async useStorage(config: StudioStorageConfig | undefined, projectDirectory: string) {
|
|
151
|
+
const key = JSON.stringify({ config: config ?? null, projectDirectory });
|
|
152
|
+
if (this.state.storageKey === key) return this.state.ready;
|
|
153
|
+
const storage = createStorage(config, projectDirectory);
|
|
154
|
+
this.state.storageKey = key;
|
|
155
|
+
this.state.storage = storage;
|
|
156
|
+
this.state.ready = storage.loadAll().then((stored) => {
|
|
157
|
+
// Canvases already open here (e.g. the dev server reloading) are kept as they are
|
|
158
|
+
stored.forEach(({ state, updatedAt }, name) => {
|
|
159
|
+
if (this.snapshots.has(name)) return;
|
|
160
|
+
this.snapshots.set(name, state);
|
|
161
|
+
if (updatedAt) this.state.updatedAt.set(name, updatedAt);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
this.saveOnExit();
|
|
165
|
+
return this.state.ready;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Starts Studio once: canvases kept in the storage configured (in memory if it can't be used), and in production the
|
|
170
|
+
* collaboration connection on the server `eventcatalog start` runs (the dev server's integration attaches it there).
|
|
171
|
+
*/
|
|
172
|
+
start() {
|
|
173
|
+
this.state.started ??= (async () => {
|
|
174
|
+
const projectDirectory = process.env.PROJECT_DIR || process.cwd();
|
|
175
|
+
try {
|
|
176
|
+
await this.useStorage(config.studio?.storage, projectDirectory);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error(
|
|
179
|
+
`[studio] Could not use the Studio storage configured, keeping canvases in memory: ${(error as Error).message}`
|
|
180
|
+
);
|
|
181
|
+
await this.useStorage({ type: 'memory' }, projectDirectory);
|
|
182
|
+
}
|
|
183
|
+
const httpServer = (globalThis as Record<symbol, unknown>)[HTTP_SERVER_KEY] as HttpServer | undefined;
|
|
184
|
+
if (httpServer && !this.attached.has(httpServer)) {
|
|
185
|
+
this.attach(httpServer);
|
|
186
|
+
const { type, location } = this.storage;
|
|
187
|
+
console.log(
|
|
188
|
+
type === 'memory'
|
|
189
|
+
? `[studio] Collaboration server ready at ${STUDIO_SOCKET_PATH}. Canvases are kept in memory and lost when the server restarts or redeploys: set studio.storage in eventcatalog.config.js to keep them.`
|
|
190
|
+
: `[studio] Collaboration server ready at ${STUDIO_SOCKET_PATH} (canvases stored in ${location})`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
return this.state.started;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Canvases change a moment before they're stored (Hocuspocus waits for edits to settle): as the process exits,
|
|
199
|
+
* the open ones are stored straight away so the last edits aren't lost. Storage writes are synchronous for this.
|
|
200
|
+
*/
|
|
201
|
+
private saveOnExit() {
|
|
202
|
+
if (this.state.savesOnExit) return;
|
|
203
|
+
this.state.savesOnExit = true;
|
|
204
|
+
const saveOpenCanvases = () =>
|
|
205
|
+
this.server.hocuspocus.documents.forEach((document, name) => {
|
|
206
|
+
saveSnapshot(this.state, name, document);
|
|
207
|
+
});
|
|
208
|
+
process.once('exit', saveOpenCanvases);
|
|
209
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
210
|
+
process.once(signal, () => {
|
|
211
|
+
saveOpenCanvases();
|
|
212
|
+
// Stopping as it would have without this (unless something else handles the signal)
|
|
213
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** How to tell whether a collaboration connection comes from someone signed in (checked when sign-in is on) */
|
|
219
|
+
useSignIn(isSignedIn: (request: Request) => Promise<boolean>) {
|
|
220
|
+
this.state.isSignedIn = isSignedIn;
|
|
221
|
+
}
|
|
222
|
+
|
|
83
223
|
/** Handle collaboration WebSocket upgrades on an HTTP server (the dev server, or the production server) */
|
|
84
224
|
attach(httpServer: HttpServer) {
|
|
85
225
|
if (this.attached.has(httpServer)) return;
|
|
@@ -95,6 +235,15 @@ class StudioRuntime {
|
|
|
95
235
|
return this.server.hocuspocus.documents.has(name) || this.snapshots.has(name);
|
|
96
236
|
}
|
|
97
237
|
|
|
238
|
+
/** Starts a canvas with a title, recording when and by whom (a person, or an agent). Returns its id. */
|
|
239
|
+
async createCanvas({ title, createdBy }: { title?: string; createdBy?: string }) {
|
|
240
|
+
const canvasId = crypto.randomUUID();
|
|
241
|
+
await this.withCanvas(canvasId, (doc) =>
|
|
242
|
+
setMeta(doc, { ...(title && { title }), createdAt: Date.now(), ...(createdBy && { createdBy }) })
|
|
243
|
+
);
|
|
244
|
+
return canvasId;
|
|
245
|
+
}
|
|
246
|
+
|
|
98
247
|
/** Read or change a canvas as the server. Changes sync to everyone on it, like anyone else's. */
|
|
99
248
|
async withCanvas<T>(canvasId: string, fn: (doc: Y.Doc) => T): Promise<T> {
|
|
100
249
|
const connection = await this.server.hocuspocus.openDirectConnection(canvasDocumentName(canvasId));
|
|
@@ -137,7 +286,7 @@ class StudioRuntime {
|
|
|
137
286
|
return response;
|
|
138
287
|
}
|
|
139
288
|
|
|
140
|
-
listCanvases(): CanvasSummary[] {
|
|
289
|
+
listCanvases({ withPreview = false }: { withPreview?: boolean } = {}): CanvasSummary[] {
|
|
141
290
|
const names = new Set([...this.server.hocuspocus.documents.keys(), ...this.snapshots.keys()]);
|
|
142
291
|
return [...names].flatMap((name) => {
|
|
143
292
|
const canvasId = canvasIdFromDocumentName(name);
|
|
@@ -151,11 +300,15 @@ class StudioRuntime {
|
|
|
151
300
|
{
|
|
152
301
|
canvasId,
|
|
153
302
|
title: meta.title,
|
|
303
|
+
status: getCanvasStatus(meta),
|
|
154
304
|
createdAt: meta.createdAt,
|
|
305
|
+
...(meta.createdBy && { createdBy: meta.createdBy }),
|
|
306
|
+
updatedAt: this.state.updatedAt.get(name) ?? meta.createdAt,
|
|
155
307
|
nodeCount: nodes.size,
|
|
156
308
|
edgeCount: edges.size,
|
|
157
309
|
openComments: Array.from(threads.values()).filter((thread) => !thread.get('resolved')).length,
|
|
158
310
|
people: live ? this.peopleOn(canvasId) : [],
|
|
311
|
+
...(withPreview && { preview: getCanvasPreview(Array.from(nodes.values()), Array.from(edges.values())) }),
|
|
159
312
|
},
|
|
160
313
|
];
|
|
161
314
|
});
|
|
@@ -205,11 +358,37 @@ class StudioRuntime {
|
|
|
205
358
|
const RUNTIME_KEY = Symbol.for('eventcatalog.studio.runtime');
|
|
206
359
|
const store = globalThis as typeof globalThis & { [RUNTIME_KEY]?: RuntimeState };
|
|
207
360
|
|
|
361
|
+
/**
|
|
362
|
+
* State kept from before a code change (the dev server reloads code but keeps globalThis) gets what's been added to it
|
|
363
|
+
* since. Hooks given to Hocuspocus when it was created (e.g. onChange, onConnect) only change on a restart.
|
|
364
|
+
*/
|
|
365
|
+
const upgrade = (state: RuntimeState) => {
|
|
366
|
+
state.updatedAt ??= new Map();
|
|
367
|
+
return state;
|
|
368
|
+
};
|
|
369
|
+
|
|
208
370
|
/** The process wide collaboration runtime (created on first use) */
|
|
209
|
-
export const getStudioRuntime = (): StudioRuntime => new StudioRuntime((store[RUNTIME_KEY] ??= createState()));
|
|
371
|
+
export const getStudioRuntime = (): StudioRuntime => new StudioRuntime(upgrade((store[RUNTIME_KEY] ??= createState())));
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* In production, `eventcatalog start` leaves the HTTP server it runs here, so Studio's collaboration connection can be
|
|
375
|
+
* served on it (Astro's Node adapter doesn't expose it otherwise)
|
|
376
|
+
*/
|
|
377
|
+
const HTTP_SERVER_KEY = Symbol.for('eventcatalog.http-server');
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Studio, started: what pages and tools use before working with canvases. Pages say how to tell who's signed in,
|
|
381
|
+
* for the collaboration connection.
|
|
382
|
+
*/
|
|
383
|
+
export const startStudio = async ({ isSignedIn }: { isSignedIn?: (request: Request) => Promise<boolean> } = {}) => {
|
|
384
|
+
const runtime = getStudioRuntime();
|
|
385
|
+
if (isSignedIn) runtime.useSignIn(isSignedIn);
|
|
386
|
+
await runtime.start();
|
|
387
|
+
return runtime;
|
|
388
|
+
};
|
|
210
389
|
|
|
211
390
|
/** The runtime if the collaboration server is running in this process */
|
|
212
391
|
export const findStudioRuntime = (): StudioRuntime | undefined => {
|
|
213
392
|
const state = store[RUNTIME_KEY];
|
|
214
|
-
return state ? new StudioRuntime(state) : undefined;
|
|
393
|
+
return state ? new StudioRuntime(upgrade(state)) : undefined;
|
|
215
394
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getSession } from '@utils/auth-astro/server';
|
|
2
|
+
import type { NormalizedUser } from '@features/auth/middleware/middleware-auth';
|
|
3
|
+
|
|
4
|
+
/** Someone signed in with the catalog's sign-in (SSO): Studio shows them by these, and they can't change them */
|
|
5
|
+
export type SignedInUser = { name: string; picture?: string };
|
|
6
|
+
|
|
7
|
+
/** For the collaboration connection (see startStudio): it has the same cookies as the pages */
|
|
8
|
+
export const isSignedIn = async (request: Request) => Boolean(await getSession(request));
|
|
9
|
+
|
|
10
|
+
/** Who's signed in, from what the sign-in middleware found (nobody when sign-in is off) */
|
|
11
|
+
export const getSignedInUser = (locals: object): SignedInUser | undefined => {
|
|
12
|
+
const user = (locals as { user?: NormalizedUser }).user;
|
|
13
|
+
const name = user?.name || user?.email;
|
|
14
|
+
if (!user || !name) return undefined;
|
|
15
|
+
// Pictures are loaded from the provider (Auth.js calls it `image`), over HTTPS only
|
|
16
|
+
const picture = user.picture ?? user.raw?.user?.image;
|
|
17
|
+
return { name, ...(typeof picture === 'string' && picture.startsWith('https://') && { picture }) };
|
|
18
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where Studio keeps canvases (`studio.storage` in eventcatalog.config.js). People and agents always work on the copy
|
|
6
|
+
* the collaboration server holds in memory, so storage is only read when the server starts, and written a moment
|
|
7
|
+
* after a canvas changes: it doesn't slow down editing. Canvases are stored as their whole Yjs document
|
|
8
|
+
* (Y.encodeStateAsUpdate), so nothing is lost between restarts.
|
|
9
|
+
*/
|
|
10
|
+
export type StudioStorageConfig =
|
|
11
|
+
/** Kept only while the server runs: canvases are lost when it restarts or redeploys (the default) */
|
|
12
|
+
| { type: 'memory' }
|
|
13
|
+
/**
|
|
14
|
+
* One SQLite database file (default `.eventcatalog/studio.db` in the catalog). Needs Node.js 22.13 or later. In a
|
|
15
|
+
* container, put it on a volume so it survives redeploys.
|
|
16
|
+
*/
|
|
17
|
+
| { type: 'sqlite'; path?: string };
|
|
18
|
+
|
|
19
|
+
export type StorageType = StudioStorageConfig['type'];
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_STORAGE: StudioStorageConfig = { type: 'memory' };
|
|
22
|
+
const DEFAULT_SQLITE_PATH = '.eventcatalog/studio.db';
|
|
23
|
+
|
|
24
|
+
/** A canvas as stored: its whole document, and when it last changed */
|
|
25
|
+
export type StoredCanvas = { state: Uint8Array; updatedAt?: number };
|
|
26
|
+
|
|
27
|
+
export type CanvasStorage = {
|
|
28
|
+
type: StorageType;
|
|
29
|
+
/** Where it is, for logs (e.g. the database file) */
|
|
30
|
+
location?: string;
|
|
31
|
+
/** Every stored canvas, by document name (read once, when the server starts) */
|
|
32
|
+
loadAll: () => Promise<Map<string, StoredCanvas>>;
|
|
33
|
+
/**
|
|
34
|
+
* Stores a canvas's document, with when it last changed (now when not known). Synchronous, so canvases can still
|
|
35
|
+
* be saved as the process exits.
|
|
36
|
+
*/
|
|
37
|
+
save: (documentName: string, state: Uint8Array, updatedAt?: number) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const memoryStorage = (): CanvasStorage => ({
|
|
41
|
+
type: 'memory',
|
|
42
|
+
loadAll: async () => new Map(),
|
|
43
|
+
save: () => {},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
type SqliteDatabase = {
|
|
47
|
+
exec: (sql: string) => void;
|
|
48
|
+
prepare: (sql: string) => { run: (...values: unknown[]) => unknown; all: () => unknown[] };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const sqliteStorage = (file: string): CanvasStorage => {
|
|
52
|
+
let database: SqliteDatabase | undefined;
|
|
53
|
+
let upsert: ReturnType<SqliteDatabase['prepare']> | undefined;
|
|
54
|
+
return {
|
|
55
|
+
type: 'sqlite',
|
|
56
|
+
location: file,
|
|
57
|
+
loadAll: async () => {
|
|
58
|
+
// Node's built-in SQLite (no native package to install), asked of Node itself: the dev server loads this
|
|
59
|
+
// through Vite, which doesn't know node:sqlite and fails to import it
|
|
60
|
+
type Sqlite = { DatabaseSync: new (file: string) => SqliteDatabase };
|
|
61
|
+
const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
|
|
62
|
+
let sqlite: Sqlite | undefined;
|
|
63
|
+
try {
|
|
64
|
+
sqlite = getBuiltinModule
|
|
65
|
+
? (getBuiltinModule('node:sqlite') as Sqlite | undefined)
|
|
66
|
+
: await import(/* @vite-ignore */ ['node', 'sqlite'].join(':'));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`Could not load node:sqlite (${(error as Error).message})`);
|
|
69
|
+
}
|
|
70
|
+
if (!sqlite?.DatabaseSync) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`SQLite storage needs Node.js 22.13 or later (it uses node:sqlite); this is Node.js ${process.versions.node}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
database = new sqlite.DatabaseSync(file);
|
|
77
|
+
database.exec('PRAGMA journal_mode = WAL');
|
|
78
|
+
database.exec(
|
|
79
|
+
'CREATE TABLE IF NOT EXISTS canvases (name TEXT PRIMARY KEY, state BLOB NOT NULL, updated_at INTEGER NOT NULL)'
|
|
80
|
+
);
|
|
81
|
+
upsert = database.prepare(
|
|
82
|
+
'INSERT INTO canvases (name, state, updated_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at'
|
|
83
|
+
);
|
|
84
|
+
const rows = database.prepare('SELECT name, state, updated_at FROM canvases').all() as {
|
|
85
|
+
name: string;
|
|
86
|
+
state: Uint8Array;
|
|
87
|
+
updated_at: number;
|
|
88
|
+
}[];
|
|
89
|
+
return new Map(rows.map((row) => [row.name, { state: new Uint8Array(row.state), updatedAt: Number(row.updated_at) }]));
|
|
90
|
+
},
|
|
91
|
+
save: (documentName, state, updatedAt = Date.now()) => {
|
|
92
|
+
if (!upsert) throw new Error('SQLite storage was used before it was opened');
|
|
93
|
+
upsert.run(documentName, state, updatedAt);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The storage a config asks for, with paths relative to the catalog. Unknown types are an error. */
|
|
99
|
+
export const createStorage = (config: StudioStorageConfig | undefined, projectDirectory: string): CanvasStorage => {
|
|
100
|
+
const chosen = config ?? DEFAULT_STORAGE;
|
|
101
|
+
const resolve = (location: string | undefined, fallback: string) => path.resolve(projectDirectory, location ?? fallback);
|
|
102
|
+
switch (chosen.type) {
|
|
103
|
+
case 'memory':
|
|
104
|
+
return memoryStorage();
|
|
105
|
+
case 'sqlite':
|
|
106
|
+
return sqliteStorage(resolve(chosen.path, DEFAULT_SQLITE_PATH));
|
|
107
|
+
default:
|
|
108
|
+
throw new Error(`Unknown studio.storage type "${(chosen as { type?: string }).type}": use "memory" or "sqlite"`);
|
|
109
|
+
}
|
|
110
|
+
};
|