@tuongaz/seeflow 0.1.31 → 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/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, realpathSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import { streamSSE } from 'hono/streaming';
@@ -12,6 +12,7 @@ import {
12
12
  validateDemo,
13
13
  } from './diagram.ts';
14
14
  import type { EventBus } from './events.ts';
15
+ import { type LayoutOptions, computeLayout } from './layout.ts';
15
16
  import {
16
17
  ConnectorPatchBodySchema,
17
18
  CreateProjectBodySchema,
@@ -35,6 +36,7 @@ import {
35
36
  reorderNodeImpl,
36
37
  resolveFilePath,
37
38
  validateImpl,
39
+ writeFileAtomic,
38
40
  } from './operations.ts';
39
41
  import type { ProcessSpawner } from './process-spawner.ts';
40
42
  import {
@@ -47,7 +49,7 @@ import {
47
49
  stopAllPlays as defaultStopAllPlays,
48
50
  } from './proxy.ts';
49
51
  import type { Registry } from './registry.ts';
50
- import { FlowSchema } from './schema.ts';
52
+ import { FlowSchema, ResolvedFlowSchema } from './schema.ts';
51
53
  import { type Spawner, defaultSpawner } from './shellout.ts';
52
54
  import type { StatusRunner } from './status-runner.ts';
53
55
  import { readMergedFlow } from './watcher.ts';
@@ -270,10 +272,10 @@ export function createApi(options: ApiOptions): Hono {
270
272
  return c.json(validateDemo(parsed.data));
271
273
  });
272
274
 
273
- // POST /api/validate — stateless schema validator for the architecture +
274
- // optional style files. No flow id, no registry side-effects, no file://
275
- // resolution (validation is structural only). Returns 200 even on
276
- // validation failure — the result is the validation report itself.
275
+ // POST /api/validate — stateless schema validator for the flow + optional
276
+ // style files. No flow id, no registry side-effects, no file:// resolution
277
+ // (validation is structural only). Returns 200 even on validation failure —
278
+ // the result is the validation report itself.
277
279
  api.post('/validate', async (c) => {
278
280
  let body: unknown;
279
281
  try {
@@ -281,8 +283,8 @@ export function createApi(options: ApiOptions): Hono {
281
283
  } catch {
282
284
  return c.json({ error: 'Invalid JSON body' }, 400);
283
285
  }
284
- if (!body || typeof body !== 'object' || !('architecture' in body)) {
285
- return c.json({ error: 'Body must be { architecture, style? }' }, 400);
286
+ if (!body || typeof body !== 'object' || !('flow' in body)) {
287
+ return c.json({ error: 'Body must be { flow, style? }' }, 400);
286
288
  }
287
289
  return c.json(validateImpl(body as ValidateBody));
288
290
  });
@@ -307,7 +309,7 @@ export function createApi(options: ApiOptions): Hono {
307
309
  // POST /api/diagram/assemble — Phase 7a. The skill POSTs wiring + layout
308
310
  // and gets back the assembled demo (IDs normalized, dupes dropped, dangling
309
311
  // connectors removed, positions snapped to a 24px grid). Pure compute; the
310
- // skill writes the response to $TARGET/.seeflow/seeflow.json. No schema
312
+ // skill writes the response to $TARGET/.seeflow/flow.json. No schema
311
313
  // validation here — call /demos/validate for that.
312
314
  api.post('/diagram/assemble', async (c) => {
313
315
  let body: unknown;
@@ -320,18 +322,117 @@ export function createApi(options: ApiOptions): Hono {
320
322
  if (!parsed.success) {
321
323
  return c.json({ error: 'Invalid assemble body', issues: parsed.error.issues }, 400);
322
324
  }
323
- return c.json(assembleDemo(parsed.data));
325
+ return c.json(await assembleDemo(parsed.data));
326
+ });
327
+
328
+ // POST /api/layout — stateless ELK layout. Two request shapes:
329
+ // 1. `{ flow, options? }` — skill (Phase 3 + Phase 5). Flow is validated
330
+ // through FlowSchema; failure returns `{ ok: false, issues }` matching
331
+ // /api/validate's shape.
332
+ // 2. `{ nodes, edges, options? }` — canvas Tidy button. Loose structural
333
+ // input (id + type + measured width/height per node, id + source +
334
+ // target per edge). Skips FlowSchema validation since Tidy has DOM
335
+ // sizes but not full node data payloads.
336
+ // Both return `{ ok: true, nodes, connectors }` — positions keyed by node
337
+ // id, handle assignments keyed by connector id. Pure compute; no
338
+ // persistence.
339
+ api.post('/layout', async (c) => {
340
+ let body: unknown;
341
+ try {
342
+ body = await c.req.json();
343
+ } catch {
344
+ return c.json({ error: 'Body must be valid JSON' }, 400);
345
+ }
346
+ if (!body || typeof body !== 'object') {
347
+ return c.json(
348
+ { error: 'Body must be { flow, options? } or { nodes, edges, options? }' },
349
+ 400,
350
+ );
351
+ }
352
+ const options = (body as { options?: LayoutOptions }).options;
353
+
354
+ if ('flow' in body) {
355
+ const flowParse = FlowSchema.safeParse((body as { flow: unknown }).flow);
356
+ if (!flowParse.success) {
357
+ return c.json({
358
+ ok: false as const,
359
+ issues: flowParse.error.issues.map((i) => ({
360
+ scope: 'flow' as const,
361
+ path: [...i.path],
362
+ message: i.message,
363
+ code: i.code,
364
+ })),
365
+ });
366
+ }
367
+ const flow = flowParse.data;
368
+ const result = await computeLayout(
369
+ flow.nodes.map((n) => ({
370
+ id: n.id,
371
+ type: n.type,
372
+ // Only `shape` matters for layout (floating-annotation detection +
373
+ // shape-specific sizing). Other Flow data fields are irrelevant.
374
+ data:
375
+ n.type === 'shapeNode' ? { shape: (n.data as { shape?: string }).shape } : undefined,
376
+ })),
377
+ flow.connectors.map((c) => ({ id: c.id, source: c.source, target: c.target })),
378
+ options,
379
+ );
380
+ return c.json({ ok: true as const, nodes: result.nodes, connectors: result.connectors });
381
+ }
382
+
383
+ if ('nodes' in body && 'edges' in body) {
384
+ const { nodes, edges } = body as { nodes: unknown; edges: unknown };
385
+ if (!Array.isArray(nodes) || !Array.isArray(edges)) {
386
+ return c.json({ error: '`nodes` and `edges` must be arrays' }, 400);
387
+ }
388
+ // Trust the caller-supplied structural input — Tidy already measures
389
+ // DOM dimensions, so we use them verbatim. Any malformed entry is
390
+ // dropped silently rather than failing the whole layout.
391
+ const layoutNodes = nodes
392
+ .filter(
393
+ (n): n is { id: string; type: string; width?: number; height?: number } =>
394
+ n && typeof n === 'object' && typeof (n as { id?: unknown }).id === 'string',
395
+ )
396
+ .map((n) => ({
397
+ id: n.id,
398
+ type: n.type as
399
+ | 'playNode'
400
+ | 'stateNode'
401
+ | 'shapeNode'
402
+ | 'imageNode'
403
+ | 'iconNode'
404
+ | 'htmlNode',
405
+ data:
406
+ typeof n.width === 'number' && typeof n.height === 'number'
407
+ ? { width: n.width, height: n.height }
408
+ : undefined,
409
+ }));
410
+ const layoutEdges = edges
411
+ .filter(
412
+ (e): e is { id: string; source: string; target: string } =>
413
+ e &&
414
+ typeof e === 'object' &&
415
+ typeof (e as { id?: unknown }).id === 'string' &&
416
+ typeof (e as { source?: unknown }).source === 'string' &&
417
+ typeof (e as { target?: unknown }).target === 'string',
418
+ )
419
+ .map((e) => ({ id: e.id, source: e.source, target: e.target }));
420
+ const result = await computeLayout(layoutNodes, layoutEdges, options);
421
+ return c.json({ ok: true as const, nodes: result.nodes, connectors: result.connectors });
422
+ }
423
+
424
+ return c.json({ error: 'Body must be { flow, options? } or { nodes, edges, options? }' }, 400);
324
425
  });
325
426
 
326
427
  // POST /api/projects — UI-driven "Create new project" flow (US-020). Two
327
428
  // branches based on whether the target folder already has a SeeFlow
328
- // project set up at `<folderPath>/.seeflow/seeflow.json`:
429
+ // project set up at `<folderPath>/.seeflow/flow.json`:
329
430
  // 1. Existing setup: read + validate the on-disk demo and register it
330
431
  // as-is (no overwrite, no scaffolding). The user-supplied `name`
331
432
  // becomes the registry display name; the on-disk demo's `name` is
332
433
  // preserved on disk.
333
434
  // 2. Fresh scaffold: mkdir -p the folder + .seeflow/, write a default
334
- // scaffold seeflow.json keyed off `name`, and run the same SDK-emit
435
+ // scaffold flow.json keyed off `name`, and run the same SDK-emit
335
436
  // helper write the CLI register flow uses (a no-op for an empty
336
437
  // scaffold, but kept for parity).
337
438
  api.post('/projects', async (c) => {
@@ -578,6 +679,97 @@ export function createApi(options: ApiOptions): Hono {
578
679
  }
579
680
  });
580
681
 
682
+ // POST /api/flows/:id/layout — registered-flow ELK layout. Reads flow.json
683
+ // from disk via the registry entry, computes layout, writes style.json
684
+ // atomically next to flow.json, and broadcasts flow:reload so any open
685
+ // canvas refreshes. Body is empty or `{ options? }`. Response on success is
686
+ // just `{ ok: true }` — the layout is already on disk. On schema failure
687
+ // returns `{ ok: false, issues }` mirroring /api/validate; on missing flow
688
+ // file / unknown id / bad JSON / write failure returns HTTP 4xx/5xx.
689
+ api.post('/flows/:id/layout', async (c) => {
690
+ const id = c.req.param('id');
691
+ const entry = registry.getById(id);
692
+ if (!entry) return c.json({ error: 'unknown demo' }, 404);
693
+
694
+ const flowAbs = resolveFilePath(entry.repoPath, entry.flowPath);
695
+ if (!existsSync(flowAbs)) return c.json({ error: `Flow file not found: ${flowAbs}` }, 404);
696
+
697
+ let raw: unknown;
698
+ try {
699
+ raw = JSON.parse(readFileSync(flowAbs, 'utf8'));
700
+ } catch (err) {
701
+ return c.json(
702
+ {
703
+ error: 'Flow file is not valid JSON',
704
+ detail: err instanceof Error ? err.message : String(err),
705
+ },
706
+ 400,
707
+ );
708
+ }
709
+
710
+ const flowParse = FlowSchema.safeParse(raw);
711
+ if (!flowParse.success) {
712
+ return c.json({
713
+ ok: false as const,
714
+ issues: flowParse.error.issues.map((i) => ({
715
+ scope: 'flow' as const,
716
+ path: [...i.path],
717
+ message: i.message,
718
+ code: i.code,
719
+ })),
720
+ });
721
+ }
722
+
723
+ // Empty body is valid — the skill always uses defaults. Only parse if the
724
+ // caller actually sent something.
725
+ let options: LayoutOptions | undefined;
726
+ const text = await c.req.text();
727
+ if (text.length > 0) {
728
+ try {
729
+ const parsed = JSON.parse(text) as { options?: LayoutOptions };
730
+ options = parsed?.options;
731
+ } catch {
732
+ return c.json({ error: 'Body must be valid JSON' }, 400);
733
+ }
734
+ }
735
+
736
+ const flow = flowParse.data;
737
+ const result = await computeLayout(
738
+ flow.nodes.map((n) => ({
739
+ id: n.id,
740
+ type: n.type,
741
+ // Only `shape` matters for layout (floating-annotation detection +
742
+ // shape-specific sizing). Other Flow data fields are irrelevant.
743
+ data: n.type === 'shapeNode' ? { shape: (n.data as { shape?: string }).shape } : undefined,
744
+ })),
745
+ flow.connectors.map((c) => ({ id: c.id, source: c.source, target: c.target })),
746
+ options,
747
+ );
748
+
749
+ const styleAbs = join(dirname(flowAbs), 'style.json');
750
+ const styleContent = `${JSON.stringify(result, null, 2)}\n`;
751
+ try {
752
+ writeFileAtomic(styleAbs, styleContent);
753
+ } catch (err) {
754
+ const msg = err instanceof Error ? err.message : String(err);
755
+ return c.json({ error: `Failed to write style file: ${msg}` }, 500);
756
+ }
757
+
758
+ // Reparse + notifyWritten: the watcher seeds its snapshot AND broadcasts
759
+ // flow:reload with the new merged payload directly, while suppressing the
760
+ // fs-watcher echo that the style.json write would otherwise trigger.
761
+ const snap = watcher?.reparse(id);
762
+ if (watcher && snap) {
763
+ const flowContent = readFileSync(flowAbs, 'utf8');
764
+ watcher.notifyWritten(id, snap, flowContent, styleContent);
765
+ } else {
766
+ // No watcher (test harness, or watch() hasn't been called yet) — emit a
767
+ // bare flow:reload so any subscribers still react.
768
+ events?.broadcast({ type: 'flow:reload', flowId: id, payload: {} });
769
+ }
770
+ return c.json({ ok: true as const });
771
+ });
772
+
581
773
  api.post('/flows/:id/play/:nodeId', async (c) => {
582
774
  const id = c.req.param('id');
583
775
  const nodeId = c.req.param('nodeId');
@@ -587,7 +779,7 @@ export function createApi(options: ApiOptions): Hono {
587
779
 
588
780
  // Always re-read from disk so the user's most recent edit (validated or
589
781
  // not yet observed by the watcher) drives the actual fetch.
590
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
782
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
591
783
  if (!existsSync(fullPath)) {
592
784
  return c.json({ error: `Flow file not found: ${fullPath}` }, 404);
593
785
  }
@@ -655,7 +847,7 @@ export function createApi(options: ApiOptions): Hono {
655
847
  if (!entry) return c.json({ error: 'unknown demo' }, 404);
656
848
  if (!events) return c.json({ error: 'events not enabled' }, 500);
657
849
 
658
- const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
850
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
659
851
  if (!existsSync(fullPath)) {
660
852
  return c.json({ error: `Flow file not found: ${fullPath}` }, 404);
661
853
  }
@@ -713,7 +905,7 @@ export function createApi(options: ApiOptions): Hono {
713
905
  return c.json({ ok: true, calledResetAction });
714
906
  });
715
907
 
716
- // PATCH a single node's position back into the on-disk seeflow.json. This is
908
+ // PATCH a single node's position back into the on-disk flow.json. This is
717
909
  // the second (and only other) place the studio mutates user files — the
718
910
  // first being the SDK helper write in `register`. Atomic write via tempfile
719
911
  // + rename keeps editor diffs clean and avoids corruption mid-write.
@@ -797,7 +989,7 @@ export function createApi(options: ApiOptions): Hono {
797
989
  // the high-frequency drag fast-path above) flows through here. The mutation
798
990
  // is performed against the raw parsed JSON (so unknown v2 fields the schema
799
991
  // doesn't yet recognize survive round-trips) and the WHOLE resulting demo
800
- // is re-validated through FlowSchema before commit, preventing partial
992
+ // is re-validated through ResolvedFlowSchema before commit, preventing partial
801
993
  // writes from breaking invariants like the connector→node superRefine.
802
994
  api.patch('/flows/:id/nodes/:nodeId', async (c) => {
803
995
  const id = c.req.param('id');
@@ -834,7 +1026,7 @@ export function createApi(options: ApiOptions): Hono {
834
1026
  });
835
1027
 
836
1028
  // POST a new node into the demo. Body is the node payload (id auto-generated
837
- // server-side if absent). Atomicity + final-FlowSchema validation match the
1029
+ // server-side if absent). Atomicity + final-ResolvedFlowSchema validation match the
838
1030
  // PATCH path above, so a malformed node never produces a half-written file.
839
1031
  api.post('/flows/:id/nodes', async (c) => {
840
1032
  const id = c.req.param('id');
@@ -867,7 +1059,7 @@ export function createApi(options: ApiOptions): Hono {
867
1059
  });
868
1060
 
869
1061
  // DELETE a node and cascade-remove every connector with source === nodeId or
870
- // target === nodeId in the same atomic write. Final-FlowSchema validation
1062
+ // target === nodeId in the same atomic write. Final-ResolvedFlowSchema validation
871
1063
  // is still run after the mutation — connector cascade closure means it
872
1064
  // should always pass, but the check makes the failure mode honest if the
873
1065
  // file had a pre-existing schema violation we'd otherwise paper over.
@@ -897,7 +1089,7 @@ export function createApi(options: ApiOptions): Hono {
897
1089
  // PATCH a single connector — partial update of label/style/color/direction
898
1090
  // and (optionally) kind + per-kind payload fields. When `kind` changes,
899
1091
  // stale kind-specific fields are dropped before the merge. The whole demo
900
- // is re-validated through FlowSchema before commit so the discriminated
1092
+ // is re-validated through ResolvedFlowSchema before commit so the discriminated
901
1093
  // union catches missing-required-fields (e.g. kind='event' without
902
1094
  // eventName) and the superRefine still gates source/target referential
903
1095
  // integrity.
@@ -938,7 +1130,7 @@ export function createApi(options: ApiOptions): Hono {
938
1130
  // POST a new connector. Body is the connector payload; `id` is auto-generated
939
1131
  // server-side if absent and `kind` defaults to 'default' (the no-semantics
940
1132
  // user-drawn variant). Source/target referential integrity is enforced by
941
- // FlowSchema's superRefine on the post-mutation parse.
1133
+ // ResolvedFlowSchema's superRefine on the post-mutation parse.
942
1134
  api.post('/flows/:id/connectors', async (c) => {
943
1135
  const id = c.req.param('id');
944
1136