@tuongaz/seeflow 0.1.47 → 0.1.52

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.
@@ -0,0 +1,86 @@
1
+ import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
2
+ import { basename, dirname } from 'node:path';
3
+ import type { EventBus } from './events.ts';
4
+ import type { Registry } from './registry.ts';
5
+
6
+ const DEFAULT_DEBOUNCE_MS = 100;
7
+
8
+ /**
9
+ * Internal sentinel flowId used to broadcast registry-scoped events on the
10
+ * (flowId-keyed) EventBus. SSE consumers subscribe to this exact channel.
11
+ */
12
+ export const REGISTRY_CHANNEL = '__registry__';
13
+
14
+ export interface RegistryWatcherDeps {
15
+ registry: Registry;
16
+ events: EventBus;
17
+ /** Override for tests. */
18
+ debounceMs?: number;
19
+ }
20
+
21
+ export interface RegistryWatcher {
22
+ start(): void;
23
+ close(): void;
24
+ }
25
+
26
+ export function createRegistryWatcher(deps: RegistryWatcherDeps): RegistryWatcher {
27
+ const { registry, events } = deps;
28
+ const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
29
+
30
+ const filePath = registry.path;
31
+ const dir = dirname(filePath);
32
+ const base = basename(filePath);
33
+
34
+ let fsWatcher: FSWatcher | null = null;
35
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
36
+ let started = false;
37
+
38
+ const onChange = () => {
39
+ if (!existsSync(filePath)) return;
40
+ let contents: string;
41
+ try {
42
+ contents = readFileSync(filePath, 'utf8');
43
+ } catch {
44
+ return;
45
+ }
46
+ if (registry.isOwnWrite(contents)) return;
47
+ registry.reload();
48
+ events.broadcast({
49
+ type: 'registry:reload',
50
+ flowId: REGISTRY_CHANNEL,
51
+ payload: {},
52
+ });
53
+ };
54
+
55
+ return {
56
+ start() {
57
+ if (started) return;
58
+ started = true;
59
+ if (!existsSync(dir)) {
60
+ // Parent directory may not exist yet on a clean machine. The studio
61
+ // creates it on first persist, but we'd miss that event. Bail without
62
+ // throwing — callers can choose to start() again later.
63
+ return;
64
+ }
65
+ try {
66
+ fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
67
+ if (changed && changed !== base) return;
68
+ if (debounceTimer) clearTimeout(debounceTimer);
69
+ debounceTimer = setTimeout(() => {
70
+ debounceTimer = null;
71
+ onChange();
72
+ }, debounceMs);
73
+ });
74
+ } catch (err) {
75
+ console.error(`[registry-watcher] failed to watch ${dir}:`, err);
76
+ }
77
+ },
78
+ close() {
79
+ if (debounceTimer) clearTimeout(debounceTimer);
80
+ debounceTimer = null;
81
+ if (fsWatcher) fsWatcher.close();
82
+ fsWatcher = null;
83
+ started = false;
84
+ },
85
+ };
86
+ }
package/src/registry.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
4
+ import { writeFileAtomic } from './atomic-write.ts';
3
5
  import { seeflowHome } from './paths.ts';
4
6
  import { shortId } from './short-id.ts';
5
7
 
@@ -7,6 +9,7 @@ export interface FlowEntry {
7
9
  id: string;
8
10
  slug: string;
9
11
  name: string;
12
+ description?: string;
10
13
  repoPath: string;
11
14
  flowPath: string;
12
15
  lastModified: number;
@@ -15,6 +18,7 @@ export interface FlowEntry {
15
18
 
16
19
  export interface RegisterInput {
17
20
  name: string;
21
+ description?: string;
18
22
  repoPath: string;
19
23
  flowPath: string;
20
24
  valid?: boolean;
@@ -22,6 +26,8 @@ export interface RegisterInput {
22
26
  }
23
27
 
24
28
  export interface Registry {
29
+ /** Resolved path of the registry file on disk. */
30
+ readonly path: string;
25
31
  list(): FlowEntry[];
26
32
  getById(id: string): FlowEntry | undefined;
27
33
  getBySlug(slug: string): FlowEntry | undefined;
@@ -29,6 +35,12 @@ export interface Registry {
29
35
  getByRepoPathAndFlowPath(repoPath: string, flowPath: string): FlowEntry | undefined;
30
36
  upsert(input: RegisterInput): FlowEntry;
31
37
  remove(id: string): boolean;
38
+ /** Subscribe to external changes detected via reload(). Returns unsubscribe. */
39
+ onChange(fn: () => void): () => void;
40
+ /** Drop the in-memory cache and re-read from disk. Fires onChange listeners. */
41
+ reload(): void;
42
+ /** True when `contents` matches a hash this registry recently persisted. */
43
+ isOwnWrite(contents: string): boolean;
32
44
  }
33
45
 
34
46
  export function defaultRegistryPath(): string {
@@ -43,39 +55,95 @@ export function slugify(name: string): string {
43
55
  return slug || 'demo';
44
56
  }
45
57
 
58
+ const OWN_WRITE_RING_SIZE = 4;
59
+
46
60
  export function createRegistry(options: { path?: string } = {}): Registry {
47
61
  const path = options.path ?? defaultRegistryPath();
48
62
  const entries = new Map<string, FlowEntry>();
63
+ const writtenHashes: string[] = [];
64
+ const listeners = new Set<() => void>();
65
+
66
+ const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
67
+
68
+ const rememberWrite = (contents: string) => {
69
+ writtenHashes.push(sha256(contents));
70
+ if (writtenHashes.length > OWN_WRITE_RING_SIZE) writtenHashes.shift();
71
+ };
72
+
73
+ // Mtime of the last on-disk state we observed (load or persist). Used by
74
+ // refreshIfStale() to detect external writes (notably the in-process CLI
75
+ // mutating registry.json while the studio is running) without waiting for
76
+ // the debounced fs.watch callback.
77
+ let lastSeenMtimeMs = 0;
78
+
79
+ const statMtimeMs = (): number | null => {
80
+ try {
81
+ return statSync(path).mtimeMs;
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
49
86
 
50
- if (existsSync(path)) {
87
+ const loadFromDisk = () => {
88
+ entries.clear();
89
+ if (!existsSync(path)) {
90
+ lastSeenMtimeMs = 0;
91
+ return;
92
+ }
51
93
  try {
52
94
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
53
- if (Array.isArray(parsed)) {
54
- for (const e of parsed) {
55
- if (
56
- e &&
57
- typeof e.id === 'string' &&
58
- typeof e.slug === 'string' &&
59
- typeof e.repoPath === 'string'
60
- ) {
61
- if (typeof e.flowPath !== 'string') {
62
- console.warn(
63
- `[registry] ignoring legacy entry ${e.id} (${e.slug}) — pre-split format, please re-register`,
64
- );
65
- continue;
66
- }
67
- entries.set(e.id, e as FlowEntry);
95
+ if (!Array.isArray(parsed)) {
96
+ lastSeenMtimeMs = statMtimeMs() ?? 0;
97
+ return;
98
+ }
99
+ for (const e of parsed) {
100
+ if (
101
+ e &&
102
+ typeof e.id === 'string' &&
103
+ typeof e.slug === 'string' &&
104
+ typeof e.repoPath === 'string'
105
+ ) {
106
+ if (typeof e.flowPath !== 'string') {
107
+ console.warn(
108
+ `[registry] ignoring legacy entry ${e.id} (${e.slug}) — pre-split format, please re-register`,
109
+ );
110
+ continue;
68
111
  }
112
+ const entry = e as FlowEntry;
113
+ if (entry.description !== undefined && typeof entry.description !== 'string') {
114
+ entry.description = undefined;
115
+ }
116
+ entries.set(entry.id, entry);
69
117
  }
70
118
  }
119
+ lastSeenMtimeMs = statMtimeMs() ?? 0;
71
120
  } catch (err) {
72
121
  console.error(`[registry] failed to load ${path}, starting empty:`, err);
122
+ lastSeenMtimeMs = statMtimeMs() ?? 0;
123
+ }
124
+ };
125
+
126
+ loadFromDisk();
127
+
128
+ // Cheap stat-and-reload guard for read paths. When another process (the
129
+ // in-process CLI) has written registry.json since our last load, the
130
+ // debounced fs.watch reload can lag the next HTTP read; this closes that
131
+ // gap with one stat() per request.
132
+ const refreshIfStale = () => {
133
+ const mtime = statMtimeMs();
134
+ if (mtime === null) {
135
+ if (lastSeenMtimeMs !== 0) loadFromDisk();
136
+ return;
73
137
  }
74
- }
138
+ if (mtime !== lastSeenMtimeMs) loadFromDisk();
139
+ };
75
140
 
76
141
  const persist = () => {
77
142
  mkdirSync(dirname(path), { recursive: true });
78
- writeFileSync(path, JSON.stringify([...entries.values()], null, 2));
143
+ const contents = JSON.stringify([...entries.values()], null, 2);
144
+ rememberWrite(contents);
145
+ writeFileAtomic(path, contents);
146
+ lastSeenMtimeMs = statMtimeMs() ?? lastSeenMtimeMs;
79
147
  };
80
148
 
81
149
  const findByRepoPath = (repoPath: string): FlowEntry | undefined => {
@@ -101,19 +169,39 @@ export function createRegistry(options: { path?: string } = {}): Registry {
101
169
  };
102
170
 
103
171
  return {
104
- list: () => [...entries.values()],
105
- getById: (id) => entries.get(id),
106
- getBySlug: (slug) => [...entries.values()].find((e) => e.slug === slug),
107
- getByRepoPath: findByRepoPath,
108
- getByRepoPathAndFlowPath: findByRepoPathAndFlowPath,
172
+ path,
173
+ list: () => {
174
+ refreshIfStale();
175
+ return [...entries.values()];
176
+ },
177
+ getById: (id) => {
178
+ refreshIfStale();
179
+ return entries.get(id);
180
+ },
181
+ getBySlug: (slug) => {
182
+ refreshIfStale();
183
+ return [...entries.values()].find((e) => e.slug === slug);
184
+ },
185
+ getByRepoPath: (repoPath) => {
186
+ refreshIfStale();
187
+ return findByRepoPath(repoPath);
188
+ },
189
+ getByRepoPathAndFlowPath: (repoPath, flowPath) => {
190
+ refreshIfStale();
191
+ return findByRepoPathAndFlowPath(repoPath, flowPath);
192
+ },
109
193
  upsert(input) {
110
194
  const lastModified = input.lastModified ?? Date.now();
111
195
  const valid = input.valid ?? true;
112
196
  const existing = findByRepoPathAndFlowPath(input.repoPath, input.flowPath);
113
197
  if (existing) {
198
+ // input.description reflects the current flow.json on every call —
199
+ // when an author removes the description, we drop it from the entry
200
+ // too (JSON.stringify skips undefined values on persist).
114
201
  const updated: FlowEntry = {
115
202
  ...existing,
116
203
  name: input.name,
204
+ description: input.description,
117
205
  flowPath: input.flowPath,
118
206
  lastModified,
119
207
  valid,
@@ -128,6 +216,7 @@ export function createRegistry(options: { path?: string } = {}): Registry {
128
216
  id,
129
217
  slug,
130
218
  name: input.name,
219
+ description: input.description,
131
220
  repoPath: input.repoPath,
132
221
  flowPath: input.flowPath,
133
222
  lastModified,
@@ -142,5 +231,24 @@ export function createRegistry(options: { path?: string } = {}): Registry {
142
231
  if (removed) persist();
143
232
  return removed;
144
233
  },
234
+ onChange(fn) {
235
+ listeners.add(fn);
236
+ return () => {
237
+ listeners.delete(fn);
238
+ };
239
+ },
240
+ reload() {
241
+ loadFromDisk();
242
+ for (const fn of listeners) {
243
+ try {
244
+ fn();
245
+ } catch (err) {
246
+ console.error('[registry] onChange listener threw:', err);
247
+ }
248
+ }
249
+ },
250
+ isOwnWrite(contents) {
251
+ return writtenHashes.includes(sha256(contents));
252
+ },
145
253
  };
146
254
  }
package/src/schema.ts CHANGED
@@ -385,6 +385,7 @@ export const ResolvedFlowSchema = z
385
385
  .object({
386
386
  version: z.literal(2),
387
387
  name: z.string().min(1),
388
+ description: z.string().optional(),
388
389
  nodes: z.array(NodeSchema),
389
390
  connectors: z.array(ConnectorSchema),
390
391
  // Optional one-shot script the studio runs when the user clicks Restart.
@@ -610,6 +611,7 @@ export const FlowSchema = z
610
611
  .object({
611
612
  version: z.literal(2),
612
613
  name: z.string().min(1),
614
+ description: z.string().optional(),
613
615
  resetAction: ResetActionSchema.optional(),
614
616
  nodes: z.array(FlowNodeSchema),
615
617
  connectors: z.array(FlowConnectorSchema),
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import { createDemoRouter } from './demo.ts';
8
8
  import { type EventBus, createEventBus } from './events.ts';
9
9
  import { createMcpServer } from './mcp.ts';
10
10
  import { type ProcessSpawner, defaultProcessSpawner } from './process-spawner.ts';
11
+ import { type RegistryWatcher, createRegistryWatcher } from './registry-watcher.ts';
11
12
  import { type Registry, createRegistry } from './registry.ts';
12
13
  import type { Spawner } from './shellout.ts';
13
14
  import { type StatusRunner, createStatusRunner } from './status-runner.ts';
@@ -33,6 +34,8 @@ export interface CreateAppOptions {
33
34
  events?: EventBus;
34
35
  /** Inject a watcher; defaults to one wired to the registry + event bus. */
35
36
  watcher?: FlowWatcher;
37
+ /** Inject a registry-watcher; defaults to one wired to the registry + event bus. */
38
+ registryWatcher?: RegistryWatcher;
36
39
  /** Skip starting fs.watch on registered demos. Useful for tests. */
37
40
  watchAllOnBoot?: boolean;
38
41
  /** Disable file watching entirely (no fs handles leaked). Useful for tests. */
@@ -74,6 +77,9 @@ export function createApp(options: CreateAppOptions = {}): Hono {
74
77
  const watcher = options.disableWatcher
75
78
  ? undefined
76
79
  : (options.watcher ?? createWatcher({ registry, events }));
80
+ const registryWatcher = options.disableWatcher
81
+ ? undefined
82
+ : (options.registryWatcher ?? createRegistryWatcher({ registry, events }));
77
83
  const statusRunner =
78
84
  options.statusRunner ??
79
85
  createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
@@ -81,6 +87,9 @@ export function createApp(options: CreateAppOptions = {}): Hono {
81
87
  if (watcher && (options.watchAllOnBoot ?? true)) {
82
88
  watcher.watchAll();
83
89
  }
90
+ if (registryWatcher && (options.watchAllOnBoot ?? true)) {
91
+ registryWatcher.start();
92
+ }
84
93
 
85
94
  const app = new Hono();
86
95
 
package/src/watcher.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
2
+ import { type FSWatcher, existsSync, readFileSync, statSync, watch } from 'node:fs';
3
3
  import { basename, dirname, isAbsolute, join } from 'node:path';
4
4
  import type { EventBus } from './events.ts';
5
5
  import { resolveFileRefs } from './file-ref.ts';
@@ -255,6 +255,13 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
255
255
 
256
256
  const handles = new Map<string, WatchHandle>();
257
257
  const snapshots = new Map<string, FlowSnapshot>();
258
+ /**
259
+ * Combined flow+style mtime witnessed during the most recent reparse, per
260
+ * flowId. Used by snapshot() to detect external writes (notably the
261
+ * in-process CLI) that the debounced fs watcher hasn't dispatched yet.
262
+ * Two files → larger of the two mtimes; missing style.json contributes 0.
263
+ */
264
+ const lastSeenMtimes = new Map<string, number>();
258
265
  /**
259
266
  * Ring buffer of recent self-write content hashes per flow. The fs watcher
260
267
  * computes the same hash on its debounced callback and short-circuits when
@@ -379,6 +386,13 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
379
386
  }
380
387
  };
381
388
 
389
+ const combinedMtimeMs = (filePath: string): number => {
390
+ const stylePath = join(dirname(filePath), 'style.json');
391
+ const flowMtime = existsSync(filePath) ? statSync(filePath).mtimeMs : 0;
392
+ const styleMtime = existsSync(stylePath) ? statSync(stylePath).mtimeMs : 0;
393
+ return Math.max(flowMtime, styleMtime);
394
+ };
395
+
382
396
  const reparse = (flowId: string): FlowSnapshot | null => {
383
397
  const entry = registry.getById(flowId);
384
398
  if (!entry) return null;
@@ -393,6 +407,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
393
407
  : { flow: previous?.flow ?? null, valid: false, error: result.error, filePath, parsedAt };
394
408
 
395
409
  snapshots.set(flowId, next);
410
+ lastSeenMtimes.set(flowId, combinedMtimeMs(filePath));
396
411
 
397
412
  // Reconcile the referenced-file watch set: imageNode.path from
398
413
  // flow + any file:// targets that resolved cleanly. Schema errors
@@ -481,7 +496,19 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
481
496
 
482
497
  return {
483
498
  snapshot(flowId) {
484
- return snapshots.get(flowId) ?? null;
499
+ const current = snapshots.get(flowId) ?? null;
500
+ if (!current) return null;
501
+ // Stat-and-reparse guard: when an external process (notably the
502
+ // in-process CLI) rewrites flow.json or style.json, the fs.watch
503
+ // callback is debounced by ~100ms — long enough for a fast HTTP read
504
+ // to hit a stale snapshot. Catching it here closes the gap with one
505
+ // stat() per read.
506
+ const seen = lastSeenMtimes.get(flowId);
507
+ const now = combinedMtimeMs(current.filePath);
508
+ if (seen !== undefined && now > seen) {
509
+ return reparse(flowId) ?? current;
510
+ }
511
+ return current;
485
512
  },
486
513
  watch(flowId) {
487
514
  startWatch(flowId);
@@ -494,6 +521,7 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
494
521
  closeFileWatchers(h);
495
522
  handles.delete(flowId);
496
523
  snapshots.delete(flowId);
524
+ lastSeenMtimes.delete(flowId);
497
525
  },
498
526
  watchAll() {
499
527
  for (const entry of registry.list()) startWatch(entry.id);
@@ -506,11 +534,13 @@ export function createWatcher(deps: WatcherDeps): FlowWatcher {
506
534
  }
507
535
  handles.clear();
508
536
  snapshots.clear();
537
+ lastSeenMtimes.clear();
509
538
  writtenHashes.clear();
510
539
  },
511
540
  reparse,
512
541
  notifyWritten(flowId, snap, flowContent, styleContent) {
513
542
  snapshots.set(flowId, snap);
543
+ lastSeenMtimes.set(flowId, combinedMtimeMs(snap.filePath));
514
544
  rememberWrittenHash(flowId, sha256Hex(combinedContent(flowContent, styleContent)));
515
545
  broadcastReload(flowId, snap);
516
546
  },