@tuongaz/seeflow 0.1.31 → 0.1.40

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/registry.ts CHANGED
@@ -7,7 +7,7 @@ export interface FlowEntry {
7
7
  slug: string;
8
8
  name: string;
9
9
  repoPath: string;
10
- architecturePath: string;
10
+ flowPath: string;
11
11
  lastModified: number;
12
12
  valid: boolean;
13
13
  }
@@ -15,7 +15,7 @@ export interface FlowEntry {
15
15
  export interface RegisterInput {
16
16
  name: string;
17
17
  repoPath: string;
18
- architecturePath: string;
18
+ flowPath: string;
19
19
  valid?: boolean;
20
20
  lastModified?: number;
21
21
  }
@@ -25,10 +25,7 @@ export interface Registry {
25
25
  getById(id: string): FlowEntry | undefined;
26
26
  getBySlug(slug: string): FlowEntry | undefined;
27
27
  getByRepoPath(repoPath: string): FlowEntry | undefined;
28
- getByRepoPathAndArchitecturePath(
29
- repoPath: string,
30
- architecturePath: string,
31
- ): FlowEntry | undefined;
28
+ getByRepoPathAndFlowPath(repoPath: string, flowPath: string): FlowEntry | undefined;
32
29
  upsert(input: RegisterInput): FlowEntry;
33
30
  remove(id: string): boolean;
34
31
  }
@@ -60,7 +57,7 @@ export function createRegistry(options: { path?: string } = {}): Registry {
60
57
  typeof e.slug === 'string' &&
61
58
  typeof e.repoPath === 'string'
62
59
  ) {
63
- if (typeof e.architecturePath !== 'string') {
60
+ if (typeof e.flowPath !== 'string') {
64
61
  console.warn(
65
62
  `[registry] ignoring legacy entry ${e.id} (${e.slug}) — pre-split format, please re-register`,
66
63
  );
@@ -87,12 +84,9 @@ export function createRegistry(options: { path?: string } = {}): Registry {
87
84
  return undefined;
88
85
  };
89
86
 
90
- const findByRepoPathAndArchitecturePath = (
91
- repoPath: string,
92
- architecturePath: string,
93
- ): FlowEntry | undefined => {
87
+ const findByRepoPathAndFlowPath = (repoPath: string, flowPath: string): FlowEntry | undefined => {
94
88
  for (const e of entries.values()) {
95
- if (e.repoPath === repoPath && e.architecturePath === architecturePath) return e;
89
+ if (e.repoPath === repoPath && e.flowPath === flowPath) return e;
96
90
  }
97
91
  return undefined;
98
92
  };
@@ -110,16 +104,16 @@ export function createRegistry(options: { path?: string } = {}): Registry {
110
104
  getById: (id) => entries.get(id),
111
105
  getBySlug: (slug) => [...entries.values()].find((e) => e.slug === slug),
112
106
  getByRepoPath: findByRepoPath,
113
- getByRepoPathAndArchitecturePath: findByRepoPathAndArchitecturePath,
107
+ getByRepoPathAndFlowPath: findByRepoPathAndFlowPath,
114
108
  upsert(input) {
115
109
  const lastModified = input.lastModified ?? Date.now();
116
110
  const valid = input.valid ?? true;
117
- const existing = findByRepoPathAndArchitecturePath(input.repoPath, input.architecturePath);
111
+ const existing = findByRepoPathAndFlowPath(input.repoPath, input.flowPath);
118
112
  if (existing) {
119
113
  const updated: FlowEntry = {
120
114
  ...existing,
121
115
  name: input.name,
122
- architecturePath: input.architecturePath,
116
+ flowPath: input.flowPath,
123
117
  lastModified,
124
118
  valid,
125
119
  };
@@ -134,7 +128,7 @@ export function createRegistry(options: { path?: string } = {}): Registry {
134
128
  slug,
135
129
  name: input.name,
136
130
  repoPath: input.repoPath,
137
- architecturePath: input.architecturePath,
131
+ flowPath: input.flowPath,
138
132
  lastModified,
139
133
  valid,
140
134
  };
package/src/schema.ts CHANGED
@@ -27,10 +27,6 @@ export const ColorTokenSchema = z.enum([
27
27
 
28
28
  // Visual fields shared by every node type (functional + decorative). All
29
29
  // optional — existing demo files predate them and must continue to parse.
30
- // US-019: `locked` freezes a node in place (no drag / resize / delete) and
31
- // renders a lock badge on its top-right corner. Absent → unlocked default.
32
- // Mirrored explicitly into IconNodeDataSchema below
33
- // since that variant doesn't spread this base shape.
34
30
  const NodeVisualBaseShape = {
35
31
  width: z.number().positive().optional(),
36
32
  height: z.number().positive().optional(),
@@ -41,7 +37,6 @@ const NodeVisualBaseShape = {
41
37
  fontSize: z.number().positive().optional(),
42
38
  textColor: ColorTokenSchema.optional(),
43
39
  cornerRadius: z.number().min(0).optional(),
44
- locked: z.boolean().optional(),
45
40
  };
46
41
 
47
42
  // Consolidated three-field metadata shared by every node variant. `description`
@@ -267,9 +262,6 @@ const IconNodeDataSchema = z.object({
267
262
  // `alt` (screen-reader text). Absent / empty → no caption rendered and the
268
263
  // node's bounding box is byte-identical to the unlabeled layout.
269
264
  name: z.string().optional(),
270
- // US-019: lock state mirror of NodeVisualBaseShape.locked. IconNode does
271
- // not spread the visual base so we declare it here explicitly.
272
- locked: z.boolean().optional(),
273
265
  ...NodeDescriptionBaseShape,
274
266
  });
275
267
 
@@ -396,7 +388,7 @@ const ConnectorSchema = z.discriminatedUnion('kind', [
396
388
  DefaultConnectorSchema,
397
389
  ]);
398
390
 
399
- export const FlowSchema = z
391
+ export const ResolvedFlowSchema = z
400
392
  .object({
401
393
  version: z.literal(2),
402
394
  name: z.string().min(1),
@@ -408,9 +400,9 @@ export const FlowSchema = z
408
400
  // script (US-008), so the script sees no stragglers.
409
401
  resetAction: ResetActionSchema.optional(),
410
402
  })
411
- .superRefine((flow, ctx) => {
412
- const nodeIds = new Set(flow.nodes.map((n) => n.id));
413
- flow.connectors.forEach((c, idx) => {
403
+ .superRefine((resolved, ctx) => {
404
+ const nodeIds = new Set(resolved.nodes.map((n) => n.id));
405
+ resolved.connectors.forEach((c, idx) => {
414
406
  if (!nodeIds.has(c.source)) {
415
407
  ctx.addIssue({
416
408
  code: z.ZodIssueCode.custom,
@@ -428,8 +420,8 @@ export const FlowSchema = z
428
420
  });
429
421
  });
430
422
 
431
- export type Flow = z.infer<typeof FlowSchema>;
432
- export type FlowNode = z.infer<typeof NodeSchema>;
423
+ export type ResolvedFlow = z.infer<typeof ResolvedFlowSchema>;
424
+ export type ResolvedFlowNode = z.infer<typeof NodeSchema>;
433
425
  export type ShapeNode = z.infer<typeof ShapeNodeSchema>;
434
426
  export type ImageNode = z.infer<typeof ImageNodeSchema>;
435
427
  export type IconNode = z.infer<typeof IconNodeSchema>;
@@ -454,11 +446,11 @@ export type ResetAction = z.infer<typeof ResetActionSchema>;
454
446
  export type StateSource = z.infer<typeof StateSourceSchema>;
455
447
 
456
448
  // =============================================================================
457
- // Architecture schema — pure semantic data, every visual/layout field stripped.
458
- // What lives on disk in <project>/.seeflow/architecture.json after the split.
449
+ // Flow schema — pure semantic data, every visual/layout field stripped.
450
+ // What lives on disk in <project>/.seeflow/flow.json after the split.
459
451
  // =============================================================================
460
452
 
461
- const ArchitectureNodeDataBaseShape = {
453
+ const FlowNodeDataBaseShape = {
462
454
  name: z.string().min(1),
463
455
  kind: z.string().min(1),
464
456
  stateSource: StateSourceSchema,
@@ -467,23 +459,23 @@ const ArchitectureNodeDataBaseShape = {
467
459
  ...NodeDescriptionBaseShape,
468
460
  };
469
461
 
470
- const ArchitecturePlayNodeDataSchema = z
462
+ const FlowPlayNodeDataSchema = z
471
463
  .object({
472
- ...ArchitectureNodeDataBaseShape,
464
+ ...FlowNodeDataBaseShape,
473
465
  playAction: PlayActionSchema,
474
466
  statusAction: StatusActionSchema.optional(),
475
467
  })
476
468
  .strict();
477
469
 
478
- const ArchitectureStateNodeDataSchema = z
470
+ const FlowStateNodeDataSchema = z
479
471
  .object({
480
- ...ArchitectureNodeDataBaseShape,
472
+ ...FlowNodeDataBaseShape,
481
473
  playAction: PlayActionSchema.optional(),
482
474
  statusAction: StatusActionSchema.optional(),
483
475
  })
484
476
  .strict();
485
477
 
486
- const ArchitectureShapeNodeDataSchema = z
478
+ const FlowShapeNodeDataSchema = z
487
479
  .object({
488
480
  shape: ShapeKindSchema,
489
481
  name: z.string().optional(),
@@ -491,7 +483,7 @@ const ArchitectureShapeNodeDataSchema = z
491
483
  })
492
484
  .strict();
493
485
 
494
- const ArchitectureImageNodeDataSchema = z
486
+ const FlowImageNodeDataSchema = z
495
487
  .object({
496
488
  path: z.string().min(1).refine(isCleanRelativePath, {
497
489
  message: 'path must be a relative path under .seeflow/ (no absolute / traversal)',
@@ -501,7 +493,7 @@ const ArchitectureImageNodeDataSchema = z
501
493
  })
502
494
  .strict();
503
495
 
504
- const ArchitectureIconNodeDataSchema = z
496
+ const FlowIconNodeDataSchema = z
505
497
  .object({
506
498
  icon: z.string().min(1),
507
499
  alt: z.string().optional(),
@@ -510,7 +502,7 @@ const ArchitectureIconNodeDataSchema = z
510
502
  })
511
503
  .strict();
512
504
 
513
- const ArchitectureHtmlNodeDataSchema = z
505
+ const FlowHtmlNodeDataSchema = z
514
506
  .object({
515
507
  htmlPath: z.string().min(1).refine(isCleanRelativePath, {
516
508
  message: 'htmlPath must be a relative path under .seeflow/ (no absolute / traversal)',
@@ -521,66 +513,66 @@ const ArchitectureHtmlNodeDataSchema = z
521
513
  })
522
514
  .strict();
523
515
 
524
- const ArchitectureNodeBaseShape = {
516
+ const FlowNodeBaseShape = {
525
517
  id: z.string().min(1),
526
518
  };
527
519
 
528
- const ArchitectureNodeSchema = z.discriminatedUnion('type', [
520
+ const FlowNodeSchema = z.discriminatedUnion('type', [
529
521
  z
530
522
  .object({
531
- ...ArchitectureNodeBaseShape,
523
+ ...FlowNodeBaseShape,
532
524
  type: z.literal('playNode'),
533
- data: ArchitecturePlayNodeDataSchema,
525
+ data: FlowPlayNodeDataSchema,
534
526
  })
535
527
  .strict(),
536
528
  z
537
529
  .object({
538
- ...ArchitectureNodeBaseShape,
530
+ ...FlowNodeBaseShape,
539
531
  type: z.literal('stateNode'),
540
- data: ArchitectureStateNodeDataSchema,
532
+ data: FlowStateNodeDataSchema,
541
533
  })
542
534
  .strict(),
543
535
  z
544
536
  .object({
545
- ...ArchitectureNodeBaseShape,
537
+ ...FlowNodeBaseShape,
546
538
  type: z.literal('shapeNode'),
547
- data: ArchitectureShapeNodeDataSchema,
539
+ data: FlowShapeNodeDataSchema,
548
540
  })
549
541
  .strict(),
550
542
  z
551
543
  .object({
552
- ...ArchitectureNodeBaseShape,
544
+ ...FlowNodeBaseShape,
553
545
  type: z.literal('imageNode'),
554
- data: ArchitectureImageNodeDataSchema,
546
+ data: FlowImageNodeDataSchema,
555
547
  })
556
548
  .strict(),
557
549
  z
558
550
  .object({
559
- ...ArchitectureNodeBaseShape,
551
+ ...FlowNodeBaseShape,
560
552
  type: z.literal('iconNode'),
561
- data: ArchitectureIconNodeDataSchema,
553
+ data: FlowIconNodeDataSchema,
562
554
  })
563
555
  .strict(),
564
556
  z
565
557
  .object({
566
- ...ArchitectureNodeBaseShape,
558
+ ...FlowNodeBaseShape,
567
559
  type: z.literal('htmlNode'),
568
- data: ArchitectureHtmlNodeDataSchema,
560
+ data: FlowHtmlNodeDataSchema,
569
561
  })
570
562
  .strict(),
571
563
  ]);
572
564
 
573
- const ArchitectureConnectorBaseShape = {
565
+ const FlowConnectorBaseShape = {
574
566
  id: z.string().min(1),
575
567
  source: z.string().min(1),
576
568
  target: z.string().min(1),
577
569
  label: z.string().optional(),
578
570
  };
579
571
 
580
- const ArchitectureConnectorSchema = z.discriminatedUnion('kind', [
572
+ const FlowConnectorSchema = z.discriminatedUnion('kind', [
581
573
  z
582
574
  .object({
583
- ...ArchitectureConnectorBaseShape,
575
+ ...FlowConnectorBaseShape,
584
576
  kind: z.literal('http'),
585
577
  method: HttpMethodSchema.optional(),
586
578
  url: z.string().min(1).optional(),
@@ -588,38 +580,38 @@ const ArchitectureConnectorSchema = z.discriminatedUnion('kind', [
588
580
  .strict(),
589
581
  z
590
582
  .object({
591
- ...ArchitectureConnectorBaseShape,
583
+ ...FlowConnectorBaseShape,
592
584
  kind: z.literal('event'),
593
585
  eventName: z.string().min(1),
594
586
  })
595
587
  .strict(),
596
588
  z
597
589
  .object({
598
- ...ArchitectureConnectorBaseShape,
590
+ ...FlowConnectorBaseShape,
599
591
  kind: z.literal('queue'),
600
592
  queueName: z.string().min(1),
601
593
  })
602
594
  .strict(),
603
595
  z
604
596
  .object({
605
- ...ArchitectureConnectorBaseShape,
597
+ ...FlowConnectorBaseShape,
606
598
  kind: z.literal('default'),
607
599
  })
608
600
  .strict(),
609
601
  ]);
610
602
 
611
- export const ArchitectureSchema = z
603
+ export const FlowSchema = z
612
604
  .object({
613
605
  version: z.literal(2),
614
606
  name: z.string().min(1),
615
607
  resetAction: ResetActionSchema.optional(),
616
- nodes: z.array(ArchitectureNodeSchema),
617
- connectors: z.array(ArchitectureConnectorSchema),
608
+ nodes: z.array(FlowNodeSchema),
609
+ connectors: z.array(FlowConnectorSchema),
618
610
  })
619
611
  .strict()
620
- .superRefine((arch, ctx) => {
621
- const ids = new Set(arch.nodes.map((n) => n.id));
622
- arch.connectors.forEach((c, idx) => {
612
+ .superRefine((flow, ctx) => {
613
+ const ids = new Set(flow.nodes.map((n) => n.id));
614
+ flow.connectors.forEach((c, idx) => {
623
615
  if (!ids.has(c.source)) {
624
616
  ctx.addIssue({
625
617
  code: z.ZodIssueCode.custom,
@@ -637,9 +629,9 @@ export const ArchitectureSchema = z
637
629
  });
638
630
  });
639
631
 
640
- export type Architecture = z.infer<typeof ArchitectureSchema>;
641
- export type ArchitectureNode = z.infer<typeof ArchitectureNodeSchema>;
642
- export type ArchitectureConnector = z.infer<typeof ArchitectureConnectorSchema>;
632
+ export type Flow = z.infer<typeof FlowSchema>;
633
+ export type FlowNode = z.infer<typeof FlowNodeSchema>;
634
+ export type FlowConnector = z.infer<typeof FlowConnectorSchema>;
643
635
 
644
636
  // =============================================================================
645
637
  // Style schema — keyed map of presentation overrides, side-table by id.
@@ -658,7 +650,6 @@ const NodeStyleSchema = z
658
650
  fontSize: z.number().positive().optional(),
659
651
  textColor: ColorTokenSchema.optional(),
660
652
  cornerRadius: z.number().min(0).optional(),
661
- locked: z.boolean().optional(),
662
653
  // imageNode-specific
663
654
  borderWidth: z.number().min(1).max(8).optional(),
664
655
  // iconNode-specific
@@ -25,7 +25,7 @@ import { isAbsolute, join, resolve, sep } from 'node:path';
25
25
  import type { EventBus } from './events.ts';
26
26
  import { type ProcessSpawner, type SpawnHandle, defaultProcessSpawner } from './process-spawner.ts';
27
27
  import type { FlowEntry, Registry } from './registry.ts';
28
- import { type Flow, type StatusAction, StatusReportSchema } from './schema.ts';
28
+ import { type ResolvedFlow, type StatusAction, StatusReportSchema } from './schema.ts';
29
29
  import { readMergedFlow } from './watcher.ts';
30
30
 
31
31
  export interface StatusRunner {
@@ -141,10 +141,10 @@ function truncate(s: string, n: number): string {
141
141
  return s.length > n ? `${s.slice(0, n)}…` : s;
142
142
  }
143
143
 
144
- async function loadDemo(entry: FlowEntry): Promise<Flow | undefined> {
145
- const fullPath = isAbsolute(entry.architecturePath)
146
- ? entry.architecturePath
147
- : join(entry.repoPath, entry.architecturePath);
144
+ async function loadDemo(entry: FlowEntry): Promise<ResolvedFlow | undefined> {
145
+ const fullPath = isAbsolute(entry.flowPath)
146
+ ? entry.flowPath
147
+ : join(entry.repoPath, entry.flowPath);
148
148
  if (!existsSync(fullPath)) return undefined;
149
149
  const result = readMergedFlow(fullPath);
150
150
  return result.flow ?? undefined;
@@ -155,7 +155,7 @@ interface StatusNode {
155
155
  action: StatusAction;
156
156
  }
157
157
 
158
- function collectStatusNodes(demo: Flow): StatusNode[] {
158
+ function collectStatusNodes(demo: ResolvedFlow): StatusNode[] {
159
159
  const out: StatusNode[] = [];
160
160
  for (const node of demo.nodes) {
161
161
  if (node.type !== 'playNode' && node.type !== 'stateNode') continue;
package/src/watcher.ts CHANGED
@@ -1,16 +1,31 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
2
3
  import { basename, dirname, isAbsolute, join } from 'node:path';
3
4
  import type { EventBus } from './events.ts';
4
5
  import { resolveFileRefs } from './file-ref.ts';
5
- import { mergeArchitectureAndStyle } from './merge.ts';
6
+ import { mergeFlowAndStyle } from './merge.ts';
6
7
  import type { Registry } from './registry.ts';
7
- import { type Architecture, ArchitectureSchema, type Flow, StyleSchema } from './schema.ts';
8
+ import { type Flow, FlowSchema, type ResolvedFlow, StyleSchema } from './schema.ts';
8
9
 
9
10
  const DEFAULT_DEBOUNCE_MS = 100;
10
11
 
12
+ /** Max recent self-write hashes retained per flow for own-echo suppression. */
13
+ const WRITTEN_HASH_RING_SIZE = 4;
14
+
15
+ const sha256Hex = (s: string): string => createHash('sha256').update(s).digest('hex');
16
+
17
+ /**
18
+ * Canonical "what's on disk for this flow" string used for own-write
19
+ * dedupe. Combines flow.json and style.json bytes so a self-write that
20
+ * touches either file is recognized; a NUL separator keeps the boundary
21
+ * unambiguous. `styleContent` is `''` when style.json doesn't exist.
22
+ */
23
+ const combinedContent = (flowContent: string, styleContent: string): string =>
24
+ `${flowContent}\0${styleContent}`;
25
+
11
26
  export interface FlowSnapshot {
12
27
  /** Last successfully parsed flow, if we ever saw one. */
13
- flow: Flow | null;
28
+ flow: ResolvedFlow | null;
14
29
  /** Result of the most recent parse attempt. */
15
30
  valid: boolean;
16
31
  /** Human-readable error from the most recent parse, when `valid: false`. */
@@ -41,6 +56,19 @@ export interface FlowWatcher {
41
56
  closeAll(): void;
42
57
  /** Force a reparse synchronously. Useful for tests + initial load. */
43
58
  reparse(flowId: string): FlowSnapshot | null;
59
+ /**
60
+ * Record a snapshot that the server just wrote and broadcast flow:reload
61
+ * directly from it. Stores the file-content hash so the upcoming fs-watcher
62
+ * echo for this same write is suppressed (see startWatch's debounce
63
+ * callback). `flowContent` / `styleContent` are the exact bytes written —
64
+ * pass `''` for style when style.json was deleted or doesn't exist.
65
+ */
66
+ notifyWritten(
67
+ flowId: string,
68
+ snap: FlowSnapshot,
69
+ flowContent: string,
70
+ styleContent: string,
71
+ ): void;
44
72
  /**
45
73
  * Relative paths (under `<project>/.seeflow/`) currently being watched
46
74
  * because they're referenced by a node's `data.htmlPath` or `data.path`.
@@ -69,8 +97,24 @@ interface WatchHandle {
69
97
  fileWatchers: Map<string, FileWatchEntry>;
70
98
  }
71
99
 
72
- const resolveFilePath = (repoPath: string, architecturePath: string): string =>
73
- isAbsolute(architecturePath) ? architecturePath : join(repoPath, architecturePath);
100
+ const resolveFilePath = (repoPath: string, flowPath: string): string =>
101
+ isAbsolute(flowPath) ? flowPath : join(repoPath, flowPath);
102
+
103
+ // `file://` refs in flow.json resolve against `<project>/.seeflow/` per the
104
+ // skill spec — not against the flow file's own directory. Walk up from the
105
+ // flow's parent looking for an ancestor named `.seeflow`. Fallback to the
106
+ // flow's parent for flows registered outside the `.seeflow/` convention.
107
+ const computeSeeflowRoot = (flowPath: string): string => {
108
+ const flowDir = dirname(flowPath);
109
+ let current = flowDir;
110
+ while (true) {
111
+ if (basename(current) === '.seeflow') return current;
112
+ const parent = dirname(current);
113
+ if (parent === current) break;
114
+ current = parent;
115
+ }
116
+ return flowDir;
117
+ };
74
118
 
75
119
  const isCleanRelativePath = (p: string): boolean => {
76
120
  if (!p) return false;
@@ -85,7 +129,7 @@ const isCleanRelativePath = (p: string): boolean => {
85
129
  };
86
130
 
87
131
  /**
88
- * Walk raw architecture JSON (pre-schema-parse) collecting referenced file
132
+ * Walk raw flow JSON (pre-schema-parse) collecting referenced file
89
133
  * paths: `nodes[].data.htmlPath` (htmlNode) and `nodes[].data.path`
90
134
  * (imageNode). Operates on the raw JSON so the watcher works before those
91
135
  * fields are formally validated.
@@ -110,21 +154,21 @@ const collectReferencedPaths = (raw: unknown): string[] => {
110
154
  };
111
155
 
112
156
  /**
113
- * Read architecture.json + optional style.json, resolve file:// refs in the
114
- * architecture, validate both, and merge into a Flow. Shared by the watcher
115
- * and by sync read fallbacks (getFlowImpl) so they produce identical results.
157
+ * Read flow.json + optional style.json, resolve file:// refs in the flow,
158
+ * validate both, and merge into a ResolvedFlow. Shared by the watcher and
159
+ * by sync read fallbacks (getFlowImpl) so they produce identical results.
116
160
  */
117
161
  export interface ReadMergedFlowResult {
118
- flow: Flow | null;
162
+ flow: ResolvedFlow | null;
119
163
  valid: boolean;
120
164
  error: string | null;
121
165
  /** Sorted relative paths under `<seeflowRoot>` resolved via file://. */
122
166
  fileRefs: string[];
123
- /** Architecture file paths referenced via htmlPath / imageNode.path. */
167
+ /** Flow file paths referenced via htmlPath / imageNode.path. */
124
168
  staticRefs: string[];
125
169
  }
126
170
 
127
- export function readMergedFlow(archPath: string): ReadMergedFlowResult {
171
+ export function readMergedFlow(flowPath: string): ReadMergedFlowResult {
128
172
  const empty: ReadMergedFlowResult = {
129
173
  flow: null,
130
174
  valid: false,
@@ -132,34 +176,35 @@ export function readMergedFlow(archPath: string): ReadMergedFlowResult {
132
176
  fileRefs: [],
133
177
  staticRefs: [],
134
178
  };
135
- if (!existsSync(archPath)) {
136
- return { ...empty, error: `Architecture file not found: ${archPath}` };
179
+ if (!existsSync(flowPath)) {
180
+ return { ...empty, error: `Flow file not found: ${flowPath}` };
137
181
  }
138
182
 
139
- const seeflowRoot = dirname(archPath);
140
- const stylePath = join(seeflowRoot, 'style.json');
183
+ const flowDir = dirname(flowPath);
184
+ const seeflowRoot = computeSeeflowRoot(flowPath);
185
+ const stylePath = join(flowDir, 'style.json');
141
186
 
142
- let rawArch: unknown;
187
+ let rawFlow: unknown;
143
188
  try {
144
- rawArch = JSON.parse(readFileSync(archPath, 'utf8'));
189
+ rawFlow = JSON.parse(readFileSync(flowPath, 'utf8'));
145
190
  } catch (err) {
146
191
  return {
147
192
  ...empty,
148
- error: `Invalid JSON in architecture.json: ${err instanceof Error ? err.message : String(err)}`,
193
+ error: `Invalid JSON in flow.json: ${err instanceof Error ? err.message : String(err)}`,
149
194
  };
150
195
  }
151
196
 
152
- const { resolved, refs } = resolveFileRefs(rawArch, seeflowRoot);
153
- const staticRefs = collectReferencedPaths(rawArch);
197
+ const { resolved, refs } = resolveFileRefs(rawFlow, seeflowRoot);
198
+ const staticRefs = collectReferencedPaths(rawFlow);
154
199
 
155
- const archParse = ArchitectureSchema.safeParse(resolved);
156
- if (!archParse.success) {
157
- const message = archParse.error.issues
200
+ const flowParse = FlowSchema.safeParse(resolved);
201
+ if (!flowParse.success) {
202
+ const message = flowParse.error.issues
158
203
  .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
159
204
  .join('; ');
160
205
  return {
161
206
  ...empty,
162
- error: `Architecture schema validation failed: ${message}`,
207
+ error: `Flow schema validation failed: ${message}`,
163
208
  fileRefs: refs,
164
209
  staticRefs,
165
210
  };
@@ -192,7 +237,7 @@ export function readMergedFlow(archPath: string): ReadMergedFlowResult {
192
237
  };
193
238
  }
194
239
 
195
- const flow = mergeArchitectureAndStyle(archParse.data as Architecture, styleParse.data);
240
+ const flow = mergeFlowAndStyle(flowParse.data as Flow, styleParse.data);
196
241
  return { flow, valid: true, error: null, fileRefs: refs, staticRefs };
197
242
  }
198
243
 
@@ -210,6 +255,43 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
210
255
 
211
256
  const handles = new Map<string, WatchHandle>();
212
257
  const snapshots = new Map<string, FlowSnapshot>();
258
+ /**
259
+ * Ring buffer of recent self-write content hashes per flow. The fs watcher
260
+ * computes the same hash on its debounced callback and short-circuits when
261
+ * it matches — that's how a server-initiated PATCH avoids re-broadcasting
262
+ * itself on top of the direct notifyWritten broadcast.
263
+ */
264
+ const writtenHashes = new Map<string, string[]>();
265
+
266
+ const rememberWrittenHash = (flowId: string, hash: string): void => {
267
+ const ring = writtenHashes.get(flowId);
268
+ if (!ring) {
269
+ writtenHashes.set(flowId, [hash]);
270
+ return;
271
+ }
272
+ ring.push(hash);
273
+ if (ring.length > WRITTEN_HASH_RING_SIZE) ring.shift();
274
+ };
275
+
276
+ const isOwnWriteEcho = (flowId: string, hash: string): boolean =>
277
+ writtenHashes.get(flowId)?.includes(hash) ?? false;
278
+
279
+ /**
280
+ * Read flow.json + style.json bytes at this moment so the fs callback can
281
+ * compute the same combined hash that notifyWritten recorded. Missing
282
+ * style.json maps to empty string — matches notifyWritten's contract.
283
+ */
284
+ const readCombinedFromDisk = (flowPath: string): string | null => {
285
+ let flowContent: string;
286
+ try {
287
+ flowContent = readFileSync(flowPath, 'utf8');
288
+ } catch {
289
+ return null;
290
+ }
291
+ const stylePath = join(dirname(flowPath), 'style.json');
292
+ const styleContent = existsSync(stylePath) ? readFileSync(stylePath, 'utf8') : '';
293
+ return combinedContent(flowContent, styleContent);
294
+ };
213
295
 
214
296
  // Reconcile the file-watch set for `flowId` against the desired referenced
215
297
  // paths. Closes watchers for dirs that disappeared, updates the basename
@@ -300,7 +382,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
300
382
  const reparse = (flowId: string): FlowSnapshot | null => {
301
383
  const entry = registry.getById(flowId);
302
384
  if (!entry) return null;
303
- const filePath = resolveFilePath(entry.repoPath, entry.architecturePath);
385
+ const filePath = resolveFilePath(entry.repoPath, entry.flowPath);
304
386
 
305
387
  const previous = snapshots.get(flowId) ?? null;
306
388
  const parsedAt = Date.now();
@@ -313,14 +395,14 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
313
395
  snapshots.set(flowId, next);
314
396
 
315
397
  // Reconcile the referenced-file watch set: htmlPath/imageNode.path from
316
- // architecture + any file:// targets that resolved cleanly. Schema errors
398
+ // flow + any file:// targets that resolved cleanly. Schema errors
317
399
  // shouldn't drop the watch set — the user is mid-edit and the referenced
318
400
  // files are still valid targets, so this reconciles whenever the JSON
319
401
  // parsed (even if schema validation failed).
320
402
  const handle = handles.get(flowId);
321
403
  if (handle) {
322
404
  const allRefs = [...result.fileRefs, ...result.staticRefs];
323
- reconcileFileWatchers(flowId, handle, dirname(filePath), allRefs);
405
+ reconcileFileWatchers(flowId, handle, computeSeeflowRoot(filePath), allRefs);
324
406
  }
325
407
 
326
408
  return next;
@@ -346,7 +428,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
346
428
  const entry = registry.getById(flowId);
347
429
  if (!entry) return;
348
430
 
349
- const filePath = resolveFilePath(entry.repoPath, entry.architecturePath);
431
+ const filePath = resolveFilePath(entry.repoPath, entry.flowPath);
350
432
  const dir = dirname(filePath);
351
433
  const base = basename(filePath);
352
434
 
@@ -360,7 +442,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
360
442
  let fsWatcher: FSWatcher;
361
443
  try {
362
444
  fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
363
- // React to architecture.json, style.json, or rename-on-save events
445
+ // React to flow.json, style.json, or rename-on-save events
364
446
  // (some platforms emit those with no filename).
365
447
  if (changed && changed !== base && changed !== 'style.json') return;
366
448
  const handle = handles.get(flowId);
@@ -368,6 +450,11 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
368
450
  if (handle.debounceTimer) clearTimeout(handle.debounceTimer);
369
451
  handle.debounceTimer = setTimeout(() => {
370
452
  handle.debounceTimer = null;
453
+ // Own-write dedupe: if the on-disk bytes match what the server just
454
+ // wrote (recent hash in the ring), this is our own echo — drop it.
455
+ // notifyWritten already broadcast and seeded the snapshot.
456
+ const combined = readCombinedFromDisk(filePath);
457
+ if (combined !== null && isOwnWriteEcho(flowId, sha256Hex(combined))) return;
371
458
  const snap = reparse(flowId);
372
459
  if (snap) broadcastReload(flowId, snap);
373
460
  }, debounceMs);
@@ -419,8 +506,14 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
419
506
  }
420
507
  handles.clear();
421
508
  snapshots.clear();
509
+ writtenHashes.clear();
422
510
  },
423
511
  reparse,
512
+ notifyWritten(flowId, snap, flowContent, styleContent) {
513
+ snapshots.set(flowId, snap);
514
+ rememberWrittenHash(flowId, sha256Hex(combinedContent(flowContent, styleContent)));
515
+ broadcastReload(flowId, snap);
516
+ },
424
517
  referencedPaths(flowId) {
425
518
  const h = handles.get(flowId);
426
519
  if (!h) return [];