@tuongaz/seeflow 0.1.47 → 0.1.52
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 +14 -0
- package/dist/web/assets/{index-BYeYJkCQ.js → index-0SsuN--u.js} +3 -3
- package/dist/web/assets/{index.es-CqkMwhBu.js → index.es-DVWwGuiE.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DLHTB6Rk.js → jspdf.es.min-vbzfEOq-.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/api.ts +138 -102
- package/src/cli-helpers.ts +79 -0
- package/src/cli-manifest.ts +772 -0
- package/src/cli-ops.ts +18 -0
- package/src/cli.ts +164 -137
- package/src/events.ts +2 -1
- package/src/mcp.ts +104 -35
- package/src/merge.ts +3 -0
- package/src/operations.ts +320 -0
- package/src/registry-watcher.ts +86 -0
- package/src/registry.ts +132 -24
- package/src/schema.ts +2 -0
- package/src/server.ts +9 -0
- package/src/watcher.ts +32 -2
package/src/mcp.ts
CHANGED
|
@@ -12,26 +12,11 @@ import {
|
|
|
12
12
|
CreateProjectBodySchema,
|
|
13
13
|
NodePatchBodySchema,
|
|
14
14
|
NodesBulkBodySchema,
|
|
15
|
-
type
|
|
15
|
+
type Operations,
|
|
16
16
|
PositionBodySchema,
|
|
17
17
|
RegisterBodySchema,
|
|
18
18
|
ReorderBodySchema,
|
|
19
|
-
|
|
20
|
-
addConnectorsBulkImpl,
|
|
21
|
-
addNodeImpl,
|
|
22
|
-
addNodesBulkImpl,
|
|
23
|
-
createProjectImpl,
|
|
24
|
-
deleteConnectorImpl,
|
|
25
|
-
deleteFlowImpl,
|
|
26
|
-
deleteNodeImpl,
|
|
27
|
-
getFlowImpl,
|
|
28
|
-
listDemosImpl,
|
|
29
|
-
moveNodeImpl,
|
|
30
|
-
patchConnectorImpl,
|
|
31
|
-
patchNodeImpl,
|
|
32
|
-
registerFlowImpl,
|
|
33
|
-
reorderNodeImpl,
|
|
34
|
-
validateImpl,
|
|
19
|
+
createOperations,
|
|
35
20
|
} from './operations.ts';
|
|
36
21
|
import type { Registry } from './registry.ts';
|
|
37
22
|
import type { FlowWatcher } from './watcher.ts';
|
|
@@ -187,13 +172,25 @@ const DeleteConnectorInputSchema = z.object({
|
|
|
187
172
|
connectorId: z.string().min(1),
|
|
188
173
|
});
|
|
189
174
|
|
|
190
|
-
const buildTools = (
|
|
175
|
+
const buildTools = (ops: Operations): McpTool[] => [
|
|
191
176
|
{
|
|
192
177
|
name: 'seeflow_list_flows',
|
|
193
178
|
description: 'List every flow registered with the studio.',
|
|
194
179
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
195
180
|
handler: async () => {
|
|
196
|
-
const result =
|
|
181
|
+
const result = ops.listFlows();
|
|
182
|
+
return okResult(result.data);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'seeflow_list_flows_summary',
|
|
187
|
+
description:
|
|
188
|
+
'List registered flows as { id, name, description } only. Use this for ' +
|
|
189
|
+
'cheap discovery before drilling into a specific flow with ' +
|
|
190
|
+
'seeflow_get_flow_graph or seeflow_get_node.',
|
|
191
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
192
|
+
handler: async () => {
|
|
193
|
+
const result = ops.listFlowsSummary();
|
|
197
194
|
return okResult(result.data);
|
|
198
195
|
},
|
|
199
196
|
},
|
|
@@ -217,7 +214,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
217
214
|
if (!body || !('flow' in body)) {
|
|
218
215
|
return errorResult('Body must include `flow`');
|
|
219
216
|
}
|
|
220
|
-
const result =
|
|
217
|
+
const result = ops.validate({
|
|
221
218
|
flow: body.flow,
|
|
222
219
|
style: body.style as unknown,
|
|
223
220
|
});
|
|
@@ -231,7 +228,28 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
231
228
|
handler: async (args) => {
|
|
232
229
|
const v = requireFlowId(args);
|
|
233
230
|
if ('error' in v) return errorResult(v.error);
|
|
234
|
-
const result = await
|
|
231
|
+
const result = await ops.getFlow(v.flowId);
|
|
232
|
+
switch (result.kind) {
|
|
233
|
+
case 'ok':
|
|
234
|
+
return okResult(result.data);
|
|
235
|
+
case 'notFound':
|
|
236
|
+
return errorResult('not found');
|
|
237
|
+
case 'fileNotFound':
|
|
238
|
+
return errorResult(`Flow file not found: ${result.path}`);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'seeflow_get_flow_graph',
|
|
244
|
+
description:
|
|
245
|
+
"Get a flow's nodes + connectors without inlining per-node file-backed " +
|
|
246
|
+
'content (`detail`, `html`). Cheap topology read — pair with seeflow_get_node ' +
|
|
247
|
+
"when you need a specific node's long-form body.",
|
|
248
|
+
inputSchema: FLOW_ID_INPUT_SCHEMA,
|
|
249
|
+
handler: async (args) => {
|
|
250
|
+
const v = requireFlowId(args);
|
|
251
|
+
if ('error' in v) return errorResult(v.error);
|
|
252
|
+
const result = await ops.getFlowGraph(v.flowId);
|
|
235
253
|
switch (result.kind) {
|
|
236
254
|
case 'ok':
|
|
237
255
|
return okResult(result.data);
|
|
@@ -239,6 +257,56 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
239
257
|
return errorResult('not found');
|
|
240
258
|
case 'fileNotFound':
|
|
241
259
|
return errorResult(`Flow file not found: ${result.path}`);
|
|
260
|
+
case 'badJson':
|
|
261
|
+
return errorResult(`Flow file is not valid JSON: ${result.detail}`);
|
|
262
|
+
case 'badSchema':
|
|
263
|
+
return errorResult(
|
|
264
|
+
`Flow file failed schema validation: ${JSON.stringify(result.issues)}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'seeflow_get_node',
|
|
271
|
+
description:
|
|
272
|
+
'Get a single node from a flow with its file-backed content (detail.md, ' +
|
|
273
|
+
'view.html) inlined. Use after seeflow_get_flow_graph to drill into one node.',
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: {
|
|
277
|
+
flowId: { type: 'string', minLength: 1 },
|
|
278
|
+
nodeId: { type: 'string', minLength: 1 },
|
|
279
|
+
},
|
|
280
|
+
required: ['flowId', 'nodeId'],
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
},
|
|
283
|
+
handler: async (args) => {
|
|
284
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
285
|
+
return errorResult('Invalid arguments: expected an object with flowId + nodeId');
|
|
286
|
+
}
|
|
287
|
+
const { flowId, nodeId } = args as { flowId?: unknown; nodeId?: unknown };
|
|
288
|
+
if (typeof flowId !== 'string' || flowId.length === 0) {
|
|
289
|
+
return errorResult('Invalid arguments: flowId must be a non-empty string');
|
|
290
|
+
}
|
|
291
|
+
if (typeof nodeId !== 'string' || nodeId.length === 0) {
|
|
292
|
+
return errorResult('Invalid arguments: nodeId must be a non-empty string');
|
|
293
|
+
}
|
|
294
|
+
const result = await ops.getNode(flowId, nodeId);
|
|
295
|
+
switch (result.kind) {
|
|
296
|
+
case 'ok':
|
|
297
|
+
return okResult(result.data);
|
|
298
|
+
case 'notFound':
|
|
299
|
+
return errorResult('not found');
|
|
300
|
+
case 'fileNotFound':
|
|
301
|
+
return errorResult(`Flow file not found: ${result.path}`);
|
|
302
|
+
case 'unknownNode':
|
|
303
|
+
return errorResult(`Unknown nodeId: ${nodeId}`);
|
|
304
|
+
case 'badJson':
|
|
305
|
+
return errorResult(`Flow file is not valid JSON: ${result.detail}`);
|
|
306
|
+
case 'badSchema':
|
|
307
|
+
return errorResult(
|
|
308
|
+
`Flow file failed schema validation: ${JSON.stringify(result.issues)}`,
|
|
309
|
+
);
|
|
242
310
|
}
|
|
243
311
|
},
|
|
244
312
|
},
|
|
@@ -251,7 +319,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
251
319
|
if (!parsed.success) {
|
|
252
320
|
return errorResult(`Invalid register body: ${JSON.stringify(parsed.error.issues)}`);
|
|
253
321
|
}
|
|
254
|
-
const result = await
|
|
322
|
+
const result = await ops.registerFlow(parsed.data);
|
|
255
323
|
switch (result.kind) {
|
|
256
324
|
case 'ok':
|
|
257
325
|
return okResult(result.data);
|
|
@@ -275,7 +343,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
275
343
|
handler: async (args) => {
|
|
276
344
|
const v = requireFlowId(args);
|
|
277
345
|
if ('error' in v) return errorResult(v.error);
|
|
278
|
-
const result =
|
|
346
|
+
const result = ops.deleteFlow(v.flowId);
|
|
279
347
|
switch (result.kind) {
|
|
280
348
|
case 'ok':
|
|
281
349
|
return okResult({ ok: true });
|
|
@@ -293,7 +361,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
293
361
|
if (!parsed.success) {
|
|
294
362
|
return errorResult(`Invalid create project body: ${JSON.stringify(parsed.error.issues)}`);
|
|
295
363
|
}
|
|
296
|
-
const result = await
|
|
364
|
+
const result = await ops.createProject(parsed.data);
|
|
297
365
|
switch (result.kind) {
|
|
298
366
|
case 'ok':
|
|
299
367
|
return okResult(result.data);
|
|
@@ -321,7 +389,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
321
389
|
return errorResult(`Invalid add_node arguments: ${JSON.stringify(parsed.error.issues)}`);
|
|
322
390
|
}
|
|
323
391
|
const { flowId, node } = parsed.data;
|
|
324
|
-
const result = await
|
|
392
|
+
const result = await ops.addNode(flowId, node);
|
|
325
393
|
switch (result.kind) {
|
|
326
394
|
case 'ok':
|
|
327
395
|
return okResult({ ok: true, id: result.data.id, node: result.data.node });
|
|
@@ -349,7 +417,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
349
417
|
return errorResult(`Invalid add_nodes arguments: ${JSON.stringify(parsed.error.issues)}`);
|
|
350
418
|
}
|
|
351
419
|
const { flowId, nodes } = parsed.data;
|
|
352
|
-
const result = await
|
|
420
|
+
const result = await ops.addNodesBulk(flowId, { nodes });
|
|
353
421
|
switch (result.kind) {
|
|
354
422
|
case 'ok':
|
|
355
423
|
return okResult({ ok: true, nodes: result.data.nodes });
|
|
@@ -380,7 +448,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
380
448
|
return errorResult(`Invalid delete_node arguments: ${JSON.stringify(parsed.error.issues)}`);
|
|
381
449
|
}
|
|
382
450
|
const { flowId, nodeId } = parsed.data;
|
|
383
|
-
const result = await
|
|
451
|
+
const result = await ops.deleteNode(flowId, nodeId);
|
|
384
452
|
switch (result.kind) {
|
|
385
453
|
case 'ok':
|
|
386
454
|
return okResult({ ok: true });
|
|
@@ -409,7 +477,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
409
477
|
return errorResult(`Invalid move_node arguments: ${JSON.stringify(parsed.error.issues)}`);
|
|
410
478
|
}
|
|
411
479
|
const { flowId, nodeId, x, y } = parsed.data;
|
|
412
|
-
const result = await
|
|
480
|
+
const result = await ops.moveNode(flowId, nodeId, { x, y });
|
|
413
481
|
switch (result.kind) {
|
|
414
482
|
case 'ok':
|
|
415
483
|
return okResult({ ok: true, position: result.data.position });
|
|
@@ -439,7 +507,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
439
507
|
return errorResult(`Invalid patch_node arguments: ${JSON.stringify(parsed.error.issues)}`);
|
|
440
508
|
}
|
|
441
509
|
const { flowId, nodeId, ...updates } = parsed.data;
|
|
442
|
-
const result = await
|
|
510
|
+
const result = await ops.patchNode(flowId, nodeId, updates);
|
|
443
511
|
switch (result.kind) {
|
|
444
512
|
case 'ok':
|
|
445
513
|
return okResult({ ok: true });
|
|
@@ -475,7 +543,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
475
543
|
// reorderNodeImpl receives the same discriminated union the REST route
|
|
476
544
|
// does — keeps a single source of truth for op semantics.
|
|
477
545
|
const reorderBody = ReorderBodySchema.parse(body);
|
|
478
|
-
const result = await
|
|
546
|
+
const result = await ops.reorderNode(flowId, nodeId, reorderBody);
|
|
479
547
|
switch (result.kind) {
|
|
480
548
|
case 'ok':
|
|
481
549
|
return okResult({ ok: true });
|
|
@@ -507,7 +575,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
507
575
|
);
|
|
508
576
|
}
|
|
509
577
|
const { flowId, connector } = parsed.data;
|
|
510
|
-
const result = await
|
|
578
|
+
const result = await ops.addConnector(flowId, connector);
|
|
511
579
|
switch (result.kind) {
|
|
512
580
|
case 'ok':
|
|
513
581
|
return okResult({ ok: true, id: result.data.id });
|
|
@@ -537,7 +605,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
537
605
|
);
|
|
538
606
|
}
|
|
539
607
|
const { flowId, connectors } = parsed.data;
|
|
540
|
-
const result = await
|
|
608
|
+
const result = await ops.addConnectorsBulk(flowId, { connectors });
|
|
541
609
|
switch (result.kind) {
|
|
542
610
|
case 'ok':
|
|
543
611
|
return okResult({ ok: true, connectors: result.data.connectors });
|
|
@@ -571,7 +639,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
571
639
|
);
|
|
572
640
|
}
|
|
573
641
|
const { flowId, connectorId, ...updates } = parsed.data;
|
|
574
|
-
const result = await
|
|
642
|
+
const result = await ops.patchConnector(flowId, connectorId, updates);
|
|
575
643
|
switch (result.kind) {
|
|
576
644
|
case 'ok':
|
|
577
645
|
return okResult({ ok: true });
|
|
@@ -602,7 +670,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
602
670
|
);
|
|
603
671
|
}
|
|
604
672
|
const { flowId, connectorId } = parsed.data;
|
|
605
|
-
const result = await
|
|
673
|
+
const result = await ops.deleteConnector(flowId, connectorId);
|
|
606
674
|
switch (result.kind) {
|
|
607
675
|
case 'ok':
|
|
608
676
|
return okResult({ ok: true });
|
|
@@ -630,11 +698,12 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
630
698
|
* mcp-shim.ts) — every request builds its own server in stateless mode.
|
|
631
699
|
*/
|
|
632
700
|
export function createMcpServer(options: CreateMcpServerOptions): Server {
|
|
633
|
-
const
|
|
701
|
+
const ops = createOperations({
|
|
634
702
|
registry: options.registry,
|
|
635
703
|
watcher: options.watcher,
|
|
636
704
|
projectBaseDir: options.projectBaseDir,
|
|
637
705
|
});
|
|
706
|
+
const tools = buildTools(ops);
|
|
638
707
|
|
|
639
708
|
const server = new Server({ name: 'seeflow', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
640
709
|
|
package/src/merge.ts
CHANGED
|
@@ -31,6 +31,7 @@ export function mergeFlowAndStyle(flow: Flow, style: Style): ResolvedFlow {
|
|
|
31
31
|
return {
|
|
32
32
|
version: flow.version,
|
|
33
33
|
name: flow.name,
|
|
34
|
+
...(flow.description !== undefined ? { description: flow.description } : {}),
|
|
34
35
|
...(flow.resetAction ? { resetAction: flow.resetAction } : {}),
|
|
35
36
|
nodes: mergedNodes,
|
|
36
37
|
connectors: mergedConnectors,
|
|
@@ -111,6 +112,7 @@ const CONNECTOR_STYLE_KEYS = new Set([
|
|
|
111
112
|
export function splitFlow(resolved: {
|
|
112
113
|
version: number;
|
|
113
114
|
name: string;
|
|
115
|
+
description?: string;
|
|
114
116
|
resetAction?: unknown;
|
|
115
117
|
nodes: Array<Record<string, unknown>>;
|
|
116
118
|
connectors: Array<Record<string, unknown>>;
|
|
@@ -179,6 +181,7 @@ export function splitFlow(resolved: {
|
|
|
179
181
|
nodes: flowNodes,
|
|
180
182
|
connectors: flowConnectors,
|
|
181
183
|
};
|
|
184
|
+
if (resolved.description !== undefined) flow.description = resolved.description;
|
|
182
185
|
if (resolved.resetAction !== undefined) flow.resetAction = resolved.resetAction;
|
|
183
186
|
|
|
184
187
|
const style: Record<string, unknown> = {};
|
package/src/operations.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSyn
|
|
|
11
11
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
12
12
|
import { type ZodIssue, z } from 'zod';
|
|
13
13
|
import { writeFileAtomic } from './atomic-write.ts';
|
|
14
|
+
import { type LayoutOptions, computeLayout } from './layout.ts';
|
|
14
15
|
import { mergeFlowAndStyle, splitFlow } from './merge.ts';
|
|
15
16
|
import {
|
|
16
17
|
EXTERNALIZED_NODE_FIELDS,
|
|
@@ -281,11 +282,58 @@ export interface CreateProjectSuccess {
|
|
|
281
282
|
|
|
282
283
|
export type ListFlowsOutcome = { kind: 'ok'; data: FlowListItem[] };
|
|
283
284
|
|
|
285
|
+
// Minimal projection for agent/CLI discovery — `description` and `name` come
|
|
286
|
+
// from the live watcher snapshot when available so author edits to flow.json
|
|
287
|
+
// surface immediately; fall back to the registry value at startup before
|
|
288
|
+
// any reparse has happened.
|
|
289
|
+
export interface FlowSummary {
|
|
290
|
+
id: string;
|
|
291
|
+
name: string;
|
|
292
|
+
description?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export type ListFlowsSummaryOutcome = { kind: 'ok'; data: FlowSummary[] };
|
|
296
|
+
|
|
284
297
|
export type GetFlowOutcome =
|
|
285
298
|
| { kind: 'ok'; data: FlowGetResponse }
|
|
286
299
|
| { kind: 'notFound' }
|
|
287
300
|
| { kind: 'fileNotFound'; path: string };
|
|
288
301
|
|
|
302
|
+
// Lightweight graph projection — flow + nodes + connectors with file-backed
|
|
303
|
+
// fields (`detail` on every node, `html` on htmlNode) stripped so the
|
|
304
|
+
// caller can navigate the topology without paying for inlined bodies.
|
|
305
|
+
export interface FlowGraphResponse {
|
|
306
|
+
id: string;
|
|
307
|
+
slug: string;
|
|
308
|
+
name: string;
|
|
309
|
+
description?: string;
|
|
310
|
+
nodes: Array<Record<string, unknown>>;
|
|
311
|
+
connectors: Array<Record<string, unknown>>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type GetFlowGraphOutcome =
|
|
315
|
+
| { kind: 'ok'; data: FlowGraphResponse }
|
|
316
|
+
| { kind: 'notFound' }
|
|
317
|
+
| { kind: 'fileNotFound'; path: string }
|
|
318
|
+
| { kind: 'badJson'; detail: string }
|
|
319
|
+
| { kind: 'badSchema'; issues: ZodIssue[] };
|
|
320
|
+
|
|
321
|
+
// Single node, content resolved. The node shape mirrors the on-disk Flow
|
|
322
|
+
// node shape (no position / style) with `file://` refs already inlined.
|
|
323
|
+
export interface GetNodeResponse {
|
|
324
|
+
id: string;
|
|
325
|
+
flowId: string;
|
|
326
|
+
node: Record<string, unknown>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export type GetNodeOutcome =
|
|
330
|
+
| { kind: 'ok'; data: GetNodeResponse }
|
|
331
|
+
| { kind: 'notFound' }
|
|
332
|
+
| { kind: 'fileNotFound'; path: string }
|
|
333
|
+
| { kind: 'badJson'; detail: string }
|
|
334
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
335
|
+
| { kind: 'unknownNode' };
|
|
336
|
+
|
|
289
337
|
export type RegisterFlowOutcome =
|
|
290
338
|
| { kind: 'ok'; data: RegisterFlowSuccess }
|
|
291
339
|
| { kind: 'fileNotFound'; path: string }
|
|
@@ -776,6 +824,22 @@ export function listDemosImpl(deps: OperationsDeps): ListFlowsOutcome {
|
|
|
776
824
|
return { kind: 'ok', data };
|
|
777
825
|
}
|
|
778
826
|
|
|
827
|
+
export function listFlowsSummaryImpl(deps: OperationsDeps): ListFlowsSummaryOutcome {
|
|
828
|
+
const { registry, watcher } = deps;
|
|
829
|
+
const data = registry.list().map((e) => {
|
|
830
|
+
const snap = watcher?.snapshot(e.id) ?? null;
|
|
831
|
+
const liveFlow = snap?.valid ? snap.flow : null;
|
|
832
|
+
const item: FlowSummary = {
|
|
833
|
+
id: e.id,
|
|
834
|
+
name: liveFlow?.name ?? e.name,
|
|
835
|
+
};
|
|
836
|
+
const description = liveFlow?.description ?? e.description;
|
|
837
|
+
if (description !== undefined) item.description = description;
|
|
838
|
+
return item;
|
|
839
|
+
});
|
|
840
|
+
return { kind: 'ok', data };
|
|
841
|
+
}
|
|
842
|
+
|
|
779
843
|
export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise<GetFlowOutcome> {
|
|
780
844
|
const { registry, watcher } = deps;
|
|
781
845
|
const entry = registry.getById(flowId);
|
|
@@ -813,6 +877,101 @@ export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise
|
|
|
813
877
|
};
|
|
814
878
|
}
|
|
815
879
|
|
|
880
|
+
// Strip the only file-backed fields that ride on `node.data` today.
|
|
881
|
+
// Keep narrow on purpose — if file-ref.ts ever grows another resolved field,
|
|
882
|
+
// this list must grow with it (covered by the operations.test.ts coverage).
|
|
883
|
+
const FILE_BACKED_NODE_DATA_FIELDS = ['detail', 'html'] as const;
|
|
884
|
+
|
|
885
|
+
function stripFileBackedFields(node: Record<string, unknown>): Record<string, unknown> {
|
|
886
|
+
const data = node.data;
|
|
887
|
+
if (!data || typeof data !== 'object') return node;
|
|
888
|
+
const stripped: Record<string, unknown> = {};
|
|
889
|
+
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
890
|
+
if ((FILE_BACKED_NODE_DATA_FIELDS as readonly string[]).includes(k)) continue;
|
|
891
|
+
stripped[k] = v;
|
|
892
|
+
}
|
|
893
|
+
return { ...node, data: stripped };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export async function getFlowGraphImpl(
|
|
897
|
+
deps: OperationsDeps,
|
|
898
|
+
flowId: string,
|
|
899
|
+
): Promise<GetFlowGraphOutcome> {
|
|
900
|
+
const entry = deps.registry.getById(flowId);
|
|
901
|
+
if (!entry) return { kind: 'notFound' };
|
|
902
|
+
|
|
903
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
904
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
905
|
+
|
|
906
|
+
let raw: unknown;
|
|
907
|
+
try {
|
|
908
|
+
raw = JSON.parse(readFileSync(fullPath, 'utf8'));
|
|
909
|
+
} catch (err) {
|
|
910
|
+
return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Parse the on-disk Flow shape directly (no resolveFileRefs, no style.json
|
|
914
|
+
// merge) so we never read the per-node detail/html files from disk.
|
|
915
|
+
const parsed = FlowSchema.safeParse(raw);
|
|
916
|
+
if (!parsed.success) return { kind: 'badSchema', issues: parsed.error.issues };
|
|
917
|
+
|
|
918
|
+
const data: FlowGraphResponse = {
|
|
919
|
+
id: entry.id,
|
|
920
|
+
slug: entry.slug,
|
|
921
|
+
name: parsed.data.name,
|
|
922
|
+
nodes: parsed.data.nodes.map((n) => stripFileBackedFields(n as Record<string, unknown>)),
|
|
923
|
+
connectors: parsed.data.connectors as Array<Record<string, unknown>>,
|
|
924
|
+
};
|
|
925
|
+
if (parsed.data.description !== undefined) data.description = parsed.data.description;
|
|
926
|
+
return { kind: 'ok', data };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export async function getNodeImpl(
|
|
930
|
+
deps: OperationsDeps,
|
|
931
|
+
flowId: string,
|
|
932
|
+
nodeId: string,
|
|
933
|
+
): Promise<GetNodeOutcome> {
|
|
934
|
+
const { registry, watcher } = deps;
|
|
935
|
+
const entry = registry.getById(flowId);
|
|
936
|
+
if (!entry) return { kind: 'notFound' };
|
|
937
|
+
|
|
938
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
939
|
+
|
|
940
|
+
// Prefer a live snapshot (already has file:// refs resolved) so we don't
|
|
941
|
+
// re-walk the filesystem on every call. Fall back to readMergedFlow for
|
|
942
|
+
// callers without a watcher (CLI, MCP-only setups).
|
|
943
|
+
const snap = watcher?.snapshot(flowId) ?? watcher?.reparse(flowId) ?? null;
|
|
944
|
+
let flow: ResolvedFlow | null = snap?.valid ? snap.flow : null;
|
|
945
|
+
|
|
946
|
+
if (!flow) {
|
|
947
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
948
|
+
const result = readMergedFlow(fullPath);
|
|
949
|
+
if (!result.valid || !result.flow) {
|
|
950
|
+
if (result.error?.startsWith('Invalid JSON')) {
|
|
951
|
+
return { kind: 'badJson', detail: result.error };
|
|
952
|
+
}
|
|
953
|
+
// Schema failed — re-parse to surface ZodIssues.
|
|
954
|
+
try {
|
|
955
|
+
const raw = JSON.parse(readFileSync(fullPath, 'utf8'));
|
|
956
|
+
const parsed = FlowSchema.safeParse(raw);
|
|
957
|
+
if (!parsed.success) return { kind: 'badSchema', issues: parsed.error.issues };
|
|
958
|
+
} catch (err) {
|
|
959
|
+
return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
|
|
960
|
+
}
|
|
961
|
+
return { kind: 'badJson', detail: result.error ?? 'unknown error' };
|
|
962
|
+
}
|
|
963
|
+
flow = result.flow;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const node = flow.nodes.find((n) => n.id === nodeId);
|
|
967
|
+
if (!node) return { kind: 'unknownNode' };
|
|
968
|
+
|
|
969
|
+
return {
|
|
970
|
+
kind: 'ok',
|
|
971
|
+
data: { id: nodeId, flowId, node: node as unknown as Record<string, unknown> },
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
816
975
|
export async function registerFlowImpl(
|
|
817
976
|
deps: OperationsDeps,
|
|
818
977
|
body: RegisterBody,
|
|
@@ -845,6 +1004,7 @@ export async function registerFlowImpl(
|
|
|
845
1004
|
const lastModified = statSync(fullPath).mtimeMs;
|
|
846
1005
|
const entry = registry.upsert({
|
|
847
1006
|
name: body.name ?? merged.flow.name,
|
|
1007
|
+
description: merged.flow.description,
|
|
848
1008
|
repoPath,
|
|
849
1009
|
flowPath,
|
|
850
1010
|
valid: true,
|
|
@@ -1522,3 +1682,163 @@ export function validateImpl(body: ValidateBody): ValidateOutcome {
|
|
|
1522
1682
|
|
|
1523
1683
|
return issues.length === 0 ? { ok: true } : { ok: false, issues };
|
|
1524
1684
|
}
|
|
1685
|
+
|
|
1686
|
+
// ---------------------------------------------------------------------------
|
|
1687
|
+
// applyLayoutImpl — ELK layout for a registered flow. Reads flow.json from
|
|
1688
|
+
// the registry-resolved path, validates it, computes layout, writes
|
|
1689
|
+
// style.json atomically, and (if a watcher is present) calls notifyWritten so
|
|
1690
|
+
// the studio's flow watcher seeds its snapshot and broadcasts flow:reload
|
|
1691
|
+
// without echoing the style.json fs event.
|
|
1692
|
+
// ---------------------------------------------------------------------------
|
|
1693
|
+
|
|
1694
|
+
export type ApplyLayoutOutcome =
|
|
1695
|
+
| { kind: 'ok' }
|
|
1696
|
+
| { kind: 'flowNotFound' }
|
|
1697
|
+
| { kind: 'fileNotFound'; path: string }
|
|
1698
|
+
| { kind: 'badJson'; detail: string }
|
|
1699
|
+
| { kind: 'badSchema'; issues: ValidationIssue[] }
|
|
1700
|
+
| { kind: 'writeFailed'; message: string };
|
|
1701
|
+
|
|
1702
|
+
export async function applyLayoutImpl(
|
|
1703
|
+
deps: OperationsDeps,
|
|
1704
|
+
flowId: string,
|
|
1705
|
+
options: LayoutOptions | undefined,
|
|
1706
|
+
): Promise<ApplyLayoutOutcome> {
|
|
1707
|
+
const entry = deps.registry.getById(flowId);
|
|
1708
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
1709
|
+
|
|
1710
|
+
const flowAbs = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1711
|
+
if (!existsSync(flowAbs)) return { kind: 'fileNotFound', path: flowAbs };
|
|
1712
|
+
|
|
1713
|
+
let raw: unknown;
|
|
1714
|
+
try {
|
|
1715
|
+
raw = JSON.parse(readFileSync(flowAbs, 'utf8'));
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const flowParse = FlowSchema.safeParse(raw);
|
|
1721
|
+
if (!flowParse.success) {
|
|
1722
|
+
return {
|
|
1723
|
+
kind: 'badSchema',
|
|
1724
|
+
issues: flowParse.error.issues.map((i) => ({
|
|
1725
|
+
scope: 'flow',
|
|
1726
|
+
path: [...i.path],
|
|
1727
|
+
message: i.message,
|
|
1728
|
+
code: i.code,
|
|
1729
|
+
})),
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
const flow = flowParse.data;
|
|
1734
|
+
const result = await computeLayout(
|
|
1735
|
+
flow.nodes.map((n) => ({
|
|
1736
|
+
id: n.id,
|
|
1737
|
+
type: n.type,
|
|
1738
|
+
data: n.type === 'shapeNode' ? { shape: (n.data as { shape?: string }).shape } : undefined,
|
|
1739
|
+
})),
|
|
1740
|
+
flow.connectors.map((c) => ({ id: c.id, source: c.source, target: c.target })),
|
|
1741
|
+
options,
|
|
1742
|
+
);
|
|
1743
|
+
|
|
1744
|
+
const styleAbs = join(dirname(flowAbs), 'style.json');
|
|
1745
|
+
const styleContent = `${JSON.stringify(result, null, 2)}\n`;
|
|
1746
|
+
try {
|
|
1747
|
+
writeFileAtomic(styleAbs, styleContent);
|
|
1748
|
+
} catch (err) {
|
|
1749
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Inform the watcher so it broadcasts flow:reload with the new payload and
|
|
1753
|
+
// suppresses the upcoming fs-watcher echo for this style.json write.
|
|
1754
|
+
const snap = deps.watcher?.reparse(flowId);
|
|
1755
|
+
if (deps.watcher && snap) {
|
|
1756
|
+
const flowContent = readFileSync(flowAbs, 'utf8');
|
|
1757
|
+
deps.watcher.notifyWritten(flowId, snap, flowContent, styleContent);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
return { kind: 'ok' };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// ---------------------------------------------------------------------------
|
|
1764
|
+
// createOperations — thin handle that exposes every *Impl as a bound method.
|
|
1765
|
+
// Consumers (api.ts, mcp.ts, cli.ts) construct one of these at startup so they
|
|
1766
|
+
// don't re-thread `deps` through every call site. No behaviour change — every
|
|
1767
|
+
// method delegates to the existing *Impl function.
|
|
1768
|
+
// ---------------------------------------------------------------------------
|
|
1769
|
+
|
|
1770
|
+
export interface Operations {
|
|
1771
|
+
listFlows(): ReturnType<typeof listDemosImpl>;
|
|
1772
|
+
listFlowsSummary(): ReturnType<typeof listFlowsSummaryImpl>;
|
|
1773
|
+
getFlow(id: string): ReturnType<typeof getFlowImpl>;
|
|
1774
|
+
getFlowGraph(id: string): ReturnType<typeof getFlowGraphImpl>;
|
|
1775
|
+
getNode(flowId: string, nodeId: string): ReturnType<typeof getNodeImpl>;
|
|
1776
|
+
addNode(flowId: string, body: Record<string, unknown>): ReturnType<typeof addNodeImpl>;
|
|
1777
|
+
addNodesBulk(
|
|
1778
|
+
flowId: string,
|
|
1779
|
+
body: Parameters<typeof addNodesBulkImpl>[2],
|
|
1780
|
+
): ReturnType<typeof addNodesBulkImpl>;
|
|
1781
|
+
patchNode(
|
|
1782
|
+
flowId: string,
|
|
1783
|
+
nodeId: string,
|
|
1784
|
+
body: Parameters<typeof patchNodeImpl>[3],
|
|
1785
|
+
): ReturnType<typeof patchNodeImpl>;
|
|
1786
|
+
moveNode(
|
|
1787
|
+
flowId: string,
|
|
1788
|
+
nodeId: string,
|
|
1789
|
+
body: Parameters<typeof moveNodeImpl>[3],
|
|
1790
|
+
): ReturnType<typeof moveNodeImpl>;
|
|
1791
|
+
reorderNode(
|
|
1792
|
+
flowId: string,
|
|
1793
|
+
nodeId: string,
|
|
1794
|
+
body: Parameters<typeof reorderNodeImpl>[3],
|
|
1795
|
+
): ReturnType<typeof reorderNodeImpl>;
|
|
1796
|
+
deleteNode(flowId: string, nodeId: string): ReturnType<typeof deleteNodeImpl>;
|
|
1797
|
+
addConnector(flowId: string, body: Record<string, unknown>): ReturnType<typeof addConnectorImpl>;
|
|
1798
|
+
addConnectorsBulk(
|
|
1799
|
+
flowId: string,
|
|
1800
|
+
body: Parameters<typeof addConnectorsBulkImpl>[2],
|
|
1801
|
+
): ReturnType<typeof addConnectorsBulkImpl>;
|
|
1802
|
+
patchConnector(
|
|
1803
|
+
flowId: string,
|
|
1804
|
+
connectorId: string,
|
|
1805
|
+
body: Parameters<typeof patchConnectorImpl>[3],
|
|
1806
|
+
): ReturnType<typeof patchConnectorImpl>;
|
|
1807
|
+
deleteConnector(flowId: string, connectorId: string): ReturnType<typeof deleteConnectorImpl>;
|
|
1808
|
+
registerFlow(body: Parameters<typeof registerFlowImpl>[1]): ReturnType<typeof registerFlowImpl>;
|
|
1809
|
+
createProject(
|
|
1810
|
+
body: Parameters<typeof createProjectImpl>[1],
|
|
1811
|
+
): ReturnType<typeof createProjectImpl>;
|
|
1812
|
+
deleteFlow(id: string): ReturnType<typeof deleteFlowImpl>;
|
|
1813
|
+
validate(body: ValidateBody): ReturnType<typeof validateImpl>;
|
|
1814
|
+
applyLayout(
|
|
1815
|
+
flowId: string,
|
|
1816
|
+
options: LayoutOptions | undefined,
|
|
1817
|
+
): ReturnType<typeof applyLayoutImpl>;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
export function createOperations(deps: OperationsDeps): Operations {
|
|
1821
|
+
return {
|
|
1822
|
+
listFlows: () => listDemosImpl(deps),
|
|
1823
|
+
listFlowsSummary: () => listFlowsSummaryImpl(deps),
|
|
1824
|
+
getFlow: (id) => getFlowImpl(deps, id),
|
|
1825
|
+
getFlowGraph: (id) => getFlowGraphImpl(deps, id),
|
|
1826
|
+
getNode: (flowId, nodeId) => getNodeImpl(deps, flowId, nodeId),
|
|
1827
|
+
addNode: (flowId, body) => addNodeImpl(deps, flowId, body),
|
|
1828
|
+
addNodesBulk: (flowId, body) => addNodesBulkImpl(deps, flowId, body),
|
|
1829
|
+
patchNode: (flowId, nodeId, body) => patchNodeImpl(deps, flowId, nodeId, body),
|
|
1830
|
+
moveNode: (flowId, nodeId, body) => moveNodeImpl(deps, flowId, nodeId, body),
|
|
1831
|
+
reorderNode: (flowId, nodeId, body) => reorderNodeImpl(deps, flowId, nodeId, body),
|
|
1832
|
+
deleteNode: (flowId, nodeId) => deleteNodeImpl(deps, flowId, nodeId),
|
|
1833
|
+
addConnector: (flowId, body) => addConnectorImpl(deps, flowId, body),
|
|
1834
|
+
addConnectorsBulk: (flowId, body) => addConnectorsBulkImpl(deps, flowId, body),
|
|
1835
|
+
patchConnector: (flowId, connectorId, body) =>
|
|
1836
|
+
patchConnectorImpl(deps, flowId, connectorId, body),
|
|
1837
|
+
deleteConnector: (flowId, connectorId) => deleteConnectorImpl(deps, flowId, connectorId),
|
|
1838
|
+
registerFlow: (body) => registerFlowImpl(deps, body),
|
|
1839
|
+
createProject: (body) => createProjectImpl(deps, body),
|
|
1840
|
+
deleteFlow: (id) => deleteFlowImpl(deps, id),
|
|
1841
|
+
validate: (body) => validateImpl(body),
|
|
1842
|
+
applyLayout: (flowId, options) => applyLayoutImpl(deps, flowId, options),
|
|
1843
|
+
};
|
|
1844
|
+
}
|