@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.
- package/README.md +14 -0
- package/dist/web/assets/{index-BYeYJkCQ.js → index-0SsuN--u.js} +3 -3
- package/dist/web/assets/{index.es-CqkMwhBu.js → index.es-DVWwGuiE.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DLHTB6Rk.js → jspdf.es.min-vbzfEOq-.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/api.ts +138 -102
- package/src/cli-helpers.ts +79 -0
- package/src/cli-manifest.ts +772 -0
- package/src/cli-ops.ts +18 -0
- package/src/cli.ts +164 -137
- package/src/events.ts +2 -1
- package/src/mcp.ts +104 -35
- package/src/merge.ts +3 -0
- package/src/operations.ts +320 -0
- package/src/registry-watcher.ts +86 -0
- package/src/registry.ts +132 -24
- package/src/schema.ts +2 -0
- package/src/server.ts +9 -0
- package/src/watcher.ts +32 -2
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
},
|