@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/README.md +14 -0
- package/dist/web/assets/{index-BPUoNIBm.js → index-CFn1Jdmi.js} +17 -17
- package/dist/web/assets/{index-BlkUOp7f.css → index-DSfixlbD.css} +1 -1
- package/dist/web/assets/{index.es-mje3R_63.js → index.es-DQFAA-Eu.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DX3imOs2.js → jspdf.es.min-D7KeFi-m.js} +3 -3
- package/dist/web/index.html +2 -2
- package/examples/ecommerce-platform/.seeflow/flow.json +8 -8
- package/examples/order-pipeline/.seeflow/flow.json +4 -4
- package/package.json +2 -1
- package/src/api.ts +138 -102
- package/src/cli-e2e.ts +10 -6
- package/src/cli-helpers.ts +79 -0
- package/src/cli-manifest.ts +772 -0
- package/src/cli-ops.ts +18 -0
- package/src/cli.ts +164 -137
- package/src/events.ts +2 -1
- package/src/file-ref.ts +27 -16
- package/src/mcp.ts +104 -35
- package/src/merge.ts +3 -0
- package/src/node-files.ts +5 -2
- package/src/operations.ts +341 -7
- package/src/registry-watcher.ts +86 -0
- package/src/registry.ts +132 -24
- package/src/schema.ts +2 -0
- package/src/server.ts +9 -0
- package/src/watcher.ts +32 -2
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
+
}
|