@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/README.md +6 -6
- package/dist/web/assets/index-B5Aku4dw.js +7838 -0
- package/dist/web/assets/index-BwdVgB2y.css +1 -0
- package/dist/web/assets/{index.es-B9awKpqd.js → index.es-PUp1NFtk.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-BPVV_TTL.js → jspdf.es.min-zaUNYWJ0.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 +154 -33
- 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/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
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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' || !('
|
|
285
|
-
return c.json({ error: 'Body must be {
|
|
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/
|
|
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/
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
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
|
|