@tuongaz/seeflow 0.1.31 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/web/assets/index-BwdVgB2y.css +1 -0
- package/dist/web/assets/index-DTNk6GGk.js +7838 -0
- package/dist/web/assets/{index.es-B9awKpqd.js → index.es-D_iCCj4R.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-BPVV_TTL.js → jspdf.es.min-C9FG4HQT.js} +3 -3
- package/dist/web/index.html +2 -2
- package/package.json +3 -4
- package/src/api.ts +212 -20
- package/src/cli.ts +156 -35
- package/src/diagram.ts +29 -69
- package/src/layout.ts +217 -0
- package/src/mcp.ts +10 -10
- package/src/merge.ts +50 -51
- package/src/operations.ts +184 -121
- package/src/registry.ts +10 -16
- package/src/schema.ts +46 -55
- package/src/status-runner.ts +6 -6
- package/src/watcher.ts +124 -31
- package/dist/web/assets/index-CYxryPhh.css +0 -1
- package/dist/web/assets/index-CeQZymwF.js +0 -7838
- /package/examples/ecommerce-platform/.seeflow/{architecture.json → flow.json} +0 -0
- /package/examples/order-pipeline/.seeflow/{architecture.json → flow.json} +0 -0
package/src/cli.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { cpSync, existsSync } from 'node:fs';
|
|
3
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
2
|
+
import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { createEventBus } from './events.ts';
|
|
5
5
|
import { seeflowHome } from './paths.ts';
|
|
6
6
|
import { defaultProcessSpawner } from './process-spawner.ts';
|
|
7
7
|
import { type Registry, createRegistry } from './registry.ts';
|
|
8
8
|
import {
|
|
9
|
+
DEFAULT_CONFIG,
|
|
9
10
|
clearPid,
|
|
10
11
|
defaultPidPath,
|
|
11
12
|
isPidAlive,
|
|
@@ -15,13 +16,15 @@ import {
|
|
|
15
16
|
writeConfig,
|
|
16
17
|
writePid,
|
|
17
18
|
} from './runtime.ts';
|
|
18
|
-
import {
|
|
19
|
+
import { FlowSchema } from './schema.ts';
|
|
19
20
|
import { serve } from './server.ts';
|
|
20
21
|
import { createStatusRunner } from './status-runner.ts';
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
+
const DEFAULT_FLOW_PATH = '.seeflow/flow.json';
|
|
23
24
|
const HEALTH_TIMEOUT_MS = 10_000;
|
|
24
25
|
const HEALTH_POLL_INTERVAL_MS = 150;
|
|
26
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
27
|
+
const STOP_POLL_INTERVAL_MS = 100;
|
|
25
28
|
|
|
26
29
|
const argv = process.argv.slice(2);
|
|
27
30
|
const sub = argv[0];
|
|
@@ -37,12 +40,22 @@ const flagValue = (name: string): string | undefined => {
|
|
|
37
40
|
|
|
38
41
|
const hasFlag = (name: string): boolean => argv.includes(`--${name}`);
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
const DEBUG = hasFlag('debug') || process.env.SEEFLOW_DEBUG === '1';
|
|
44
|
+
const dbg = (msg: string) => {
|
|
45
|
+
if (DEBUG) console.error(`[debug] ${msg}`);
|
|
46
|
+
};
|
|
47
|
+
const daemonLogPath = () => join(seeflowHome(), 'seeflow.log');
|
|
48
|
+
|
|
49
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
50
|
+
await printVersion();
|
|
51
|
+
} else if (sub === 'help' || sub === '--help' || sub === '-h') {
|
|
41
52
|
printHelp();
|
|
42
|
-
} else if (sub === '
|
|
53
|
+
} else if (sub === 'version') {
|
|
54
|
+
await printVersion();
|
|
55
|
+
} else if (!sub || sub === 'start' || sub.startsWith('-')) {
|
|
43
56
|
await runStart();
|
|
44
57
|
} else if (sub === 'stop') {
|
|
45
|
-
runStop();
|
|
58
|
+
await runStop();
|
|
46
59
|
} else if (sub === 'register') {
|
|
47
60
|
await runRegister();
|
|
48
61
|
} else if (['unregister', 'list'].includes(sub)) {
|
|
@@ -60,29 +73,36 @@ function printHelp() {
|
|
|
60
73
|
seeflow — local studio for file-defined interactive demos
|
|
61
74
|
|
|
62
75
|
Usage:
|
|
63
|
-
npx @tuongaz/seeflow
|
|
76
|
+
npx -y @tuongaz/seeflow@latest [command] [options]
|
|
64
77
|
|
|
65
78
|
Commands:
|
|
66
|
-
start Start the SeeFlow Studio server (default port 4321)
|
|
79
|
+
start Start the SeeFlow Studio server (default port 4321) — default when no command is given
|
|
67
80
|
stop Stop a background studio instance
|
|
68
81
|
register Register a demo repo with the running studio
|
|
82
|
+
version Print the CLI version
|
|
69
83
|
help Show this help message
|
|
70
84
|
|
|
85
|
+
Global options:
|
|
86
|
+
--version, -v Print the CLI version and exit
|
|
87
|
+
|
|
71
88
|
Options (start):
|
|
72
89
|
--port <n> Listen on port n (default: 4321)
|
|
73
|
-
--
|
|
90
|
+
--foreground Run attached to the terminal (default: background)
|
|
91
|
+
--daemon Deprecated alias — background is already the default
|
|
92
|
+
--debug Verbose logs + pipe daemon output to ~/.seeflow/seeflow.log
|
|
74
93
|
|
|
75
94
|
Options (register):
|
|
76
95
|
--path <dir> Path to repo root (default: current directory)
|
|
77
96
|
--flow <file> Path to flow JSON, relative to repo root
|
|
78
|
-
(default: .seeflow/
|
|
97
|
+
(default: .seeflow/flow.json)
|
|
79
98
|
--no-start Fail if studio is not already running
|
|
80
99
|
|
|
81
100
|
Examples:
|
|
82
|
-
npx @tuongaz/seeflow
|
|
83
|
-
npx @tuongaz/seeflow
|
|
84
|
-
npx @tuongaz/seeflow
|
|
85
|
-
npx @tuongaz/seeflow
|
|
101
|
+
npx -y @tuongaz/seeflow@latest
|
|
102
|
+
npx -y @tuongaz/seeflow@latest --port 8080
|
|
103
|
+
npx -y @tuongaz/seeflow@latest start --foreground
|
|
104
|
+
npx -y @tuongaz/seeflow@latest register --path ./my-app
|
|
105
|
+
npx -y @tuongaz/seeflow@latest stop
|
|
86
106
|
`.trim(),
|
|
87
107
|
);
|
|
88
108
|
}
|
|
@@ -90,13 +110,26 @@ Examples:
|
|
|
90
110
|
async function runStart() {
|
|
91
111
|
const config = readConfig();
|
|
92
112
|
const portArg = flagValue('port');
|
|
93
|
-
|
|
113
|
+
// --port wins; otherwise always fall back to the schema default (not the
|
|
114
|
+
// last persisted value) so a previous run's port doesn't silently stick.
|
|
115
|
+
const port = portArg ? Number(portArg) : DEFAULT_CONFIG.port;
|
|
94
116
|
if (!Number.isFinite(port) || port <= 0) {
|
|
95
117
|
console.error(`Invalid --port: ${portArg}`);
|
|
96
118
|
process.exit(1);
|
|
97
119
|
}
|
|
98
120
|
|
|
99
|
-
|
|
121
|
+
// Default to background. `--foreground` (or `--no-daemon`) keeps us attached
|
|
122
|
+
// to the terminal; `--daemon` is a no-op alias kept for backwards compat. The
|
|
123
|
+
// SEEFLOW_DAEMON env var marks the spawned child, so it must always run in
|
|
124
|
+
// the foreground to avoid infinite re-spawning.
|
|
125
|
+
const isDaemonChild = process.env.SEEFLOW_DAEMON === '1';
|
|
126
|
+
const wantsForeground = hasFlag('foreground') || hasFlag('no-daemon');
|
|
127
|
+
dbg(
|
|
128
|
+
`runStart port=${port} host=${config.host} mode=${
|
|
129
|
+
isDaemonChild ? 'daemon-child' : wantsForeground ? 'foreground' : 'background'
|
|
130
|
+
}`,
|
|
131
|
+
);
|
|
132
|
+
if (!isDaemonChild && !wantsForeground) {
|
|
100
133
|
await spawnDaemon(port, config.host);
|
|
101
134
|
return;
|
|
102
135
|
}
|
|
@@ -140,7 +173,7 @@ async function seedExamples(registry: Registry) {
|
|
|
140
173
|
|
|
141
174
|
async function seedExample(registry: Registry, exampleName: string) {
|
|
142
175
|
const destDir = join(seeflowHome(), exampleName);
|
|
143
|
-
const
|
|
176
|
+
const flowPath = '.seeflow/flow.json';
|
|
144
177
|
|
|
145
178
|
// Always sync from source so that schema changes and example updates are
|
|
146
179
|
// reflected on every startup, even when the dest directory already exists.
|
|
@@ -148,9 +181,9 @@ async function seedExample(registry: Registry, exampleName: string) {
|
|
|
148
181
|
if (!existsSync(srcDir)) return;
|
|
149
182
|
cpSync(srcDir, destDir, { recursive: true });
|
|
150
183
|
|
|
151
|
-
if (registry.
|
|
184
|
+
if (registry.getByRepoPathAndFlowPath(destDir, flowPath)) return;
|
|
152
185
|
|
|
153
|
-
const flowFile = join(destDir,
|
|
186
|
+
const flowFile = join(destDir, flowPath);
|
|
154
187
|
if (!existsSync(flowFile)) return;
|
|
155
188
|
|
|
156
189
|
let demo: unknown;
|
|
@@ -160,15 +193,16 @@ async function seedExample(registry: Registry, exampleName: string) {
|
|
|
160
193
|
return;
|
|
161
194
|
}
|
|
162
195
|
|
|
163
|
-
const parsed =
|
|
196
|
+
const parsed = FlowSchema.safeParse(demo);
|
|
164
197
|
if (!parsed.success) return;
|
|
165
198
|
|
|
166
|
-
registry.upsert({ name: parsed.data.name, repoPath: destDir,
|
|
199
|
+
registry.upsert({ name: parsed.data.name, repoPath: destDir, flowPath });
|
|
167
200
|
console.log(`Seeded example: ${parsed.data.name} → ${destDir}`);
|
|
168
201
|
}
|
|
169
202
|
|
|
170
203
|
async function spawnDaemon(port: number, host: string) {
|
|
171
204
|
const url = `http://${host}:${port}`;
|
|
205
|
+
dbg(`probing existing studio at ${url}/health`);
|
|
172
206
|
if (await healthOk(url)) {
|
|
173
207
|
console.log(`Studio already running at ${url}`);
|
|
174
208
|
return;
|
|
@@ -177,25 +211,69 @@ async function spawnDaemon(port: number, host: string) {
|
|
|
177
211
|
const proc = spawnDetachedStudio(port);
|
|
178
212
|
writePid(proc.pid);
|
|
179
213
|
writeConfig({ port, host });
|
|
214
|
+
dbg(`spawned daemon pid=${proc.pid}${proc.logPath ? ` log=${proc.logPath}` : ''}`);
|
|
180
215
|
|
|
181
216
|
if (!(await waitForHealth(url, HEALTH_TIMEOUT_MS))) {
|
|
182
217
|
console.error(`Timed out waiting for studio at ${url}/health`);
|
|
218
|
+
reportDaemonFailure(proc.logPath);
|
|
183
219
|
process.exit(1);
|
|
184
220
|
}
|
|
185
221
|
console.log(`SeeFlow Studio started in background on ${url} (pid ${proc.pid})`);
|
|
222
|
+
if (proc.logPath) console.log(`Daemon log: ${proc.logPath}`);
|
|
186
223
|
}
|
|
187
224
|
|
|
188
|
-
function spawnDetachedStudio(port: number): { pid: number } {
|
|
225
|
+
function spawnDetachedStudio(port: number): { pid: number; logPath?: string } {
|
|
226
|
+
let stdout: 'ignore' | number = 'ignore';
|
|
227
|
+
let stderr: 'ignore' | number = 'ignore';
|
|
228
|
+
let logPath: string | undefined;
|
|
229
|
+
if (DEBUG) {
|
|
230
|
+
logPath = daemonLogPath();
|
|
231
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
232
|
+
const fd = openSync(logPath, 'a');
|
|
233
|
+
// Bun owns the fd once spawn runs; we close our handle after spawn returns.
|
|
234
|
+
stdout = fd;
|
|
235
|
+
stderr = fd;
|
|
236
|
+
}
|
|
237
|
+
const cmd = [process.execPath, import.meta.path, 'start', `--port=${port}`];
|
|
238
|
+
if (DEBUG) cmd.push('--debug');
|
|
189
239
|
const proc = Bun.spawn({
|
|
190
|
-
cmd
|
|
191
|
-
stdio: ['ignore',
|
|
192
|
-
env: {
|
|
240
|
+
cmd,
|
|
241
|
+
stdio: ['ignore', stdout, stderr],
|
|
242
|
+
env: {
|
|
243
|
+
...process.env,
|
|
244
|
+
SEEFLOW_DAEMON: '1',
|
|
245
|
+
...(DEBUG ? { SEEFLOW_DEBUG: '1' } : {}),
|
|
246
|
+
},
|
|
193
247
|
});
|
|
194
248
|
proc.unref();
|
|
195
|
-
|
|
249
|
+
if (typeof stdout === 'number') closeSync(stdout);
|
|
250
|
+
return { pid: proc.pid, logPath };
|
|
196
251
|
}
|
|
197
252
|
|
|
198
|
-
function
|
|
253
|
+
function reportDaemonFailure(logPath: string | undefined) {
|
|
254
|
+
if (!logPath) {
|
|
255
|
+
console.error('Hint: rerun with --debug to capture the daemon output.');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let log: string;
|
|
259
|
+
try {
|
|
260
|
+
log = readFileSync(logPath, 'utf8');
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(`(could not read ${logPath}: ${err instanceof Error ? err.message : err})`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const tail = log.split('\n').slice(-50).join('\n');
|
|
266
|
+
console.error(`\nLast lines of ${logPath}:`);
|
|
267
|
+
console.error(tail || '(log is empty — daemon exited before writing anything)');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function printVersion() {
|
|
271
|
+
const pkgPath = join(import.meta.dir, '../package.json');
|
|
272
|
+
const pkg = (await Bun.file(pkgPath).json()) as { version?: string };
|
|
273
|
+
console.log(pkg.version ?? 'unknown');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function runStop() {
|
|
199
277
|
const pid = readPid();
|
|
200
278
|
if (!pid) {
|
|
201
279
|
console.log(`No studio running (no pid file at ${defaultPidPath()}).`);
|
|
@@ -206,18 +284,57 @@ function runStop() {
|
|
|
206
284
|
clearPid();
|
|
207
285
|
return;
|
|
208
286
|
}
|
|
287
|
+
|
|
209
288
|
try {
|
|
210
289
|
process.kill(pid, 'SIGTERM');
|
|
211
|
-
console.log(`Sent SIGTERM to studio (pid ${pid}).`);
|
|
212
290
|
} catch (err) {
|
|
213
|
-
|
|
291
|
+
if (isEsrch(err)) {
|
|
292
|
+
console.log(`Studio (pid ${pid}) was already gone.`);
|
|
293
|
+
clearPid();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
console.error(`Failed to signal pid ${pid}: ${String(err)}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (await waitForExit(pid, STOP_TIMEOUT_MS)) {
|
|
301
|
+
console.log(`Stopped studio (pid ${pid}).`);
|
|
302
|
+
clearPid();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Still alive after timeout — escalate.
|
|
307
|
+
try {
|
|
308
|
+
process.kill(pid, 'SIGKILL');
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (isEsrch(err)) {
|
|
311
|
+
console.log(`Stopped studio (pid ${pid}).`);
|
|
312
|
+
clearPid();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
console.error(`Failed to force-kill pid ${pid}: ${String(err)}`);
|
|
214
316
|
process.exit(1);
|
|
215
317
|
}
|
|
318
|
+
console.warn(`Force-killed studio (pid ${pid}) after ${STOP_TIMEOUT_MS}ms.`);
|
|
319
|
+
clearPid();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
323
|
+
const deadline = Date.now() + timeoutMs;
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
if (!isPidAlive(pid)) return true;
|
|
326
|
+
await Bun.sleep(STOP_POLL_INTERVAL_MS);
|
|
327
|
+
}
|
|
328
|
+
return !isPidAlive(pid);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isEsrch(err: unknown): boolean {
|
|
332
|
+
return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ESRCH';
|
|
216
333
|
}
|
|
217
334
|
|
|
218
335
|
async function runRegister() {
|
|
219
336
|
const repoPath = resolve(flagValue('path') ?? '.');
|
|
220
|
-
const demoPathArg = flagValue('flow') ??
|
|
337
|
+
const demoPathArg = flagValue('flow') ?? DEFAULT_FLOW_PATH;
|
|
221
338
|
const noStart = hasFlag('no-start');
|
|
222
339
|
const config = readConfig();
|
|
223
340
|
const overrideUrl = process.env.SEEFLOW_STUDIO_URL?.replace(/\/+$/, '');
|
|
@@ -226,7 +343,7 @@ async function runRegister() {
|
|
|
226
343
|
const fullPath = isAbsolute(demoPathArg) ? demoPathArg : join(repoPath, demoPathArg);
|
|
227
344
|
if (!existsSync(fullPath)) {
|
|
228
345
|
console.error(`No demo file at ${fullPath}`);
|
|
229
|
-
console.error(`Create ${
|
|
346
|
+
console.error(`Create ${DEFAULT_FLOW_PATH} in your repo, or pass --flow <path>.`);
|
|
230
347
|
process.exit(1);
|
|
231
348
|
}
|
|
232
349
|
|
|
@@ -238,7 +355,7 @@ async function runRegister() {
|
|
|
238
355
|
process.exit(1);
|
|
239
356
|
}
|
|
240
357
|
|
|
241
|
-
const parsed =
|
|
358
|
+
const parsed = FlowSchema.safeParse(demo);
|
|
242
359
|
if (!parsed.success) {
|
|
243
360
|
console.error(`${fullPath} failed schema validation:`);
|
|
244
361
|
for (const issue of parsed.error.issues) {
|
|
@@ -257,7 +374,7 @@ async function runRegister() {
|
|
|
257
374
|
body: JSON.stringify({
|
|
258
375
|
name: parsed.data.name,
|
|
259
376
|
repoPath,
|
|
260
|
-
|
|
377
|
+
flowPath: demoPathArg,
|
|
261
378
|
}),
|
|
262
379
|
});
|
|
263
380
|
} catch (err) {
|
|
@@ -321,8 +438,12 @@ async function healthOk(url: string): Promise<boolean> {
|
|
|
321
438
|
|
|
322
439
|
async function waitForHealth(url: string, timeoutMs: number): Promise<boolean> {
|
|
323
440
|
const deadline = Date.now() + timeoutMs;
|
|
441
|
+
let probe = 0;
|
|
324
442
|
while (Date.now() < deadline) {
|
|
325
|
-
|
|
443
|
+
probe++;
|
|
444
|
+
const ok = await healthOk(url);
|
|
445
|
+
dbg(`health probe #${probe} → ${ok ? 'ok' : 'no'} (${url}/health)`);
|
|
446
|
+
if (ok) return true;
|
|
326
447
|
await Bun.sleep(HEALTH_POLL_INTERVAL_MS);
|
|
327
448
|
}
|
|
328
449
|
return false;
|
package/src/diagram.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import dagre from 'dagre';
|
|
2
1
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
2
|
+
import { type LayoutEdge, type LayoutNode, computeLayout } from './layout.ts';
|
|
3
|
+
import { type FlowNode, ResolvedFlowSchema } from './schema.ts';
|
|
4
4
|
|
|
5
5
|
// Pure-compute helpers backing the three diagram-pipeline endpoints. No file
|
|
6
6
|
// I/O lives here — the skill writes responses to disk on the user's machine.
|
|
@@ -153,7 +153,7 @@ const slugify = (raw: string): string =>
|
|
|
153
153
|
.replace(/^-+|-+$/g, '')
|
|
154
154
|
.slice(0, 80);
|
|
155
155
|
|
|
156
|
-
export const assembleDemo = (req: AssembleRequest): AssembleResult => {
|
|
156
|
+
export const assembleDemo = async (req: AssembleRequest): Promise<AssembleResult> => {
|
|
157
157
|
const stats: AssembleStats = {
|
|
158
158
|
nodesIn: req.wiring.nodes.length,
|
|
159
159
|
connectorsIn: req.wiring.connectors.length,
|
|
@@ -170,7 +170,7 @@ export const assembleDemo = (req: AssembleRequest): AssembleResult => {
|
|
|
170
170
|
const nodes = normalizeNodes(req.wiring.nodes, positions, stats);
|
|
171
171
|
const nodeIds = new Set(nodes.map((n) => n.id as string));
|
|
172
172
|
const connectors = normalizeConnectors(req.wiring.connectors, nodeIds, stats);
|
|
173
|
-
const positionedNodes = autoLayout(nodes, connectors, stats);
|
|
173
|
+
const positionedNodes = await autoLayout(nodes, connectors, stats);
|
|
174
174
|
|
|
175
175
|
stats.nodesOut = positionedNodes.length;
|
|
176
176
|
stats.connectorsOut = connectors.length;
|
|
@@ -235,89 +235,49 @@ const normalizeConnectors = (
|
|
|
235
235
|
return [...seen.values()];
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
// a freshly-assembled demo
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// right, matching the lifecycle-lane intent of the layout-arranger agent.
|
|
244
|
-
// • `nodesep` / `ranksep` defaults give connectors a bit of extra length so
|
|
245
|
-
// edge labels fit and the canvas reads at a glance.
|
|
246
|
-
// • Sticky / text shape nodes stay pinned to their input position — they
|
|
247
|
-
// are floating annotations, not part of the flow graph.
|
|
248
|
-
const LAYOUT_NODESEP = 60;
|
|
249
|
-
const LAYOUT_RANKSEP = 140;
|
|
250
|
-
const LAYOUT_DEFAULT_W = 200;
|
|
251
|
-
const LAYOUT_DEFAULT_H = 120;
|
|
252
|
-
|
|
238
|
+
// ELK-backed auto-layout. Delegates to `computeLayout` in layout.ts, the
|
|
239
|
+
// same engine that backs POST /api/layout and the canvas Tidy button — so
|
|
240
|
+
// pressing Tidy on a freshly-assembled demo doesn't reshuffle the diagram.
|
|
241
|
+
// Single-flow-node graphs short-circuit so callers can pin standalone
|
|
242
|
+
// nodes via `layout.positions`.
|
|
253
243
|
const isFloatingAnnotation = (n: Record<string, unknown>): boolean => {
|
|
254
244
|
if (n.type !== 'shapeNode') return false;
|
|
255
245
|
const shape = (n.data as { shape?: string } | undefined)?.shape;
|
|
256
246
|
return shape === 'sticky' || shape === 'text';
|
|
257
247
|
};
|
|
258
248
|
|
|
259
|
-
const
|
|
260
|
-
const data = (n.data ?? {}) as { width?: number; height?: number; shape?: string };
|
|
261
|
-
const w =
|
|
262
|
-
typeof data.width === 'number'
|
|
263
|
-
? data.width
|
|
264
|
-
: n.type === 'shapeNode' && data.shape === 'text'
|
|
265
|
-
? 160
|
|
266
|
-
: LAYOUT_DEFAULT_W;
|
|
267
|
-
let h = LAYOUT_DEFAULT_H;
|
|
268
|
-
if (typeof data.height === 'number') h = data.height;
|
|
269
|
-
else if (n.type === 'shapeNode' && data.shape === 'text') h = 40;
|
|
270
|
-
else if (n.type === 'shapeNode' && data.shape === 'sticky') h = 180;
|
|
271
|
-
else if (n.type === 'imageNode') h = 150;
|
|
272
|
-
return { w, h };
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
const autoLayout = (
|
|
249
|
+
const autoLayout = async (
|
|
276
250
|
nodes: ReadonlyArray<Record<string, unknown>>,
|
|
277
251
|
connectors: ReadonlyArray<Record<string, unknown>>,
|
|
278
252
|
stats: AssembleStats,
|
|
279
|
-
): Array<Record<string, unknown
|
|
253
|
+
): Promise<Array<Record<string, unknown>>> => {
|
|
280
254
|
if (nodes.length === 0) return [];
|
|
281
255
|
|
|
282
256
|
const flowNodes = nodes.filter((n) => !isFloatingAnnotation(n));
|
|
283
|
-
// A single flow node has no layout to compute — keep its input position so
|
|
284
|
-
// callers can pin standalone nodes via `layout.positions`.
|
|
285
257
|
if (flowNodes.length <= 1) return [...nodes];
|
|
286
258
|
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
let edgeCounter = 0;
|
|
302
|
-
for (const c of connectors) {
|
|
303
|
-
const s = c.source as string;
|
|
304
|
-
const t = c.target as string;
|
|
305
|
-
if (!flowIds.has(s) || !flowIds.has(t) || s === t) continue;
|
|
306
|
-
// multigraph + unique edge name preserves parallel connectors.
|
|
307
|
-
g.setEdge(s, t, {}, `e${edgeCounter++}`);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
dagre.layout(g);
|
|
259
|
+
const layoutNodes: LayoutNode[] = nodes.map((n) => ({
|
|
260
|
+
id: n.id as string,
|
|
261
|
+
type: n.type as FlowNode['type'],
|
|
262
|
+
data: n.data as LayoutNode['data'],
|
|
263
|
+
}));
|
|
264
|
+
const layoutEdges: LayoutEdge[] = connectors
|
|
265
|
+
.filter((c) => c.source !== c.target)
|
|
266
|
+
.map((c, idx) => ({
|
|
267
|
+
id: (c.id as string | undefined) ?? `e${idx}`,
|
|
268
|
+
source: c.source as string,
|
|
269
|
+
target: c.target as string,
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
const result = await computeLayout(layoutNodes, layoutEdges);
|
|
311
273
|
|
|
312
274
|
const snap = (v: number) => Math.round(v / GRID) * GRID;
|
|
313
275
|
return nodes.map((n) => {
|
|
314
276
|
if (isFloatingAnnotation(n)) return n;
|
|
315
277
|
const id = n.id as string;
|
|
316
|
-
const laid =
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// dagre returns center coords; convert to top-left for React Flow.
|
|
320
|
-
const snapped = { x: snap(laid.x - d.w / 2), y: snap(laid.y - d.h / 2) };
|
|
278
|
+
const laid = result.nodes[id]?.position;
|
|
279
|
+
if (!laid) return n;
|
|
280
|
+
const snapped = { x: snap(laid.x), y: snap(laid.y) };
|
|
321
281
|
const prev = (n.position as { x: number; y: number } | undefined) ?? { x: 0, y: 0 };
|
|
322
282
|
if (snapped.x !== prev.x || snapped.y !== prev.y) stats.positionsShifted++;
|
|
323
283
|
return { ...n, position: snapped };
|
|
@@ -364,7 +324,7 @@ export const validateDemo = (req: ValidateRequest): ValidateReport => {
|
|
|
364
324
|
const issues: ValidateIssue[] = [];
|
|
365
325
|
const warnings: ValidateIssue[] = [];
|
|
366
326
|
|
|
367
|
-
const parsed =
|
|
327
|
+
const parsed = ResolvedFlowSchema.safeParse(req.demo);
|
|
368
328
|
if (!parsed.success) {
|
|
369
329
|
for (const issue of parsed.error.issues) {
|
|
370
330
|
issues.push({
|