@tuongaz/seeflow 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/web/assets/{index-BMaMEi2a.js → index-CdNWAi1U.js} +4 -4
  2. package/dist/web/assets/{index.es-M1iBDKG6.js → index.es-CPyvUCV3.js} +1 -1
  3. package/dist/web/assets/{jspdf.es.min-xZpq8bcn.js → jspdf.es.min-Dkq0NSxE.js} +3 -3
  4. package/dist/web/index.html +1 -1
  5. package/examples/ecommerce-platform/.seeflow/{seeflow.json → architecture.json} +14 -77
  6. package/examples/ecommerce-platform/.seeflow/details/api-gateway.md +14 -0
  7. package/examples/ecommerce-platform/.seeflow/details/auth-service.md +9 -0
  8. package/examples/ecommerce-platform/.seeflow/details/cart-service.md +10 -0
  9. package/examples/ecommerce-platform/.seeflow/details/notification-service.md +13 -0
  10. package/examples/ecommerce-platform/.seeflow/details/order-service.md +16 -0
  11. package/examples/ecommerce-platform/.seeflow/details/payment-service.md +16 -0
  12. package/examples/ecommerce-platform/.seeflow/details/product-service.md +10 -0
  13. package/examples/ecommerce-platform/.seeflow/style.json +85 -0
  14. package/examples/order-pipeline/.seeflow/architecture.json +93 -0
  15. package/examples/order-pipeline/.seeflow/details/fulfillment-service.md +21 -0
  16. package/examples/order-pipeline/.seeflow/details/inventory-service.md +23 -0
  17. package/examples/order-pipeline/.seeflow/details/payment-service.md +23 -0
  18. package/examples/order-pipeline/.seeflow/details/post-orders.md +19 -0
  19. package/examples/order-pipeline/.seeflow/scripts/play.ts +2 -2
  20. package/examples/order-pipeline/.seeflow/style.json +42 -0
  21. package/package.json +1 -1
  22. package/src/api.ts +118 -118
  23. package/src/cli.ts +13 -13
  24. package/src/demo.ts +6 -6
  25. package/src/diagram.ts +4 -4
  26. package/src/events.ts +14 -14
  27. package/src/file-ref.ts +79 -0
  28. package/src/mcp.ts +117 -89
  29. package/src/merge.ts +190 -0
  30. package/src/operations.ts +415 -416
  31. package/src/proxy.ts +31 -31
  32. package/src/registry.ts +32 -20
  33. package/src/schema.ts +252 -8
  34. package/src/sdk-template.ts +2 -2
  35. package/src/sdk-writer.ts +2 -2
  36. package/src/server.ts +2 -2
  37. package/src/status-runner.ts +34 -38
  38. package/src/watcher.ts +165 -114
  39. package/examples/order-pipeline/.seeflow/seeflow.json +0 -123
@@ -4,10 +4,10 @@
4
4
  * newline-delimited JSON stdout into `node:status` SSE events.
5
5
  *
6
6
  * Lifecycle per demo (held in `trackedByDemo`):
7
- * restart(demoId) → kill previous batch (SIGTERM → 2s grace → SIGKILL in
7
+ * restart(flowId) → kill previous batch (SIGTERM → 2s grace → SIGKILL in
8
8
  * parallel) → re-read demo from disk → spawn each `statusAction` node in
9
9
  * parallel.
10
- * stop(demoId) / stopAll() → kill without respawn.
10
+ * stop(flowId) / stopAll() → kill without respawn.
11
11
  *
12
12
  * Per-script lifecycle: spawn → drain stdout line-by-line → for each line,
13
13
  * JSON.parse + StatusReportSchema.safeParse → on success broadcast `node:status`,
@@ -24,14 +24,15 @@ import { existsSync, realpathSync } from 'node:fs';
24
24
  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
- import type { DemoEntry, Registry } from './registry.ts';
28
- import { type Demo, DemoSchema, type StatusAction, StatusReportSchema } from './schema.ts';
27
+ import type { FlowEntry, Registry } from './registry.ts';
28
+ import { type Flow, type StatusAction, StatusReportSchema } from './schema.ts';
29
+ import { readMergedFlow } from './watcher.ts';
29
30
 
30
31
  export interface StatusRunner {
31
- /** Kill the current batch for `demoId` and respawn from the on-disk demo. */
32
- restart(demoId: string): Promise<void>;
33
- /** Kill all status scripts for `demoId`. */
34
- stop(demoId: string): Promise<void>;
32
+ /** Kill the current batch for `flowId` and respawn from the on-disk demo. */
33
+ restart(flowId: string): Promise<void>;
34
+ /** Kill all status scripts for `flowId`. */
35
+ stop(flowId: string): Promise<void>;
35
36
  /** Kill all status scripts for every demo. Used at studio shutdown. */
36
37
  stopAll(): Promise<void>;
37
38
  }
@@ -140,18 +141,13 @@ function truncate(s: string, n: number): string {
140
141
  return s.length > n ? `${s.slice(0, n)}…` : s;
141
142
  }
142
143
 
143
- async function loadDemo(entry: DemoEntry): Promise<Demo | undefined> {
144
- const fullPath = isAbsolute(entry.demoPath)
145
- ? entry.demoPath
146
- : join(entry.repoPath, entry.demoPath);
144
+ async function loadDemo(entry: FlowEntry): Promise<Flow | undefined> {
145
+ const fullPath = isAbsolute(entry.architecturePath)
146
+ ? entry.architecturePath
147
+ : join(entry.repoPath, entry.architecturePath);
147
148
  if (!existsSync(fullPath)) return undefined;
148
- try {
149
- const raw = await Bun.file(fullPath).json();
150
- const parsed = DemoSchema.safeParse(raw);
151
- return parsed.success ? parsed.data : undefined;
152
- } catch {
153
- return undefined;
154
- }
149
+ const result = readMergedFlow(fullPath);
150
+ return result.flow ?? undefined;
155
151
  }
156
152
 
157
153
  interface StatusNode {
@@ -159,7 +155,7 @@ interface StatusNode {
159
155
  action: StatusAction;
160
156
  }
161
157
 
162
- function collectStatusNodes(demo: Demo): StatusNode[] {
158
+ function collectStatusNodes(demo: Flow): StatusNode[] {
163
159
  const out: StatusNode[] = [];
164
160
  for (const node of demo.nodes) {
165
161
  if (node.type !== 'playNode' && node.type !== 'stateNode') continue;
@@ -176,7 +172,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
176
172
  const trackedByDemo = new Map<string, TrackedHandle[]>();
177
173
 
178
174
  function spawnStatusScript(
179
- demoId: string,
175
+ flowId: string,
180
176
  repoPath: string,
181
177
  sn: StatusNode,
182
178
  ): TrackedHandle | undefined {
@@ -186,7 +182,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
186
182
  if (!resolved.ok) {
187
183
  events.broadcast({
188
184
  type: 'node:status',
189
- demoId,
185
+ flowId,
190
186
  payload: {
191
187
  nodeId,
192
188
  state: 'error',
@@ -199,7 +195,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
199
195
 
200
196
  const runId = crypto.randomUUID();
201
197
  const env = buildChildEnv({
202
- SEEFLOW_DEMO_ID: demoId,
198
+ SEEFLOW_DEMO_ID: flowId,
203
199
  SEEFLOW_NODE_ID: nodeId,
204
200
  SEEFLOW_RUN_ID: runId,
205
201
  });
@@ -216,7 +212,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
216
212
  const message = err instanceof Error ? err.message : String(err);
217
213
  events.broadcast({
218
214
  type: 'node:status',
219
- demoId,
215
+ flowId,
220
216
  payload: {
221
217
  nodeId,
222
218
  state: 'error',
@@ -244,7 +240,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
244
240
  tracked.expectingKill = true;
245
241
  events.broadcast({
246
242
  type: 'node:status',
247
- demoId,
243
+ flowId,
248
244
  payload: {
249
245
  nodeId,
250
246
  state: 'error',
@@ -264,7 +260,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
264
260
  } catch (err) {
265
261
  const reason = err instanceof Error ? err.message : String(err);
266
262
  console.warn(
267
- `[status-runner] malformed status line (demo=${demoId} node=${nodeId}): ${truncate(trimmed, MALFORMED_LINE_TRUNCATE)} (${reason})`,
263
+ `[status-runner] malformed status line (demo=${flowId} node=${nodeId}): ${truncate(trimmed, MALFORMED_LINE_TRUNCATE)} (${reason})`,
268
264
  );
269
265
  return;
270
266
  }
@@ -272,14 +268,14 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
272
268
  if (!result.success) {
273
269
  const reason = result.error.issues[0]?.message ?? 'schema validation failed';
274
270
  console.warn(
275
- `[status-runner] invalid status report (demo=${demoId} node=${nodeId}): ${truncate(trimmed, MALFORMED_LINE_TRUNCATE)} (${reason})`,
271
+ `[status-runner] invalid status report (demo=${flowId} node=${nodeId}): ${truncate(trimmed, MALFORMED_LINE_TRUNCATE)} (${reason})`,
276
272
  );
277
273
  return;
278
274
  }
279
275
  const report = result.data;
280
276
  events.broadcast({
281
277
  type: 'node:status',
282
- demoId,
278
+ flowId,
283
279
  payload: {
284
280
  nodeId,
285
281
  state: report.state,
@@ -302,7 +298,7 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
302
298
  if (code !== 0) {
303
299
  events.broadcast({
304
300
  type: 'node:status',
305
- demoId,
301
+ flowId,
306
302
  payload: {
307
303
  nodeId,
308
304
  state: 'error',
@@ -336,14 +332,14 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
336
332
  );
337
333
  }
338
334
 
339
- async function restart(demoId: string): Promise<void> {
340
- const existing = trackedByDemo.get(demoId);
335
+ async function restart(flowId: string): Promise<void> {
336
+ const existing = trackedByDemo.get(flowId);
341
337
  if (existing) {
342
- trackedByDemo.delete(demoId);
338
+ trackedByDemo.delete(flowId);
343
339
  await killBatch(existing);
344
340
  }
345
341
 
346
- const entry = registry.getById(demoId);
342
+ const entry = registry.getById(flowId);
347
343
  if (!entry) return;
348
344
  const demo = await loadDemo(entry);
349
345
  if (!demo) return;
@@ -352,16 +348,16 @@ export function createStatusRunner(options: CreateStatusRunnerOptions): StatusRu
352
348
 
353
349
  const batch: TrackedHandle[] = [];
354
350
  for (const sn of statusNodes) {
355
- const t = spawnStatusScript(demoId, entry.repoPath, sn);
351
+ const t = spawnStatusScript(flowId, entry.repoPath, sn);
356
352
  if (t) batch.push(t);
357
353
  }
358
- if (batch.length > 0) trackedByDemo.set(demoId, batch);
354
+ if (batch.length > 0) trackedByDemo.set(flowId, batch);
359
355
  }
360
356
 
361
- async function stop(demoId: string): Promise<void> {
362
- const existing = trackedByDemo.get(demoId);
357
+ async function stop(flowId: string): Promise<void> {
358
+ const existing = trackedByDemo.get(flowId);
363
359
  if (!existing) return;
364
- trackedByDemo.delete(demoId);
360
+ trackedByDemo.delete(flowId);
365
361
  await killBatch(existing);
366
362
  }
367
363
 
package/src/watcher.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join } from 'node:path';
3
3
  import type { EventBus } from './events.ts';
4
+ import { resolveFileRefs } from './file-ref.ts';
5
+ import { mergeArchitectureAndStyle } from './merge.ts';
4
6
  import type { Registry } from './registry.ts';
5
- import { type Demo, DemoSchema } from './schema.ts';
7
+ import { type Architecture, ArchitectureSchema, type Flow, StyleSchema } from './schema.ts';
6
8
 
7
9
  const DEFAULT_DEBOUNCE_MS = 100;
8
10
 
9
- export interface DemoSnapshot {
10
- /** Last successfully parsed demo, if we ever saw one. */
11
- demo: Demo | null;
11
+ export interface FlowSnapshot {
12
+ /** Last successfully parsed flow, if we ever saw one. */
13
+ flow: Flow | null;
12
14
  /** Result of the most recent parse attempt. */
13
15
  valid: boolean;
14
16
  /** Human-readable error from the most recent parse, when `valid: false`. */
@@ -26,25 +28,25 @@ export interface WatcherDeps {
26
28
  debounceMs?: number;
27
29
  }
28
30
 
29
- export interface DemoWatcher {
31
+ export interface FlowWatcher {
30
32
  /** Read the current snapshot for a demo, or null if unknown. */
31
- snapshot(demoId: string): DemoSnapshot | null;
33
+ snapshot(flowId: string): FlowSnapshot | null;
32
34
  /** Begin watching the file backing the given demo id. Idempotent. */
33
- watch(demoId: string): void;
35
+ watch(flowId: string): void;
34
36
  /** Stop watching a single demo. */
35
- unwatch(demoId: string): void;
37
+ unwatch(flowId: string): void;
36
38
  /** Start watchers for every entry currently in the registry. */
37
39
  watchAll(): void;
38
40
  /** Stop everything (used in tests + on shutdown). */
39
41
  closeAll(): void;
40
42
  /** Force a reparse synchronously. Useful for tests + initial load. */
41
- reparse(demoId: string): DemoSnapshot | null;
43
+ reparse(flowId: string): FlowSnapshot | null;
42
44
  /**
43
45
  * Relative paths (under `<project>/.seeflow/`) currently being watched
44
46
  * because they're referenced by a node's `data.htmlPath` or `data.path`.
45
47
  * Sorted for stable assertion order. Used by tests.
46
48
  */
47
- referencedPaths(demoId: string): string[];
49
+ referencedPaths(flowId: string): string[];
48
50
  }
49
51
 
50
52
  interface FileWatchEntry {
@@ -67,8 +69,8 @@ interface WatchHandle {
67
69
  fileWatchers: Map<string, FileWatchEntry>;
68
70
  }
69
71
 
70
- const resolveFilePath = (repoPath: string, demoPath: string): string =>
71
- isAbsolute(demoPath) ? demoPath : join(repoPath, demoPath);
72
+ const resolveFilePath = (repoPath: string, architecturePath: string): string =>
73
+ isAbsolute(architecturePath) ? architecturePath : join(repoPath, architecturePath);
72
74
 
73
75
  const isCleanRelativePath = (p: string): boolean => {
74
76
  if (!p) return false;
@@ -83,11 +85,10 @@ const isCleanRelativePath = (p: string): boolean => {
83
85
  };
84
86
 
85
87
  /**
86
- * Walk raw demo JSON (pre-schema-parse) collecting referenced file paths:
87
- * `nodes[].data.htmlPath` (htmlNode) and `nodes[].data.path` (imageNode after
88
- * US-004). Operates on the raw JSON so the watcher works before those fields
89
- * are formally declared in the schema — Zod's default-strip would drop them
90
- * during validation, but the file watcher still needs to know about them.
88
+ * Walk raw architecture JSON (pre-schema-parse) collecting referenced file
89
+ * paths: `nodes[].data.htmlPath` (htmlNode) and `nodes[].data.path`
90
+ * (imageNode). Operates on the raw JSON so the watcher works before those
91
+ * fields are formally validated.
91
92
  */
92
93
  const collectReferencedPaths = (raw: unknown): string[] => {
93
94
  if (!raw || typeof raw !== 'object') return [];
@@ -108,6 +109,93 @@ const collectReferencedPaths = (raw: unknown): string[] => {
108
109
  return [...out];
109
110
  };
110
111
 
112
+ /**
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.
116
+ */
117
+ export interface ReadMergedFlowResult {
118
+ flow: Flow | null;
119
+ valid: boolean;
120
+ error: string | null;
121
+ /** Sorted relative paths under `<seeflowRoot>` resolved via file://. */
122
+ fileRefs: string[];
123
+ /** Architecture file paths referenced via htmlPath / imageNode.path. */
124
+ staticRefs: string[];
125
+ }
126
+
127
+ export function readMergedFlow(archPath: string): ReadMergedFlowResult {
128
+ const empty: ReadMergedFlowResult = {
129
+ flow: null,
130
+ valid: false,
131
+ error: null,
132
+ fileRefs: [],
133
+ staticRefs: [],
134
+ };
135
+ if (!existsSync(archPath)) {
136
+ return { ...empty, error: `Architecture file not found: ${archPath}` };
137
+ }
138
+
139
+ const seeflowRoot = dirname(archPath);
140
+ const stylePath = join(seeflowRoot, 'style.json');
141
+
142
+ let rawArch: unknown;
143
+ try {
144
+ rawArch = JSON.parse(readFileSync(archPath, 'utf8'));
145
+ } catch (err) {
146
+ return {
147
+ ...empty,
148
+ error: `Invalid JSON in architecture.json: ${err instanceof Error ? err.message : String(err)}`,
149
+ };
150
+ }
151
+
152
+ const { resolved, refs } = resolveFileRefs(rawArch, seeflowRoot);
153
+ const staticRefs = collectReferencedPaths(rawArch);
154
+
155
+ const archParse = ArchitectureSchema.safeParse(resolved);
156
+ if (!archParse.success) {
157
+ const message = archParse.error.issues
158
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
159
+ .join('; ');
160
+ return {
161
+ ...empty,
162
+ error: `Architecture schema validation failed: ${message}`,
163
+ fileRefs: refs,
164
+ staticRefs,
165
+ };
166
+ }
167
+
168
+ let rawStyle: unknown = {};
169
+ if (existsSync(stylePath)) {
170
+ try {
171
+ rawStyle = JSON.parse(readFileSync(stylePath, 'utf8'));
172
+ } catch (err) {
173
+ return {
174
+ ...empty,
175
+ error: `Invalid JSON in style.json: ${err instanceof Error ? err.message : String(err)}`,
176
+ fileRefs: refs,
177
+ staticRefs,
178
+ };
179
+ }
180
+ }
181
+
182
+ const styleParse = StyleSchema.safeParse(rawStyle);
183
+ if (!styleParse.success) {
184
+ const message = styleParse.error.issues
185
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
186
+ .join('; ');
187
+ return {
188
+ ...empty,
189
+ error: `Style schema validation failed: ${message}`,
190
+ fileRefs: refs,
191
+ staticRefs,
192
+ };
193
+ }
194
+
195
+ const flow = mergeArchitectureAndStyle(archParse.data as Architecture, styleParse.data);
196
+ return { flow, valid: true, error: null, fileRefs: refs, staticRefs };
197
+ }
198
+
111
199
  const closeFileWatchers = (handle: WatchHandle): void => {
112
200
  for (const entry of handle.fileWatchers.values()) {
113
201
  entry.fsWatcher.close();
@@ -116,18 +204,18 @@ const closeFileWatchers = (handle: WatchHandle): void => {
116
204
  handle.fileWatchers.clear();
117
205
  };
118
206
 
119
- export function createWatcher(deps: WatcherDeps): DemoWatcher {
207
+ export function createWatcher(deps: WatcherDeps): FlowWatcher {
120
208
  const { registry, events } = deps;
121
209
  const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
122
210
 
123
211
  const handles = new Map<string, WatchHandle>();
124
- const snapshots = new Map<string, DemoSnapshot>();
212
+ const snapshots = new Map<string, FlowSnapshot>();
125
213
 
126
- // Reconcile the file-watch set for `demoId` against the desired referenced
214
+ // Reconcile the file-watch set for `flowId` against the desired referenced
127
215
  // paths. Closes watchers for dirs that disappeared, updates the basename
128
216
  // map for dirs that survived, opens new fs.watch handles for new dirs.
129
217
  const reconcileFileWatchers = (
130
- demoId: string,
218
+ flowId: string,
131
219
  handle: WatchHandle,
132
220
  seeflowRoot: string,
133
221
  refs: string[],
@@ -190,14 +278,14 @@ export function createWatcher(deps: WatcherDeps): DemoWatcher {
190
278
  cur.timers.delete(changed);
191
279
  events.broadcast({
192
280
  type: 'file:changed',
193
- demoId,
281
+ flowId,
194
282
  payload: { path: rel },
195
283
  });
196
284
  }, debounceMs);
197
285
  cur.timers.set(changed, timer);
198
286
  });
199
287
  } catch (err) {
200
- console.error(`[watcher] failed to watch ${dir} for demo ${demoId}:`, err);
288
+ console.error(`[watcher] failed to watch ${dir} for demo ${flowId}:`, err);
201
289
  continue;
202
290
  }
203
291
 
@@ -209,153 +297,116 @@ export function createWatcher(deps: WatcherDeps): DemoWatcher {
209
297
  }
210
298
  };
211
299
 
212
- const reparse = (demoId: string): DemoSnapshot | null => {
213
- const entry = registry.getById(demoId);
300
+ const reparse = (flowId: string): FlowSnapshot | null => {
301
+ const entry = registry.getById(flowId);
214
302
  if (!entry) return null;
215
- const filePath = resolveFilePath(entry.repoPath, entry.demoPath);
303
+ const filePath = resolveFilePath(entry.repoPath, entry.architecturePath);
216
304
 
217
- const previous = snapshots.get(demoId) ?? null;
305
+ const previous = snapshots.get(flowId) ?? null;
218
306
  const parsedAt = Date.now();
219
- const fail = (error: string): DemoSnapshot => ({
220
- demo: previous?.demo ?? null,
221
- valid: false,
222
- error,
223
- filePath,
224
- parsedAt,
225
- });
226
-
227
- let next: DemoSnapshot;
228
- let raw: unknown = null;
229
- let rawOk = false;
230
-
231
- if (!existsSync(filePath)) {
232
- next = fail(`Demo file not found: ${filePath}`);
233
- } else {
234
- let parseError: string | null = null;
235
- try {
236
- raw = JSON.parse(readFileSync(filePath, 'utf8'));
237
- rawOk = true;
238
- } catch (err) {
239
- parseError = `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`;
240
- }
241
-
242
- if (!rawOk) {
243
- next = fail(parseError ?? 'Invalid JSON');
244
- } else {
245
- const parsed = DemoSchema.safeParse(raw);
246
- if (!parsed.success) {
247
- const message = parsed.error.issues
248
- .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
249
- .join('; ');
250
- next = fail(`Schema validation failed: ${message}`);
251
- } else {
252
- next = { demo: parsed.data, valid: true, error: null, filePath, parsedAt };
253
- }
254
- }
255
- }
256
-
257
- snapshots.set(demoId, next);
258
-
259
- // Recompute the referenced-files watch set whenever raw JSON parsed
260
- // cleanly (regardless of schema validity). Schema errors shouldn't drop
261
- // the watch set — the user is mid-edit and the referenced files are
262
- // still valid targets.
263
- if (rawOk) {
264
- const handle = handles.get(demoId);
265
- if (handle) {
266
- reconcileFileWatchers(
267
- demoId,
268
- handle,
269
- join(entry.repoPath, '.seeflow'),
270
- collectReferencedPaths(raw),
271
- );
272
- }
307
+ const result = readMergedFlow(filePath);
308
+
309
+ const next: FlowSnapshot = result.valid
310
+ ? { flow: result.flow, valid: true, error: null, filePath, parsedAt }
311
+ : { flow: previous?.flow ?? null, valid: false, error: result.error, filePath, parsedAt };
312
+
313
+ snapshots.set(flowId, next);
314
+
315
+ // Reconcile the referenced-file watch set: htmlPath/imageNode.path from
316
+ // architecture + any file:// targets that resolved cleanly. Schema errors
317
+ // shouldn't drop the watch set — the user is mid-edit and the referenced
318
+ // files are still valid targets, so this reconciles whenever the JSON
319
+ // parsed (even if schema validation failed).
320
+ const handle = handles.get(flowId);
321
+ if (handle) {
322
+ const allRefs = [...result.fileRefs, ...result.staticRefs];
323
+ reconcileFileWatchers(flowId, handle, dirname(filePath), allRefs);
273
324
  }
274
325
 
275
326
  return next;
276
327
  };
277
328
 
278
- const broadcastReload = (demoId: string, snap: DemoSnapshot) => {
329
+ const broadcastReload = (flowId: string, snap: FlowSnapshot) => {
279
330
  events.broadcast({
280
- type: 'demo:reload',
281
- demoId,
282
- payload: snap.valid ? { valid: true, demo: snap.demo } : { valid: false, error: snap.error },
331
+ type: 'flow:reload',
332
+ flowId,
333
+ payload: snap.valid ? { valid: true, flow: snap.flow } : { valid: false, error: snap.error },
283
334
  });
284
335
  };
285
336
 
286
- const startWatch = (demoId: string) => {
287
- const existing = handles.get(demoId);
337
+ const startWatch = (flowId: string) => {
338
+ const existing = handles.get(flowId);
288
339
  if (existing) {
289
340
  existing.fsWatcher.close();
290
341
  if (existing.debounceTimer) clearTimeout(existing.debounceTimer);
291
342
  closeFileWatchers(existing);
292
- handles.delete(demoId);
343
+ handles.delete(flowId);
293
344
  }
294
345
 
295
- const entry = registry.getById(demoId);
346
+ const entry = registry.getById(flowId);
296
347
  if (!entry) return;
297
348
 
298
- const filePath = resolveFilePath(entry.repoPath, entry.demoPath);
349
+ const filePath = resolveFilePath(entry.repoPath, entry.architecturePath);
299
350
  const dir = dirname(filePath);
300
351
  const base = basename(filePath);
301
352
 
302
353
  if (!existsSync(dir)) {
303
354
  // Directory missing — record an invalid snapshot but don't try to watch.
304
- const snap = reparse(demoId);
305
- if (snap) broadcastReload(demoId, snap);
355
+ const snap = reparse(flowId);
356
+ if (snap) broadcastReload(flowId, snap);
306
357
  return;
307
358
  }
308
359
 
309
360
  let fsWatcher: FSWatcher;
310
361
  try {
311
362
  fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
312
- // Only react to the demo file (or to events with no filename, which
313
- // some platforms emit for rename-on-save patterns).
314
- if (changed && changed !== base) return;
315
- const handle = handles.get(demoId);
363
+ // React to architecture.json, style.json, or rename-on-save events
364
+ // (some platforms emit those with no filename).
365
+ if (changed && changed !== base && changed !== 'style.json') return;
366
+ const handle = handles.get(flowId);
316
367
  if (!handle) return;
317
368
  if (handle.debounceTimer) clearTimeout(handle.debounceTimer);
318
369
  handle.debounceTimer = setTimeout(() => {
319
370
  handle.debounceTimer = null;
320
- const snap = reparse(demoId);
321
- if (snap) broadcastReload(demoId, snap);
371
+ const snap = reparse(flowId);
372
+ if (snap) broadcastReload(flowId, snap);
322
373
  }, debounceMs);
323
374
  });
324
375
  } catch (err) {
325
- console.error(`[watcher] failed to watch ${dir} for demo ${demoId}:`, err);
326
- const snap = reparse(demoId);
327
- if (snap) broadcastReload(demoId, snap);
376
+ console.error(`[watcher] failed to watch ${dir} for flow ${flowId}:`, err);
377
+ const snap = reparse(flowId);
378
+ if (snap) broadcastReload(flowId, snap);
328
379
  return;
329
380
  }
330
381
 
331
- handles.set(demoId, {
382
+ handles.set(flowId, {
332
383
  fsWatcher,
333
384
  debounceTimer: null,
334
385
  filePath,
335
386
  fileWatchers: new Map(),
336
387
  });
337
388
 
338
- // Seed the snapshot from disk so callers can serve GET /api/demos/:id
389
+ // Seed the snapshot from disk so callers can serve GET /api/flows/:id
339
390
  // without having to wait for the first fs event. Also seeds the
340
391
  // referenced-file watch set via reconcileFileWatchers().
341
- reparse(demoId);
392
+ reparse(flowId);
342
393
  };
343
394
 
344
395
  return {
345
- snapshot(demoId) {
346
- return snapshots.get(demoId) ?? null;
396
+ snapshot(flowId) {
397
+ return snapshots.get(flowId) ?? null;
347
398
  },
348
- watch(demoId) {
349
- startWatch(demoId);
399
+ watch(flowId) {
400
+ startWatch(flowId);
350
401
  },
351
- unwatch(demoId) {
352
- const h = handles.get(demoId);
402
+ unwatch(flowId) {
403
+ const h = handles.get(flowId);
353
404
  if (!h) return;
354
405
  h.fsWatcher.close();
355
406
  if (h.debounceTimer) clearTimeout(h.debounceTimer);
356
407
  closeFileWatchers(h);
357
- handles.delete(demoId);
358
- snapshots.delete(demoId);
408
+ handles.delete(flowId);
409
+ snapshots.delete(flowId);
359
410
  },
360
411
  watchAll() {
361
412
  for (const entry of registry.list()) startWatch(entry.id);
@@ -370,8 +421,8 @@ export function createWatcher(deps: WatcherDeps): DemoWatcher {
370
421
  snapshots.clear();
371
422
  },
372
423
  reparse,
373
- referencedPaths(demoId) {
374
- const h = handles.get(demoId);
424
+ referencedPaths(flowId) {
425
+ const h = handles.get(flowId);
375
426
  if (!h) return [];
376
427
  const paths: string[] = [];
377
428
  for (const entry of h.fileWatchers.values()) {