@tuongaz/seeflow 0.1.31 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/operations.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  } from 'node:fs';
19
19
  import { dirname, isAbsolute, join } from 'node:path';
20
20
  import { type ZodIssue, z } from 'zod';
21
- import { mergeArchitectureAndStyle, splitFlow } from './merge.ts';
21
+ import { mergeFlowAndStyle, splitFlow } from './merge.ts';
22
22
  import { seeflowHome } from './paths.ts';
23
23
  import { type Registry, slugify } from './registry.ts';
24
24
  import {
@@ -26,19 +26,21 @@ import {
26
26
  EdgePinSchema,
27
27
  type Flow,
28
28
  FlowSchema,
29
+ type ResolvedFlow,
30
+ ResolvedFlowSchema,
29
31
  SourceHandleIdSchema,
32
+ StyleSchema,
30
33
  TargetHandleIdSchema,
31
34
  } from './schema.ts';
32
- import { ArchitectureSchema, StyleSchema } from './schema.ts';
33
35
  import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
34
36
  import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
35
37
 
36
- const DEFAULT_ARCHITECTURE_RELATIVE_PATH = '.seeflow/architecture.json';
38
+ const DEFAULT_FLOW_RELATIVE_PATH = '.seeflow/flow.json';
37
39
 
38
40
  export const RegisterBodySchema = z.object({
39
41
  name: z.string().min(1).optional(),
40
42
  repoPath: z.string().min(1),
41
- architecturePath: z.string().min(1),
43
+ flowPath: z.string().min(1),
42
44
  });
43
45
  export type RegisterBody = z.infer<typeof RegisterBodySchema>;
44
46
 
@@ -68,7 +70,7 @@ export type ReorderBody = z.infer<typeof ReorderBodySchema>;
68
70
 
69
71
  // Partial node update body. Top-level `position` lands on node.position; every
70
72
  // other key lands inside node.data. Final validity is enforced by re-parsing
71
- // the whole demo through FlowSchema after the merge — this body schema just
73
+ // the whole demo through ResolvedFlowSchema after the merge — this body schema just
72
74
  // rejects unknown top-level keys to catch typos.
73
75
  export const NodePatchBodySchema = z
74
76
  .object({
@@ -89,7 +91,7 @@ export const NodePatchBodySchema = z
89
91
  // that autoSize:true never coexists with persisted width/height.
90
92
  autoSize: z.boolean().optional(),
91
93
  shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
92
- // iconNode-only: stroke color token. Lands at data.color; FlowSchema's
94
+ // iconNode-only: stroke color token. Lands at data.color; ResolvedFlowSchema's
93
95
  // post-merge reparse gates that this is only valid on an iconNode.
94
96
  color: ColorTokenSchema.optional(),
95
97
  // iconNode-only: glyph stroke width. Lands at data.strokeWidth; the
@@ -103,9 +105,6 @@ export const NodePatchBodySchema = z
103
105
  // clears the field (mergeNodeUpdates strips the key from disk) — mirrors
104
106
  // the empty-string clear convention used for description / detail.
105
107
  icon: z.string().min(1).nullable().optional(),
106
- // US-019: lock state. Lands at data.locked; persists across save/reload.
107
- // Absent → unlocked default (no badge, all gestures work).
108
- locked: z.boolean().optional(),
109
108
  // Three-field consolidation: free-text metadata on every node variant.
110
109
  // Empty string on `description` or `detail` is the documented clear-on-
111
110
  // serialize signal — `mergeNodeUpdates` strips the key from disk.
@@ -137,7 +136,6 @@ const NODE_DATA_PATCH_KEYS = [
137
136
  'strokeWidth',
138
137
  'alt',
139
138
  'icon',
140
- 'locked',
141
139
  'description',
142
140
  'detail',
143
141
  ] as const satisfies ReadonlyArray<keyof NodePatchBody>;
@@ -156,7 +154,7 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
156
154
  if (updates[key] === undefined) continue;
157
155
  // Empty string on the two free-text metadata fields is the documented
158
156
  // clear-on-serialize signal — strip the key instead of writing "" to disk
159
- // so seeflow.json stays compact and round-tripping a cleared node doesn't
157
+ // so flow.json stays compact and round-tripping a cleared node doesn't
160
158
  // reintroduce the field.
161
159
  if ((key === 'description' || key === 'detail') && updates[key] === '') {
162
160
  if (key in data) {
@@ -237,7 +235,7 @@ export interface FlowGetResponse {
237
235
  slug: string;
238
236
  name: string;
239
237
  filePath: string;
240
- flow: Flow | null;
238
+ flow: ResolvedFlow | null;
241
239
  valid: boolean;
242
240
  error: string | null;
243
241
  }
@@ -326,7 +324,7 @@ export type PatchNodeOutcome =
326
324
 
327
325
  // Partial connector update body. Strict at the top level so client typos
328
326
  // surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
329
- // are enforced post-merge by re-parsing the whole demo through FlowSchema.
327
+ // are enforced post-merge by re-parsing the whole demo through ResolvedFlowSchema.
330
328
  const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
331
329
  export const ConnectorPatchBodySchema = z
332
330
  .object({
@@ -344,7 +342,7 @@ export const ConnectorPatchBodySchema = z
344
342
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
345
343
  url: z.string().optional(),
346
344
  // Reconnect: drag an edge endpoint onto another node's handle. The
347
- // post-merge FlowSchema parse rejects dangling references, so we don't
345
+ // post-merge ResolvedFlowSchema parse rejects dangling references, so we don't
348
346
  // need a referential check here.
349
347
  source: z.string().min(1).optional(),
350
348
  target: z.string().min(1).optional(),
@@ -378,7 +376,7 @@ export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
378
376
  // Kind-specific connector fields. When `kind` changes via PATCH, these are
379
377
  // dropped first so the resulting connector doesn't carry phantom payloads
380
378
  // from the previous kind (e.g. an event→default change leaving eventName
381
- // behind, which FlowSchema would silently strip on parse but leave on disk).
379
+ // behind, which ResolvedFlowSchema would silently strip on parse but leave on disk).
382
380
  const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
383
381
 
384
382
  export const mergeConnectorUpdates = (
@@ -429,8 +427,8 @@ export type DeleteConnectorOutcome =
429
427
  | { kind: 'unknownConnector' }
430
428
  | { kind: 'writeFailed'; message: string };
431
429
 
432
- export const resolveFilePath = (repoPath: string, architecturePath: string): string =>
433
- isAbsolute(architecturePath) ? architecturePath : join(repoPath, architecturePath);
430
+ export const resolveFilePath = (repoPath: string, flowPath: string): string =>
431
+ isAbsolute(flowPath) ? flowPath : join(repoPath, flowPath);
434
432
 
435
433
  // Per-demo serialization: read-modify-write of the demo file isn't atomic
436
434
  // across multiple PATCHes, so two concurrent drags would race (later writer's
@@ -472,25 +470,25 @@ export const writeFileAtomic = (filePath: string, content: string): void => {
472
470
  };
473
471
 
474
472
  /**
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.
473
+ * Read flow.json + optional style.json, return the raw parsed JSON so
474
+ * operations can mutate the merged-flow shape without losing forward-compat
475
+ * fields. Returns null if the flow file is missing or invalid JSON.
478
476
  */
479
477
  type ReadRawResult =
480
- | { kind: 'ok'; rawArch: Record<string, unknown>; rawStyle: Record<string, unknown> }
478
+ | { kind: 'ok'; rawFlow: Record<string, unknown>; rawStyle: Record<string, unknown> }
481
479
  | { kind: 'badJson'; message: string };
482
480
 
483
- function readRawArchAndStyle(archPath: string): ReadRawResult {
484
- let rawArch: unknown;
481
+ function readRawFlowAndStyle(flowPath: string): ReadRawResult {
482
+ let rawFlow: unknown;
485
483
  try {
486
- rawArch = JSON.parse(readFileSync(archPath, 'utf8'));
484
+ rawFlow = JSON.parse(readFileSync(flowPath, 'utf8'));
487
485
  } catch (err) {
488
486
  return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
489
487
  }
490
- if (!rawArch || typeof rawArch !== 'object' || Array.isArray(rawArch)) {
491
- return { kind: 'badJson', message: 'architecture.json is not an object' };
488
+ if (!rawFlow || typeof rawFlow !== 'object' || Array.isArray(rawFlow)) {
489
+ return { kind: 'badJson', message: 'flow.json is not an object' };
492
490
  }
493
- const stylePath = join(dirname(archPath), 'style.json');
491
+ const stylePath = join(dirname(flowPath), 'style.json');
494
492
  let rawStyle: Record<string, unknown> = {};
495
493
  if (existsSync(stylePath)) {
496
494
  try {
@@ -506,12 +504,12 @@ function readRawArchAndStyle(archPath: string): ReadRawResult {
506
504
  };
507
505
  }
508
506
  }
509
- return { kind: 'ok', rawArch: rawArch as Record<string, unknown>, rawStyle };
507
+ return { kind: 'ok', rawFlow: rawFlow as Record<string, unknown>, rawStyle };
510
508
  }
511
509
 
512
510
  /**
513
511
  * 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
512
+ * to the mutator, then split back into flow + style and atomically
515
513
  * write both. The mutator returns either { kind: 'ok' } or a discriminated
516
514
  * outcome for early-exits (e.g. unknownNode). On schema-validation failure,
517
515
  * neither file is written.
@@ -524,26 +522,36 @@ type MutateMergedFlowMutator<E> = (flow: {
524
522
  connectors: Array<Record<string, unknown>>;
525
523
  }) => { kind: 'ok' } | E;
526
524
 
525
+ type MutateMergedFlowOk = {
526
+ kind: 'ok';
527
+ /** Validated post-write merged flow — hand straight to watcher.notifyWritten. */
528
+ snap: FlowSnapshot;
529
+ /** Exact bytes written to flow.json — used for own-write hash dedupe. */
530
+ flowContent: string;
531
+ /** Exact bytes written to style.json, or '' when style.json was deleted / never existed. */
532
+ styleContent: string;
533
+ };
534
+
527
535
  type MutateMergedFlowResult<E> =
528
- | { kind: 'ok' }
536
+ | MutateMergedFlowOk
529
537
  | { kind: 'badJson'; message: string }
530
538
  | { kind: 'badSchema'; issues: ZodIssue[] }
531
539
  | { kind: 'writeFailed'; message: string }
532
540
  | E;
533
541
 
534
542
  export async function mutateMergedFlow<E extends { kind: string }>(
535
- archPath: string,
543
+ flowPath: string,
536
544
  mutator: MutateMergedFlowMutator<E>,
537
545
  ): Promise<MutateMergedFlowResult<E>> {
538
- const read = readRawArchAndStyle(archPath);
546
+ const read = readRawFlowAndStyle(flowPath);
539
547
  if (read.kind === 'badJson') return { kind: 'badJson', message: read.message };
540
548
 
541
- const archParse = ArchitectureSchema.safeParse(read.rawArch);
542
- if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
549
+ const flowParse = FlowSchema.safeParse(read.rawFlow);
550
+ if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
543
551
  const styleParse = StyleSchema.safeParse(read.rawStyle);
544
552
  if (!styleParse.success) return { kind: 'badSchema', issues: styleParse.error.issues };
545
553
 
546
- const merged = mergeArchitectureAndStyle(archParse.data, styleParse.data) as unknown as {
554
+ const merged = mergeFlowAndStyle(flowParse.data, styleParse.data) as unknown as {
547
555
  version: number;
548
556
  name: string;
549
557
  resetAction?: unknown;
@@ -552,29 +560,36 @@ export async function mutateMergedFlow<E extends { kind: string }>(
552
560
  };
553
561
 
554
562
  const outcome = mutator(merged);
555
- if (outcome.kind !== 'ok') return outcome;
563
+ // E is generic — TS can't prove the narrowed branch isn't `{ kind: 'ok' }`,
564
+ // so we cast. By convention, E never reuses `'ok'` as a discriminant.
565
+ if (outcome.kind !== 'ok') return outcome as E;
556
566
 
557
- // Final FlowSchema parse so per-kind invariants (event needs eventName, etc.)
567
+ // Final ResolvedFlowSchema parse so per-kind invariants (event needs eventName, etc.)
558
568
  // surface honestly instead of being silently papered over.
559
- const finalParse = FlowSchema.safeParse(merged);
569
+ const finalParse = ResolvedFlowSchema.safeParse(merged);
560
570
  if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
561
571
 
562
- const { architecture, style } = splitFlow(merged);
572
+ const { flow, style } = splitFlow(merged);
563
573
  // Re-validate the post-split files to catch the rare case where a forward-
564
574
  // 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 };
575
+ // empty maps; flow revalidation rejects unknown keys via strict().
576
+ const flowCheck = FlowSchema.safeParse(flow);
577
+ if (!flowCheck.success) return { kind: 'badSchema', issues: flowCheck.error.issues };
568
578
  const styleCheck = StyleSchema.safeParse(style);
569
579
  if (!styleCheck.success) return { kind: 'badSchema', issues: styleCheck.error.issues };
570
580
 
571
- const stylePath = join(dirname(archPath), 'style.json');
581
+ const stylePath = join(dirname(flowPath), 'style.json');
572
582
  const styleIsEmpty =
573
583
  (!style.nodes || Object.keys(style.nodes as Record<string, unknown>).length === 0) &&
574
584
  (!style.connectors || Object.keys(style.connectors as Record<string, unknown>).length === 0);
575
585
 
586
+ // Pre-compute the bytes we're about to write so the caller can hand them
587
+ // straight to watcher.notifyWritten without re-reading the file.
588
+ const flowContent = `${JSON.stringify(flow, null, 2)}\n`;
589
+ const styleContent = styleIsEmpty ? '' : `${JSON.stringify(style, null, 2)}\n`;
590
+
576
591
  try {
577
- writeFileAtomic(archPath, `${JSON.stringify(architecture, null, 2)}\n`);
592
+ writeFileAtomic(flowPath, flowContent);
578
593
  } catch (err) {
579
594
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
580
595
  }
@@ -583,13 +598,48 @@ export async function mutateMergedFlow<E extends { kind: string }>(
583
598
  if (styleIsEmpty) {
584
599
  if (existsSync(stylePath)) unlinkSync(stylePath);
585
600
  } else {
586
- writeFileAtomic(stylePath, `${JSON.stringify(style, null, 2)}\n`);
601
+ writeFileAtomic(stylePath, styleContent);
587
602
  }
588
603
  } catch (err) {
589
604
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
590
605
  }
591
606
 
592
- return { kind: 'ok' };
607
+ const snap: FlowSnapshot = {
608
+ flow: finalParse.data as ResolvedFlow,
609
+ valid: true,
610
+ error: null,
611
+ filePath: flowPath,
612
+ parsedAt: Date.now(),
613
+ };
614
+ return { kind: 'ok', snap, flowContent, styleContent };
615
+ }
616
+
617
+ /**
618
+ * Wrap a mutation in the write lock AND broadcast a flow:reload directly
619
+ * from the post-write snapshot. Every mutation endpoint that updates flow
620
+ * state should go through this so:
621
+ * 1. The watcher's content-hash dedupe suppresses the fs-watcher echo for
622
+ * this same write — no double broadcast, no double reparse.
623
+ * 2. The SSE event reaches the client without waiting for the fs debounce.
624
+ *
625
+ * For mutators whose ok branch carries no payload, `E` should be `never`.
626
+ */
627
+ export async function mutateMergedFlowAndBroadcast<E extends { kind: string }>(
628
+ deps: OperationsDeps,
629
+ flowId: string,
630
+ flowPath: string,
631
+ mutator: MutateMergedFlowMutator<E>,
632
+ ): Promise<MutateMergedFlowResult<E>> {
633
+ return withFlowWriteLock(flowId, async () => {
634
+ const result = await mutateMergedFlow(flowPath, mutator);
635
+ if (result.kind === 'ok') {
636
+ // E is generic, so TS can't prove the ok branch is MutateMergedFlowOk —
637
+ // cast here, parallel to the cast inside mutateMergedFlow itself.
638
+ const ok = result as MutateMergedFlowOk;
639
+ deps.watcher?.notifyWritten(flowId, ok.snap, ok.flowContent, ok.styleContent);
640
+ }
641
+ return result;
642
+ });
593
643
  }
594
644
 
595
645
  export const reorderNodes = (
@@ -644,7 +694,7 @@ export const reorderNodes = (
644
694
 
645
695
  export function listDemosImpl(deps: OperationsDeps): ListFlowsOutcome {
646
696
  const data = deps.registry.list().map((e) => {
647
- const fullPath = resolveFilePath(e.repoPath, e.architecturePath);
697
+ const fullPath = resolveFilePath(e.repoPath, e.flowPath);
648
698
  const fileExists = existsSync(fullPath);
649
699
  return {
650
700
  id: e.id,
@@ -663,7 +713,7 @@ export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise
663
713
  const entry = registry.getById(flowId);
664
714
  if (!entry) return { kind: 'notFound' };
665
715
 
666
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
716
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
667
717
  const snap = watcher?.snapshot(flowId) ?? watcher?.reparse(flowId) ?? null;
668
718
 
669
719
  const buildResponse = (s: FlowSnapshot): FlowGetResponse => ({
@@ -700,8 +750,8 @@ export async function registerFlowImpl(
700
750
  body: RegisterBody,
701
751
  ): Promise<RegisterFlowOutcome> {
702
752
  const { registry, watcher } = deps;
703
- const { repoPath, architecturePath } = body;
704
- const fullPath = resolveFilePath(repoPath, architecturePath);
753
+ const { repoPath, flowPath } = body;
754
+ const fullPath = resolveFilePath(repoPath, flowPath);
705
755
 
706
756
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
707
757
 
@@ -712,14 +762,14 @@ export async function registerFlowImpl(
712
762
  }
713
763
  // Schema validation failed — surface the issues as a bad-schema outcome
714
764
  // by re-running parse to get ZodIssue[].
715
- let rawArch: unknown;
765
+ let rawFlow: unknown;
716
766
  try {
717
- rawArch = JSON.parse(readFileSync(fullPath, 'utf8'));
767
+ rawFlow = JSON.parse(readFileSync(fullPath, 'utf8'));
718
768
  } catch (err) {
719
769
  return { kind: 'badJson', detail: String(err) };
720
770
  }
721
- const archParse = ArchitectureSchema.safeParse(rawArch);
722
- if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
771
+ const flowParse = FlowSchema.safeParse(rawFlow);
772
+ if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
723
773
  return { kind: 'badJson', detail: merged.error };
724
774
  }
725
775
  if (!merged.flow) return { kind: 'badJson', detail: merged.error ?? 'unknown error' };
@@ -728,7 +778,7 @@ export async function registerFlowImpl(
728
778
  const entry = registry.upsert({
729
779
  name: body.name ?? merged.flow.name,
730
780
  repoPath,
731
- architecturePath,
781
+ flowPath,
732
782
  valid: true,
733
783
  lastModified,
734
784
  });
@@ -771,7 +821,7 @@ export async function createProjectImpl(
771
821
  const baseDir = deps.projectBaseDir ?? seeflowHome();
772
822
  const folderPath = join(baseDir, slugify(name));
773
823
 
774
- const demoFullPath = join(folderPath, DEFAULT_ARCHITECTURE_RELATIVE_PATH);
824
+ const demoFullPath = join(folderPath, DEFAULT_FLOW_RELATIVE_PATH);
775
825
 
776
826
  if (existsSync(demoFullPath)) {
777
827
  let raw: unknown;
@@ -780,14 +830,14 @@ export async function createProjectImpl(
780
830
  } catch (err) {
781
831
  return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
782
832
  }
783
- const archParse = ArchitectureSchema.safeParse(raw);
784
- if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
833
+ const flowParse = FlowSchema.safeParse(raw);
834
+ if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
785
835
 
786
836
  const lastModified = statSync(demoFullPath).mtimeMs;
787
837
  const entry = registry.upsert({
788
838
  name,
789
839
  repoPath: folderPath,
790
- architecturePath: DEFAULT_ARCHITECTURE_RELATIVE_PATH,
840
+ flowPath: DEFAULT_FLOW_RELATIVE_PATH,
791
841
  valid: true,
792
842
  lastModified,
793
843
  });
@@ -795,7 +845,7 @@ export async function createProjectImpl(
795
845
  return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
796
846
  }
797
847
 
798
- // Architecture-only scaffold: empty nodes/connectors, no style.json needed.
848
+ // Flow-only scaffold: empty nodes/connectors, no style.json needed.
799
849
  const scaffold: Flow = { version: 2, name, nodes: [], connectors: [] };
800
850
 
801
851
  try {
@@ -818,7 +868,7 @@ export async function createProjectImpl(
818
868
  const entry = registry.upsert({
819
869
  name,
820
870
  repoPath: folderPath,
821
- architecturePath: DEFAULT_ARCHITECTURE_RELATIVE_PATH,
871
+ flowPath: DEFAULT_FLOW_RELATIVE_PATH,
822
872
  valid: true,
823
873
  lastModified,
824
874
  });
@@ -826,7 +876,7 @@ export async function createProjectImpl(
826
876
  return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
827
877
  }
828
878
 
829
- // Append a new node to the demo. Auto-generates an id when absent; FlowSchema
879
+ // Append a new node to the demo. Auto-generates an id when absent; ResolvedFlowSchema
830
880
  // is re-run on the post-mutation raw object before commit so a malformed
831
881
  // payload never produces a half-written file.
832
882
  export async function addNodeImpl(
@@ -842,7 +892,7 @@ export async function addNodeImpl(
842
892
  newNode.id = `node-${crypto.randomUUID()}`;
843
893
  }
844
894
  const newId = newNode.id as string;
845
- // Default position so the post-merge FlowSchema parse passes. Position lives
895
+ // Default position so the post-merge ResolvedFlowSchema parse passes. Position lives
846
896
  // on style.json after the split — callers who care set it explicitly.
847
897
  if (!newNode.position || typeof newNode.position !== 'object') {
848
898
  newNode.position = { x: 0, y: 0 };
@@ -876,11 +926,14 @@ export async function addNodeImpl(
876
926
  }
877
927
  }
878
928
 
879
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
929
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
880
930
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
881
931
 
882
- const result = await withFlowWriteLock(flowId, () =>
883
- mutateMergedFlow<{ kind: 'writeFailed'; message: string }>(fullPath, (flow) => {
932
+ const result = await mutateMergedFlowAndBroadcast<{ kind: 'writeFailed'; message: string }>(
933
+ deps,
934
+ flowId,
935
+ fullPath,
936
+ (flow) => {
884
937
  flow.nodes.push(newNode);
885
938
  if (starterFile) {
886
939
  try {
@@ -891,7 +944,7 @@ export async function addNodeImpl(
891
944
  }
892
945
  }
893
946
  return { kind: 'ok' };
894
- }),
947
+ },
895
948
  );
896
949
 
897
950
  if (result.kind === 'ok') return { kind: 'ok', data: { id: newId, node: newNode } };
@@ -912,11 +965,11 @@ const buildHtmlNodeStarter = (nodeId: string): string =>
912
965
  `;
913
966
 
914
967
  // Remove a node and cascade-delete every connector touching it in a single
915
- // atomic write. Final FlowSchema parse stays in place so a pre-existing
968
+ // atomic write. Final ResolvedFlowSchema parse stays in place so a pre-existing
916
969
  // schema violation surfaces honestly instead of being silently papered over.
917
970
  // US-016: when the removed node is an htmlNode whose data.htmlPath matches the
918
971
  // studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
919
- // the seeflow.json write succeeds. Hand-edited paths are left alone (symmetric
972
+ // the flow.json write succeeds. Hand-edited paths are left alone (symmetric
920
973
  // with US-015's "client-supplied htmlPath wins, no starter file written").
921
974
  export async function deleteNodeImpl(
922
975
  deps: OperationsDeps,
@@ -926,13 +979,16 @@ export async function deleteNodeImpl(
926
979
  const entry = deps.registry.getById(flowId);
927
980
  if (!entry) return { kind: 'flowNotFound' };
928
981
 
929
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
982
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
930
983
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
931
984
 
932
985
  let managedHtmlAbsPath: string | undefined;
933
986
 
934
- const result = await withFlowWriteLock(flowId, () =>
935
- mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
987
+ const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(
988
+ deps,
989
+ flowId,
990
+ fullPath,
991
+ (flow) => {
936
992
  const idx = flow.nodes.findIndex((n) => n.id === nodeId);
937
993
  if (idx < 0) return { kind: 'unknownNode' };
938
994
  const removed = flow.nodes[idx];
@@ -942,7 +998,7 @@ export async function deleteNodeImpl(
942
998
  (cn) => cn.source !== nodeId && cn.target !== nodeId,
943
999
  );
944
1000
  return { kind: 'ok' };
945
- }),
1001
+ },
946
1002
  );
947
1003
 
948
1004
  if (result.kind === 'ok' && managedHtmlAbsPath) {
@@ -992,22 +1048,24 @@ export async function moveNodeImpl(
992
1048
  const entry = deps.registry.getById(flowId);
993
1049
  if (!entry) return { kind: 'flowNotFound' };
994
1050
 
995
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1051
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
996
1052
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
997
1053
 
998
- const result = await withFlowWriteLock(flowId, () =>
999
- mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
1054
+ const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(
1055
+ deps,
1056
+ flowId,
1057
+ fullPath,
1058
+ (flow) => {
1000
1059
  const node = flow.nodes.find((n) => n.id === nodeId) as
1001
1060
  | { id: string; position?: { x: number; y: number } }
1002
1061
  | undefined;
1003
1062
  if (!node) return { kind: 'unknownNode' };
1004
1063
  node.position = { x: position.x, y: position.y };
1005
1064
  return { kind: 'ok' };
1006
- }),
1065
+ },
1007
1066
  );
1008
1067
 
1009
1068
  if (result.kind === 'ok') {
1010
- deps.watcher?.reparse(flowId);
1011
1069
  return { kind: 'ok', data: { position: { x: position.x, y: position.y } } };
1012
1070
  }
1013
1071
  return result;
@@ -1015,7 +1073,7 @@ export async function moveNodeImpl(
1015
1073
 
1016
1074
  // Apply a partial PATCH body to a single node. Mutation runs against the
1017
1075
  // raw parsed JSON (so unknown forward-compat fields survive a round-trip),
1018
- // and the whole demo is re-validated through FlowSchema before commit so
1076
+ // and the whole demo is re-validated through ResolvedFlowSchema before commit so
1019
1077
  // partial writes can't break invariants like the connector→node superRefine.
1020
1078
  export async function patchNodeImpl(
1021
1079
  deps: OperationsDeps,
@@ -1026,17 +1084,15 @@ export async function patchNodeImpl(
1026
1084
  const entry = deps.registry.getById(flowId);
1027
1085
  if (!entry) return { kind: 'flowNotFound' };
1028
1086
 
1029
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1087
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1030
1088
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1031
1089
 
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
- );
1090
+ return mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(deps, flowId, fullPath, (flow) => {
1091
+ const node = flow.nodes.find((n) => n.id === nodeId);
1092
+ if (!node) return { kind: 'unknownNode' };
1093
+ mergeNodeUpdates(node, updates);
1094
+ return { kind: 'ok' };
1095
+ });
1040
1096
  }
1041
1097
 
1042
1098
  // Reorder a node within demo.nodes[] (changes paint order in the canvas).
@@ -1051,17 +1107,20 @@ export async function reorderNodeImpl(
1051
1107
  const entry = deps.registry.getById(flowId);
1052
1108
  if (!entry) return { kind: 'flowNotFound' };
1053
1109
 
1054
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1110
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1055
1111
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1056
1112
 
1057
- const result = await withFlowWriteLock(flowId, () =>
1058
- mutateMergedFlow<{ kind: 'unknownNode' } | { kind: 'noop' }>(fullPath, (flow) => {
1113
+ const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' } | { kind: 'noop' }>(
1114
+ deps,
1115
+ flowId,
1116
+ fullPath,
1117
+ (flow) => {
1059
1118
  const fromIdx = flow.nodes.findIndex((n) => n.id === nodeId);
1060
1119
  if (fromIdx < 0) return { kind: 'unknownNode' };
1061
1120
  const moved = reorderNodes(flow.nodes, fromIdx, body);
1062
1121
  if (!moved) return { kind: 'noop' };
1063
1122
  return { kind: 'ok' };
1064
- }),
1123
+ },
1065
1124
  );
1066
1125
 
1067
1126
  if (result.kind === 'noop') return { kind: 'ok' };
@@ -1070,7 +1129,7 @@ export async function reorderNodeImpl(
1070
1129
 
1071
1130
  // Append a new connector to demo.connectors. `id` is auto-generated when
1072
1131
  // absent and `kind` defaults to 'default' (the no-semantics user-drawn
1073
- // variant). Source/target referential integrity is enforced by FlowSchema's
1132
+ // variant). Source/target referential integrity is enforced by ResolvedFlowSchema's
1074
1133
  // superRefine on the post-mutation parse.
1075
1134
  export async function addConnectorImpl(
1076
1135
  deps: OperationsDeps,
@@ -1089,15 +1148,13 @@ export async function addConnectorImpl(
1089
1148
  }
1090
1149
  const newId = newConn.id as string;
1091
1150
 
1092
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1151
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1093
1152
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1094
1153
 
1095
- const result = await withFlowWriteLock(flowId, () =>
1096
- mutateMergedFlow<never>(fullPath, (flow) => {
1097
- flow.connectors.push(newConn);
1098
- return { kind: 'ok' };
1099
- }),
1100
- );
1154
+ const result = await mutateMergedFlowAndBroadcast<never>(deps, flowId, fullPath, (flow) => {
1155
+ flow.connectors.push(newConn);
1156
+ return { kind: 'ok' };
1157
+ });
1101
1158
 
1102
1159
  if (result.kind === 'ok') return { kind: 'ok', data: { id: newId } };
1103
1160
  return result;
@@ -1108,7 +1165,7 @@ export async function addConnectorImpl(
1108
1165
  // When `kind` changes, the previous kind's payload fields are dropped first
1109
1166
  // so the connector doesn't carry phantom data; explicit `null` in the patch
1110
1167
  // clears the field on disk (used by reconnect-to-body to drop a pinned
1111
- // handle id). The whole demo is re-validated through FlowSchema before
1168
+ // handle id). The whole demo is re-validated through ResolvedFlowSchema before
1112
1169
  // commit so the discriminated union catches missing-required-fields
1113
1170
  // (e.g. kind='event' without eventName) and the superRefine gates
1114
1171
  // source/target referential integrity + handle role invariants.
@@ -1121,21 +1178,24 @@ export async function patchConnectorImpl(
1121
1178
  const entry = deps.registry.getById(flowId);
1122
1179
  if (!entry) return { kind: 'flowNotFound' };
1123
1180
 
1124
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1181
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1125
1182
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1126
1183
 
1127
- return withFlowWriteLock(flowId, () =>
1128
- mutateMergedFlow<{ kind: 'unknownConnector' }>(fullPath, (flow) => {
1184
+ return mutateMergedFlowAndBroadcast<{ kind: 'unknownConnector' }>(
1185
+ deps,
1186
+ flowId,
1187
+ fullPath,
1188
+ (flow) => {
1129
1189
  const conn = flow.connectors.find((cn) => cn.id === connectorId);
1130
1190
  if (!conn) return { kind: 'unknownConnector' };
1131
1191
  mergeConnectorUpdates(conn, updates);
1132
1192
  return { kind: 'ok' };
1133
- }),
1193
+ },
1134
1194
  );
1135
1195
  }
1136
1196
 
1137
1197
  // Remove a connector by id. No cascade — node deletion is what cascades,
1138
- // not connector deletion. Final FlowSchema parse still runs so a pre-existing
1198
+ // not connector deletion. Final ResolvedFlowSchema parse still runs so a pre-existing
1139
1199
  // schema violation surfaces honestly instead of being silently papered over.
1140
1200
  export async function deleteConnectorImpl(
1141
1201
  deps: OperationsDeps,
@@ -1145,16 +1205,19 @@ export async function deleteConnectorImpl(
1145
1205
  const entry = deps.registry.getById(flowId);
1146
1206
  if (!entry) return { kind: 'flowNotFound' };
1147
1207
 
1148
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
1208
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1149
1209
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1150
1210
 
1151
- return withFlowWriteLock(flowId, () =>
1152
- mutateMergedFlow<{ kind: 'unknownConnector' }>(fullPath, (flow) => {
1211
+ return mutateMergedFlowAndBroadcast<{ kind: 'unknownConnector' }>(
1212
+ deps,
1213
+ flowId,
1214
+ fullPath,
1215
+ (flow) => {
1153
1216
  const idx = flow.connectors.findIndex((cn) => cn.id === connectorId);
1154
1217
  if (idx < 0) return { kind: 'unknownConnector' };
1155
1218
  flow.connectors.splice(idx, 1);
1156
1219
  return { kind: 'ok' };
1157
- }),
1220
+ },
1158
1221
  );
1159
1222
  }
1160
1223
 
@@ -1165,12 +1228,12 @@ export async function deleteConnectorImpl(
1165
1228
  // =============================================================================
1166
1229
 
1167
1230
  export interface ValidateBody {
1168
- architecture: unknown;
1231
+ flow: unknown;
1169
1232
  style?: unknown;
1170
1233
  }
1171
1234
 
1172
1235
  export interface ValidationIssue {
1173
- scope: 'architecture' | 'style' | 'cross';
1236
+ scope: 'flow' | 'style' | 'cross';
1174
1237
  path: (string | number)[];
1175
1238
  message: string;
1176
1239
  code: string;
@@ -1181,11 +1244,11 @@ export type ValidateOutcome = { ok: true } | { ok: false; issues: ValidationIssu
1181
1244
  export function validateImpl(body: ValidateBody): ValidateOutcome {
1182
1245
  const issues: ValidationIssue[] = [];
1183
1246
 
1184
- const archParse = ArchitectureSchema.safeParse(body.architecture);
1185
- if (!archParse.success) {
1186
- for (const i of archParse.error.issues) {
1247
+ const flowParse = FlowSchema.safeParse(body.flow);
1248
+ if (!flowParse.success) {
1249
+ for (const i of flowParse.error.issues) {
1187
1250
  issues.push({
1188
- scope: 'architecture',
1251
+ scope: 'flow',
1189
1252
  path: [...i.path],
1190
1253
  message: i.message,
1191
1254
  code: i.code,
@@ -1212,11 +1275,11 @@ export function validateImpl(body: ValidateBody): ValidateOutcome {
1212
1275
  }
1213
1276
  }
1214
1277
 
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));
1278
+ if (flowParse.success && styleData) {
1279
+ const flowNodeIds = new Set(flowParse.data.nodes.map((n) => n.id));
1280
+ const flowConnIds = new Set(flowParse.data.connectors.map((c) => c.id));
1218
1281
  for (const id of Object.keys(styleData.nodes ?? {})) {
1219
- if (!archNodeIds.has(id)) {
1282
+ if (!flowNodeIds.has(id)) {
1220
1283
  issues.push({
1221
1284
  scope: 'cross',
1222
1285
  path: ['nodes', id],
@@ -1226,7 +1289,7 @@ export function validateImpl(body: ValidateBody): ValidateOutcome {
1226
1289
  }
1227
1290
  }
1228
1291
  for (const id of Object.keys(styleData.connectors ?? {})) {
1229
- if (!archConnIds.has(id)) {
1292
+ if (!flowConnIds.has(id)) {
1230
1293
  issues.push({
1231
1294
  scope: 'cross',
1232
1295
  path: ['connectors', id],