@tuongaz/seeflow 0.1.40 → 0.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +2 -15
  2. package/dist/web/assets/{index-DTNk6GGk.js → index-BPUoNIBm.js} +1541 -1541
  3. package/dist/web/assets/{index-BwdVgB2y.css → index-BlkUOp7f.css} +1 -1
  4. package/dist/web/assets/{index.es-D_iCCj4R.js → index.es-mje3R_63.js} +1 -1
  5. package/dist/web/assets/{jspdf.es.min-C9FG4HQT.js → jspdf.es.min-DX3imOs2.js} +3 -3
  6. package/dist/web/index.html +2 -2
  7. package/examples/ecommerce-platform/.seeflow/flow.json +47 -47
  8. package/examples/ecommerce-platform/.seeflow/style.json +10 -10
  9. package/examples/order-pipeline/.seeflow/flow.json +17 -17
  10. package/examples/order-pipeline/.seeflow/style.json +4 -4
  11. package/package.json +1 -1
  12. package/src/api.ts +101 -14
  13. package/src/atomic-write.ts +16 -0
  14. package/src/cli-e2e.ts +420 -0
  15. package/src/cli-helpers.ts +65 -0
  16. package/src/cli.ts +371 -17
  17. package/src/mcp.ts +116 -23
  18. package/src/merge.ts +1 -1
  19. package/src/node-files.ts +45 -0
  20. package/src/operations.ts +304 -98
  21. package/src/proxy.ts +35 -6
  22. package/src/registry.ts +2 -1
  23. package/src/schema.ts +31 -25
  24. package/src/short-id.ts +24 -0
  25. package/src/status-runner.ts +9 -8
  26. package/src/watcher.ts +14 -14
  27. /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
  28. /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
  29. /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
  30. /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
  31. /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
  32. /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
  33. /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
  34. /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
  35. /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
  36. /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
  37. /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
  38. /package/examples/order-pipeline/.seeflow/{details/fulfillment-service.md → nodes/node-zUIH7WFnhK/detail.md} +0 -0
package/src/mcp.ts CHANGED
@@ -8,14 +8,18 @@ import { type ZodTypeAny, z } from 'zod';
8
8
  import { zodToJsonSchema } from 'zod-to-json-schema';
9
9
  import {
10
10
  ConnectorPatchBodySchema,
11
+ ConnectorsBulkBodySchema,
11
12
  CreateProjectBodySchema,
12
13
  NodePatchBodySchema,
14
+ NodesBulkBodySchema,
13
15
  type OperationsDeps,
14
16
  PositionBodySchema,
15
17
  RegisterBodySchema,
16
18
  ReorderBodySchema,
17
19
  addConnectorImpl,
20
+ addConnectorsBulkImpl,
18
21
  addNodeImpl,
22
+ addNodesBulkImpl,
19
23
  createProjectImpl,
20
24
  deleteConnectorImpl,
21
25
  deleteFlowImpl,
@@ -55,16 +59,25 @@ export interface McpTool {
55
59
  // default. The MCP `tools/list` response carries `inputSchema` inline, so
56
60
  // stripping the wrapper keeps the wire payload tidy without losing any of
57
61
  // the actual shape constraints.
62
+ //
63
+ // MCP clients validate that every `inputSchema.type === "object"`. Plain
64
+ // `z.object(...)` schemas already produce that, but `z.discriminatedUnion`
65
+ // emits `{anyOf: [...]}` with no top-level `type` — so we force it on.
66
+ // Every tool argument is an object envelope, so this is always correct.
58
67
  const inputSchemaFromZod = (schema: ZodTypeAny): Record<string, unknown> => {
59
68
  const json = zodToJsonSchema(schema, { $refStrategy: 'none' }) as Record<string, unknown>;
60
69
  const { $schema: _$schema, ...rest } = json;
61
- return rest;
70
+ return rest.type === 'object' ? rest : { type: 'object', ...rest };
62
71
  };
63
72
 
64
73
  const okResult = (value: unknown): CallToolResult => ({
65
74
  content: [{ type: 'text', text: JSON.stringify(value) }],
66
75
  });
67
76
 
77
+ // Error payloads (e.g. 'unknown demo', 'Failed to write demo file') still say
78
+ // "demo" so the strings match the REST handlers in api.ts byte-for-byte.
79
+ // Renaming requires updating api.ts + ~18 test assertions in lockstep — a
80
+ // separate refactor from this MCP review.
68
81
  const errorResult = (text: string): CallToolResult => ({
69
82
  isError: true,
70
83
  content: [{ type: 'text', text }],
@@ -73,14 +86,14 @@ const errorResult = (text: string): CallToolResult => ({
73
86
  // Most MCP tools take a single flowId argument. Defined inline as plain
74
87
  // JSON Schema (rather than a one-off Zod schema) because there's no REST
75
88
  // counterpart to share with.
76
- const DEMO_ID_INPUT_SCHEMA = {
89
+ const FLOW_ID_INPUT_SCHEMA = {
77
90
  type: 'object',
78
91
  properties: { flowId: { type: 'string', minLength: 1 } },
79
92
  required: ['flowId'],
80
93
  additionalProperties: false,
81
94
  } as const;
82
95
 
83
- const requireDemoId = (args: unknown): { flowId: string } | { error: string } => {
96
+ const requireFlowId = (args: unknown): { flowId: string } | { error: string } => {
84
97
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
85
98
  return { error: 'Invalid arguments: expected an object with flowId' };
86
99
  }
@@ -92,7 +105,7 @@ const requireDemoId = (args: unknown): { flowId: string } | { error: string } =>
92
105
  };
93
106
 
94
107
  // {flowId, nodeId} body shape shared by move + reorder + delete inputs.
95
- const DemoNodeIdBaseSchema = z.object({
108
+ const FlowNodeIdBaseSchema = z.object({
96
109
  flowId: z.string().min(1),
97
110
  nodeId: z.string().min(1),
98
111
  });
@@ -105,11 +118,19 @@ const AddNodeInputSchema = z.object({
105
118
  node: z.record(z.unknown()),
106
119
  });
107
120
 
108
- const DeleteNodeInputSchema = DemoNodeIdBaseSchema;
121
+ // add_nodes input: { flowId, nodes: [...] }. Same loose per-item shape as
122
+ // add_node — ResolvedFlowSchema runs once over the whole batch server-side
123
+ // after the merge. Min/max bounds come from NodesBulkBodySchema so the
124
+ // 100-item cap shows up in the JSON Schema the agent introspects.
125
+ const AddNodesInputSchema = NodesBulkBodySchema.extend({
126
+ flowId: z.string().min(1),
127
+ });
128
+
129
+ const DeleteNodeInputSchema = FlowNodeIdBaseSchema;
109
130
 
110
131
  // move_node input: { flowId, nodeId } extended with PositionBodySchema's
111
132
  // { x, y } fields so agents see one flat schema.
112
- const MoveNodeInputSchema = DemoNodeIdBaseSchema.extend({
133
+ const MoveNodeInputSchema = FlowNodeIdBaseSchema.extend({
113
134
  x: PositionBodySchema.shape.x,
114
135
  y: PositionBodySchema.shape.y,
115
136
  });
@@ -118,11 +139,11 @@ const MoveNodeInputSchema = DemoNodeIdBaseSchema.extend({
118
139
  // discriminated union extended with flowId/nodeId. Keeps the discriminator
119
140
  // on `op` so the emitted JSON Schema is an oneOf the agent can introspect.
120
141
  const ReorderNodeInputSchema = z.discriminatedUnion('op', [
121
- DemoNodeIdBaseSchema.extend({ op: z.literal('forward') }),
122
- DemoNodeIdBaseSchema.extend({ op: z.literal('backward') }),
123
- DemoNodeIdBaseSchema.extend({ op: z.literal('toFront') }),
124
- DemoNodeIdBaseSchema.extend({ op: z.literal('toBack') }),
125
- DemoNodeIdBaseSchema.extend({
142
+ FlowNodeIdBaseSchema.extend({ op: z.literal('forward') }),
143
+ FlowNodeIdBaseSchema.extend({ op: z.literal('backward') }),
144
+ FlowNodeIdBaseSchema.extend({ op: z.literal('toFront') }),
145
+ FlowNodeIdBaseSchema.extend({ op: z.literal('toBack') }),
146
+ FlowNodeIdBaseSchema.extend({
126
147
  op: z.literal('toIndex'),
127
148
  index: z.number().int().nonnegative(),
128
149
  }),
@@ -147,6 +168,11 @@ const AddConnectorInputSchema = z.object({
147
168
  connector: z.record(z.unknown()),
148
169
  });
149
170
 
171
+ // add_connectors input: { flowId, connectors: [...] }. Mirrors add_nodes.
172
+ const AddConnectorsInputSchema = ConnectorsBulkBodySchema.extend({
173
+ flowId: z.string().min(1),
174
+ });
175
+
150
176
  // patch_connector input: { flowId, connectorId } merged with the strict
151
177
  // ConnectorPatchBodySchema. .extend() preserves strict mode so unknown
152
178
  // top-level keys trip the Zod parse before any IO — matching the REST
@@ -164,7 +190,7 @@ const DeleteConnectorInputSchema = z.object({
164
190
  const buildTools = (deps: OperationsDeps): McpTool[] => [
165
191
  {
166
192
  name: 'seeflow_list_flows',
167
- description: 'List every demo registered with the studio.',
193
+ description: 'List every flow registered with the studio.',
168
194
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
169
195
  handler: async () => {
170
196
  const result = listDemosImpl(deps);
@@ -200,10 +226,10 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
200
226
  },
201
227
  {
202
228
  name: 'seeflow_get_flow',
203
- description: 'Get the full demo definition and on-disk state for a flowId.',
204
- inputSchema: DEMO_ID_INPUT_SCHEMA,
229
+ description: 'Get the full flow definition and on-disk state for a flowId.',
230
+ inputSchema: FLOW_ID_INPUT_SCHEMA,
205
231
  handler: async (args) => {
206
- const v = requireDemoId(args);
232
+ const v = requireFlowId(args);
207
233
  if ('error' in v) return errorResult(v.error);
208
234
  const result = await getFlowImpl(deps, v.flowId);
209
235
  switch (result.kind) {
@@ -218,7 +244,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
218
244
  },
219
245
  {
220
246
  name: 'seeflow_register_flow',
221
- description: 'Register an existing demo file on disk with the studio.',
247
+ description: 'Register an existing flow file on disk with the studio.',
222
248
  inputSchema: inputSchemaFromZod(RegisterBodySchema),
223
249
  handler: async (args) => {
224
250
  const parsed = RegisterBodySchema.safeParse(args);
@@ -244,10 +270,10 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
244
270
  },
245
271
  {
246
272
  name: 'seeflow_delete_flow',
247
- description: 'Unregister a demo from the studio (the on-disk file is left untouched).',
248
- inputSchema: DEMO_ID_INPUT_SCHEMA,
273
+ description: 'Unregister a flow from the studio (the on-disk file is left untouched).',
274
+ inputSchema: FLOW_ID_INPUT_SCHEMA,
249
275
  handler: async (args) => {
250
- const v = requireDemoId(args);
276
+ const v = requireFlowId(args);
251
277
  if ('error' in v) return errorResult(v.error);
252
278
  const result = deleteFlowImpl(deps, v.flowId);
253
279
  switch (result.kind) {
@@ -286,7 +312,8 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
286
312
  },
287
313
  {
288
314
  name: 'seeflow_add_node',
289
- description: 'Append a new node to a demo (cascade-safe; id auto-generated when omitted).',
315
+ description:
316
+ 'Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on htmlNode) are auto-externalized to <project>/.seeflow/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.',
290
317
  inputSchema: inputSchemaFromZod(AddNodeInputSchema),
291
318
  handler: async (args) => {
292
319
  const parsed = AddNodeInputSchema.safeParse(args);
@@ -311,6 +338,38 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
311
338
  }
312
339
  },
313
340
  },
341
+ {
342
+ name: 'seeflow_add_nodes',
343
+ description:
344
+ 'Append 1–100 nodes to a flow in a single transactional write. Either every node lands or nothing does — if any item fails schema validation the whole batch is rejected. Use this instead of multiple seeflow_add_node calls when seeding a flow; it avoids per-item round-trips. Same per-item shape and externalization rules as seeflow_add_node.',
345
+ inputSchema: inputSchemaFromZod(AddNodesInputSchema),
346
+ handler: async (args) => {
347
+ const parsed = AddNodesInputSchema.safeParse(args);
348
+ if (!parsed.success) {
349
+ return errorResult(`Invalid add_nodes arguments: ${JSON.stringify(parsed.error.issues)}`);
350
+ }
351
+ const { flowId, nodes } = parsed.data;
352
+ const result = await addNodesBulkImpl(deps, flowId, { nodes });
353
+ switch (result.kind) {
354
+ case 'ok':
355
+ return okResult({ ok: true, nodes: result.data.nodes });
356
+ case 'flowNotFound':
357
+ return errorResult('unknown demo');
358
+ case 'fileNotFound':
359
+ return errorResult(`Flow file not found: ${result.path}`);
360
+ case 'badJson':
361
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
362
+ case 'badSchema':
363
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
364
+ case 'duplicateIdInBatch':
365
+ return errorResult(`Duplicate id in batch: ${result.id}`);
366
+ case 'idAlreadyExists':
367
+ return errorResult(`Node id already exists: ${result.id}`);
368
+ case 'writeFailed':
369
+ return errorResult(`Failed to write demo file: ${result.message}`);
370
+ }
371
+ },
372
+ },
314
373
  {
315
374
  name: 'seeflow_delete_node',
316
375
  description: 'Delete a node and cascade-remove every connector touching it.',
@@ -372,7 +431,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
372
431
  {
373
432
  name: 'seeflow_patch_node',
374
433
  description:
375
- 'Update fields on an existing node (position, name, description, detail, colors, border, font, shape, dimensions).',
434
+ 'Update fields on an existing node (position, name, description, detail, icon, colors, border, font, shape, dimensions, autoSize, plus iconNode-only color/strokeWidth/alt). Setting detail (every node) or html (htmlNode) writes the content to <project>/.seeflow/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.',
376
435
  inputSchema: inputSchemaFromZod(PatchNodeInputSchema),
377
436
  handler: async (args) => {
378
437
  const parsed = PatchNodeInputSchema.safeParse(args);
@@ -402,7 +461,7 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
402
461
  {
403
462
  name: 'seeflow_reorder_node',
404
463
  description:
405
- 'Reorder a node within demo.nodes[] (forward / backward / toFront / toBack / toIndex).',
464
+ 'Reorder a node within flow.nodes[] (forward / backward / toFront / toBack / toIndex).',
406
465
  inputSchema: inputSchemaFromZod(ReorderNodeInputSchema),
407
466
  handler: async (args) => {
408
467
  const parsed = ReorderNodeInputSchema.safeParse(args);
@@ -465,10 +524,44 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
465
524
  }
466
525
  },
467
526
  },
527
+ {
528
+ name: 'seeflow_add_connectors',
529
+ description:
530
+ 'Append 1–100 connectors to a flow in a single transactional write. Either every connector lands or nothing does — a dangling source/target on any item rolls back the whole batch. Use after seeflow_add_nodes when seeding a flow.',
531
+ inputSchema: inputSchemaFromZod(AddConnectorsInputSchema),
532
+ handler: async (args) => {
533
+ const parsed = AddConnectorsInputSchema.safeParse(args);
534
+ if (!parsed.success) {
535
+ return errorResult(
536
+ `Invalid add_connectors arguments: ${JSON.stringify(parsed.error.issues)}`,
537
+ );
538
+ }
539
+ const { flowId, connectors } = parsed.data;
540
+ const result = await addConnectorsBulkImpl(deps, flowId, { connectors });
541
+ switch (result.kind) {
542
+ case 'ok':
543
+ return okResult({ ok: true, connectors: result.data.connectors });
544
+ case 'flowNotFound':
545
+ return errorResult('unknown demo');
546
+ case 'fileNotFound':
547
+ return errorResult(`Flow file not found: ${result.path}`);
548
+ case 'badJson':
549
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
550
+ case 'badSchema':
551
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
552
+ case 'duplicateIdInBatch':
553
+ return errorResult(`Duplicate id in batch: ${result.id}`);
554
+ case 'idAlreadyExists':
555
+ return errorResult(`Connector id already exists: ${result.id}`);
556
+ case 'writeFailed':
557
+ return errorResult(`Failed to write demo file: ${result.message}`);
558
+ }
559
+ },
560
+ },
468
561
  {
469
562
  name: 'seeflow_patch_connector',
470
563
  description:
471
- 'Update fields on an existing connector (label, style, color, kind, per-kind payload, reconnect endpoints).',
564
+ 'Update fields on an existing connector (label, style, color, direction, path, borderSize, fontSize, kind, per-kind payload, reconnect endpoints + handles + pins).',
472
565
  inputSchema: inputSchemaFromZod(PatchConnectorInputSchema),
473
566
  handler: async (args) => {
474
567
  const parsed = PatchConnectorInputSchema.safeParse(args);
package/src/merge.ts CHANGED
@@ -52,7 +52,7 @@ const NODE_DATA_FLOW_KEYS = new Set([
52
52
  'shape',
53
53
  'path',
54
54
  'alt',
55
- 'htmlPath',
55
+ 'html',
56
56
  ]);
57
57
 
58
58
  const NODE_STYLE_KEYS = new Set([
@@ -0,0 +1,45 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { writeFileAtomic } from './atomic-write.ts';
4
+
5
+ // Spec for fields that the studio externalizes to disk under
6
+ // `<project>/.seeflow/nodes/<id>/<fileName>`. `nodeTypes` (when present)
7
+ // scopes the spec entry to specific node types; absent means "applies to
8
+ // every node type". Adding a future text field is one line.
9
+ export interface ExternalizedFieldSpec {
10
+ field: string;
11
+ fileName: string;
12
+ nodeTypes?: readonly string[];
13
+ }
14
+
15
+ export const EXTERNALIZED_NODE_FIELDS: readonly ExternalizedFieldSpec[] = [
16
+ { field: 'detail', fileName: 'detail.md' },
17
+ { field: 'html', fileName: 'view.html', nodeTypes: ['htmlNode'] },
18
+ ];
19
+
20
+ export const externalizedFieldsForNodeType = (
21
+ nodeType: unknown,
22
+ ): readonly ExternalizedFieldSpec[] => {
23
+ if (typeof nodeType !== 'string') return EXTERNALIZED_NODE_FIELDS.filter((e) => !e.nodeTypes);
24
+ return EXTERNALIZED_NODE_FIELDS.filter((e) => !e.nodeTypes || e.nodeTypes.includes(nodeType));
25
+ };
26
+
27
+ export type ExternalizedFieldName = (typeof EXTERNALIZED_NODE_FIELDS)[number]['field'];
28
+
29
+ export const nodeFileRelPath = (nodeId: string, fileName: string): string =>
30
+ `nodes/${nodeId}/${fileName}`;
31
+
32
+ export const nodeFileRef = (nodeId: string, fileName: string): string =>
33
+ `file://${nodeFileRelPath(nodeId, fileName)}`;
34
+
35
+ export const nodeFileAbsPath = (repoPath: string, nodeId: string, fileName: string): string =>
36
+ join(repoPath, '.seeflow', nodeFileRelPath(nodeId, fileName));
37
+
38
+ export function writeNodeFile(absPath: string, content: string): void {
39
+ mkdirSync(dirname(absPath), { recursive: true });
40
+ writeFileAtomic(absPath, content);
41
+ }
42
+
43
+ export function removeNodeDir(repoPath: string, nodeId: string): void {
44
+ rmSync(join(repoPath, '.seeflow', 'nodes', nodeId), { recursive: true, force: true });
45
+ }