@tuongaz/seeflow 0.1.25 → 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.
- package/dist/web/assets/index-BotEftAD.css +1 -0
- package/dist/web/assets/{index-BJ7xSozm.js → index-CdNWAi1U.js} +4 -4
- package/dist/web/assets/{index.es-B3xFOWmE.js → index.es-CPyvUCV3.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-Dh_oxn-h.js → jspdf.es.min-Dkq0NSxE.js} +3 -3
- package/dist/web/index.html +2 -2
- package/examples/ecommerce-platform/.seeflow/{seeflow.json → architecture.json} +14 -77
- package/examples/ecommerce-platform/.seeflow/details/api-gateway.md +14 -0
- package/examples/ecommerce-platform/.seeflow/details/auth-service.md +9 -0
- package/examples/ecommerce-platform/.seeflow/details/cart-service.md +10 -0
- package/examples/ecommerce-platform/.seeflow/details/notification-service.md +13 -0
- package/examples/ecommerce-platform/.seeflow/details/order-service.md +16 -0
- package/examples/ecommerce-platform/.seeflow/details/payment-service.md +16 -0
- package/examples/ecommerce-platform/.seeflow/details/product-service.md +10 -0
- package/examples/ecommerce-platform/.seeflow/style.json +85 -0
- package/examples/order-pipeline/.seeflow/architecture.json +93 -0
- package/examples/order-pipeline/.seeflow/details/fulfillment-service.md +21 -0
- package/examples/order-pipeline/.seeflow/details/inventory-service.md +23 -0
- package/examples/order-pipeline/.seeflow/details/payment-service.md +23 -0
- package/examples/order-pipeline/.seeflow/details/post-orders.md +19 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +2 -2
- package/examples/order-pipeline/.seeflow/style.json +42 -0
- package/package.json +1 -1
- package/src/api.ts +118 -118
- package/src/cli.ts +13 -13
- package/src/demo.ts +6 -6
- package/src/diagram.ts +4 -4
- package/src/events.ts +14 -14
- package/src/file-ref.ts +79 -0
- package/src/mcp.ts +117 -89
- package/src/merge.ts +190 -0
- package/src/operations.ts +415 -416
- package/src/proxy.ts +31 -31
- package/src/registry.ts +32 -20
- package/src/schema.ts +252 -8
- package/src/sdk-template.ts +2 -2
- package/src/sdk-writer.ts +2 -2
- package/src/server.ts +2 -2
- package/src/status-runner.ts +34 -38
- package/src/watcher.ts +165 -114
- package/dist/web/assets/index-Dwa7Bp5j.css +0 -1
- package/examples/order-pipeline/.seeflow/seeflow.json +0 -123
package/src/status-runner.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 {
|
|
28
|
-
import { type
|
|
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 `
|
|
32
|
-
restart(
|
|
33
|
-
/** Kill all status scripts for `
|
|
34
|
-
stop(
|
|
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:
|
|
144
|
-
const fullPath = isAbsolute(entry.
|
|
145
|
-
? entry.
|
|
146
|
-
: join(entry.repoPath, entry.
|
|
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
|
-
|
|
149
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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=${
|
|
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=${
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
340
|
-
const existing = trackedByDemo.get(
|
|
335
|
+
async function restart(flowId: string): Promise<void> {
|
|
336
|
+
const existing = trackedByDemo.get(flowId);
|
|
341
337
|
if (existing) {
|
|
342
|
-
trackedByDemo.delete(
|
|
338
|
+
trackedByDemo.delete(flowId);
|
|
343
339
|
await killBatch(existing);
|
|
344
340
|
}
|
|
345
341
|
|
|
346
|
-
const entry = registry.getById(
|
|
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(
|
|
351
|
+
const t = spawnStatusScript(flowId, entry.repoPath, sn);
|
|
356
352
|
if (t) batch.push(t);
|
|
357
353
|
}
|
|
358
|
-
if (batch.length > 0) trackedByDemo.set(
|
|
354
|
+
if (batch.length > 0) trackedByDemo.set(flowId, batch);
|
|
359
355
|
}
|
|
360
356
|
|
|
361
|
-
async function stop(
|
|
362
|
-
const existing = trackedByDemo.get(
|
|
357
|
+
async function stop(flowId: string): Promise<void> {
|
|
358
|
+
const existing = trackedByDemo.get(flowId);
|
|
363
359
|
if (!existing) return;
|
|
364
|
-
trackedByDemo.delete(
|
|
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
|
|
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
|
|
10
|
-
/** Last successfully parsed
|
|
11
|
-
|
|
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
|
|
31
|
+
export interface FlowWatcher {
|
|
30
32
|
/** Read the current snapshot for a demo, or null if unknown. */
|
|
31
|
-
snapshot(
|
|
33
|
+
snapshot(flowId: string): FlowSnapshot | null;
|
|
32
34
|
/** Begin watching the file backing the given demo id. Idempotent. */
|
|
33
|
-
watch(
|
|
35
|
+
watch(flowId: string): void;
|
|
34
36
|
/** Stop watching a single demo. */
|
|
35
|
-
unwatch(
|
|
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(
|
|
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(
|
|
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,
|
|
71
|
-
isAbsolute(
|
|
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
|
|
87
|
-
* `nodes[].data.htmlPath` (htmlNode) and `nodes[].data.path`
|
|
88
|
-
*
|
|
89
|
-
* are formally
|
|
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):
|
|
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,
|
|
212
|
+
const snapshots = new Map<string, FlowSnapshot>();
|
|
125
213
|
|
|
126
|
-
// Reconcile the file-watch set for `
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 = (
|
|
213
|
-
const entry = registry.getById(
|
|
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.
|
|
303
|
+
const filePath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
216
304
|
|
|
217
|
-
const previous = snapshots.get(
|
|
305
|
+
const previous = snapshots.get(flowId) ?? null;
|
|
218
306
|
const parsedAt = Date.now();
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
error,
|
|
223
|
-
filePath,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 = (
|
|
329
|
+
const broadcastReload = (flowId: string, snap: FlowSnapshot) => {
|
|
279
330
|
events.broadcast({
|
|
280
|
-
type: '
|
|
281
|
-
|
|
282
|
-
payload: snap.valid ? { valid: true,
|
|
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 = (
|
|
287
|
-
const existing = handles.get(
|
|
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(
|
|
343
|
+
handles.delete(flowId);
|
|
293
344
|
}
|
|
294
345
|
|
|
295
|
-
const entry = registry.getById(
|
|
346
|
+
const entry = registry.getById(flowId);
|
|
296
347
|
if (!entry) return;
|
|
297
348
|
|
|
298
|
-
const filePath = resolveFilePath(entry.repoPath, entry.
|
|
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(
|
|
305
|
-
if (snap) broadcastReload(
|
|
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
|
-
//
|
|
313
|
-
// some platforms emit
|
|
314
|
-
if (changed && changed !== base) return;
|
|
315
|
-
const handle = handles.get(
|
|
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(
|
|
321
|
-
if (snap) broadcastReload(
|
|
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
|
|
326
|
-
const snap = reparse(
|
|
327
|
-
if (snap) broadcastReload(
|
|
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(
|
|
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/
|
|
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(
|
|
392
|
+
reparse(flowId);
|
|
342
393
|
};
|
|
343
394
|
|
|
344
395
|
return {
|
|
345
|
-
snapshot(
|
|
346
|
-
return snapshots.get(
|
|
396
|
+
snapshot(flowId) {
|
|
397
|
+
return snapshots.get(flowId) ?? null;
|
|
347
398
|
},
|
|
348
|
-
watch(
|
|
349
|
-
startWatch(
|
|
399
|
+
watch(flowId) {
|
|
400
|
+
startWatch(flowId);
|
|
350
401
|
},
|
|
351
|
-
unwatch(
|
|
352
|
-
const h = handles.get(
|
|
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(
|
|
358
|
-
snapshots.delete(
|
|
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(
|
|
374
|
-
const h = handles.get(
|
|
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()) {
|