@tuongaz/seeflow 0.1.3
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/README.md +95 -0
- package/bin/seeflow +32 -0
- package/bin/seeflow-mcp +23 -0
- package/dist/web/assets/html2canvas.esm-CBrSDip1.js +22 -0
- package/dist/web/assets/index-BlhIMoXf.js +8005 -0
- package/dist/web/assets/index-CIpouxGY.css +1 -0
- package/dist/web/assets/index.es-D6Hswegt.js +18 -0
- package/dist/web/assets/purify.es-CLGrRn1w.js +3 -0
- package/dist/web/index.html +13 -0
- package/examples/ecommerce-platform/.seeflow/scripts/play.ts +2 -0
- package/examples/ecommerce-platform/.seeflow/seeflow.json +250 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +18 -0
- package/examples/order-pipeline/.seeflow/seeflow.json +86 -0
- package/examples/order-pipeline/README.md +11 -0
- package/examples/order-pipeline/package.json +6 -0
- package/package.json +55 -0
- package/public/runtime/tailwind.js +24394 -0
- package/src/api.ts +1093 -0
- package/src/cli.ts +329 -0
- package/src/demo.ts +65 -0
- package/src/diagram.ts +432 -0
- package/src/events.ts +70 -0
- package/src/mcp-shim.ts +93 -0
- package/src/mcp.ts +540 -0
- package/src/operations.ts +1192 -0
- package/src/process-spawner.ts +75 -0
- package/src/proxy.ts +393 -0
- package/src/registry.ts +139 -0
- package/src/runtime.ts +78 -0
- package/src/schema.ts +441 -0
- package/src/sdk-template.ts +37 -0
- package/src/sdk-writer.ts +37 -0
- package/src/server.ts +211 -0
- package/src/shellout.ts +30 -0
- package/src/status-runner.ts +374 -0
- package/src/watcher.ts +383 -0
package/src/diagram.ts
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import dagre from 'dagre';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DemoSchema } from './schema.ts';
|
|
4
|
+
|
|
5
|
+
// Pure-compute helpers backing the three diagram-pipeline endpoints. No file
|
|
6
|
+
// I/O lives here — the skill writes responses to disk on the user's machine.
|
|
7
|
+
// Logic was extracted from skills/diagram/scripts/{propose-scope,assemble-demo,
|
|
8
|
+
// validate-demo}.mjs so it can be exercised in-process and via HTTP.
|
|
9
|
+
|
|
10
|
+
// === propose-scope =========================================================
|
|
11
|
+
|
|
12
|
+
const ScanFileSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
path: z.string(),
|
|
15
|
+
category: z.string(),
|
|
16
|
+
})
|
|
17
|
+
.passthrough();
|
|
18
|
+
|
|
19
|
+
export const ProposeScopeRequestSchema = z.object({
|
|
20
|
+
files: z.array(ScanFileSchema),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type ProposeScopeRequest = z.infer<typeof ProposeScopeRequestSchema>;
|
|
24
|
+
|
|
25
|
+
export interface ScopeCandidate {
|
|
26
|
+
path: string;
|
|
27
|
+
score: number;
|
|
28
|
+
reasons: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ENTRY_NAMES = new Set([
|
|
32
|
+
'server.ts',
|
|
33
|
+
'server.js',
|
|
34
|
+
'index.ts',
|
|
35
|
+
'index.js',
|
|
36
|
+
'app.ts',
|
|
37
|
+
'app.js',
|
|
38
|
+
'main.ts',
|
|
39
|
+
'main.js',
|
|
40
|
+
'app.py',
|
|
41
|
+
'main.py',
|
|
42
|
+
'wsgi.py',
|
|
43
|
+
'asgi.py',
|
|
44
|
+
'manage.py',
|
|
45
|
+
'main.go',
|
|
46
|
+
'main.rs',
|
|
47
|
+
'application.rb',
|
|
48
|
+
'config.ru',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const scoreFile = (path: string): number => {
|
|
52
|
+
let score = 0;
|
|
53
|
+
const base = path.split('/').pop() ?? '';
|
|
54
|
+
if (ENTRY_NAMES.has(base)) score += 10;
|
|
55
|
+
if (path.startsWith('src/')) score += 4;
|
|
56
|
+
if (path.startsWith('apps/')) score += 3;
|
|
57
|
+
const depth = path.split('/').length;
|
|
58
|
+
score += Math.max(0, 6 - depth);
|
|
59
|
+
if (/\bindex\.(ts|js)$/i.test(path)) score += 2;
|
|
60
|
+
if (/\b(server|app|main)\.(ts|js|py|go|rs)$/i.test(path)) score += 5;
|
|
61
|
+
if (/test|spec|__tests__|\.test\.|\.spec\./i.test(path)) score -= 8;
|
|
62
|
+
if (path.includes('node_modules')) score -= 50;
|
|
63
|
+
return score;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const scoreReasons = (path: string): string[] => {
|
|
67
|
+
const reasons: string[] = [];
|
|
68
|
+
const base = path.split('/').pop() ?? '';
|
|
69
|
+
if (ENTRY_NAMES.has(base)) reasons.push(`canonical entry name: ${base}`);
|
|
70
|
+
if (path.startsWith('src/') || path.startsWith('apps/'))
|
|
71
|
+
reasons.push('top-level src/apps directory');
|
|
72
|
+
if (path.split('/').length <= 3) reasons.push('shallow path');
|
|
73
|
+
return reasons;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const proposeScope = (req: ProposeScopeRequest): { candidates: ScopeCandidate[] } => {
|
|
77
|
+
const candidates: ScopeCandidate[] = [];
|
|
78
|
+
for (const f of req.files) {
|
|
79
|
+
if (f.category !== 'code') continue;
|
|
80
|
+
const score = scoreFile(f.path);
|
|
81
|
+
if (score > 0) candidates.push({ path: f.path, score, reasons: scoreReasons(f.path) });
|
|
82
|
+
}
|
|
83
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
84
|
+
return { candidates: candidates.slice(0, 30) };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// === assemble ==============================================================
|
|
88
|
+
|
|
89
|
+
const GRID = 24;
|
|
90
|
+
|
|
91
|
+
const WiringNodeSchema = z
|
|
92
|
+
.object({
|
|
93
|
+
id: z.string().min(1),
|
|
94
|
+
position: z
|
|
95
|
+
.object({
|
|
96
|
+
x: z.number(),
|
|
97
|
+
y: z.number(),
|
|
98
|
+
})
|
|
99
|
+
.optional(),
|
|
100
|
+
})
|
|
101
|
+
.passthrough();
|
|
102
|
+
|
|
103
|
+
const WiringConnectorSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
id: z.string().optional(),
|
|
106
|
+
source: z.string().min(1),
|
|
107
|
+
target: z.string().min(1),
|
|
108
|
+
})
|
|
109
|
+
.passthrough();
|
|
110
|
+
|
|
111
|
+
export const AssembleRequestSchema = z.object({
|
|
112
|
+
wiring: z.object({
|
|
113
|
+
name: z.string().optional(),
|
|
114
|
+
nodes: z.array(WiringNodeSchema),
|
|
115
|
+
connectors: z.array(WiringConnectorSchema),
|
|
116
|
+
}),
|
|
117
|
+
layout: z
|
|
118
|
+
.object({
|
|
119
|
+
positions: z.record(z.string(), z.object({ x: z.number(), y: z.number() })).optional(),
|
|
120
|
+
})
|
|
121
|
+
.optional(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type AssembleRequest = z.infer<typeof AssembleRequestSchema>;
|
|
125
|
+
|
|
126
|
+
export interface AssembleStats {
|
|
127
|
+
nodesIn: number;
|
|
128
|
+
connectorsIn: number;
|
|
129
|
+
nodesOut: number;
|
|
130
|
+
connectorsOut: number;
|
|
131
|
+
duplicateNodesDropped: number;
|
|
132
|
+
duplicateConnectorsDropped: number;
|
|
133
|
+
danglingConnectorsDropped: number;
|
|
134
|
+
positionsSnapped: number;
|
|
135
|
+
positionsShifted: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface AssembleResult {
|
|
139
|
+
demo: {
|
|
140
|
+
version: 1;
|
|
141
|
+
name: string;
|
|
142
|
+
nodes: Array<Record<string, unknown>>;
|
|
143
|
+
connectors: Array<Record<string, unknown>>;
|
|
144
|
+
};
|
|
145
|
+
stats: AssembleStats;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const slugify = (raw: string): string =>
|
|
149
|
+
String(raw)
|
|
150
|
+
.trim()
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
153
|
+
.replace(/^-+|-+$/g, '')
|
|
154
|
+
.slice(0, 80);
|
|
155
|
+
|
|
156
|
+
export const assembleDemo = (req: AssembleRequest): AssembleResult => {
|
|
157
|
+
const stats: AssembleStats = {
|
|
158
|
+
nodesIn: req.wiring.nodes.length,
|
|
159
|
+
connectorsIn: req.wiring.connectors.length,
|
|
160
|
+
nodesOut: 0,
|
|
161
|
+
connectorsOut: 0,
|
|
162
|
+
duplicateNodesDropped: 0,
|
|
163
|
+
duplicateConnectorsDropped: 0,
|
|
164
|
+
danglingConnectorsDropped: 0,
|
|
165
|
+
positionsSnapped: 0,
|
|
166
|
+
positionsShifted: 0,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const positions = req.layout?.positions ?? {};
|
|
170
|
+
const nodes = normalizeNodes(req.wiring.nodes, positions, stats);
|
|
171
|
+
const nodeIds = new Set(nodes.map((n) => n.id as string));
|
|
172
|
+
const connectors = normalizeConnectors(req.wiring.connectors, nodeIds, stats);
|
|
173
|
+
const positionedNodes = autoLayout(nodes, connectors, stats);
|
|
174
|
+
|
|
175
|
+
stats.nodesOut = positionedNodes.length;
|
|
176
|
+
stats.connectorsOut = connectors.length;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
demo: {
|
|
180
|
+
version: 1,
|
|
181
|
+
name: req.wiring.name ?? 'Untitled diagram',
|
|
182
|
+
nodes: positionedNodes,
|
|
183
|
+
connectors,
|
|
184
|
+
},
|
|
185
|
+
stats,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const normalizeNodes = (
|
|
190
|
+
rawNodes: ReadonlyArray<Record<string, unknown>>,
|
|
191
|
+
positionMap: Record<string, { x: number; y: number }>,
|
|
192
|
+
stats: AssembleStats,
|
|
193
|
+
): Array<Record<string, unknown>> => {
|
|
194
|
+
const seen = new Map<string, Record<string, unknown>>();
|
|
195
|
+
for (const raw of rawNodes) {
|
|
196
|
+
const id = slugify(String(raw.id ?? ''));
|
|
197
|
+
if (!id) continue;
|
|
198
|
+
const rawPos = positionMap[id] ??
|
|
199
|
+
(raw.position as { x: number; y: number } | undefined) ?? { x: 0, y: 0 };
|
|
200
|
+
const snapped = {
|
|
201
|
+
x: Math.round(rawPos.x / GRID) * GRID,
|
|
202
|
+
y: Math.round(rawPos.y / GRID) * GRID,
|
|
203
|
+
};
|
|
204
|
+
if (snapped.x !== rawPos.x || snapped.y !== rawPos.y) stats.positionsSnapped++;
|
|
205
|
+
if (seen.has(id)) stats.duplicateNodesDropped++;
|
|
206
|
+
seen.set(id, { ...raw, id, position: snapped });
|
|
207
|
+
}
|
|
208
|
+
return [...seen.values()];
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const normalizeConnectors = (
|
|
212
|
+
rawConnectors: ReadonlyArray<Record<string, unknown>>,
|
|
213
|
+
nodeIds: Set<string>,
|
|
214
|
+
stats: AssembleStats,
|
|
215
|
+
): Array<Record<string, unknown>> => {
|
|
216
|
+
const seen = new Map<string, Record<string, unknown>>();
|
|
217
|
+
for (const raw of rawConnectors) {
|
|
218
|
+
const source = slugify(String(raw.source ?? ''));
|
|
219
|
+
const target = slugify(String(raw.target ?? ''));
|
|
220
|
+
const id = slugify(String(raw.id ?? `c-${source}-${target}`));
|
|
221
|
+
if (!nodeIds.has(source) || !nodeIds.has(target)) {
|
|
222
|
+
stats.danglingConnectorsDropped++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const key = [
|
|
226
|
+
source,
|
|
227
|
+
target,
|
|
228
|
+
String(raw.kind ?? ''),
|
|
229
|
+
String(raw.sourceHandle ?? ''),
|
|
230
|
+
String(raw.targetHandle ?? ''),
|
|
231
|
+
].join('\t');
|
|
232
|
+
if (seen.has(key)) stats.duplicateConnectorsDropped++;
|
|
233
|
+
seen.set(key, { ...raw, id, source, target });
|
|
234
|
+
}
|
|
235
|
+
return [...seen.values()];
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Dagre-based auto-layout — exactly the algorithm the web canvas's "Tidy
|
|
239
|
+
// layout" button runs (apps/web/src/lib/auto-layout.ts), so pressing Tidy on
|
|
240
|
+
// a freshly-assembled demo never reshuffles the diagram.
|
|
241
|
+
//
|
|
242
|
+
// • Direction: left-to-right (LR) — Actors on the left, data stores on the
|
|
243
|
+
// right, matching the lifecycle-lane intent of the layout-arranger agent.
|
|
244
|
+
// • `nodesep` / `ranksep` defaults give connectors a bit of extra length so
|
|
245
|
+
// edge labels fit and the canvas reads at a glance.
|
|
246
|
+
// • Sticky / text shape nodes stay pinned to their input position — they
|
|
247
|
+
// are floating annotations, not part of the flow graph.
|
|
248
|
+
const LAYOUT_NODESEP = 60;
|
|
249
|
+
const LAYOUT_RANKSEP = 140;
|
|
250
|
+
const LAYOUT_DEFAULT_W = 200;
|
|
251
|
+
const LAYOUT_DEFAULT_H = 120;
|
|
252
|
+
|
|
253
|
+
const isFloatingAnnotation = (n: Record<string, unknown>): boolean => {
|
|
254
|
+
if (n.type !== 'shapeNode') return false;
|
|
255
|
+
const shape = (n.data as { shape?: string } | undefined)?.shape;
|
|
256
|
+
return shape === 'sticky' || shape === 'text';
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const nodeDimensions = (n: Record<string, unknown>): { w: number; h: number } => {
|
|
260
|
+
const data = (n.data ?? {}) as { width?: number; height?: number; shape?: string };
|
|
261
|
+
const w =
|
|
262
|
+
typeof data.width === 'number'
|
|
263
|
+
? data.width
|
|
264
|
+
: n.type === 'shapeNode' && data.shape === 'text'
|
|
265
|
+
? 160
|
|
266
|
+
: LAYOUT_DEFAULT_W;
|
|
267
|
+
let h = LAYOUT_DEFAULT_H;
|
|
268
|
+
if (typeof data.height === 'number') h = data.height;
|
|
269
|
+
else if (n.type === 'shapeNode' && data.shape === 'text') h = 40;
|
|
270
|
+
else if (n.type === 'shapeNode' && data.shape === 'sticky') h = 180;
|
|
271
|
+
else if (n.type === 'imageNode') h = 150;
|
|
272
|
+
return { w, h };
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const autoLayout = (
|
|
276
|
+
nodes: ReadonlyArray<Record<string, unknown>>,
|
|
277
|
+
connectors: ReadonlyArray<Record<string, unknown>>,
|
|
278
|
+
stats: AssembleStats,
|
|
279
|
+
): Array<Record<string, unknown>> => {
|
|
280
|
+
if (nodes.length === 0) return [];
|
|
281
|
+
|
|
282
|
+
const flowNodes = nodes.filter((n) => !isFloatingAnnotation(n));
|
|
283
|
+
// A single flow node has no layout to compute — keep its input position so
|
|
284
|
+
// callers can pin standalone nodes via `layout.positions`.
|
|
285
|
+
if (flowNodes.length <= 1) return [...nodes];
|
|
286
|
+
|
|
287
|
+
const g = new dagre.graphlib.Graph({ multigraph: true });
|
|
288
|
+
g.setGraph({ rankdir: 'LR', nodesep: LAYOUT_NODESEP, ranksep: LAYOUT_RANKSEP });
|
|
289
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
290
|
+
|
|
291
|
+
const flowIds = new Set<string>();
|
|
292
|
+
const dims = new Map<string, { w: number; h: number }>();
|
|
293
|
+
for (const n of flowNodes) {
|
|
294
|
+
const id = n.id as string;
|
|
295
|
+
const { w, h } = nodeDimensions(n);
|
|
296
|
+
g.setNode(id, { width: w, height: h });
|
|
297
|
+
flowIds.add(id);
|
|
298
|
+
dims.set(id, { w, h });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let edgeCounter = 0;
|
|
302
|
+
for (const c of connectors) {
|
|
303
|
+
const s = c.source as string;
|
|
304
|
+
const t = c.target as string;
|
|
305
|
+
if (!flowIds.has(s) || !flowIds.has(t) || s === t) continue;
|
|
306
|
+
// multigraph + unique edge name preserves parallel connectors.
|
|
307
|
+
g.setEdge(s, t, {}, `e${edgeCounter++}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
dagre.layout(g);
|
|
311
|
+
|
|
312
|
+
const snap = (v: number) => Math.round(v / GRID) * GRID;
|
|
313
|
+
return nodes.map((n) => {
|
|
314
|
+
if (isFloatingAnnotation(n)) return n;
|
|
315
|
+
const id = n.id as string;
|
|
316
|
+
const laid = g.node(id);
|
|
317
|
+
const d = dims.get(id);
|
|
318
|
+
if (!laid || !d) return n;
|
|
319
|
+
// dagre returns center coords; convert to top-left for React Flow.
|
|
320
|
+
const snapped = { x: snap(laid.x - d.w / 2), y: snap(laid.y - d.h / 2) };
|
|
321
|
+
const prev = (n.position as { x: number; y: number } | undefined) ?? { x: 0, y: 0 };
|
|
322
|
+
if (snapped.x !== prev.x || snapped.y !== prev.y) stats.positionsShifted++;
|
|
323
|
+
return { ...n, position: snapped };
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// === validate ==============================================================
|
|
328
|
+
|
|
329
|
+
export const TierSchema = z.enum(['real', 'mock', 'static']);
|
|
330
|
+
export type Tier = z.infer<typeof TierSchema>;
|
|
331
|
+
|
|
332
|
+
export const ValidateRequestSchema = z.object({
|
|
333
|
+
demo: z.unknown(),
|
|
334
|
+
tier: TierSchema.optional(),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
export type ValidateRequest = z.infer<typeof ValidateRequestSchema>;
|
|
338
|
+
|
|
339
|
+
export interface ValidateIssue {
|
|
340
|
+
kind: string;
|
|
341
|
+
path?: string;
|
|
342
|
+
message: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface ValidateReport {
|
|
346
|
+
ok: boolean;
|
|
347
|
+
stats: {
|
|
348
|
+
tier: Tier;
|
|
349
|
+
nodeCount: number;
|
|
350
|
+
connectorCount: number;
|
|
351
|
+
playableCount: number;
|
|
352
|
+
issueCount: number;
|
|
353
|
+
warningCount: number;
|
|
354
|
+
};
|
|
355
|
+
issues: ValidateIssue[];
|
|
356
|
+
warnings: ValidateIssue[];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Filesystem-bound checks (harness coverage, event-emitter index) deliberately
|
|
360
|
+
// stay in the skill — the studio doesn't reach into the user's `$TARGET`. The
|
|
361
|
+
// endpoint covers schema + node-count cap + tier playability only.
|
|
362
|
+
export const validateDemo = (req: ValidateRequest): ValidateReport => {
|
|
363
|
+
const tier: Tier = req.tier ?? 'static';
|
|
364
|
+
const issues: ValidateIssue[] = [];
|
|
365
|
+
const warnings: ValidateIssue[] = [];
|
|
366
|
+
|
|
367
|
+
const parsed = DemoSchema.safeParse(req.demo);
|
|
368
|
+
if (!parsed.success) {
|
|
369
|
+
for (const issue of parsed.error.issues) {
|
|
370
|
+
issues.push({
|
|
371
|
+
kind: 'zod',
|
|
372
|
+
path: issue.path.join('.') || '<root>',
|
|
373
|
+
message: issue.message,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Best-effort access to nodes/connectors so cap + tier checks still surface
|
|
379
|
+
// on a demo that fails Zod (e.g. one extra/missing field shouldn't hide a
|
|
380
|
+
// 50-node count problem).
|
|
381
|
+
const rawDemo = (req.demo ?? {}) as {
|
|
382
|
+
nodes?: unknown;
|
|
383
|
+
connectors?: unknown;
|
|
384
|
+
};
|
|
385
|
+
const nodes: Array<Record<string, unknown>> = Array.isArray(rawDemo.nodes)
|
|
386
|
+
? (rawDemo.nodes as Array<Record<string, unknown>>)
|
|
387
|
+
: [];
|
|
388
|
+
const connectors: unknown[] = Array.isArray(rawDemo.connectors) ? rawDemo.connectors : [];
|
|
389
|
+
|
|
390
|
+
if (nodes.length > 30) {
|
|
391
|
+
issues.push({ kind: 'cap', message: `Node count ${nodes.length} exceeds soft cap 30` });
|
|
392
|
+
} else if (nodes.length > 25) {
|
|
393
|
+
warnings.push({ kind: 'cap', message: `Node count ${nodes.length} approaching cap 30` });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const playable = nodes.filter((n) => {
|
|
397
|
+
const data = n.data as { playAction?: unknown } | undefined;
|
|
398
|
+
return n.type === 'playNode' || (n.type === 'stateNode' && data?.playAction !== undefined);
|
|
399
|
+
});
|
|
400
|
+
if (tier !== 'static' && playable.length === 0) {
|
|
401
|
+
issues.push({
|
|
402
|
+
kind: 'tier-mismatch',
|
|
403
|
+
message: `Tier '${tier}' requires at least one playable node; found 0. Either add a playNode or set tier=static.`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (tier === 'real') {
|
|
408
|
+
for (const n of nodes) {
|
|
409
|
+
const action = (n.data as { playAction?: { kind?: string; method?: string; url?: string } })
|
|
410
|
+
?.playAction;
|
|
411
|
+
if (action?.kind !== 'http' || !action.url) continue;
|
|
412
|
+
warnings.push({
|
|
413
|
+
kind: 'real-tier-reachability',
|
|
414
|
+
message: `Node '${String(n.id)}': ensure ${action.method ?? '?'} ${action.url} is reachable in your dev server before clicking.`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
ok: issues.length === 0,
|
|
421
|
+
stats: {
|
|
422
|
+
tier,
|
|
423
|
+
nodeCount: nodes.length,
|
|
424
|
+
connectorCount: connectors.length,
|
|
425
|
+
playableCount: playable.length,
|
|
426
|
+
issueCount: issues.length,
|
|
427
|
+
warningCount: warnings.length,
|
|
428
|
+
},
|
|
429
|
+
issues,
|
|
430
|
+
warnings,
|
|
431
|
+
};
|
|
432
|
+
};
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory pub/sub keyed by demoId. Subscribers receive every event published
|
|
3
|
+
* for that demo until they unsubscribe; subscribers for other demos are not
|
|
4
|
+
* notified.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type StudioEventType =
|
|
8
|
+
| 'demo:reload'
|
|
9
|
+
| 'demo:reset'
|
|
10
|
+
| 'node:running'
|
|
11
|
+
| 'node:done'
|
|
12
|
+
| 'node:error'
|
|
13
|
+
| 'node:status'
|
|
14
|
+
| 'file:changed';
|
|
15
|
+
|
|
16
|
+
export interface StudioEvent {
|
|
17
|
+
type: StudioEventType;
|
|
18
|
+
demoId: string;
|
|
19
|
+
/** Arbitrary JSON-serializable payload. Shape depends on event type. */
|
|
20
|
+
payload: unknown;
|
|
21
|
+
/** Server-side timestamp (ms since epoch). */
|
|
22
|
+
ts: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Subscriber = (event: StudioEvent) => void;
|
|
26
|
+
|
|
27
|
+
export interface EventBus {
|
|
28
|
+
/** Subscribe to events for a specific demo. Returns an unsubscribe fn. */
|
|
29
|
+
subscribe(demoId: string, fn: Subscriber): () => void;
|
|
30
|
+
/** Broadcast an event to all subscribers of `demoId`. */
|
|
31
|
+
broadcast(event: Omit<StudioEvent, 'ts'> & { ts?: number }): void;
|
|
32
|
+
/** Number of active subscribers for a given demo (used in tests). */
|
|
33
|
+
subscriberCount(demoId: string): number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createEventBus(): EventBus {
|
|
37
|
+
const subs = new Map<string, Set<Subscriber>>();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
subscribe(demoId, fn) {
|
|
41
|
+
let set = subs.get(demoId);
|
|
42
|
+
if (!set) {
|
|
43
|
+
set = new Set();
|
|
44
|
+
subs.set(demoId, set);
|
|
45
|
+
}
|
|
46
|
+
set.add(fn);
|
|
47
|
+
return () => {
|
|
48
|
+
const current = subs.get(demoId);
|
|
49
|
+
if (!current) return;
|
|
50
|
+
current.delete(fn);
|
|
51
|
+
if (current.size === 0) subs.delete(demoId);
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
broadcast(event) {
|
|
55
|
+
const set = subs.get(event.demoId);
|
|
56
|
+
if (!set) return;
|
|
57
|
+
const full: StudioEvent = { ...event, ts: event.ts ?? Date.now() };
|
|
58
|
+
for (const fn of set) {
|
|
59
|
+
try {
|
|
60
|
+
fn(full);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[events] subscriber threw, dropping:', err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
subscriberCount(demoId) {
|
|
67
|
+
return subs.get(demoId)?.size ?? 0;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/mcp-shim.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// SeeFlow MCP stdio shim.
|
|
3
|
+
//
|
|
4
|
+
// Bridges an MCP stdio client (e.g. Claude Code via .mcp.json) to the studio's
|
|
5
|
+
// HTTP `/mcp` endpoint. Every JSON-RPC message received on stdin is forwarded
|
|
6
|
+
// to the studio via a Streamable HTTP MCP client transport; every response is
|
|
7
|
+
// piped back to stdout. The shim never interprets tool semantics — it's a
|
|
8
|
+
// transparent JSON-RPC proxy keyed off message ids.
|
|
9
|
+
//
|
|
10
|
+
// Default target: http://127.0.0.1:4321/mcp. Override with SEEFLOW_STUDIO_URL.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
StreamableHTTPClientTransport,
|
|
14
|
+
StreamableHTTPError,
|
|
15
|
+
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
+
import { type JSONRPCMessage, isJSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_URL = 'http://127.0.0.1:4321/mcp';
|
|
20
|
+
const STUDIO_NOT_RUNNING_MSG = 'SeeFlow studio is not running. Start it with `bun run dev` first.';
|
|
21
|
+
const STUDIO_WITHOUT_MCP_MSG = 'This studio version does not expose MCP. Upgrade required.';
|
|
22
|
+
|
|
23
|
+
const url = new URL(process.env.SEEFLOW_STUDIO_URL ?? DEFAULT_URL);
|
|
24
|
+
|
|
25
|
+
const stdio = new StdioServerTransport();
|
|
26
|
+
const http = new StreamableHTTPClientTransport(url);
|
|
27
|
+
|
|
28
|
+
// Walk the error and its `cause` chain looking for the signatures Bun and
|
|
29
|
+
// Node use for refused TCP connects. Bun's fetch surfaces this as a
|
|
30
|
+
// TypeError whose cause carries `code: 'ConnectionRefused'`; Node's undici
|
|
31
|
+
// uses `code: 'ECONNREFUSED'` on the cause. Match either pattern, plus the
|
|
32
|
+
// human-readable variants seen in the wild.
|
|
33
|
+
const isConnectionRefused = (err: unknown): boolean => {
|
|
34
|
+
const seen = new Set<unknown>();
|
|
35
|
+
const visit = (e: unknown): boolean => {
|
|
36
|
+
if (!e || typeof e !== 'object' || seen.has(e)) return false;
|
|
37
|
+
seen.add(e);
|
|
38
|
+
const obj = e as Record<string, unknown>;
|
|
39
|
+
const code = typeof obj.code === 'string' ? obj.code : '';
|
|
40
|
+
if (code === 'ECONNREFUSED' || code === 'ConnectionRefused') return true;
|
|
41
|
+
const message = typeof obj.message === 'string' ? obj.message : '';
|
|
42
|
+
if (/econnrefused|connection refused|unable to connect/i.test(message)) return true;
|
|
43
|
+
return visit(obj.cause);
|
|
44
|
+
};
|
|
45
|
+
return visit(err);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isMcpNotMounted = (err: unknown): boolean =>
|
|
49
|
+
err instanceof StreamableHTTPError && err.code === 404;
|
|
50
|
+
|
|
51
|
+
const errorMessageFor = (err: unknown): string => {
|
|
52
|
+
if (isConnectionRefused(err)) return STUDIO_NOT_RUNNING_MSG;
|
|
53
|
+
if (isMcpNotMounted(err)) return STUDIO_WITHOUT_MCP_MSG;
|
|
54
|
+
return err instanceof Error ? err.message : String(err);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Forward studio responses → stdout.
|
|
58
|
+
http.onmessage = (msg) => {
|
|
59
|
+
void stdio.send(msg);
|
|
60
|
+
};
|
|
61
|
+
http.onerror = () => {
|
|
62
|
+
// Errors are surfaced as JSON-RPC error responses by the stdio.onmessage
|
|
63
|
+
// catch block below. Silence the transport's own onerror so a single fetch
|
|
64
|
+
// failure doesn't double-log to stderr.
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Forward stdin requests → studio. On transport failure, synthesize a
|
|
68
|
+
// JSON-RPC error response so the upstream client sees a graceful error
|
|
69
|
+
// instead of a hang.
|
|
70
|
+
stdio.onmessage = async (msg) => {
|
|
71
|
+
try {
|
|
72
|
+
await http.send(msg);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (!isJSONRPCRequest(msg)) return;
|
|
75
|
+
const errorResponse: JSONRPCMessage = {
|
|
76
|
+
jsonrpc: '2.0',
|
|
77
|
+
id: msg.id,
|
|
78
|
+
error: { code: -32000, message: errorMessageFor(err) },
|
|
79
|
+
};
|
|
80
|
+
await stdio.send(errorResponse).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
await http.start();
|
|
85
|
+
await stdio.start();
|
|
86
|
+
|
|
87
|
+
const shutdown = async () => {
|
|
88
|
+
await stdio.close().catch(() => undefined);
|
|
89
|
+
await http.close().catch(() => undefined);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
};
|
|
92
|
+
process.on('SIGINT', () => void shutdown());
|
|
93
|
+
process.on('SIGTERM', () => void shutdown());
|