@tuongaz/seeflow 0.1.42 → 0.1.51

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/operations.ts CHANGED
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSyn
11
11
  import { dirname, isAbsolute, join } from 'node:path';
12
12
  import { type ZodIssue, z } from 'zod';
13
13
  import { writeFileAtomic } from './atomic-write.ts';
14
+ import { type LayoutOptions, computeLayout } from './layout.ts';
14
15
  import { mergeFlowAndStyle, splitFlow } from './merge.ts';
15
16
  import {
16
17
  EXTERNALIZED_NODE_FIELDS,
@@ -281,11 +282,58 @@ export interface CreateProjectSuccess {
281
282
 
282
283
  export type ListFlowsOutcome = { kind: 'ok'; data: FlowListItem[] };
283
284
 
285
+ // Minimal projection for agent/CLI discovery — `description` and `name` come
286
+ // from the live watcher snapshot when available so author edits to flow.json
287
+ // surface immediately; fall back to the registry value at startup before
288
+ // any reparse has happened.
289
+ export interface FlowSummary {
290
+ id: string;
291
+ name: string;
292
+ description?: string;
293
+ }
294
+
295
+ export type ListFlowsSummaryOutcome = { kind: 'ok'; data: FlowSummary[] };
296
+
284
297
  export type GetFlowOutcome =
285
298
  | { kind: 'ok'; data: FlowGetResponse }
286
299
  | { kind: 'notFound' }
287
300
  | { kind: 'fileNotFound'; path: string };
288
301
 
302
+ // Lightweight graph projection — flow + nodes + connectors with file-backed
303
+ // fields (`detail` on every node, `html` on htmlNode) stripped so the
304
+ // caller can navigate the topology without paying for inlined bodies.
305
+ export interface FlowGraphResponse {
306
+ id: string;
307
+ slug: string;
308
+ name: string;
309
+ description?: string;
310
+ nodes: Array<Record<string, unknown>>;
311
+ connectors: Array<Record<string, unknown>>;
312
+ }
313
+
314
+ export type GetFlowGraphOutcome =
315
+ | { kind: 'ok'; data: FlowGraphResponse }
316
+ | { kind: 'notFound' }
317
+ | { kind: 'fileNotFound'; path: string }
318
+ | { kind: 'badJson'; detail: string }
319
+ | { kind: 'badSchema'; issues: ZodIssue[] };
320
+
321
+ // Single node, content resolved. The node shape mirrors the on-disk Flow
322
+ // node shape (no position / style) with `file://` refs already inlined.
323
+ export interface GetNodeResponse {
324
+ id: string;
325
+ flowId: string;
326
+ node: Record<string, unknown>;
327
+ }
328
+
329
+ export type GetNodeOutcome =
330
+ | { kind: 'ok'; data: GetNodeResponse }
331
+ | { kind: 'notFound' }
332
+ | { kind: 'fileNotFound'; path: string }
333
+ | { kind: 'badJson'; detail: string }
334
+ | { kind: 'badSchema'; issues: ZodIssue[] }
335
+ | { kind: 'unknownNode' };
336
+
289
337
  export type RegisterFlowOutcome =
290
338
  | { kind: 'ok'; data: RegisterFlowSuccess }
291
339
  | { kind: 'fileNotFound'; path: string }
@@ -658,13 +706,27 @@ export async function mutateMergedFlow<E extends { kind: string }>(
658
706
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
659
707
  }
660
708
 
661
- const snap: FlowSnapshot = {
662
- flow: finalParse.data as ResolvedFlow,
663
- valid: true,
664
- error: null,
665
- filePath: flowPath,
666
- parsedAt: Date.now(),
667
- };
709
+ // Re-read through readMergedFlow so the snapshot we hand to notifyWritten
710
+ // carries file://-resolved content (detail.md, view.html, …). The in-memory
711
+ // `merged` tree above still holds raw `file://<name>` strings — broadcasting
712
+ // it would clobber the watcher's resolved seed and ship unresolved refs to
713
+ // every SSE subscriber until the next reparse.
714
+ const reread = readMergedFlow(flowPath);
715
+ const snap: FlowSnapshot = reread.valid
716
+ ? {
717
+ flow: reread.flow,
718
+ valid: true,
719
+ error: null,
720
+ filePath: flowPath,
721
+ parsedAt: Date.now(),
722
+ }
723
+ : {
724
+ flow: finalParse.data as ResolvedFlow,
725
+ valid: true,
726
+ error: null,
727
+ filePath: flowPath,
728
+ parsedAt: Date.now(),
729
+ };
668
730
  return { kind: 'ok', snap, flowContent, styleContent };
669
731
  }
670
732
 
@@ -762,6 +824,22 @@ export function listDemosImpl(deps: OperationsDeps): ListFlowsOutcome {
762
824
  return { kind: 'ok', data };
763
825
  }
764
826
 
827
+ export function listFlowsSummaryImpl(deps: OperationsDeps): ListFlowsSummaryOutcome {
828
+ const { registry, watcher } = deps;
829
+ const data = registry.list().map((e) => {
830
+ const snap = watcher?.snapshot(e.id) ?? null;
831
+ const liveFlow = snap?.valid ? snap.flow : null;
832
+ const item: FlowSummary = {
833
+ id: e.id,
834
+ name: liveFlow?.name ?? e.name,
835
+ };
836
+ const description = liveFlow?.description ?? e.description;
837
+ if (description !== undefined) item.description = description;
838
+ return item;
839
+ });
840
+ return { kind: 'ok', data };
841
+ }
842
+
765
843
  export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise<GetFlowOutcome> {
766
844
  const { registry, watcher } = deps;
767
845
  const entry = registry.getById(flowId);
@@ -799,6 +877,101 @@ export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise
799
877
  };
800
878
  }
801
879
 
880
+ // Strip the only file-backed fields that ride on `node.data` today.
881
+ // Keep narrow on purpose — if file-ref.ts ever grows another resolved field,
882
+ // this list must grow with it (covered by the operations.test.ts coverage).
883
+ const FILE_BACKED_NODE_DATA_FIELDS = ['detail', 'html'] as const;
884
+
885
+ function stripFileBackedFields(node: Record<string, unknown>): Record<string, unknown> {
886
+ const data = node.data;
887
+ if (!data || typeof data !== 'object') return node;
888
+ const stripped: Record<string, unknown> = {};
889
+ for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
890
+ if ((FILE_BACKED_NODE_DATA_FIELDS as readonly string[]).includes(k)) continue;
891
+ stripped[k] = v;
892
+ }
893
+ return { ...node, data: stripped };
894
+ }
895
+
896
+ export async function getFlowGraphImpl(
897
+ deps: OperationsDeps,
898
+ flowId: string,
899
+ ): Promise<GetFlowGraphOutcome> {
900
+ const entry = deps.registry.getById(flowId);
901
+ if (!entry) return { kind: 'notFound' };
902
+
903
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
904
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
905
+
906
+ let raw: unknown;
907
+ try {
908
+ raw = JSON.parse(readFileSync(fullPath, 'utf8'));
909
+ } catch (err) {
910
+ return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
911
+ }
912
+
913
+ // Parse the on-disk Flow shape directly (no resolveFileRefs, no style.json
914
+ // merge) so we never read the per-node detail/html files from disk.
915
+ const parsed = FlowSchema.safeParse(raw);
916
+ if (!parsed.success) return { kind: 'badSchema', issues: parsed.error.issues };
917
+
918
+ const data: FlowGraphResponse = {
919
+ id: entry.id,
920
+ slug: entry.slug,
921
+ name: parsed.data.name,
922
+ nodes: parsed.data.nodes.map((n) => stripFileBackedFields(n as Record<string, unknown>)),
923
+ connectors: parsed.data.connectors as Array<Record<string, unknown>>,
924
+ };
925
+ if (parsed.data.description !== undefined) data.description = parsed.data.description;
926
+ return { kind: 'ok', data };
927
+ }
928
+
929
+ export async function getNodeImpl(
930
+ deps: OperationsDeps,
931
+ flowId: string,
932
+ nodeId: string,
933
+ ): Promise<GetNodeOutcome> {
934
+ const { registry, watcher } = deps;
935
+ const entry = registry.getById(flowId);
936
+ if (!entry) return { kind: 'notFound' };
937
+
938
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
939
+
940
+ // Prefer a live snapshot (already has file:// refs resolved) so we don't
941
+ // re-walk the filesystem on every call. Fall back to readMergedFlow for
942
+ // callers without a watcher (CLI, MCP-only setups).
943
+ const snap = watcher?.snapshot(flowId) ?? watcher?.reparse(flowId) ?? null;
944
+ let flow: ResolvedFlow | null = snap?.valid ? snap.flow : null;
945
+
946
+ if (!flow) {
947
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
948
+ const result = readMergedFlow(fullPath);
949
+ if (!result.valid || !result.flow) {
950
+ if (result.error?.startsWith('Invalid JSON')) {
951
+ return { kind: 'badJson', detail: result.error };
952
+ }
953
+ // Schema failed — re-parse to surface ZodIssues.
954
+ try {
955
+ const raw = JSON.parse(readFileSync(fullPath, 'utf8'));
956
+ const parsed = FlowSchema.safeParse(raw);
957
+ if (!parsed.success) return { kind: 'badSchema', issues: parsed.error.issues };
958
+ } catch (err) {
959
+ return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
960
+ }
961
+ return { kind: 'badJson', detail: result.error ?? 'unknown error' };
962
+ }
963
+ flow = result.flow;
964
+ }
965
+
966
+ const node = flow.nodes.find((n) => n.id === nodeId);
967
+ if (!node) return { kind: 'unknownNode' };
968
+
969
+ return {
970
+ kind: 'ok',
971
+ data: { id: nodeId, flowId, node: node as unknown as Record<string, unknown> },
972
+ };
973
+ }
974
+
802
975
  export async function registerFlowImpl(
803
976
  deps: OperationsDeps,
804
977
  body: RegisterBody,
@@ -831,6 +1004,7 @@ export async function registerFlowImpl(
831
1004
  const lastModified = statSync(fullPath).mtimeMs;
832
1005
  const entry = registry.upsert({
833
1006
  name: body.name ?? merged.flow.name,
1007
+ description: merged.flow.description,
834
1008
  repoPath,
835
1009
  flowPath,
836
1010
  valid: true,
@@ -1508,3 +1682,163 @@ export function validateImpl(body: ValidateBody): ValidateOutcome {
1508
1682
 
1509
1683
  return issues.length === 0 ? { ok: true } : { ok: false, issues };
1510
1684
  }
1685
+
1686
+ // ---------------------------------------------------------------------------
1687
+ // applyLayoutImpl — ELK layout for a registered flow. Reads flow.json from
1688
+ // the registry-resolved path, validates it, computes layout, writes
1689
+ // style.json atomically, and (if a watcher is present) calls notifyWritten so
1690
+ // the studio's flow watcher seeds its snapshot and broadcasts flow:reload
1691
+ // without echoing the style.json fs event.
1692
+ // ---------------------------------------------------------------------------
1693
+
1694
+ export type ApplyLayoutOutcome =
1695
+ | { kind: 'ok' }
1696
+ | { kind: 'flowNotFound' }
1697
+ | { kind: 'fileNotFound'; path: string }
1698
+ | { kind: 'badJson'; detail: string }
1699
+ | { kind: 'badSchema'; issues: ValidationIssue[] }
1700
+ | { kind: 'writeFailed'; message: string };
1701
+
1702
+ export async function applyLayoutImpl(
1703
+ deps: OperationsDeps,
1704
+ flowId: string,
1705
+ options: LayoutOptions | undefined,
1706
+ ): Promise<ApplyLayoutOutcome> {
1707
+ const entry = deps.registry.getById(flowId);
1708
+ if (!entry) return { kind: 'flowNotFound' };
1709
+
1710
+ const flowAbs = resolveFilePath(entry.repoPath, entry.flowPath);
1711
+ if (!existsSync(flowAbs)) return { kind: 'fileNotFound', path: flowAbs };
1712
+
1713
+ let raw: unknown;
1714
+ try {
1715
+ raw = JSON.parse(readFileSync(flowAbs, 'utf8'));
1716
+ } catch (err) {
1717
+ return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
1718
+ }
1719
+
1720
+ const flowParse = FlowSchema.safeParse(raw);
1721
+ if (!flowParse.success) {
1722
+ return {
1723
+ kind: 'badSchema',
1724
+ issues: flowParse.error.issues.map((i) => ({
1725
+ scope: 'flow',
1726
+ path: [...i.path],
1727
+ message: i.message,
1728
+ code: i.code,
1729
+ })),
1730
+ };
1731
+ }
1732
+
1733
+ const flow = flowParse.data;
1734
+ const result = await computeLayout(
1735
+ flow.nodes.map((n) => ({
1736
+ id: n.id,
1737
+ type: n.type,
1738
+ data: n.type === 'shapeNode' ? { shape: (n.data as { shape?: string }).shape } : undefined,
1739
+ })),
1740
+ flow.connectors.map((c) => ({ id: c.id, source: c.source, target: c.target })),
1741
+ options,
1742
+ );
1743
+
1744
+ const styleAbs = join(dirname(flowAbs), 'style.json');
1745
+ const styleContent = `${JSON.stringify(result, null, 2)}\n`;
1746
+ try {
1747
+ writeFileAtomic(styleAbs, styleContent);
1748
+ } catch (err) {
1749
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1750
+ }
1751
+
1752
+ // Inform the watcher so it broadcasts flow:reload with the new payload and
1753
+ // suppresses the upcoming fs-watcher echo for this style.json write.
1754
+ const snap = deps.watcher?.reparse(flowId);
1755
+ if (deps.watcher && snap) {
1756
+ const flowContent = readFileSync(flowAbs, 'utf8');
1757
+ deps.watcher.notifyWritten(flowId, snap, flowContent, styleContent);
1758
+ }
1759
+
1760
+ return { kind: 'ok' };
1761
+ }
1762
+
1763
+ // ---------------------------------------------------------------------------
1764
+ // createOperations — thin handle that exposes every *Impl as a bound method.
1765
+ // Consumers (api.ts, mcp.ts, cli.ts) construct one of these at startup so they
1766
+ // don't re-thread `deps` through every call site. No behaviour change — every
1767
+ // method delegates to the existing *Impl function.
1768
+ // ---------------------------------------------------------------------------
1769
+
1770
+ export interface Operations {
1771
+ listFlows(): ReturnType<typeof listDemosImpl>;
1772
+ listFlowsSummary(): ReturnType<typeof listFlowsSummaryImpl>;
1773
+ getFlow(id: string): ReturnType<typeof getFlowImpl>;
1774
+ getFlowGraph(id: string): ReturnType<typeof getFlowGraphImpl>;
1775
+ getNode(flowId: string, nodeId: string): ReturnType<typeof getNodeImpl>;
1776
+ addNode(flowId: string, body: Record<string, unknown>): ReturnType<typeof addNodeImpl>;
1777
+ addNodesBulk(
1778
+ flowId: string,
1779
+ body: Parameters<typeof addNodesBulkImpl>[2],
1780
+ ): ReturnType<typeof addNodesBulkImpl>;
1781
+ patchNode(
1782
+ flowId: string,
1783
+ nodeId: string,
1784
+ body: Parameters<typeof patchNodeImpl>[3],
1785
+ ): ReturnType<typeof patchNodeImpl>;
1786
+ moveNode(
1787
+ flowId: string,
1788
+ nodeId: string,
1789
+ body: Parameters<typeof moveNodeImpl>[3],
1790
+ ): ReturnType<typeof moveNodeImpl>;
1791
+ reorderNode(
1792
+ flowId: string,
1793
+ nodeId: string,
1794
+ body: Parameters<typeof reorderNodeImpl>[3],
1795
+ ): ReturnType<typeof reorderNodeImpl>;
1796
+ deleteNode(flowId: string, nodeId: string): ReturnType<typeof deleteNodeImpl>;
1797
+ addConnector(flowId: string, body: Record<string, unknown>): ReturnType<typeof addConnectorImpl>;
1798
+ addConnectorsBulk(
1799
+ flowId: string,
1800
+ body: Parameters<typeof addConnectorsBulkImpl>[2],
1801
+ ): ReturnType<typeof addConnectorsBulkImpl>;
1802
+ patchConnector(
1803
+ flowId: string,
1804
+ connectorId: string,
1805
+ body: Parameters<typeof patchConnectorImpl>[3],
1806
+ ): ReturnType<typeof patchConnectorImpl>;
1807
+ deleteConnector(flowId: string, connectorId: string): ReturnType<typeof deleteConnectorImpl>;
1808
+ registerFlow(body: Parameters<typeof registerFlowImpl>[1]): ReturnType<typeof registerFlowImpl>;
1809
+ createProject(
1810
+ body: Parameters<typeof createProjectImpl>[1],
1811
+ ): ReturnType<typeof createProjectImpl>;
1812
+ deleteFlow(id: string): ReturnType<typeof deleteFlowImpl>;
1813
+ validate(body: ValidateBody): ReturnType<typeof validateImpl>;
1814
+ applyLayout(
1815
+ flowId: string,
1816
+ options: LayoutOptions | undefined,
1817
+ ): ReturnType<typeof applyLayoutImpl>;
1818
+ }
1819
+
1820
+ export function createOperations(deps: OperationsDeps): Operations {
1821
+ return {
1822
+ listFlows: () => listDemosImpl(deps),
1823
+ listFlowsSummary: () => listFlowsSummaryImpl(deps),
1824
+ getFlow: (id) => getFlowImpl(deps, id),
1825
+ getFlowGraph: (id) => getFlowGraphImpl(deps, id),
1826
+ getNode: (flowId, nodeId) => getNodeImpl(deps, flowId, nodeId),
1827
+ addNode: (flowId, body) => addNodeImpl(deps, flowId, body),
1828
+ addNodesBulk: (flowId, body) => addNodesBulkImpl(deps, flowId, body),
1829
+ patchNode: (flowId, nodeId, body) => patchNodeImpl(deps, flowId, nodeId, body),
1830
+ moveNode: (flowId, nodeId, body) => moveNodeImpl(deps, flowId, nodeId, body),
1831
+ reorderNode: (flowId, nodeId, body) => reorderNodeImpl(deps, flowId, nodeId, body),
1832
+ deleteNode: (flowId, nodeId) => deleteNodeImpl(deps, flowId, nodeId),
1833
+ addConnector: (flowId, body) => addConnectorImpl(deps, flowId, body),
1834
+ addConnectorsBulk: (flowId, body) => addConnectorsBulkImpl(deps, flowId, body),
1835
+ patchConnector: (flowId, connectorId, body) =>
1836
+ patchConnectorImpl(deps, flowId, connectorId, body),
1837
+ deleteConnector: (flowId, connectorId) => deleteConnectorImpl(deps, flowId, connectorId),
1838
+ registerFlow: (body) => registerFlowImpl(deps, body),
1839
+ createProject: (body) => createProjectImpl(deps, body),
1840
+ deleteFlow: (id) => deleteFlowImpl(deps, id),
1841
+ validate: (body) => validateImpl(body),
1842
+ applyLayout: (flowId, options) => applyLayoutImpl(deps, flowId, options),
1843
+ };
1844
+ }
@@ -0,0 +1,86 @@
1
+ import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
2
+ import { basename, dirname } from 'node:path';
3
+ import type { EventBus } from './events.ts';
4
+ import type { Registry } from './registry.ts';
5
+
6
+ const DEFAULT_DEBOUNCE_MS = 100;
7
+
8
+ /**
9
+ * Internal sentinel flowId used to broadcast registry-scoped events on the
10
+ * (flowId-keyed) EventBus. SSE consumers subscribe to this exact channel.
11
+ */
12
+ export const REGISTRY_CHANNEL = '__registry__';
13
+
14
+ export interface RegistryWatcherDeps {
15
+ registry: Registry;
16
+ events: EventBus;
17
+ /** Override for tests. */
18
+ debounceMs?: number;
19
+ }
20
+
21
+ export interface RegistryWatcher {
22
+ start(): void;
23
+ close(): void;
24
+ }
25
+
26
+ export function createRegistryWatcher(deps: RegistryWatcherDeps): RegistryWatcher {
27
+ const { registry, events } = deps;
28
+ const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
29
+
30
+ const filePath = registry.path;
31
+ const dir = dirname(filePath);
32
+ const base = basename(filePath);
33
+
34
+ let fsWatcher: FSWatcher | null = null;
35
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
36
+ let started = false;
37
+
38
+ const onChange = () => {
39
+ if (!existsSync(filePath)) return;
40
+ let contents: string;
41
+ try {
42
+ contents = readFileSync(filePath, 'utf8');
43
+ } catch {
44
+ return;
45
+ }
46
+ if (registry.isOwnWrite(contents)) return;
47
+ registry.reload();
48
+ events.broadcast({
49
+ type: 'registry:reload',
50
+ flowId: REGISTRY_CHANNEL,
51
+ payload: {},
52
+ });
53
+ };
54
+
55
+ return {
56
+ start() {
57
+ if (started) return;
58
+ started = true;
59
+ if (!existsSync(dir)) {
60
+ // Parent directory may not exist yet on a clean machine. The studio
61
+ // creates it on first persist, but we'd miss that event. Bail without
62
+ // throwing — callers can choose to start() again later.
63
+ return;
64
+ }
65
+ try {
66
+ fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
67
+ if (changed && changed !== base) return;
68
+ if (debounceTimer) clearTimeout(debounceTimer);
69
+ debounceTimer = setTimeout(() => {
70
+ debounceTimer = null;
71
+ onChange();
72
+ }, debounceMs);
73
+ });
74
+ } catch (err) {
75
+ console.error(`[registry-watcher] failed to watch ${dir}:`, err);
76
+ }
77
+ },
78
+ close() {
79
+ if (debounceTimer) clearTimeout(debounceTimer);
80
+ debounceTimer = null;
81
+ if (fsWatcher) fsWatcher.close();
82
+ fsWatcher = null;
83
+ started = false;
84
+ },
85
+ };
86
+ }