@tuongaz/seeflow 0.1.25 → 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 (41) hide show
  1. package/dist/web/assets/index-BotEftAD.css +1 -0
  2. package/dist/web/assets/{index-BJ7xSozm.js → index-CdNWAi1U.js} +4 -4
  3. package/dist/web/assets/{index.es-B3xFOWmE.js → index.es-CPyvUCV3.js} +1 -1
  4. package/dist/web/assets/{jspdf.es.min-Dh_oxn-h.js → jspdf.es.min-Dkq0NSxE.js} +3 -3
  5. package/dist/web/index.html +2 -2
  6. package/examples/ecommerce-platform/.seeflow/{seeflow.json → architecture.json} +14 -77
  7. package/examples/ecommerce-platform/.seeflow/details/api-gateway.md +14 -0
  8. package/examples/ecommerce-platform/.seeflow/details/auth-service.md +9 -0
  9. package/examples/ecommerce-platform/.seeflow/details/cart-service.md +10 -0
  10. package/examples/ecommerce-platform/.seeflow/details/notification-service.md +13 -0
  11. package/examples/ecommerce-platform/.seeflow/details/order-service.md +16 -0
  12. package/examples/ecommerce-platform/.seeflow/details/payment-service.md +16 -0
  13. package/examples/ecommerce-platform/.seeflow/details/product-service.md +10 -0
  14. package/examples/ecommerce-platform/.seeflow/style.json +85 -0
  15. package/examples/order-pipeline/.seeflow/architecture.json +93 -0
  16. package/examples/order-pipeline/.seeflow/details/fulfillment-service.md +21 -0
  17. package/examples/order-pipeline/.seeflow/details/inventory-service.md +23 -0
  18. package/examples/order-pipeline/.seeflow/details/payment-service.md +23 -0
  19. package/examples/order-pipeline/.seeflow/details/post-orders.md +19 -0
  20. package/examples/order-pipeline/.seeflow/scripts/play.ts +2 -2
  21. package/examples/order-pipeline/.seeflow/style.json +42 -0
  22. package/package.json +1 -1
  23. package/src/api.ts +118 -118
  24. package/src/cli.ts +13 -13
  25. package/src/demo.ts +6 -6
  26. package/src/diagram.ts +4 -4
  27. package/src/events.ts +14 -14
  28. package/src/file-ref.ts +79 -0
  29. package/src/mcp.ts +117 -89
  30. package/src/merge.ts +190 -0
  31. package/src/operations.ts +415 -416
  32. package/src/proxy.ts +31 -31
  33. package/src/registry.ts +32 -20
  34. package/src/schema.ts +252 -8
  35. package/src/sdk-template.ts +2 -2
  36. package/src/sdk-writer.ts +2 -2
  37. package/src/server.ts +2 -2
  38. package/src/status-runner.ts +34 -38
  39. package/src/watcher.ts +165 -114
  40. package/dist/web/assets/index-Dwa7Bp5j.css +0 -1
  41. package/examples/order-pipeline/.seeflow/seeflow.json +0 -123
package/src/operations.ts CHANGED
@@ -7,28 +7,38 @@
7
7
  // Helpers extracted in US-003: node lifecycle (add/delete/move/reorder).
8
8
  // Future stories add patch_node + connector helpers alongside these.
9
9
 
10
- import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ renameSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
11
19
  import { dirname, isAbsolute, join } from 'node:path';
12
20
  import { type ZodIssue, z } from 'zod';
21
+ import { mergeArchitectureAndStyle, splitFlow } from './merge.ts';
13
22
  import { seeflowHome } from './paths.ts';
14
23
  import { type Registry, slugify } from './registry.ts';
15
24
  import {
16
25
  ColorTokenSchema,
17
- type Demo,
18
- DemoSchema,
19
26
  EdgePinSchema,
27
+ type Flow,
28
+ FlowSchema,
20
29
  SourceHandleIdSchema,
21
30
  TargetHandleIdSchema,
22
31
  } from './schema.ts';
32
+ import { ArchitectureSchema, StyleSchema } from './schema.ts';
23
33
  import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
24
- import type { DemoSnapshot, DemoWatcher } from './watcher.ts';
34
+ import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
25
35
 
26
- const DEFAULT_DEMO_RELATIVE_PATH = '.seeflow/seeflow.json';
36
+ const DEFAULT_ARCHITECTURE_RELATIVE_PATH = '.seeflow/architecture.json';
27
37
 
28
38
  export const RegisterBodySchema = z.object({
29
39
  name: z.string().min(1).optional(),
30
40
  repoPath: z.string().min(1),
31
- demoPath: z.string().min(1),
41
+ architecturePath: z.string().min(1),
32
42
  });
33
43
  export type RegisterBody = z.infer<typeof RegisterBodySchema>;
34
44
 
@@ -58,7 +68,7 @@ export type ReorderBody = z.infer<typeof ReorderBodySchema>;
58
68
 
59
69
  // Partial node update body. Top-level `position` lands on node.position; every
60
70
  // other key lands inside node.data. Final validity is enforced by re-parsing
61
- // the whole demo through DemoSchema after the merge — this body schema just
71
+ // the whole demo through FlowSchema after the merge — this body schema just
62
72
  // rejects unknown top-level keys to catch typos.
63
73
  export const NodePatchBodySchema = z
64
74
  .object({
@@ -79,7 +89,7 @@ export const NodePatchBodySchema = z
79
89
  // that autoSize:true never coexists with persisted width/height.
80
90
  autoSize: z.boolean().optional(),
81
91
  shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
82
- // iconNode-only: stroke color token. Lands at data.color; DemoSchema's
92
+ // iconNode-only: stroke color token. Lands at data.color; FlowSchema's
83
93
  // post-merge reparse gates that this is only valid on an iconNode.
84
94
  color: ColorTokenSchema.optional(),
85
95
  // iconNode-only: glyph stroke width. Lands at data.strokeWidth; the
@@ -204,7 +214,7 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
204
214
 
205
215
  export interface OperationsDeps {
206
216
  registry: Registry;
207
- watcher?: DemoWatcher;
217
+ watcher?: FlowWatcher;
208
218
  /**
209
219
  * Override the base directory for new projects. Defaults to seeflowHome()
210
220
  * — `${SEEFLOW_WORKSPACE}/.seeflow` inside Docker, `~/.seeflow` locally.
@@ -213,7 +223,7 @@ export interface OperationsDeps {
213
223
  projectBaseDir?: string;
214
224
  }
215
225
 
216
- export interface DemoListItem {
226
+ export interface FlowListItem {
217
227
  id: string;
218
228
  slug: string;
219
229
  name: string;
@@ -222,17 +232,17 @@ export interface DemoListItem {
222
232
  valid: boolean;
223
233
  }
224
234
 
225
- export interface DemoGetResponse {
235
+ export interface FlowGetResponse {
226
236
  id: string;
227
237
  slug: string;
228
238
  name: string;
229
239
  filePath: string;
230
- demo: Demo | null;
240
+ flow: Flow | null;
231
241
  valid: boolean;
232
242
  error: string | null;
233
243
  }
234
244
 
235
- export interface RegisterDemoSuccess {
245
+ export interface RegisterFlowSuccess {
236
246
  id: string;
237
247
  slug: string;
238
248
  sdk: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
@@ -244,21 +254,21 @@ export interface CreateProjectSuccess {
244
254
  scaffolded: boolean;
245
255
  }
246
256
 
247
- export type ListDemosOutcome = { kind: 'ok'; data: DemoListItem[] };
257
+ export type ListFlowsOutcome = { kind: 'ok'; data: FlowListItem[] };
248
258
 
249
- export type GetDemoOutcome =
250
- | { kind: 'ok'; data: DemoGetResponse }
259
+ export type GetFlowOutcome =
260
+ | { kind: 'ok'; data: FlowGetResponse }
251
261
  | { kind: 'notFound' }
252
262
  | { kind: 'fileNotFound'; path: string };
253
263
 
254
- export type RegisterDemoOutcome =
255
- | { kind: 'ok'; data: RegisterDemoSuccess }
264
+ export type RegisterFlowOutcome =
265
+ | { kind: 'ok'; data: RegisterFlowSuccess }
256
266
  | { kind: 'fileNotFound'; path: string }
257
267
  | { kind: 'badJson'; detail: string }
258
268
  | { kind: 'badSchema'; issues: ZodIssue[] }
259
269
  | { kind: 'sdkWriteFailed'; id: string; slug: string; message: string };
260
270
 
261
- export type DeleteDemoOutcome = { kind: 'ok' } | { kind: 'notFound' };
271
+ export type DeleteFlowOutcome = { kind: 'ok' } | { kind: 'notFound' };
262
272
 
263
273
  export type CreateProjectOutcome =
264
274
  | { kind: 'ok'; data: CreateProjectSuccess }
@@ -272,7 +282,7 @@ export type CreateProjectOutcome =
272
282
  // same status code + JSON body it used to emit directly.
273
283
  export type AddNodeOutcome =
274
284
  | { kind: 'ok'; data: { id: string; node: Record<string, unknown> } }
275
- | { kind: 'demoNotFound' }
285
+ | { kind: 'flowNotFound' }
276
286
  | { kind: 'fileNotFound'; path: string }
277
287
  | { kind: 'badJson'; message: string }
278
288
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -280,7 +290,7 @@ export type AddNodeOutcome =
280
290
 
281
291
  export type DeleteNodeOutcome =
282
292
  | { kind: 'ok' }
283
- | { kind: 'demoNotFound' }
293
+ | { kind: 'flowNotFound' }
284
294
  | { kind: 'fileNotFound'; path: string }
285
295
  | { kind: 'badJson'; message: string }
286
296
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -289,7 +299,7 @@ export type DeleteNodeOutcome =
289
299
 
290
300
  export type MoveNodeOutcome =
291
301
  | { kind: 'ok'; data: { position: PositionBody } }
292
- | { kind: 'demoNotFound' }
302
+ | { kind: 'flowNotFound' }
293
303
  | { kind: 'fileNotFound'; path: string }
294
304
  | { kind: 'badJson'; message: string }
295
305
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -298,7 +308,7 @@ export type MoveNodeOutcome =
298
308
 
299
309
  export type ReorderNodeOutcome =
300
310
  | { kind: 'ok' }
301
- | { kind: 'demoNotFound' }
311
+ | { kind: 'flowNotFound' }
302
312
  | { kind: 'fileNotFound'; path: string }
303
313
  | { kind: 'badJson'; message: string }
304
314
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -307,7 +317,7 @@ export type ReorderNodeOutcome =
307
317
 
308
318
  export type PatchNodeOutcome =
309
319
  | { kind: 'ok' }
310
- | { kind: 'demoNotFound' }
320
+ | { kind: 'flowNotFound' }
311
321
  | { kind: 'fileNotFound'; path: string }
312
322
  | { kind: 'badJson'; message: string }
313
323
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -316,7 +326,7 @@ export type PatchNodeOutcome =
316
326
 
317
327
  // Partial connector update body. Strict at the top level so client typos
318
328
  // surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
319
- // are enforced post-merge by re-parsing the whole demo through DemoSchema.
329
+ // are enforced post-merge by re-parsing the whole demo through FlowSchema.
320
330
  const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
321
331
  export const ConnectorPatchBodySchema = z
322
332
  .object({
@@ -334,7 +344,7 @@ export const ConnectorPatchBodySchema = z
334
344
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
335
345
  url: z.string().optional(),
336
346
  // Reconnect: drag an edge endpoint onto another node's handle. The
337
- // post-merge DemoSchema parse rejects dangling references, so we don't
347
+ // post-merge FlowSchema parse rejects dangling references, so we don't
338
348
  // need a referential check here.
339
349
  source: z.string().min(1).optional(),
340
350
  target: z.string().min(1).optional(),
@@ -368,7 +378,7 @@ export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
368
378
  // Kind-specific connector fields. When `kind` changes via PATCH, these are
369
379
  // dropped first so the resulting connector doesn't carry phantom payloads
370
380
  // from the previous kind (e.g. an event→default change leaving eventName
371
- // behind, which DemoSchema would silently strip on parse but leave on disk).
381
+ // behind, which FlowSchema would silently strip on parse but leave on disk).
372
382
  const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
373
383
 
374
384
  export const mergeConnectorUpdates = (
@@ -395,7 +405,7 @@ export const mergeConnectorUpdates = (
395
405
 
396
406
  export type AddConnectorOutcome =
397
407
  | { kind: 'ok'; data: { id: string } }
398
- | { kind: 'demoNotFound' }
408
+ | { kind: 'flowNotFound' }
399
409
  | { kind: 'fileNotFound'; path: string }
400
410
  | { kind: 'badJson'; message: string }
401
411
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -403,7 +413,7 @@ export type AddConnectorOutcome =
403
413
 
404
414
  export type PatchConnectorOutcome =
405
415
  | { kind: 'ok' }
406
- | { kind: 'demoNotFound' }
416
+ | { kind: 'flowNotFound' }
407
417
  | { kind: 'fileNotFound'; path: string }
408
418
  | { kind: 'badJson'; message: string }
409
419
  | { kind: 'badSchema'; issues: ZodIssue[] }
@@ -412,28 +422,28 @@ export type PatchConnectorOutcome =
412
422
 
413
423
  export type DeleteConnectorOutcome =
414
424
  | { kind: 'ok' }
415
- | { kind: 'demoNotFound' }
425
+ | { kind: 'flowNotFound' }
416
426
  | { kind: 'fileNotFound'; path: string }
417
427
  | { kind: 'badJson'; message: string }
418
428
  | { kind: 'badSchema'; issues: ZodIssue[] }
419
429
  | { kind: 'unknownConnector' }
420
430
  | { kind: 'writeFailed'; message: string };
421
431
 
422
- export const resolveDemoPath = (repoPath: string, demoPath: string): string =>
423
- isAbsolute(demoPath) ? demoPath : join(repoPath, demoPath);
432
+ export const resolveFilePath = (repoPath: string, architecturePath: string): string =>
433
+ isAbsolute(architecturePath) ? architecturePath : join(repoPath, architecturePath);
424
434
 
425
435
  // Per-demo serialization: read-modify-write of the demo file isn't atomic
426
436
  // across multiple PATCHes, so two concurrent drags would race (later writer's
427
437
  // older read clobbers the earlier writer's update). We chain writes per
428
- // demoId so the read+write sequence is effectively serialized.
429
- const demoWriteChains = new Map<string, Promise<unknown>>();
430
- export const withDemoWriteLock = <T>(demoId: string, fn: () => Promise<T>): Promise<T> => {
431
- const prev = demoWriteChains.get(demoId) ?? Promise.resolve();
438
+ // flowId so the read+write sequence is effectively serialized.
439
+ const flowWriteChains = new Map<string, Promise<unknown>>();
440
+ export const withFlowWriteLock = <T>(flowId: string, fn: () => Promise<T>): Promise<T> => {
441
+ const prev = flowWriteChains.get(flowId) ?? Promise.resolve();
432
442
  const next = prev.then(fn, fn);
433
443
  // Replace with a tail that swallows errors so the chain keeps moving even
434
444
  // if one write fails — but the original promise still rejects to its caller.
435
- demoWriteChains.set(
436
- demoId,
445
+ flowWriteChains.set(
446
+ flowId,
437
447
  next.catch(() => undefined),
438
448
  );
439
449
  return next as Promise<T>;
@@ -461,6 +471,127 @@ export const writeFileAtomic = (filePath: string, content: string): void => {
461
471
  }
462
472
  };
463
473
 
474
+ /**
475
+ * Read architecture.json + optional style.json, return the raw parsed JSON
476
+ * so operations can mutate the merged-flow shape without losing forward-compat
477
+ * fields. Returns null if the architecture file is missing or invalid JSON.
478
+ */
479
+ type ReadRawResult =
480
+ | { kind: 'ok'; rawArch: Record<string, unknown>; rawStyle: Record<string, unknown> }
481
+ | { kind: 'badJson'; message: string };
482
+
483
+ function readRawArchAndStyle(archPath: string): ReadRawResult {
484
+ let rawArch: unknown;
485
+ try {
486
+ rawArch = JSON.parse(readFileSync(archPath, 'utf8'));
487
+ } catch (err) {
488
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
489
+ }
490
+ if (!rawArch || typeof rawArch !== 'object' || Array.isArray(rawArch)) {
491
+ return { kind: 'badJson', message: 'architecture.json is not an object' };
492
+ }
493
+ const stylePath = join(dirname(archPath), 'style.json');
494
+ let rawStyle: Record<string, unknown> = {};
495
+ if (existsSync(stylePath)) {
496
+ try {
497
+ const parsed = JSON.parse(readFileSync(stylePath, 'utf8'));
498
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
499
+ return { kind: 'badJson', message: 'style.json is not an object' };
500
+ }
501
+ rawStyle = parsed as Record<string, unknown>;
502
+ } catch (err) {
503
+ return {
504
+ kind: 'badJson',
505
+ message: `style.json: ${err instanceof Error ? err.message : String(err)}`,
506
+ };
507
+ }
508
+ }
509
+ return { kind: 'ok', rawArch: rawArch as Record<string, unknown>, rawStyle };
510
+ }
511
+
512
+ /**
513
+ * Mutate-in-place helper: read both files into a merged Flow shape, hand it
514
+ * to the mutator, then split back into architecture + style and atomically
515
+ * write both. The mutator returns either { kind: 'ok' } or a discriminated
516
+ * outcome for early-exits (e.g. unknownNode). On schema-validation failure,
517
+ * neither file is written.
518
+ */
519
+ type MutateMergedFlowMutator<E> = (flow: {
520
+ version: number;
521
+ name: string;
522
+ resetAction?: unknown;
523
+ nodes: Array<Record<string, unknown>>;
524
+ connectors: Array<Record<string, unknown>>;
525
+ }) => { kind: 'ok' } | E;
526
+
527
+ type MutateMergedFlowResult<E> =
528
+ | { kind: 'ok' }
529
+ | { kind: 'badJson'; message: string }
530
+ | { kind: 'badSchema'; issues: ZodIssue[] }
531
+ | { kind: 'writeFailed'; message: string }
532
+ | E;
533
+
534
+ export async function mutateMergedFlow<E extends { kind: string }>(
535
+ archPath: string,
536
+ mutator: MutateMergedFlowMutator<E>,
537
+ ): Promise<MutateMergedFlowResult<E>> {
538
+ const read = readRawArchAndStyle(archPath);
539
+ if (read.kind === 'badJson') return { kind: 'badJson', message: read.message };
540
+
541
+ const archParse = ArchitectureSchema.safeParse(read.rawArch);
542
+ if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
543
+ const styleParse = StyleSchema.safeParse(read.rawStyle);
544
+ if (!styleParse.success) return { kind: 'badSchema', issues: styleParse.error.issues };
545
+
546
+ const merged = mergeArchitectureAndStyle(archParse.data, styleParse.data) as unknown as {
547
+ version: number;
548
+ name: string;
549
+ resetAction?: unknown;
550
+ nodes: Array<Record<string, unknown>>;
551
+ connectors: Array<Record<string, unknown>>;
552
+ };
553
+
554
+ const outcome = mutator(merged);
555
+ if (outcome.kind !== 'ok') return outcome;
556
+
557
+ // Final FlowSchema parse so per-kind invariants (event needs eventName, etc.)
558
+ // surface honestly instead of being silently papered over.
559
+ const finalParse = FlowSchema.safeParse(merged);
560
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
561
+
562
+ const { architecture, style } = splitFlow(merged);
563
+ // Re-validate the post-split files to catch the rare case where a forward-
564
+ // compat field landed in the wrong bucket. Style validation is a no-op for
565
+ // empty maps; architecture revalidation rejects unknown keys via strict().
566
+ const archCheck = ArchitectureSchema.safeParse(architecture);
567
+ if (!archCheck.success) return { kind: 'badSchema', issues: archCheck.error.issues };
568
+ const styleCheck = StyleSchema.safeParse(style);
569
+ if (!styleCheck.success) return { kind: 'badSchema', issues: styleCheck.error.issues };
570
+
571
+ const stylePath = join(dirname(archPath), 'style.json');
572
+ const styleIsEmpty =
573
+ (!style.nodes || Object.keys(style.nodes as Record<string, unknown>).length === 0) &&
574
+ (!style.connectors || Object.keys(style.connectors as Record<string, unknown>).length === 0);
575
+
576
+ try {
577
+ writeFileAtomic(archPath, `${JSON.stringify(architecture, null, 2)}\n`);
578
+ } catch (err) {
579
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
580
+ }
581
+
582
+ try {
583
+ if (styleIsEmpty) {
584
+ if (existsSync(stylePath)) unlinkSync(stylePath);
585
+ } else {
586
+ writeFileAtomic(stylePath, `${JSON.stringify(style, null, 2)}\n`);
587
+ }
588
+ } catch (err) {
589
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
590
+ }
591
+
592
+ return { kind: 'ok' };
593
+ }
594
+
464
595
  export const reorderNodes = (
465
596
  nodes: Array<Record<string, unknown>>,
466
597
  fromIdx: number,
@@ -511,9 +642,9 @@ export const reorderNodes = (
511
642
  }
512
643
  };
513
644
 
514
- export function listDemosImpl(deps: OperationsDeps): ListDemosOutcome {
645
+ export function listDemosImpl(deps: OperationsDeps): ListFlowsOutcome {
515
646
  const data = deps.registry.list().map((e) => {
516
- const fullPath = resolveDemoPath(e.repoPath, e.demoPath);
647
+ const fullPath = resolveFilePath(e.repoPath, e.architecturePath);
517
648
  const fileExists = existsSync(fullPath);
518
649
  return {
519
650
  id: e.id,
@@ -527,20 +658,20 @@ export function listDemosImpl(deps: OperationsDeps): ListDemosOutcome {
527
658
  return { kind: 'ok', data };
528
659
  }
529
660
 
530
- export async function getDemoImpl(deps: OperationsDeps, demoId: string): Promise<GetDemoOutcome> {
661
+ export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise<GetFlowOutcome> {
531
662
  const { registry, watcher } = deps;
532
- const entry = registry.getById(demoId);
663
+ const entry = registry.getById(flowId);
533
664
  if (!entry) return { kind: 'notFound' };
534
665
 
535
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
536
- const snap = watcher?.snapshot(demoId) ?? watcher?.reparse(demoId) ?? null;
666
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
667
+ const snap = watcher?.snapshot(flowId) ?? watcher?.reparse(flowId) ?? null;
537
668
 
538
- const buildResponse = (s: DemoSnapshot): DemoGetResponse => ({
669
+ const buildResponse = (s: FlowSnapshot): FlowGetResponse => ({
539
670
  id: entry.id,
540
671
  slug: entry.slug,
541
672
  name: entry.name,
542
673
  filePath: fullPath,
543
- demo: s.demo,
674
+ flow: s.flow,
544
675
  valid: s.valid,
545
676
  error: s.valid ? null : s.error,
546
677
  });
@@ -551,75 +682,53 @@ export async function getDemoImpl(deps: OperationsDeps, demoId: string): Promise
551
682
  // callers without a long-lived watcher still get a current snapshot.
552
683
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
553
684
 
554
- let raw: unknown;
555
- try {
556
- raw = await Bun.file(fullPath).json();
557
- } catch (err) {
558
- return {
559
- kind: 'ok',
560
- data: buildResponse({
561
- demo: null,
562
- valid: false,
563
- error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
564
- filePath: fullPath,
565
- parsedAt: Date.now(),
566
- }),
567
- };
568
- }
569
- const parsed = DemoSchema.safeParse(raw);
570
- if (!parsed.success) {
571
- return {
572
- kind: 'ok',
573
- data: buildResponse({
574
- demo: null,
575
- valid: false,
576
- error: parsed.error.issues
577
- .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
578
- .join('; '),
579
- filePath: fullPath,
580
- parsedAt: Date.now(),
581
- }),
582
- };
583
- }
685
+ const result = readMergedFlow(fullPath);
584
686
  return {
585
687
  kind: 'ok',
586
688
  data: buildResponse({
587
- demo: parsed.data,
588
- valid: true,
589
- error: null,
689
+ flow: result.flow,
690
+ valid: result.valid,
691
+ error: result.error,
590
692
  filePath: fullPath,
591
693
  parsedAt: Date.now(),
592
694
  }),
593
695
  };
594
696
  }
595
697
 
596
- export async function registerDemoImpl(
698
+ export async function registerFlowImpl(
597
699
  deps: OperationsDeps,
598
700
  body: RegisterBody,
599
- ): Promise<RegisterDemoOutcome> {
701
+ ): Promise<RegisterFlowOutcome> {
600
702
  const { registry, watcher } = deps;
601
- const { repoPath, demoPath } = body;
602
- const fullPath = resolveDemoPath(repoPath, demoPath);
703
+ const { repoPath, architecturePath } = body;
704
+ const fullPath = resolveFilePath(repoPath, architecturePath);
603
705
 
604
706
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
605
707
 
606
- let demo: unknown;
607
- try {
608
- demo = await Bun.file(fullPath).json();
609
- } catch (err) {
610
- // REST uses String(err) here (preserves "SyntaxError: ..." prefix) —
611
- // keep byte-identical so api.test.ts assertions stay green.
612
- return { kind: 'badJson', detail: String(err) };
708
+ const merged = readMergedFlow(fullPath);
709
+ if (merged.error && merged.flow === null) {
710
+ if (merged.error.startsWith('Invalid JSON')) {
711
+ return { kind: 'badJson', detail: merged.error };
712
+ }
713
+ // Schema validation failed surface the issues as a bad-schema outcome
714
+ // by re-running parse to get ZodIssue[].
715
+ let rawArch: unknown;
716
+ try {
717
+ rawArch = JSON.parse(readFileSync(fullPath, 'utf8'));
718
+ } catch (err) {
719
+ return { kind: 'badJson', detail: String(err) };
720
+ }
721
+ const archParse = ArchitectureSchema.safeParse(rawArch);
722
+ if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
723
+ return { kind: 'badJson', detail: merged.error };
613
724
  }
614
-
615
- const demoParse = DemoSchema.safeParse(demo);
616
- if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
725
+ if (!merged.flow) return { kind: 'badJson', detail: merged.error ?? 'unknown error' };
617
726
 
618
727
  const lastModified = statSync(fullPath).mtimeMs;
619
728
  const entry = registry.upsert({
620
- name: body.name ?? demoParse.data.name,
729
+ name: body.name ?? merged.flow.name,
621
730
  repoPath,
622
- demoPath,
731
+ architecturePath,
623
732
  valid: true,
624
733
  lastModified,
625
734
  });
@@ -628,7 +737,7 @@ export async function registerDemoImpl(
628
737
 
629
738
  let sdkResult: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
630
739
  try {
631
- sdkResult = writeSdkEmitIfNeeded(repoPath, demoParse.data);
740
+ sdkResult = writeSdkEmitIfNeeded(repoPath, merged.flow);
632
741
  } catch (err) {
633
742
  const message = err instanceof Error ? err.message : String(err);
634
743
  return { kind: 'sdkWriteFailed', id: entry.id, slug: entry.slug, message };
@@ -644,7 +753,7 @@ export async function registerDemoImpl(
644
753
  };
645
754
  }
646
755
 
647
- export function deleteDemoImpl(deps: OperationsDeps, idOrSlug: string): DeleteDemoOutcome {
756
+ export function deleteFlowImpl(deps: OperationsDeps, idOrSlug: string): DeleteFlowOutcome {
648
757
  const { registry, watcher } = deps;
649
758
  const entry = registry.getById(idOrSlug) ?? registry.getBySlug(idOrSlug);
650
759
  if (!entry) return { kind: 'notFound' };
@@ -662,7 +771,7 @@ export async function createProjectImpl(
662
771
  const baseDir = deps.projectBaseDir ?? seeflowHome();
663
772
  const folderPath = join(baseDir, slugify(name));
664
773
 
665
- const demoFullPath = join(folderPath, DEFAULT_DEMO_RELATIVE_PATH);
774
+ const demoFullPath = join(folderPath, DEFAULT_ARCHITECTURE_RELATIVE_PATH);
666
775
 
667
776
  if (existsSync(demoFullPath)) {
668
777
  let raw: unknown;
@@ -671,14 +780,14 @@ export async function createProjectImpl(
671
780
  } catch (err) {
672
781
  return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
673
782
  }
674
- const demoParse = DemoSchema.safeParse(raw);
675
- if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
783
+ const archParse = ArchitectureSchema.safeParse(raw);
784
+ if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
676
785
 
677
786
  const lastModified = statSync(demoFullPath).mtimeMs;
678
787
  const entry = registry.upsert({
679
788
  name,
680
789
  repoPath: folderPath,
681
- demoPath: DEFAULT_DEMO_RELATIVE_PATH,
790
+ architecturePath: DEFAULT_ARCHITECTURE_RELATIVE_PATH,
682
791
  valid: true,
683
792
  lastModified,
684
793
  });
@@ -686,7 +795,8 @@ export async function createProjectImpl(
686
795
  return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
687
796
  }
688
797
 
689
- const scaffold: Demo = { version: 1, name, nodes: [], connectors: [] };
798
+ // Architecture-only scaffold: empty nodes/connectors, no style.json needed.
799
+ const scaffold: Flow = { version: 2, name, nodes: [], connectors: [] };
690
800
 
691
801
  try {
692
802
  mkdirSync(join(folderPath, '.seeflow'), { recursive: true });
@@ -708,7 +818,7 @@ export async function createProjectImpl(
708
818
  const entry = registry.upsert({
709
819
  name,
710
820
  repoPath: folderPath,
711
- demoPath: DEFAULT_DEMO_RELATIVE_PATH,
821
+ architecturePath: DEFAULT_ARCHITECTURE_RELATIVE_PATH,
712
822
  valid: true,
713
823
  lastModified,
714
824
  });
@@ -716,22 +826,27 @@ export async function createProjectImpl(
716
826
  return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
717
827
  }
718
828
 
719
- // Append a new node to the demo. Auto-generates an id when absent; DemoSchema
829
+ // Append a new node to the demo. Auto-generates an id when absent; FlowSchema
720
830
  // is re-run on the post-mutation raw object before commit so a malformed
721
831
  // payload never produces a half-written file.
722
832
  export async function addNodeImpl(
723
833
  deps: OperationsDeps,
724
- demoId: string,
834
+ flowId: string,
725
835
  nodeBody: Record<string, unknown>,
726
836
  ): Promise<AddNodeOutcome> {
727
- const entry = deps.registry.getById(demoId);
728
- if (!entry) return { kind: 'demoNotFound' };
837
+ const entry = deps.registry.getById(flowId);
838
+ if (!entry) return { kind: 'flowNotFound' };
729
839
 
730
840
  const newNode = { ...nodeBody };
731
841
  if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
732
842
  newNode.id = `node-${crypto.randomUUID()}`;
733
843
  }
734
844
  const newId = newNode.id as string;
845
+ // Default position so the post-merge FlowSchema parse passes. Position lives
846
+ // on style.json after the split — callers who care set it explicitly.
847
+ if (!newNode.position || typeof newNode.position !== 'object') {
848
+ newNode.position = { x: 0, y: 0 };
849
+ }
735
850
 
736
851
  // US-015: for htmlNode without a client-supplied htmlPath, allocate the
737
852
  // studio-managed `blocks/<id>.html` path and queue a starter-file write.
@@ -761,47 +876,23 @@ export async function addNodeImpl(
761
876
  }
762
877
  }
763
878
 
764
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
879
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
765
880
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
766
881
 
767
- type Inner =
768
- | { kind: 'ok' }
769
- | { kind: 'badJson'; message: string }
770
- | { kind: 'badSchema'; issues: ZodIssue[] }
771
- | { kind: 'writeFailed'; message: string };
772
-
773
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
774
- let raw: unknown;
775
- try {
776
- raw = await Bun.file(fullPath).json();
777
- } catch (err) {
778
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
779
- }
780
- const demoParsed = DemoSchema.safeParse(raw);
781
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
782
-
783
- const obj = raw as { nodes: Array<Record<string, unknown>> };
784
- obj.nodes.push(newNode);
785
-
786
- const finalParse = DemoSchema.safeParse(raw);
787
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
788
-
789
- if (starterFile) {
790
- try {
791
- mkdirSync(dirname(starterFile.absPath), { recursive: true });
792
- writeFileAtomic(starterFile.absPath, starterFile.content);
793
- } catch (err) {
794
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
882
+ const result = await withFlowWriteLock(flowId, () =>
883
+ mutateMergedFlow<{ kind: 'writeFailed'; message: string }>(fullPath, (flow) => {
884
+ flow.nodes.push(newNode);
885
+ if (starterFile) {
886
+ try {
887
+ mkdirSync(dirname(starterFile.absPath), { recursive: true });
888
+ writeFileAtomic(starterFile.absPath, starterFile.content);
889
+ } catch (err) {
890
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
891
+ }
795
892
  }
796
- }
797
-
798
- try {
799
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
800
- } catch (err) {
801
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
802
- }
803
- return { kind: 'ok' };
804
- });
893
+ return { kind: 'ok' };
894
+ }),
895
+ );
805
896
 
806
897
  if (result.kind === 'ok') return { kind: 'ok', data: { id: newId, node: newNode } };
807
898
  return result;
@@ -821,7 +912,7 @@ const buildHtmlNodeStarter = (nodeId: string): string =>
821
912
  `;
822
913
 
823
914
  // Remove a node and cascade-delete every connector touching it in a single
824
- // atomic write. Final DemoSchema parse stays in place so a pre-existing
915
+ // atomic write. Final FlowSchema parse stays in place so a pre-existing
825
916
  // schema violation surfaces honestly instead of being silently papered over.
826
917
  // US-016: when the removed node is an htmlNode whose data.htmlPath matches the
827
918
  // studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
@@ -829,68 +920,44 @@ const buildHtmlNodeStarter = (nodeId: string): string =>
829
920
  // with US-015's "client-supplied htmlPath wins, no starter file written").
830
921
  export async function deleteNodeImpl(
831
922
  deps: OperationsDeps,
832
- demoId: string,
923
+ flowId: string,
833
924
  nodeId: string,
834
925
  ): Promise<DeleteNodeOutcome> {
835
- const entry = deps.registry.getById(demoId);
836
- if (!entry) return { kind: 'demoNotFound' };
926
+ const entry = deps.registry.getById(flowId);
927
+ if (!entry) return { kind: 'flowNotFound' };
837
928
 
838
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
929
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
839
930
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
840
931
 
841
- type Inner =
842
- | { kind: 'ok'; managedHtmlAbsPath?: string }
843
- | { kind: 'badJson'; message: string }
844
- | { kind: 'badSchema'; issues: ZodIssue[] }
845
- | { kind: 'unknownNode' }
846
- | { kind: 'writeFailed'; message: string };
847
-
848
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
849
- let raw: unknown;
850
- try {
851
- raw = await Bun.file(fullPath).json();
852
- } catch (err) {
853
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
854
- }
855
- const demoParsed = DemoSchema.safeParse(raw);
856
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
857
-
858
- const obj = raw as {
859
- nodes: Array<Record<string, unknown>>;
860
- connectors: Array<{ source: string; target: string }>;
861
- };
862
- const idx = obj.nodes.findIndex((n) => n.id === nodeId);
863
- if (idx < 0) return { kind: 'unknownNode' };
864
- const removed = obj.nodes[idx];
865
- const managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
866
- obj.nodes.splice(idx, 1);
867
- obj.connectors = obj.connectors.filter((cn) => cn.source !== nodeId && cn.target !== nodeId);
868
-
869
- const finalParse = DemoSchema.safeParse(raw);
870
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
871
-
872
- try {
873
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
874
- } catch (err) {
875
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
876
- }
877
- return managedHtmlAbsPath ? { kind: 'ok', managedHtmlAbsPath } : { kind: 'ok' };
878
- });
932
+ let managedHtmlAbsPath: string | undefined;
933
+
934
+ const result = await withFlowWriteLock(flowId, () =>
935
+ mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
936
+ const idx = flow.nodes.findIndex((n) => n.id === nodeId);
937
+ if (idx < 0) return { kind: 'unknownNode' };
938
+ const removed = flow.nodes[idx];
939
+ managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
940
+ flow.nodes.splice(idx, 1);
941
+ flow.connectors = flow.connectors.filter(
942
+ (cn) => cn.source !== nodeId && cn.target !== nodeId,
943
+ );
944
+ return { kind: 'ok' };
945
+ }),
946
+ );
879
947
 
880
- if (result.kind === 'ok' && result.managedHtmlAbsPath) {
948
+ if (result.kind === 'ok' && managedHtmlAbsPath) {
881
949
  try {
882
- unlinkSync(result.managedHtmlAbsPath);
950
+ unlinkSync(managedHtmlAbsPath);
883
951
  } catch (err) {
884
952
  const code = (err as NodeJS.ErrnoException | undefined)?.code;
885
953
  if (code !== 'ENOENT') {
886
954
  console.warn(
887
- `[operations] failed to remove managed htmlNode file ${result.managedHtmlAbsPath}: ${
955
+ `[operations] failed to remove managed htmlNode file ${managedHtmlAbsPath}: ${
888
956
  err instanceof Error ? err.message : String(err)
889
957
  }`,
890
958
  );
891
959
  }
892
960
  }
893
- return { kind: 'ok' };
894
961
  }
895
962
 
896
963
  return result;
@@ -918,52 +985,29 @@ const managedHtmlNodePath = (
918
985
  // schema doesn't yet recognize survive the round-trip untouched.
919
986
  export async function moveNodeImpl(
920
987
  deps: OperationsDeps,
921
- demoId: string,
988
+ flowId: string,
922
989
  nodeId: string,
923
990
  position: PositionBody,
924
991
  ): Promise<MoveNodeOutcome> {
925
- const entry = deps.registry.getById(demoId);
926
- if (!entry) return { kind: 'demoNotFound' };
992
+ const entry = deps.registry.getById(flowId);
993
+ if (!entry) return { kind: 'flowNotFound' };
927
994
 
928
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
995
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
929
996
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
930
997
 
931
- type Inner =
932
- | { kind: 'ok' }
933
- | { kind: 'badJson'; message: string }
934
- | { kind: 'badSchema'; issues: ZodIssue[] }
935
- | { kind: 'unknownNode' }
936
- | { kind: 'writeFailed'; message: string };
937
-
938
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
939
- let raw: unknown;
940
- try {
941
- raw = await Bun.file(fullPath).json();
942
- } catch (err) {
943
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
944
- }
945
- const demoParsed = DemoSchema.safeParse(raw);
946
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
947
-
948
- const obj = raw as {
949
- nodes: Array<{ id: string; position: { x: number; y: number } }>;
950
- };
951
- const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
952
- if (!onDiskNode) return { kind: 'unknownNode' };
953
- onDiskNode.position = { x: position.x, y: position.y };
954
-
955
- try {
956
- writeFileAtomic(fullPath, `${JSON.stringify(obj, null, 2)}\n`);
957
- } catch (err) {
958
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
959
- }
960
- return { kind: 'ok' };
961
- });
998
+ const result = await withFlowWriteLock(flowId, () =>
999
+ mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
1000
+ const node = flow.nodes.find((n) => n.id === nodeId) as
1001
+ | { id: string; position?: { x: number; y: number } }
1002
+ | undefined;
1003
+ if (!node) return { kind: 'unknownNode' };
1004
+ node.position = { x: position.x, y: position.y };
1005
+ return { kind: 'ok' };
1006
+ }),
1007
+ );
962
1008
 
963
1009
  if (result.kind === 'ok') {
964
- // Eagerly refresh snapshot so a subsequent GET /api/demos/:id (e.g. export)
965
- // returns the updated position without waiting for the 100ms FSWatcher debounce.
966
- deps.watcher?.reparse(demoId);
1010
+ deps.watcher?.reparse(flowId);
967
1011
  return { kind: 'ok', data: { position: { x: position.x, y: position.y } } };
968
1012
  }
969
1013
  return result;
@@ -971,55 +1015,28 @@ export async function moveNodeImpl(
971
1015
 
972
1016
  // Apply a partial PATCH body to a single node. Mutation runs against the
973
1017
  // raw parsed JSON (so unknown forward-compat fields survive a round-trip),
974
- // and the whole demo is re-validated through DemoSchema before commit so
1018
+ // and the whole demo is re-validated through FlowSchema before commit so
975
1019
  // partial writes can't break invariants like the connector→node superRefine.
976
1020
  export async function patchNodeImpl(
977
1021
  deps: OperationsDeps,
978
- demoId: string,
1022
+ flowId: string,
979
1023
  nodeId: string,
980
1024
  updates: NodePatchBody,
981
1025
  ): Promise<PatchNodeOutcome> {
982
- const entry = deps.registry.getById(demoId);
983
- if (!entry) return { kind: 'demoNotFound' };
1026
+ const entry = deps.registry.getById(flowId);
1027
+ if (!entry) return { kind: 'flowNotFound' };
984
1028
 
985
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1029
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
986
1030
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
987
1031
 
988
- type Inner =
989
- | { kind: 'ok' }
990
- | { kind: 'badJson'; message: string }
991
- | { kind: 'badSchema'; issues: ZodIssue[] }
992
- | { kind: 'unknownNode' }
993
- | { kind: 'writeFailed'; message: string };
994
-
995
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
996
- let raw: unknown;
997
- try {
998
- raw = await Bun.file(fullPath).json();
999
- } catch (err) {
1000
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1001
- }
1002
- const demoParsed = DemoSchema.safeParse(raw);
1003
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1004
-
1005
- const obj = raw as { nodes: Array<Record<string, unknown>> };
1006
- const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
1007
- if (!onDiskNode) return { kind: 'unknownNode' };
1008
-
1009
- mergeNodeUpdates(onDiskNode, updates);
1010
-
1011
- const finalParse = DemoSchema.safeParse(raw);
1012
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1013
-
1014
- try {
1015
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1016
- } catch (err) {
1017
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1018
- }
1019
- return { kind: 'ok' };
1020
- });
1021
-
1022
- return result;
1032
+ return withFlowWriteLock(flowId, () =>
1033
+ mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
1034
+ const node = flow.nodes.find((n) => n.id === nodeId);
1035
+ if (!node) return { kind: 'unknownNode' };
1036
+ mergeNodeUpdates(node, updates);
1037
+ return { kind: 'ok' };
1038
+ }),
1039
+ );
1023
1040
  }
1024
1041
 
1025
1042
  // Reorder a node within demo.nodes[] (changes paint order in the canvas).
@@ -1027,65 +1044,41 @@ export async function patchNodeImpl(
1027
1044
  // writing so we don't trigger a watcher echo for nothing.
1028
1045
  export async function reorderNodeImpl(
1029
1046
  deps: OperationsDeps,
1030
- demoId: string,
1047
+ flowId: string,
1031
1048
  nodeId: string,
1032
1049
  body: ReorderBody,
1033
1050
  ): Promise<ReorderNodeOutcome> {
1034
- const entry = deps.registry.getById(demoId);
1035
- if (!entry) return { kind: 'demoNotFound' };
1051
+ const entry = deps.registry.getById(flowId);
1052
+ if (!entry) return { kind: 'flowNotFound' };
1036
1053
 
1037
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1054
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1038
1055
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1039
1056
 
1040
- type Inner =
1041
- | { kind: 'ok' }
1042
- | { kind: 'badJson'; message: string }
1043
- | { kind: 'badSchema'; issues: ZodIssue[] }
1044
- | { kind: 'unknownNode' }
1045
- | { kind: 'writeFailed'; message: string };
1046
-
1047
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
1048
- let raw: unknown;
1049
- try {
1050
- raw = await Bun.file(fullPath).json();
1051
- } catch (err) {
1052
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1053
- }
1054
- const demoParsed = DemoSchema.safeParse(raw);
1055
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1056
-
1057
- const obj = raw as { nodes: Array<Record<string, unknown>> };
1058
- const fromIdx = obj.nodes.findIndex((n) => n.id === nodeId);
1059
- if (fromIdx < 0) return { kind: 'unknownNode' };
1060
-
1061
- const moved = reorderNodes(obj.nodes, fromIdx, body);
1062
- if (!moved) return { kind: 'ok' };
1063
-
1064
- const finalParse = DemoSchema.safeParse(raw);
1065
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1066
-
1067
- try {
1068
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1069
- } catch (err) {
1070
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1071
- }
1072
- return { kind: 'ok' };
1073
- });
1057
+ const result = await withFlowWriteLock(flowId, () =>
1058
+ mutateMergedFlow<{ kind: 'unknownNode' } | { kind: 'noop' }>(fullPath, (flow) => {
1059
+ const fromIdx = flow.nodes.findIndex((n) => n.id === nodeId);
1060
+ if (fromIdx < 0) return { kind: 'unknownNode' };
1061
+ const moved = reorderNodes(flow.nodes, fromIdx, body);
1062
+ if (!moved) return { kind: 'noop' };
1063
+ return { kind: 'ok' };
1064
+ }),
1065
+ );
1074
1066
 
1075
- return result;
1067
+ if (result.kind === 'noop') return { kind: 'ok' };
1068
+ return result as ReorderNodeOutcome;
1076
1069
  }
1077
1070
 
1078
1071
  // Append a new connector to demo.connectors. `id` is auto-generated when
1079
1072
  // absent and `kind` defaults to 'default' (the no-semantics user-drawn
1080
- // variant). Source/target referential integrity is enforced by DemoSchema's
1073
+ // variant). Source/target referential integrity is enforced by FlowSchema's
1081
1074
  // superRefine on the post-mutation parse.
1082
1075
  export async function addConnectorImpl(
1083
1076
  deps: OperationsDeps,
1084
- demoId: string,
1077
+ flowId: string,
1085
1078
  connBody: Record<string, unknown>,
1086
1079
  ): Promise<AddConnectorOutcome> {
1087
- const entry = deps.registry.getById(demoId);
1088
- if (!entry) return { kind: 'demoNotFound' };
1080
+ const entry = deps.registry.getById(flowId);
1081
+ if (!entry) return { kind: 'flowNotFound' };
1089
1082
 
1090
1083
  const newConn = { ...connBody };
1091
1084
  if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
@@ -1096,38 +1089,15 @@ export async function addConnectorImpl(
1096
1089
  }
1097
1090
  const newId = newConn.id as string;
1098
1091
 
1099
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1092
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1100
1093
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1101
1094
 
1102
- type Inner =
1103
- | { kind: 'ok' }
1104
- | { kind: 'badJson'; message: string }
1105
- | { kind: 'badSchema'; issues: ZodIssue[] }
1106
- | { kind: 'writeFailed'; message: string };
1107
-
1108
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
1109
- let raw: unknown;
1110
- try {
1111
- raw = await Bun.file(fullPath).json();
1112
- } catch (err) {
1113
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1114
- }
1115
- const demoParsed = DemoSchema.safeParse(raw);
1116
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1117
-
1118
- const obj = raw as { connectors: Array<Record<string, unknown>> };
1119
- obj.connectors.push(newConn);
1120
-
1121
- const finalParse = DemoSchema.safeParse(raw);
1122
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1123
-
1124
- try {
1125
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1126
- } catch (err) {
1127
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1128
- }
1129
- return { kind: 'ok' };
1130
- });
1095
+ const result = await withFlowWriteLock(flowId, () =>
1096
+ mutateMergedFlow<never>(fullPath, (flow) => {
1097
+ flow.connectors.push(newConn);
1098
+ return { kind: 'ok' };
1099
+ }),
1100
+ );
1131
1101
 
1132
1102
  if (result.kind === 'ok') return { kind: 'ok', data: { id: newId } };
1133
1103
  return result;
@@ -1138,105 +1108,134 @@ export async function addConnectorImpl(
1138
1108
  // When `kind` changes, the previous kind's payload fields are dropped first
1139
1109
  // so the connector doesn't carry phantom data; explicit `null` in the patch
1140
1110
  // clears the field on disk (used by reconnect-to-body to drop a pinned
1141
- // handle id). The whole demo is re-validated through DemoSchema before
1111
+ // handle id). The whole demo is re-validated through FlowSchema before
1142
1112
  // commit so the discriminated union catches missing-required-fields
1143
1113
  // (e.g. kind='event' without eventName) and the superRefine gates
1144
1114
  // source/target referential integrity + handle role invariants.
1145
1115
  export async function patchConnectorImpl(
1146
1116
  deps: OperationsDeps,
1147
- demoId: string,
1117
+ flowId: string,
1148
1118
  connectorId: string,
1149
1119
  updates: ConnectorPatchBody,
1150
1120
  ): Promise<PatchConnectorOutcome> {
1151
- const entry = deps.registry.getById(demoId);
1152
- if (!entry) return { kind: 'demoNotFound' };
1121
+ const entry = deps.registry.getById(flowId);
1122
+ if (!entry) return { kind: 'flowNotFound' };
1153
1123
 
1154
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1124
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1155
1125
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1156
1126
 
1157
- type Inner =
1158
- | { kind: 'ok' }
1159
- | { kind: 'badJson'; message: string }
1160
- | { kind: 'badSchema'; issues: ZodIssue[] }
1161
- | { kind: 'unknownConnector' }
1162
- | { kind: 'writeFailed'; message: string };
1163
-
1164
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
1165
- let raw: unknown;
1166
- try {
1167
- raw = await Bun.file(fullPath).json();
1168
- } catch (err) {
1169
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1170
- }
1171
- const demoParsed = DemoSchema.safeParse(raw);
1172
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1173
-
1174
- const obj = raw as { connectors: Array<Record<string, unknown>> };
1175
- const onDiskConn = obj.connectors.find((cn) => cn.id === connectorId);
1176
- if (!onDiskConn) return { kind: 'unknownConnector' };
1177
-
1178
- mergeConnectorUpdates(onDiskConn, updates);
1179
-
1180
- const finalParse = DemoSchema.safeParse(raw);
1181
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1182
-
1183
- try {
1184
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1185
- } catch (err) {
1186
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1187
- }
1188
- return { kind: 'ok' };
1189
- });
1190
-
1191
- return result;
1127
+ return withFlowWriteLock(flowId, () =>
1128
+ mutateMergedFlow<{ kind: 'unknownConnector' }>(fullPath, (flow) => {
1129
+ const conn = flow.connectors.find((cn) => cn.id === connectorId);
1130
+ if (!conn) return { kind: 'unknownConnector' };
1131
+ mergeConnectorUpdates(conn, updates);
1132
+ return { kind: 'ok' };
1133
+ }),
1134
+ );
1192
1135
  }
1193
1136
 
1194
1137
  // Remove a connector by id. No cascade — node deletion is what cascades,
1195
- // not connector deletion. Final DemoSchema parse still runs so a pre-existing
1138
+ // not connector deletion. Final FlowSchema parse still runs so a pre-existing
1196
1139
  // schema violation surfaces honestly instead of being silently papered over.
1197
1140
  export async function deleteConnectorImpl(
1198
1141
  deps: OperationsDeps,
1199
- demoId: string,
1142
+ flowId: string,
1200
1143
  connectorId: string,
1201
1144
  ): Promise<DeleteConnectorOutcome> {
1202
- const entry = deps.registry.getById(demoId);
1203
- if (!entry) return { kind: 'demoNotFound' };
1145
+ const entry = deps.registry.getById(flowId);
1146
+ if (!entry) return { kind: 'flowNotFound' };
1204
1147
 
1205
- const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1148
+ const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1206
1149
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1207
1150
 
1208
- type Inner =
1209
- | { kind: 'ok' }
1210
- | { kind: 'badJson'; message: string }
1211
- | { kind: 'badSchema'; issues: ZodIssue[] }
1212
- | { kind: 'unknownConnector' }
1213
- | { kind: 'writeFailed'; message: string };
1151
+ return withFlowWriteLock(flowId, () =>
1152
+ mutateMergedFlow<{ kind: 'unknownConnector' }>(fullPath, (flow) => {
1153
+ const idx = flow.connectors.findIndex((cn) => cn.id === connectorId);
1154
+ if (idx < 0) return { kind: 'unknownConnector' };
1155
+ flow.connectors.splice(idx, 1);
1156
+ return { kind: 'ok' };
1157
+ }),
1158
+ );
1159
+ }
1160
+
1161
+ // =============================================================================
1162
+ // validateImpl — stateless schema validator. Powers POST /api/validate +
1163
+ // the validate_seeflow MCP tool. Schema-only: no file:// resolution, no
1164
+ // registry side-effects.
1165
+ // =============================================================================
1214
1166
 
1215
- const result = await withDemoWriteLock<Inner>(demoId, async () => {
1216
- let raw: unknown;
1217
- try {
1218
- raw = await Bun.file(fullPath).json();
1219
- } catch (err) {
1220
- return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1221
- }
1222
- const demoParsed = DemoSchema.safeParse(raw);
1223
- if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1167
+ export interface ValidateBody {
1168
+ architecture: unknown;
1169
+ style?: unknown;
1170
+ }
1224
1171
 
1225
- const obj = raw as { connectors: Array<{ id: string }> };
1226
- const idx = obj.connectors.findIndex((cn) => cn.id === connectorId);
1227
- if (idx < 0) return { kind: 'unknownConnector' };
1228
- obj.connectors.splice(idx, 1);
1172
+ export interface ValidationIssue {
1173
+ scope: 'architecture' | 'style' | 'cross';
1174
+ path: (string | number)[];
1175
+ message: string;
1176
+ code: string;
1177
+ }
1229
1178
 
1230
- const finalParse = DemoSchema.safeParse(raw);
1231
- if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1179
+ export type ValidateOutcome = { ok: true } | { ok: false; issues: ValidationIssue[] };
1232
1180
 
1233
- try {
1234
- writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1235
- } catch (err) {
1236
- return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1181
+ export function validateImpl(body: ValidateBody): ValidateOutcome {
1182
+ const issues: ValidationIssue[] = [];
1183
+
1184
+ const archParse = ArchitectureSchema.safeParse(body.architecture);
1185
+ if (!archParse.success) {
1186
+ for (const i of archParse.error.issues) {
1187
+ issues.push({
1188
+ scope: 'architecture',
1189
+ path: [...i.path],
1190
+ message: i.message,
1191
+ code: i.code,
1192
+ });
1237
1193
  }
1238
- return { kind: 'ok' };
1239
- });
1194
+ }
1240
1195
 
1241
- return result;
1196
+ let styleData:
1197
+ | { nodes?: Record<string, unknown>; connectors?: Record<string, unknown> }
1198
+ | undefined;
1199
+ if (body.style !== undefined) {
1200
+ const styleParse = StyleSchema.safeParse(body.style);
1201
+ if (!styleParse.success) {
1202
+ for (const i of styleParse.error.issues) {
1203
+ issues.push({
1204
+ scope: 'style',
1205
+ path: [...i.path],
1206
+ message: i.message,
1207
+ code: i.code,
1208
+ });
1209
+ }
1210
+ } else {
1211
+ styleData = styleParse.data as never;
1212
+ }
1213
+ }
1214
+
1215
+ if (archParse.success && styleData) {
1216
+ const archNodeIds = new Set(archParse.data.nodes.map((n) => n.id));
1217
+ const archConnIds = new Set(archParse.data.connectors.map((c) => c.id));
1218
+ for (const id of Object.keys(styleData.nodes ?? {})) {
1219
+ if (!archNodeIds.has(id)) {
1220
+ issues.push({
1221
+ scope: 'cross',
1222
+ path: ['nodes', id],
1223
+ message: `Style entry references unknown node id: ${id}`,
1224
+ code: 'orphan_style_node',
1225
+ });
1226
+ }
1227
+ }
1228
+ for (const id of Object.keys(styleData.connectors ?? {})) {
1229
+ if (!archConnIds.has(id)) {
1230
+ issues.push({
1231
+ scope: 'cross',
1232
+ path: ['connectors', id],
1233
+ message: `Style entry references unknown connector id: ${id}`,
1234
+ code: 'orphan_style_connector',
1235
+ });
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
1242
1241
  }