@tuongaz/seeflow 0.1.26 → 0.1.27

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 (39) hide show
  1. package/dist/web/assets/{index-BMaMEi2a.js → index-CdNWAi1U.js} +4 -4
  2. package/dist/web/assets/{index.es-M1iBDKG6.js → index.es-CPyvUCV3.js} +1 -1
  3. package/dist/web/assets/{jspdf.es.min-xZpq8bcn.js → jspdf.es.min-Dkq0NSxE.js} +3 -3
  4. package/dist/web/index.html +1 -1
  5. package/examples/ecommerce-platform/.seeflow/{seeflow.json → architecture.json} +14 -77
  6. package/examples/ecommerce-platform/.seeflow/details/api-gateway.md +14 -0
  7. package/examples/ecommerce-platform/.seeflow/details/auth-service.md +9 -0
  8. package/examples/ecommerce-platform/.seeflow/details/cart-service.md +10 -0
  9. package/examples/ecommerce-platform/.seeflow/details/notification-service.md +13 -0
  10. package/examples/ecommerce-platform/.seeflow/details/order-service.md +16 -0
  11. package/examples/ecommerce-platform/.seeflow/details/payment-service.md +16 -0
  12. package/examples/ecommerce-platform/.seeflow/details/product-service.md +10 -0
  13. package/examples/ecommerce-platform/.seeflow/style.json +85 -0
  14. package/examples/order-pipeline/.seeflow/architecture.json +93 -0
  15. package/examples/order-pipeline/.seeflow/details/fulfillment-service.md +21 -0
  16. package/examples/order-pipeline/.seeflow/details/inventory-service.md +23 -0
  17. package/examples/order-pipeline/.seeflow/details/payment-service.md +23 -0
  18. package/examples/order-pipeline/.seeflow/details/post-orders.md +19 -0
  19. package/examples/order-pipeline/.seeflow/scripts/play.ts +2 -2
  20. package/examples/order-pipeline/.seeflow/style.json +42 -0
  21. package/package.json +1 -1
  22. package/src/api.ts +118 -118
  23. package/src/cli.ts +13 -13
  24. package/src/demo.ts +6 -6
  25. package/src/diagram.ts +4 -4
  26. package/src/events.ts +14 -14
  27. package/src/file-ref.ts +79 -0
  28. package/src/mcp.ts +117 -89
  29. package/src/merge.ts +190 -0
  30. package/src/operations.ts +415 -416
  31. package/src/proxy.ts +31 -31
  32. package/src/registry.ts +32 -20
  33. package/src/schema.ts +252 -8
  34. package/src/sdk-template.ts +2 -2
  35. package/src/sdk-writer.ts +2 -2
  36. package/src/server.ts +2 -2
  37. package/src/status-runner.ts +34 -38
  38. package/src/watcher.ts +165 -114
  39. package/examples/order-pipeline/.seeflow/seeflow.json +0 -123
package/src/mcp.ts CHANGED
@@ -18,22 +18,23 @@ import {
18
18
  addNodeImpl,
19
19
  createProjectImpl,
20
20
  deleteConnectorImpl,
21
- deleteDemoImpl,
21
+ deleteFlowImpl,
22
22
  deleteNodeImpl,
23
- getDemoImpl,
23
+ getFlowImpl,
24
24
  listDemosImpl,
25
25
  moveNodeImpl,
26
26
  patchConnectorImpl,
27
27
  patchNodeImpl,
28
- registerDemoImpl,
28
+ registerFlowImpl,
29
29
  reorderNodeImpl,
30
+ validateImpl,
30
31
  } from './operations.ts';
31
32
  import type { Registry } from './registry.ts';
32
- import type { DemoWatcher } from './watcher.ts';
33
+ import type { FlowWatcher } from './watcher.ts';
33
34
 
34
35
  export interface CreateMcpServerOptions {
35
36
  registry: Registry;
36
- watcher?: DemoWatcher;
37
+ watcher?: FlowWatcher;
37
38
  /** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
38
39
  projectBaseDir?: string;
39
40
  }
@@ -69,44 +70,44 @@ const errorResult = (text: string): CallToolResult => ({
69
70
  content: [{ type: 'text', text }],
70
71
  });
71
72
 
72
- // Most MCP tools take a single demoId argument. Defined inline as plain
73
+ // Most MCP tools take a single flowId argument. Defined inline as plain
73
74
  // JSON Schema (rather than a one-off Zod schema) because there's no REST
74
75
  // counterpart to share with.
75
76
  const DEMO_ID_INPUT_SCHEMA = {
76
77
  type: 'object',
77
- properties: { demoId: { type: 'string', minLength: 1 } },
78
- required: ['demoId'],
78
+ properties: { flowId: { type: 'string', minLength: 1 } },
79
+ required: ['flowId'],
79
80
  additionalProperties: false,
80
81
  } as const;
81
82
 
82
- const requireDemoId = (args: unknown): { demoId: string } | { error: string } => {
83
+ const requireDemoId = (args: unknown): { flowId: string } | { error: string } => {
83
84
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
84
- return { error: 'Invalid arguments: expected an object with demoId' };
85
+ return { error: 'Invalid arguments: expected an object with flowId' };
85
86
  }
86
- const { demoId } = args as { demoId?: unknown };
87
- if (typeof demoId !== 'string' || demoId.length === 0) {
88
- return { error: 'Invalid arguments: demoId must be a non-empty string' };
87
+ const { flowId } = args as { flowId?: unknown };
88
+ if (typeof flowId !== 'string' || flowId.length === 0) {
89
+ return { error: 'Invalid arguments: flowId must be a non-empty string' };
89
90
  }
90
- return { demoId };
91
+ return { flowId };
91
92
  };
92
93
 
93
- // {demoId, nodeId} body shape shared by move + reorder + delete inputs.
94
+ // {flowId, nodeId} body shape shared by move + reorder + delete inputs.
94
95
  const DemoNodeIdBaseSchema = z.object({
95
- demoId: z.string().min(1),
96
+ flowId: z.string().min(1),
96
97
  nodeId: z.string().min(1),
97
98
  });
98
99
 
99
- // add_node input: { demoId, node: <node payload> }. The inner `node` object is
100
- // loose here (additionalProperties=true via passthrough) because DemoSchema
100
+ // add_node input: { flowId, node: <node payload> }. The inner `node` object is
101
+ // loose here (additionalProperties=true via passthrough) because FlowSchema
101
102
  // runs the full validation server-side after the new node is merged in.
102
103
  const AddNodeInputSchema = z.object({
103
- demoId: z.string().min(1),
104
+ flowId: z.string().min(1),
104
105
  node: z.record(z.unknown()),
105
106
  });
106
107
 
107
108
  const DeleteNodeInputSchema = DemoNodeIdBaseSchema;
108
109
 
109
- // move_node input: { demoId, nodeId } extended with PositionBodySchema's
110
+ // move_node input: { flowId, nodeId } extended with PositionBodySchema's
110
111
  // { x, y } fields so agents see one flat schema.
111
112
  const MoveNodeInputSchema = DemoNodeIdBaseSchema.extend({
112
113
  x: PositionBodySchema.shape.x,
@@ -114,7 +115,7 @@ const MoveNodeInputSchema = DemoNodeIdBaseSchema.extend({
114
115
  });
115
116
 
116
117
  // reorder_node input: each branch of the existing ReorderBodySchema
117
- // discriminated union extended with demoId/nodeId. Keeps the discriminator
118
+ // discriminated union extended with flowId/nodeId. Keeps the discriminator
118
119
  // on `op` so the emitted JSON Schema is an oneOf the agent can introspect.
119
120
  const ReorderNodeInputSchema = z.discriminatedUnion('op', [
120
121
  DemoNodeIdBaseSchema.extend({ op: z.literal('forward') }),
@@ -127,42 +128,42 @@ const ReorderNodeInputSchema = z.discriminatedUnion('op', [
127
128
  }),
128
129
  ]);
129
130
 
130
- // patch_node input: { demoId, nodeId } merged with NodePatchBodySchema's
131
+ // patch_node input: { flowId, nodeId } merged with NodePatchBodySchema's
131
132
  // optional fields. .extend() on the strict body schema preserves strict
132
133
  // mode, so unknown top-level keys still trip the Zod parse before any disk
133
134
  // IO — matching the REST handler's "Invalid node patch body" 400 path.
134
135
  const PatchNodeInputSchema = NodePatchBodySchema.extend({
135
- demoId: z.string().min(1),
136
+ flowId: z.string().min(1),
136
137
  nodeId: z.string().min(1),
137
138
  });
138
139
 
139
- // add_connector input: { demoId, connector: <connector payload> }. The inner
140
+ // add_connector input: { flowId, connector: <connector payload> }. The inner
140
141
  // `connector` object is loose (additionalProperties=true via z.record) because
141
- // DemoSchema runs the full validation server-side after the new connector is
142
+ // FlowSchema runs the full validation server-side after the new connector is
142
143
  // merged in (post-mutation parse catches dangling source/target refs and
143
144
  // kind-discriminator violations).
144
145
  const AddConnectorInputSchema = z.object({
145
- demoId: z.string().min(1),
146
+ flowId: z.string().min(1),
146
147
  connector: z.record(z.unknown()),
147
148
  });
148
149
 
149
- // patch_connector input: { demoId, connectorId } merged with the strict
150
+ // patch_connector input: { flowId, connectorId } merged with the strict
150
151
  // ConnectorPatchBodySchema. .extend() preserves strict mode so unknown
151
152
  // top-level keys trip the Zod parse before any IO — matching the REST
152
153
  // handler's "Invalid connector patch body" 400 path.
153
154
  const PatchConnectorInputSchema = ConnectorPatchBodySchema.extend({
154
- demoId: z.string().min(1),
155
+ flowId: z.string().min(1),
155
156
  connectorId: z.string().min(1),
156
157
  });
157
158
 
158
159
  const DeleteConnectorInputSchema = z.object({
159
- demoId: z.string().min(1),
160
+ flowId: z.string().min(1),
160
161
  connectorId: z.string().min(1),
161
162
  });
162
163
 
163
164
  const buildTools = (deps: OperationsDeps): McpTool[] => [
164
165
  {
165
- name: 'seeflow_list_demos',
166
+ name: 'seeflow_list_flows',
166
167
  description: 'List every demo registered with the studio.',
167
168
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
168
169
  handler: async () => {
@@ -171,25 +172,52 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
171
172
  },
172
173
  },
173
174
  {
174
- name: 'seeflow_get_demo',
175
- description: 'Get the full demo definition and on-disk state for a demoId.',
175
+ name: 'validate_seeflow',
176
+ description:
177
+ 'Validate an architecture.json (and optional style.json) against the ' +
178
+ 'SeeFlow schemas. Stateless: no flow id, no file:// resolution, no ' +
179
+ 'registry side-effects. Returns { ok: true } or { ok: false, issues }.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ architecture: { type: 'object' },
184
+ style: { type: 'object' },
185
+ },
186
+ required: ['architecture'],
187
+ additionalProperties: false,
188
+ },
189
+ handler: async (args) => {
190
+ const body = args as Record<string, unknown> | undefined;
191
+ if (!body || !('architecture' in body)) {
192
+ return errorResult('Body must include `architecture`');
193
+ }
194
+ const result = validateImpl({
195
+ architecture: body.architecture,
196
+ style: body.style as unknown,
197
+ });
198
+ return okResult(result);
199
+ },
200
+ },
201
+ {
202
+ name: 'seeflow_get_flow',
203
+ description: 'Get the full demo definition and on-disk state for a flowId.',
176
204
  inputSchema: DEMO_ID_INPUT_SCHEMA,
177
205
  handler: async (args) => {
178
206
  const v = requireDemoId(args);
179
207
  if ('error' in v) return errorResult(v.error);
180
- const result = await getDemoImpl(deps, v.demoId);
208
+ const result = await getFlowImpl(deps, v.flowId);
181
209
  switch (result.kind) {
182
210
  case 'ok':
183
211
  return okResult(result.data);
184
212
  case 'notFound':
185
213
  return errorResult('not found');
186
214
  case 'fileNotFound':
187
- return errorResult(`Demo file not found: ${result.path}`);
215
+ return errorResult(`Flow file not found: ${result.path}`);
188
216
  }
189
217
  },
190
218
  },
191
219
  {
192
- name: 'seeflow_register_demo',
220
+ name: 'seeflow_register_flow',
193
221
  description: 'Register an existing demo file on disk with the studio.',
194
222
  inputSchema: inputSchemaFromZod(RegisterBodySchema),
195
223
  handler: async (args) => {
@@ -197,17 +225,17 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
197
225
  if (!parsed.success) {
198
226
  return errorResult(`Invalid register body: ${JSON.stringify(parsed.error.issues)}`);
199
227
  }
200
- const result = await registerDemoImpl(deps, parsed.data);
228
+ const result = await registerFlowImpl(deps, parsed.data);
201
229
  switch (result.kind) {
202
230
  case 'ok':
203
231
  return okResult(result.data);
204
232
  case 'fileNotFound':
205
- return errorResult(`Demo file not found: ${result.path}`);
233
+ return errorResult(`Flow file not found: ${result.path}`);
206
234
  case 'badJson':
207
- return errorResult(`Demo file is not valid JSON: ${result.detail}`);
235
+ return errorResult(`Flow file is not valid JSON: ${result.detail}`);
208
236
  case 'badSchema':
209
237
  return errorResult(
210
- `Demo file failed schema validation: ${JSON.stringify(result.issues)}`,
238
+ `Flow file failed schema validation: ${JSON.stringify(result.issues)}`,
211
239
  );
212
240
  case 'sdkWriteFailed':
213
241
  return errorResult(`Failed to write SDK helper: ${result.message}`);
@@ -215,13 +243,13 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
215
243
  },
216
244
  },
217
245
  {
218
- name: 'seeflow_delete_demo',
246
+ name: 'seeflow_delete_flow',
219
247
  description: 'Unregister a demo from the studio (the on-disk file is left untouched).',
220
248
  inputSchema: DEMO_ID_INPUT_SCHEMA,
221
249
  handler: async (args) => {
222
250
  const v = requireDemoId(args);
223
251
  if ('error' in v) return errorResult(v.error);
224
- const result = deleteDemoImpl(deps, v.demoId);
252
+ const result = deleteFlowImpl(deps, v.flowId);
225
253
  switch (result.kind) {
226
254
  case 'ok':
227
255
  return okResult({ ok: true });
@@ -265,19 +293,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
265
293
  if (!parsed.success) {
266
294
  return errorResult(`Invalid add_node arguments: ${JSON.stringify(parsed.error.issues)}`);
267
295
  }
268
- const { demoId, node } = parsed.data;
269
- const result = await addNodeImpl(deps, demoId, node);
296
+ const { flowId, node } = parsed.data;
297
+ const result = await addNodeImpl(deps, flowId, node);
270
298
  switch (result.kind) {
271
299
  case 'ok':
272
300
  return okResult({ ok: true, id: result.data.id, node: result.data.node });
273
- case 'demoNotFound':
301
+ case 'flowNotFound':
274
302
  return errorResult('unknown demo');
275
303
  case 'fileNotFound':
276
- return errorResult(`Demo file not found: ${result.path}`);
304
+ return errorResult(`Flow file not found: ${result.path}`);
277
305
  case 'badJson':
278
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
306
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
279
307
  case 'badSchema':
280
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
308
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
281
309
  case 'writeFailed':
282
310
  return errorResult(`Failed to write demo file: ${result.message}`);
283
311
  }
@@ -292,19 +320,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
292
320
  if (!parsed.success) {
293
321
  return errorResult(`Invalid delete_node arguments: ${JSON.stringify(parsed.error.issues)}`);
294
322
  }
295
- const { demoId, nodeId } = parsed.data;
296
- const result = await deleteNodeImpl(deps, demoId, nodeId);
323
+ const { flowId, nodeId } = parsed.data;
324
+ const result = await deleteNodeImpl(deps, flowId, nodeId);
297
325
  switch (result.kind) {
298
326
  case 'ok':
299
327
  return okResult({ ok: true });
300
- case 'demoNotFound':
328
+ case 'flowNotFound':
301
329
  return errorResult('unknown demo');
302
330
  case 'fileNotFound':
303
- return errorResult(`Demo file not found: ${result.path}`);
331
+ return errorResult(`Flow file not found: ${result.path}`);
304
332
  case 'badJson':
305
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
333
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
306
334
  case 'badSchema':
307
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
335
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
308
336
  case 'unknownNode':
309
337
  return errorResult(`Unknown nodeId: ${nodeId}`);
310
338
  case 'writeFailed':
@@ -321,19 +349,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
321
349
  if (!parsed.success) {
322
350
  return errorResult(`Invalid move_node arguments: ${JSON.stringify(parsed.error.issues)}`);
323
351
  }
324
- const { demoId, nodeId, x, y } = parsed.data;
325
- const result = await moveNodeImpl(deps, demoId, nodeId, { x, y });
352
+ const { flowId, nodeId, x, y } = parsed.data;
353
+ const result = await moveNodeImpl(deps, flowId, nodeId, { x, y });
326
354
  switch (result.kind) {
327
355
  case 'ok':
328
356
  return okResult({ ok: true, position: result.data.position });
329
- case 'demoNotFound':
357
+ case 'flowNotFound':
330
358
  return errorResult('unknown demo');
331
359
  case 'fileNotFound':
332
- return errorResult(`Demo file not found: ${result.path}`);
360
+ return errorResult(`Flow file not found: ${result.path}`);
333
361
  case 'badJson':
334
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
362
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
335
363
  case 'badSchema':
336
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
364
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
337
365
  case 'unknownNode':
338
366
  return errorResult(`Unknown nodeId: ${nodeId}`);
339
367
  case 'writeFailed':
@@ -351,19 +379,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
351
379
  if (!parsed.success) {
352
380
  return errorResult(`Invalid patch_node arguments: ${JSON.stringify(parsed.error.issues)}`);
353
381
  }
354
- const { demoId, nodeId, ...updates } = parsed.data;
355
- const result = await patchNodeImpl(deps, demoId, nodeId, updates);
382
+ const { flowId, nodeId, ...updates } = parsed.data;
383
+ const result = await patchNodeImpl(deps, flowId, nodeId, updates);
356
384
  switch (result.kind) {
357
385
  case 'ok':
358
386
  return okResult({ ok: true });
359
- case 'demoNotFound':
387
+ case 'flowNotFound':
360
388
  return errorResult('unknown demo');
361
389
  case 'fileNotFound':
362
- return errorResult(`Demo file not found: ${result.path}`);
390
+ return errorResult(`Flow file not found: ${result.path}`);
363
391
  case 'badJson':
364
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
392
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
365
393
  case 'badSchema':
366
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
394
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
367
395
  case 'unknownNode':
368
396
  return errorResult(`Unknown nodeId: ${nodeId}`);
369
397
  case 'writeFailed':
@@ -383,23 +411,23 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
383
411
  `Invalid reorder_node arguments: ${JSON.stringify(parsed.error.issues)}`,
384
412
  );
385
413
  }
386
- const { demoId, nodeId, ...body } = parsed.data;
414
+ const { flowId, nodeId, ...body } = parsed.data;
387
415
  // Delegate the op-specific shape to the existing ReorderBodySchema so
388
416
  // reorderNodeImpl receives the same discriminated union the REST route
389
417
  // does — keeps a single source of truth for op semantics.
390
418
  const reorderBody = ReorderBodySchema.parse(body);
391
- const result = await reorderNodeImpl(deps, demoId, nodeId, reorderBody);
419
+ const result = await reorderNodeImpl(deps, flowId, nodeId, reorderBody);
392
420
  switch (result.kind) {
393
421
  case 'ok':
394
422
  return okResult({ ok: true });
395
- case 'demoNotFound':
423
+ case 'flowNotFound':
396
424
  return errorResult('unknown demo');
397
425
  case 'fileNotFound':
398
- return errorResult(`Demo file not found: ${result.path}`);
426
+ return errorResult(`Flow file not found: ${result.path}`);
399
427
  case 'badJson':
400
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
428
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
401
429
  case 'badSchema':
402
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
430
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
403
431
  case 'unknownNode':
404
432
  return errorResult(`Unknown nodeId: ${nodeId}`);
405
433
  case 'writeFailed':
@@ -419,19 +447,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
419
447
  `Invalid add_connector arguments: ${JSON.stringify(parsed.error.issues)}`,
420
448
  );
421
449
  }
422
- const { demoId, connector } = parsed.data;
423
- const result = await addConnectorImpl(deps, demoId, connector);
450
+ const { flowId, connector } = parsed.data;
451
+ const result = await addConnectorImpl(deps, flowId, connector);
424
452
  switch (result.kind) {
425
453
  case 'ok':
426
454
  return okResult({ ok: true, id: result.data.id });
427
- case 'demoNotFound':
455
+ case 'flowNotFound':
428
456
  return errorResult('unknown demo');
429
457
  case 'fileNotFound':
430
- return errorResult(`Demo file not found: ${result.path}`);
458
+ return errorResult(`Flow file not found: ${result.path}`);
431
459
  case 'badJson':
432
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
460
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
433
461
  case 'badSchema':
434
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
462
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
435
463
  case 'writeFailed':
436
464
  return errorResult(`Failed to write demo file: ${result.message}`);
437
465
  }
@@ -449,19 +477,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
449
477
  `Invalid patch_connector arguments: ${JSON.stringify(parsed.error.issues)}`,
450
478
  );
451
479
  }
452
- const { demoId, connectorId, ...updates } = parsed.data;
453
- const result = await patchConnectorImpl(deps, demoId, connectorId, updates);
480
+ const { flowId, connectorId, ...updates } = parsed.data;
481
+ const result = await patchConnectorImpl(deps, flowId, connectorId, updates);
454
482
  switch (result.kind) {
455
483
  case 'ok':
456
484
  return okResult({ ok: true });
457
- case 'demoNotFound':
485
+ case 'flowNotFound':
458
486
  return errorResult('unknown demo');
459
487
  case 'fileNotFound':
460
- return errorResult(`Demo file not found: ${result.path}`);
488
+ return errorResult(`Flow file not found: ${result.path}`);
461
489
  case 'badJson':
462
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
490
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
463
491
  case 'badSchema':
464
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
492
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
465
493
  case 'unknownConnector':
466
494
  return errorResult(`Unknown connectorId: ${connectorId}`);
467
495
  case 'writeFailed':
@@ -480,19 +508,19 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
480
508
  `Invalid delete_connector arguments: ${JSON.stringify(parsed.error.issues)}`,
481
509
  );
482
510
  }
483
- const { demoId, connectorId } = parsed.data;
484
- const result = await deleteConnectorImpl(deps, demoId, connectorId);
511
+ const { flowId, connectorId } = parsed.data;
512
+ const result = await deleteConnectorImpl(deps, flowId, connectorId);
485
513
  switch (result.kind) {
486
514
  case 'ok':
487
515
  return okResult({ ok: true });
488
- case 'demoNotFound':
516
+ case 'flowNotFound':
489
517
  return errorResult('unknown demo');
490
518
  case 'fileNotFound':
491
- return errorResult(`Demo file not found: ${result.path}`);
519
+ return errorResult(`Flow file not found: ${result.path}`);
492
520
  case 'badJson':
493
- return errorResult(`Demo file is not valid JSON: ${result.message}`);
521
+ return errorResult(`Flow file is not valid JSON: ${result.message}`);
494
522
  case 'badSchema':
495
- return errorResult(`Demo failed schema validation: ${JSON.stringify(result.issues)}`);
523
+ return errorResult(`Flow failed schema validation: ${JSON.stringify(result.issues)}`);
496
524
  case 'unknownConnector':
497
525
  return errorResult(`Unknown connectorId: ${connectorId}`);
498
526
  case 'writeFailed':
package/src/merge.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { Architecture, Flow, Style } from './schema.ts';
2
+
3
+ /**
4
+ * Merge architecture.json (semantic data) and the optional style.json
5
+ * (presentation overrides) into the merged Flow shape consumed by the API,
6
+ * the canvas, and the rest of the studio.
7
+ *
8
+ * Style entries with no matching architecture id are silently dropped — the
9
+ * write path strips dangling entries after delete, but a stale file on disk
10
+ * shouldn't break the read path.
11
+ */
12
+ export function mergeArchitectureAndStyle(arch: Architecture, style: Style): Flow {
13
+ const nodeStyles = style.nodes ?? {};
14
+ const connectorStyles = style.connectors ?? {};
15
+
16
+ const mergedNodes = arch.nodes.map((node) => {
17
+ const s = nodeStyles[node.id] ?? {};
18
+ const { position, ...visual } = s;
19
+ return {
20
+ ...node,
21
+ position: position ?? { x: 0, y: 0 },
22
+ data: { ...node.data, ...visual },
23
+ };
24
+ });
25
+
26
+ const mergedConnectors = arch.connectors.map((conn) => {
27
+ const s = connectorStyles[conn.id] ?? {};
28
+ return { ...conn, ...s };
29
+ });
30
+
31
+ return {
32
+ version: arch.version,
33
+ name: arch.name,
34
+ ...(arch.resetAction ? { resetAction: arch.resetAction } : {}),
35
+ nodes: mergedNodes,
36
+ connectors: mergedConnectors,
37
+ } as Flow;
38
+ }
39
+
40
+ // Fields that live in a node's `data` block on architecture.json. Every other
41
+ // data field is visual and routes to style.json.
42
+ const NODE_DATA_ARCH_KEYS = new Set([
43
+ 'name',
44
+ 'kind',
45
+ 'stateSource',
46
+ 'handlerModule',
47
+ 'icon',
48
+ 'description',
49
+ 'detail',
50
+ 'playAction',
51
+ 'statusAction',
52
+ 'shape',
53
+ 'path',
54
+ 'alt',
55
+ 'htmlPath',
56
+ ]);
57
+
58
+ const NODE_STYLE_KEYS = new Set([
59
+ 'width',
60
+ 'height',
61
+ 'borderColor',
62
+ 'backgroundColor',
63
+ 'borderSize',
64
+ 'borderStyle',
65
+ 'fontSize',
66
+ 'textColor',
67
+ 'cornerRadius',
68
+ 'locked',
69
+ 'borderWidth',
70
+ 'color',
71
+ 'strokeWidth',
72
+ 'autoSize',
73
+ ]);
74
+
75
+ const CONNECTOR_ARCH_KEYS = new Set([
76
+ 'id',
77
+ 'source',
78
+ 'target',
79
+ 'kind',
80
+ 'label',
81
+ 'method',
82
+ 'url',
83
+ 'eventName',
84
+ 'queueName',
85
+ ]);
86
+
87
+ const CONNECTOR_STYLE_KEYS = new Set([
88
+ 'sourceHandle',
89
+ 'targetHandle',
90
+ 'sourceHandleAutoPicked',
91
+ 'targetHandleAutoPicked',
92
+ 'sourcePin',
93
+ 'targetPin',
94
+ 'style',
95
+ 'color',
96
+ 'direction',
97
+ 'borderSize',
98
+ 'path',
99
+ 'fontSize',
100
+ ]);
101
+
102
+ /**
103
+ * Split a merged Flow back into (architecture, style) for atomic write. The
104
+ * inverse of mergeArchitectureAndStyle: position and every visual field on
105
+ * each node moves to `style.nodes[id]`; handles, pins, and visual fields on
106
+ * each connector move to `style.connectors[id]`. Architecture keeps every
107
+ * semantic data field — the routing tables above are the source of truth.
108
+ *
109
+ * Style entries that end up empty are omitted from the output so the file
110
+ * stays compact (matches the design's "delete style.json when {}" rule).
111
+ */
112
+ export function splitFlow(flow: {
113
+ version: number;
114
+ name: string;
115
+ resetAction?: unknown;
116
+ nodes: Array<Record<string, unknown>>;
117
+ connectors: Array<Record<string, unknown>>;
118
+ }): { architecture: Record<string, unknown>; style: Record<string, unknown> } {
119
+ const archNodes: Array<Record<string, unknown>> = [];
120
+ const styleNodes: Record<string, Record<string, unknown>> = {};
121
+
122
+ for (const node of flow.nodes) {
123
+ const id = node.id as string;
124
+ const archNode: Record<string, unknown> = { id, type: node.type };
125
+ const styleEntry: Record<string, unknown> = {};
126
+
127
+ if (node.position && typeof node.position === 'object') {
128
+ styleEntry.position = node.position;
129
+ }
130
+
131
+ const data = (node.data ?? {}) as Record<string, unknown>;
132
+ const archData: Record<string, unknown> = {};
133
+ for (const [k, v] of Object.entries(data)) {
134
+ if (v === undefined) continue;
135
+ if (NODE_DATA_ARCH_KEYS.has(k)) {
136
+ archData[k] = v;
137
+ } else if (NODE_STYLE_KEYS.has(k)) {
138
+ styleEntry[k] = v;
139
+ } else {
140
+ // Unknown forward-compat key — keep on architecture side so the
141
+ // schema's strict() will catch typos but extension is possible by
142
+ // updating the routing tables here.
143
+ archData[k] = v;
144
+ }
145
+ }
146
+ archNode.data = archData;
147
+ archNodes.push(archNode);
148
+
149
+ if (Object.keys(styleEntry).length > 0) {
150
+ styleNodes[id] = styleEntry;
151
+ }
152
+ }
153
+
154
+ const archConnectors: Array<Record<string, unknown>> = [];
155
+ const styleConnectors: Record<string, Record<string, unknown>> = {};
156
+
157
+ for (const conn of flow.connectors) {
158
+ const id = conn.id as string;
159
+ const archConn: Record<string, unknown> = {};
160
+ const styleEntry: Record<string, unknown> = {};
161
+ for (const [k, v] of Object.entries(conn)) {
162
+ if (v === undefined) continue;
163
+ if (CONNECTOR_ARCH_KEYS.has(k)) {
164
+ archConn[k] = v;
165
+ } else if (CONNECTOR_STYLE_KEYS.has(k)) {
166
+ styleEntry[k] = v;
167
+ } else {
168
+ archConn[k] = v;
169
+ }
170
+ }
171
+ archConnectors.push(archConn);
172
+ if (Object.keys(styleEntry).length > 0) {
173
+ styleConnectors[id] = styleEntry;
174
+ }
175
+ }
176
+
177
+ const architecture: Record<string, unknown> = {
178
+ version: flow.version,
179
+ name: flow.name,
180
+ nodes: archNodes,
181
+ connectors: archConnectors,
182
+ };
183
+ if (flow.resetAction !== undefined) architecture.resetAction = flow.resetAction;
184
+
185
+ const style: Record<string, unknown> = {};
186
+ if (Object.keys(styleNodes).length > 0) style.nodes = styleNodes;
187
+ if (Object.keys(styleConnectors).length > 0) style.connectors = styleConnectors;
188
+
189
+ return { architecture, style };
190
+ }