@tuongaz/seeflow 0.1.30 → 0.1.39

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/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 { ArchitectureSchema, FlowSchema } from './schema.ts';
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 DEFAULT_ARCHITECTURE_PATH = '.seeflow/architecture.json';
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
- if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
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 || sub === 'help' || sub === '--help' || sub === '-h') {
41
52
  printHelp();
53
+ } else if (sub === 'version') {
54
+ await printVersion();
42
55
  } else if (sub === 'start') {
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 <command> [options]
76
+ npx -y @tuongaz/seeflow <command> [options]
64
77
 
65
78
  Commands:
66
79
  start Start the SeeFlow Studio server (default port 4321)
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
- --daemon Start in background and exit
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/architecture.json)
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 start
83
- npx @tuongaz/seeflow start --port 8080 --daemon
84
- npx @tuongaz/seeflow register --path ./my-app
85
- npx @tuongaz/seeflow stop
101
+ npx -y @tuongaz/seeflow start
102
+ npx -y @tuongaz/seeflow start --port 8080
103
+ npx -y @tuongaz/seeflow start --foreground
104
+ npx -y @tuongaz/seeflow register --path ./my-app
105
+ npx -y @tuongaz/seeflow 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
- const port = portArg ? Number(portArg) : config.port;
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
- if (hasFlag('daemon')) {
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 architecturePath = '.seeflow/architecture.json';
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.getByRepoPathAndArchitecturePath(destDir, architecturePath)) return;
184
+ if (registry.getByRepoPathAndFlowPath(destDir, flowPath)) return;
152
185
 
153
- const flowFile = join(destDir, architecturePath);
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 = ArchitectureSchema.safeParse(demo);
196
+ const parsed = FlowSchema.safeParse(demo);
164
197
  if (!parsed.success) return;
165
198
 
166
- registry.upsert({ name: parsed.data.name, repoPath: destDir, architecturePath });
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: [process.execPath, import.meta.path, 'start', `--port=${port}`],
191
- stdio: ['ignore', 'ignore', 'ignore'],
192
- env: { ...process.env, SEEFLOW_DAEMON: '1' },
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
- return { pid: proc.pid };
249
+ if (typeof stdout === 'number') closeSync(stdout);
250
+ return { pid: proc.pid, logPath };
196
251
  }
197
252
 
198
- function runStop() {
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
- console.error(`Failed to stop pid ${pid}: ${String(err)}`);
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') ?? DEFAULT_ARCHITECTURE_PATH;
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 ${DEFAULT_ARCHITECTURE_PATH} in your repo, or pass --flow <path>.`);
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 = ArchitectureSchema.safeParse(demo);
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
- architecturePath: demoPathArg,
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
- if (await healthOk(url)) return true;
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 { FlowSchema } from './schema.ts';
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
- // Dagre-based auto-layout exactly the algorithm the web canvas's "Tidy
239
- // layout" button runs (apps/web/src/lib/auto-layout.ts), so pressing Tidy on
240
- // a freshly-assembled demo never reshuffles the diagram.
241
- //
242
- // Direction: left-to-right (LR) — Actors on the left, data stores on the
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 nodeDimensions = (n: Record<string, unknown>): { w: number; h: number } => {
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 g = new dagre.graphlib.Graph({ multigraph: true });
288
- g.setGraph({ rankdir: 'LR', nodesep: LAYOUT_NODESEP, ranksep: LAYOUT_RANKSEP });
289
- g.setDefaultEdgeLabel(() => ({}));
290
-
291
- const flowIds = new Set<string>();
292
- const dims = new Map<string, { w: number; h: number }>();
293
- for (const n of flowNodes) {
294
- const id = n.id as string;
295
- const { w, h } = nodeDimensions(n);
296
- g.setNode(id, { width: w, height: h });
297
- flowIds.add(id);
298
- dims.set(id, { w, h });
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 = g.node(id);
317
- const d = dims.get(id);
318
- if (!laid || !d) return n;
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 = FlowSchema.safeParse(req.demo);
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({