@tuongaz/seeflow 0.1.54 → 0.1.56
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-C0p3gN55.js → index-CN72cess.js} +920 -920
- package/dist/web/assets/{index-DSfixlbD.css → index-CwfFCUzZ.css} +1 -1
- package/dist/web/assets/{index.es-Cy_i47hK.js → index.es-BIO8Bwct.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-CZvByJr6.js → jspdf.es.min-_TZhBwgu.js} +3 -3
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/api.ts +24 -51
- package/src/cli-helpers.ts +20 -4
- package/src/cli-manifest.ts +37 -37
- package/src/cli.ts +8 -24
- package/src/mcp.ts +28 -57
- package/src/operations.ts +236 -147
- package/src/registry.ts +9 -0
package/src/operations.ts
CHANGED
|
@@ -79,8 +79,26 @@ export type ReorderBody = z.infer<typeof ReorderBodySchema>;
|
|
|
79
79
|
// other key lands inside node.data. Final validity is enforced by re-parsing
|
|
80
80
|
// the whole demo through ResolvedFlowSchema after the merge — this body schema just
|
|
81
81
|
// rejects unknown top-level keys to catch typos.
|
|
82
|
+
const NodeTypeSchema = z.enum([
|
|
83
|
+
'playNode',
|
|
84
|
+
'stateNode',
|
|
85
|
+
'shapeNode',
|
|
86
|
+
'imageNode',
|
|
87
|
+
'iconNode',
|
|
88
|
+
'htmlNode',
|
|
89
|
+
]);
|
|
90
|
+
|
|
82
91
|
export const NodePatchBodySchema = z
|
|
83
92
|
.object({
|
|
93
|
+
// When supplied AND different from the node's current type, the merged
|
|
94
|
+
// node is reclassified in place: data keys not allowed on the new type's
|
|
95
|
+
// FlowDataSchema are stripped, visuals (which route to style.json) are
|
|
96
|
+
// preserved, and the post-merge ResolvedFlowSchema reparse enforces the
|
|
97
|
+
// new type's required fields (e.g. stateNode → playNode without a
|
|
98
|
+
// playAction in the same body surfaces as `badSchema`). The per-node
|
|
99
|
+
// folder under `.seeflow/nodes/<id>/` is keyed by id, so retype keeps
|
|
100
|
+
// scripts, detail.md, and view.html attached.
|
|
101
|
+
type: NodeTypeSchema.optional(),
|
|
84
102
|
position: PositionBodySchema.optional(),
|
|
85
103
|
name: z.string().optional(),
|
|
86
104
|
borderColor: ColorTokenSchema.optional(),
|
|
@@ -164,6 +182,61 @@ const NODE_DATA_PATCH_KEYS = [
|
|
|
164
182
|
|
|
165
183
|
const EXTERNALIZED_FIELD_NAMES = new Set<string>(EXTERNALIZED_NODE_FIELDS.map((e) => e.field));
|
|
166
184
|
|
|
185
|
+
// Semantic (non-visual) data keys allowed by each node type's FlowDataSchema.
|
|
186
|
+
// Visual keys (NODE_STYLE_KEYS in merge.ts) are always preserved on retype —
|
|
187
|
+
// they route to style.json on write. Everything else gets stripped from
|
|
188
|
+
// `data` when a node changes type so the post-merge ResolvedFlowSchema reparse
|
|
189
|
+
// doesn't reject lingering fields from the previous variant. Missing required
|
|
190
|
+
// fields on the new type (e.g. stateNode → playNode without playAction)
|
|
191
|
+
// surface as the normal `badSchema` outcome from the reparse.
|
|
192
|
+
const SEMANTIC_KEYS_BY_TYPE: Record<z.infer<typeof NodeTypeSchema>, ReadonlySet<string>> = {
|
|
193
|
+
playNode: new Set([
|
|
194
|
+
'name',
|
|
195
|
+
'kind',
|
|
196
|
+
'stateSource',
|
|
197
|
+
'handlerModule',
|
|
198
|
+
'icon',
|
|
199
|
+
'description',
|
|
200
|
+
'detail',
|
|
201
|
+
'playAction',
|
|
202
|
+
'statusAction',
|
|
203
|
+
]),
|
|
204
|
+
stateNode: new Set([
|
|
205
|
+
'name',
|
|
206
|
+
'kind',
|
|
207
|
+
'stateSource',
|
|
208
|
+
'handlerModule',
|
|
209
|
+
'icon',
|
|
210
|
+
'description',
|
|
211
|
+
'detail',
|
|
212
|
+
'playAction',
|
|
213
|
+
'statusAction',
|
|
214
|
+
]),
|
|
215
|
+
shapeNode: new Set(['shape', 'name', 'description', 'detail']),
|
|
216
|
+
imageNode: new Set(['path', 'alt', 'description', 'detail']),
|
|
217
|
+
iconNode: new Set(['icon', 'alt', 'name', 'description', 'detail']),
|
|
218
|
+
htmlNode: new Set(['html', 'name', 'icon', 'description', 'detail']),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Visual data keys — routed to style.json on write by splitFlow. Kept here
|
|
222
|
+
// (duplicated from merge.ts) so mergeNodeUpdates can preserve them across a
|
|
223
|
+
// type change without taking a runtime dependency on merge.ts.
|
|
224
|
+
const NODE_VISUAL_KEYS = new Set([
|
|
225
|
+
'width',
|
|
226
|
+
'height',
|
|
227
|
+
'borderColor',
|
|
228
|
+
'backgroundColor',
|
|
229
|
+
'borderSize',
|
|
230
|
+
'borderStyle',
|
|
231
|
+
'fontSize',
|
|
232
|
+
'textColor',
|
|
233
|
+
'cornerRadius',
|
|
234
|
+
'borderWidth',
|
|
235
|
+
'color',
|
|
236
|
+
'strokeWidth',
|
|
237
|
+
'autoSize',
|
|
238
|
+
]);
|
|
239
|
+
|
|
167
240
|
export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePatchBody): void => {
|
|
168
241
|
if (updates.position !== undefined) {
|
|
169
242
|
node.position = updates.position;
|
|
@@ -205,6 +278,27 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
|
|
|
205
278
|
touchedData = true;
|
|
206
279
|
}
|
|
207
280
|
|
|
281
|
+
// Type retype: when the patch supplies a `type` that differs from the
|
|
282
|
+
// node's current type, reclassify in place. Visual keys are preserved
|
|
283
|
+
// (they route to style.json on write); any semantic data key not allowed
|
|
284
|
+
// by the new type's FlowDataSchema is stripped so the post-merge reparse
|
|
285
|
+
// doesn't reject lingering fields. The per-node folder under
|
|
286
|
+
// `.seeflow/nodes/<id>/` is keyed by id (unchanged), so scripts and
|
|
287
|
+
// externalized files stay attached. Missing required fields on the new
|
|
288
|
+
// type (e.g. stateNode → playNode without a playAction in the same patch)
|
|
289
|
+
// surface as `badSchema` from the ResolvedFlowSchema reparse.
|
|
290
|
+
if (updates.type !== undefined && updates.type !== node.type) {
|
|
291
|
+
node.type = updates.type;
|
|
292
|
+
const allowedSemantic = SEMANTIC_KEYS_BY_TYPE[updates.type];
|
|
293
|
+
for (const key of Object.keys(data)) {
|
|
294
|
+
if (NODE_VISUAL_KEYS.has(key)) continue;
|
|
295
|
+
if (!allowedSemantic.has(key)) {
|
|
296
|
+
delete data[key];
|
|
297
|
+
touchedData = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
208
302
|
// htmlNode-only invariant enforcement:
|
|
209
303
|
// autoSize === true ⊻ (width and height set).
|
|
210
304
|
// autoSize: true is the dominant signal — it strips width/height even if
|
|
@@ -361,19 +455,29 @@ export type AddNodeOutcome =
|
|
|
361
455
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
362
456
|
| { kind: 'writeFailed'; message: string };
|
|
363
457
|
|
|
364
|
-
//
|
|
365
|
-
// server-assigned ids back.
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
458
|
+
// Combined bulk add: ok payload carries every created node + connector so the
|
|
459
|
+
// caller can read server-assigned ids back. nodes/connectors arrays in the
|
|
460
|
+
// payload are empty when the input section was absent. duplicateIdInBatch
|
|
461
|
+
// fires when two items in the same collection of one request share an id;
|
|
462
|
+
// idAlreadyExists fires when a request id collides with an existing entry on
|
|
463
|
+
// disk; both are pre-write rejections (with a `collection` discriminator so
|
|
464
|
+
// callers know which array tripped). The whole batch is wrapped in one
|
|
465
|
+
// mutateMergedFlowAndBroadcast — a post-mutation ResolvedFlowSchema failure
|
|
466
|
+
// (e.g. dangling connector source/target) rolls back both arrays together.
|
|
467
|
+
export type FlowBulkOutcome =
|
|
468
|
+
| {
|
|
469
|
+
kind: 'ok';
|
|
470
|
+
data: {
|
|
471
|
+
nodes: Array<{ id: string; node: Record<string, unknown> }>;
|
|
472
|
+
connectors: Array<{ id: string }>;
|
|
473
|
+
};
|
|
474
|
+
}
|
|
371
475
|
| { kind: 'flowNotFound' }
|
|
372
476
|
| { kind: 'fileNotFound'; path: string }
|
|
373
477
|
| { kind: 'badJson'; message: string }
|
|
374
478
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
375
|
-
| { kind: 'duplicateIdInBatch'; id: string }
|
|
376
|
-
| { kind: 'idAlreadyExists'; id: string }
|
|
479
|
+
| { kind: 'duplicateIdInBatch'; collection: 'nodes' | 'connectors'; id: string }
|
|
480
|
+
| { kind: 'idAlreadyExists'; collection: 'nodes' | 'connectors'; id: string }
|
|
377
481
|
| { kind: 'writeFailed'; message: string };
|
|
378
482
|
|
|
379
483
|
export type DeleteNodeOutcome =
|
|
@@ -491,20 +595,32 @@ export const mergeConnectorUpdates = (
|
|
|
491
595
|
}
|
|
492
596
|
};
|
|
493
597
|
|
|
494
|
-
//
|
|
495
|
-
// enforced by ResolvedFlowSchema after the whole batch is merged
|
|
496
|
-
// pattern the singular add endpoints already rely on. The 100-item
|
|
497
|
-
// one SSE broadcast payload reasonable; the LLM caller is
|
|
498
|
-
// it ever needs more.
|
|
598
|
+
// Combined bulk-add envelope. Body shape gated here; per-item shape is
|
|
599
|
+
// implicit and enforced by ResolvedFlowSchema after the whole batch is merged
|
|
600
|
+
// in — same pattern the singular add endpoints already rely on. The 100-item
|
|
601
|
+
// per-kind cap keeps one SSE broadcast payload reasonable; the LLM caller is
|
|
602
|
+
// meant to chunk if it ever needs more. Both arrays are optional; the refine
|
|
603
|
+
// requires at least one non-empty so an empty {} can't no-op a write.
|
|
604
|
+
//
|
|
605
|
+
// The shape is exported as a bare ZodObject (FlowBulkBodyShape) so MCP/CLI
|
|
606
|
+
// surfaces can `.extend({ flowId })` and re-apply the refine — extend()
|
|
607
|
+
// doesn't survive ZodEffects (a refined schema), and we want the JSON Schema
|
|
608
|
+
// to stay a clean `{ type: 'object', properties: ... }` for agent
|
|
609
|
+
// introspection instead of an `allOf` intersection.
|
|
499
610
|
const BULK_MAX_ITEMS = 100;
|
|
500
|
-
export const
|
|
501
|
-
|
|
611
|
+
export const FLOW_BULK_NON_EMPTY_MESSAGE = 'Body must include at least one node or connector';
|
|
612
|
+
export const flowBulkNonEmpty = (b: {
|
|
613
|
+
nodes?: unknown[];
|
|
614
|
+
connectors?: unknown[];
|
|
615
|
+
}): boolean => (b.nodes?.length ?? 0) + (b.connectors?.length ?? 0) > 0;
|
|
616
|
+
export const FlowBulkBodyShape = z.object({
|
|
617
|
+
nodes: z.array(z.record(z.unknown())).max(BULK_MAX_ITEMS).optional(),
|
|
618
|
+
connectors: z.array(z.record(z.unknown())).max(BULK_MAX_ITEMS).optional(),
|
|
502
619
|
});
|
|
503
|
-
export
|
|
504
|
-
|
|
505
|
-
connectors: z.array(z.record(z.unknown())).min(1).max(BULK_MAX_ITEMS),
|
|
620
|
+
export const FlowBulkBodySchema = FlowBulkBodyShape.refine(flowBulkNonEmpty, {
|
|
621
|
+
message: FLOW_BULK_NON_EMPTY_MESSAGE,
|
|
506
622
|
});
|
|
507
|
-
export type
|
|
623
|
+
export type FlowBulkBody = z.infer<typeof FlowBulkBodySchema>;
|
|
508
624
|
|
|
509
625
|
export type AddConnectorOutcome =
|
|
510
626
|
| { kind: 'ok'; data: { id: string } }
|
|
@@ -514,16 +630,6 @@ export type AddConnectorOutcome =
|
|
|
514
630
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
515
631
|
| { kind: 'writeFailed'; message: string };
|
|
516
632
|
|
|
517
|
-
export type AddConnectorsBulkOutcome =
|
|
518
|
-
| { kind: 'ok'; data: { connectors: Array<{ id: string }> } }
|
|
519
|
-
| { kind: 'flowNotFound' }
|
|
520
|
-
| { kind: 'fileNotFound'; path: string }
|
|
521
|
-
| { kind: 'badJson'; message: string }
|
|
522
|
-
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
523
|
-
| { kind: 'duplicateIdInBatch'; id: string }
|
|
524
|
-
| { kind: 'idAlreadyExists'; id: string }
|
|
525
|
-
| { kind: 'writeFailed'; message: string };
|
|
526
|
-
|
|
527
633
|
export type PatchConnectorOutcome =
|
|
528
634
|
| { kind: 'ok' }
|
|
529
635
|
| { kind: 'flowNotFound' }
|
|
@@ -842,7 +948,7 @@ export function listFlowsSummaryImpl(deps: OperationsDeps): ListFlowsSummaryOutc
|
|
|
842
948
|
|
|
843
949
|
export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise<GetFlowOutcome> {
|
|
844
950
|
const { registry, watcher } = deps;
|
|
845
|
-
const entry = registry.
|
|
951
|
+
const entry = registry.resolve(flowId);
|
|
846
952
|
if (!entry) return { kind: 'notFound' };
|
|
847
953
|
|
|
848
954
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -897,7 +1003,7 @@ export async function getFlowGraphImpl(
|
|
|
897
1003
|
deps: OperationsDeps,
|
|
898
1004
|
flowId: string,
|
|
899
1005
|
): Promise<GetFlowGraphOutcome> {
|
|
900
|
-
const entry = deps.registry.
|
|
1006
|
+
const entry = deps.registry.resolve(flowId);
|
|
901
1007
|
if (!entry) return { kind: 'notFound' };
|
|
902
1008
|
|
|
903
1009
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -932,7 +1038,7 @@ export async function getNodeImpl(
|
|
|
932
1038
|
nodeId: string,
|
|
933
1039
|
): Promise<GetNodeOutcome> {
|
|
934
1040
|
const { registry, watcher } = deps;
|
|
935
|
-
const entry = registry.
|
|
1041
|
+
const entry = registry.resolve(flowId);
|
|
936
1042
|
if (!entry) return { kind: 'notFound' };
|
|
937
1043
|
|
|
938
1044
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1033,7 +1139,7 @@ export async function registerFlowImpl(
|
|
|
1033
1139
|
|
|
1034
1140
|
export function deleteFlowImpl(deps: OperationsDeps, idOrSlug: string): DeleteFlowOutcome {
|
|
1035
1141
|
const { registry, watcher } = deps;
|
|
1036
|
-
const entry = registry.
|
|
1142
|
+
const entry = registry.resolve(idOrSlug);
|
|
1037
1143
|
if (!entry) return { kind: 'notFound' };
|
|
1038
1144
|
watcher?.unwatch(entry.id);
|
|
1039
1145
|
registry.remove(entry.id);
|
|
@@ -1112,7 +1218,7 @@ export async function addNodeImpl(
|
|
|
1112
1218
|
flowId: string,
|
|
1113
1219
|
nodeBody: Record<string, unknown>,
|
|
1114
1220
|
): Promise<AddNodeOutcome> {
|
|
1115
|
-
const entry = deps.registry.
|
|
1221
|
+
const entry = deps.registry.resolve(flowId);
|
|
1116
1222
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1117
1223
|
|
|
1118
1224
|
const newNode = { ...nodeBody };
|
|
@@ -1173,39 +1279,44 @@ export async function addNodeImpl(
|
|
|
1173
1279
|
return result;
|
|
1174
1280
|
}
|
|
1175
1281
|
|
|
1176
|
-
// Bulk add — N nodes in one read-validate-write-broadcast
|
|
1177
|
-
// any single item failing the post-mutation
|
|
1178
|
-
// back the whole batch (nothing on flow.json,
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1282
|
+
// Bulk add — N nodes + M connectors in one read-validate-write-broadcast
|
|
1283
|
+
// cycle. Transactional: any single item failing the post-mutation
|
|
1284
|
+
// ResolvedFlowSchema parse rolls back the whole batch (nothing on flow.json,
|
|
1285
|
+
// no per-node folders surviving). Connectors that reference nodes added in
|
|
1286
|
+
// the same call validate correctly because the parse sees the merged graph
|
|
1287
|
+
// as a whole; a dangling source/target rolls back BOTH arrays. Per-node
|
|
1288
|
+
// externalization is queued in Phase A and flushed inside the mutator so a
|
|
1289
|
+
// writeFailed on node K leaves nodes 0..K-1 with stranded folders — same
|
|
1290
|
+
// shape as the singular path's writeFailed, but amplified by N. Caller is
|
|
1291
|
+
// expected to retry.
|
|
1292
|
+
export async function addFlowBulkImpl(
|
|
1184
1293
|
deps: OperationsDeps,
|
|
1185
1294
|
flowId: string,
|
|
1186
|
-
body:
|
|
1187
|
-
): Promise<
|
|
1188
|
-
const entry = deps.registry.
|
|
1295
|
+
body: FlowBulkBody,
|
|
1296
|
+
): Promise<FlowBulkOutcome> {
|
|
1297
|
+
const entry = deps.registry.resolve(flowId);
|
|
1189
1298
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1190
1299
|
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
// disk
|
|
1194
|
-
//
|
|
1195
|
-
const
|
|
1300
|
+
// Phase A — prepare ids + externalization, per-collection duplicate check.
|
|
1301
|
+
// No IO yet; duplicates inside a single collection trip here, before any
|
|
1302
|
+
// disk write. Cross-collection id reuse is intentionally allowed (a node
|
|
1303
|
+
// and a connector may share an id today).
|
|
1304
|
+
const preparedNodes: Array<{
|
|
1196
1305
|
id: string;
|
|
1197
1306
|
node: Record<string, unknown>;
|
|
1198
1307
|
externalized: Array<{ absPath: string; content: string }>;
|
|
1199
1308
|
}> = [];
|
|
1200
|
-
const
|
|
1201
|
-
for (const item of body.nodes) {
|
|
1309
|
+
const nodeIdsInBatch = new Set<string>();
|
|
1310
|
+
for (const item of body.nodes ?? []) {
|
|
1202
1311
|
const newNode = { ...item };
|
|
1203
1312
|
if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
|
|
1204
1313
|
newNode.id = `node-${shortId()}`;
|
|
1205
1314
|
}
|
|
1206
1315
|
const newId = newNode.id as string;
|
|
1207
|
-
if (
|
|
1208
|
-
|
|
1316
|
+
if (nodeIdsInBatch.has(newId)) {
|
|
1317
|
+
return { kind: 'duplicateIdInBatch', collection: 'nodes', id: newId };
|
|
1318
|
+
}
|
|
1319
|
+
nodeIdsInBatch.add(newId);
|
|
1209
1320
|
if (!newNode.position || typeof newNode.position !== 'object') {
|
|
1210
1321
|
newNode.position = { x: 0, y: 0 };
|
|
1211
1322
|
}
|
|
@@ -1225,32 +1336,70 @@ export async function addNodesBulkImpl(
|
|
|
1225
1336
|
});
|
|
1226
1337
|
}
|
|
1227
1338
|
newNode.data = data;
|
|
1228
|
-
|
|
1339
|
+
preparedNodes.push({ id: newId, node: newNode, externalized });
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const preparedConns: Array<{ id: string; conn: Record<string, unknown> }> = [];
|
|
1343
|
+
const connIdsInBatch = new Set<string>();
|
|
1344
|
+
for (const item of body.connectors ?? []) {
|
|
1345
|
+
const newConn = { ...item };
|
|
1346
|
+
if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
|
|
1347
|
+
newConn.id = `conn-${shortId()}`;
|
|
1348
|
+
}
|
|
1349
|
+
if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
|
|
1350
|
+
newConn.kind = 'default';
|
|
1351
|
+
}
|
|
1352
|
+
const newId = newConn.id as string;
|
|
1353
|
+
if (connIdsInBatch.has(newId)) {
|
|
1354
|
+
return { kind: 'duplicateIdInBatch', collection: 'connectors', id: newId };
|
|
1355
|
+
}
|
|
1356
|
+
connIdsInBatch.add(newId);
|
|
1357
|
+
preparedConns.push({ id: newId, conn: newConn });
|
|
1229
1358
|
}
|
|
1230
1359
|
|
|
1231
1360
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1232
1361
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1233
1362
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1363
|
+
// Phase B — single transactional mutate. Push nodes first so connectors
|
|
1364
|
+
// added in the same batch can reference them, then queue externalized file
|
|
1365
|
+
// writes. mutateMergedFlowAndBroadcast runs one post-mutation
|
|
1366
|
+
// ResolvedFlowSchema parse over the merged graph and emits one flow:reload
|
|
1367
|
+
// broadcast.
|
|
1368
|
+
type MutErr =
|
|
1369
|
+
| { kind: 'idAlreadyExists'; collection: 'nodes' | 'connectors'; id: string }
|
|
1370
|
+
| { kind: 'writeFailed'; message: string };
|
|
1371
|
+
const result = await mutateMergedFlowAndBroadcast<MutErr>(deps, flowId, fullPath, (flow) => {
|
|
1372
|
+
const existingNodeIds = new Set(
|
|
1238
1373
|
flow.nodes
|
|
1239
1374
|
.map((n) => (typeof n.id === 'string' ? n.id : null))
|
|
1240
1375
|
.filter((id): id is string => id !== null),
|
|
1241
1376
|
);
|
|
1242
|
-
for (const p of
|
|
1243
|
-
if (
|
|
1377
|
+
for (const p of preparedNodes) {
|
|
1378
|
+
if (existingNodeIds.has(p.id)) {
|
|
1379
|
+
return { kind: 'idAlreadyExists', collection: 'nodes', id: p.id };
|
|
1380
|
+
}
|
|
1244
1381
|
}
|
|
1245
|
-
|
|
1246
|
-
flow.
|
|
1382
|
+
const existingConnIds = new Set(
|
|
1383
|
+
flow.connectors
|
|
1384
|
+
.map((c) => (typeof c.id === 'string' ? c.id : null))
|
|
1385
|
+
.filter((id): id is string => id !== null),
|
|
1386
|
+
);
|
|
1387
|
+
for (const p of preparedConns) {
|
|
1388
|
+
if (existingConnIds.has(p.id)) {
|
|
1389
|
+
return { kind: 'idAlreadyExists', collection: 'connectors', id: p.id };
|
|
1390
|
+
}
|
|
1247
1391
|
}
|
|
1248
|
-
for (const p of
|
|
1392
|
+
for (const p of preparedNodes) flow.nodes.push(p.node);
|
|
1393
|
+
for (const p of preparedConns) flow.connectors.push(p.conn);
|
|
1394
|
+
for (const p of preparedNodes) {
|
|
1249
1395
|
for (const ext of p.externalized) {
|
|
1250
1396
|
try {
|
|
1251
1397
|
writeNodeFile(ext.absPath, ext.content);
|
|
1252
1398
|
} catch (err) {
|
|
1253
|
-
return {
|
|
1399
|
+
return {
|
|
1400
|
+
kind: 'writeFailed',
|
|
1401
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1402
|
+
};
|
|
1254
1403
|
}
|
|
1255
1404
|
}
|
|
1256
1405
|
}
|
|
@@ -1260,17 +1409,20 @@ export async function addNodesBulkImpl(
|
|
|
1260
1409
|
if (result.kind === 'ok') {
|
|
1261
1410
|
return {
|
|
1262
1411
|
kind: 'ok',
|
|
1263
|
-
data: {
|
|
1412
|
+
data: {
|
|
1413
|
+
nodes: preparedNodes.map((p) => ({ id: p.id, node: p.node })),
|
|
1414
|
+
connectors: preparedConns.map((p) => ({ id: p.id })),
|
|
1415
|
+
},
|
|
1264
1416
|
};
|
|
1265
1417
|
}
|
|
1266
1418
|
|
|
1267
1419
|
// Non-ok branch: the post-mutation ResolvedFlowSchema parse (or a later
|
|
1268
1420
|
// writeFailed) ran AFTER the mutator already wrote per-node folders. The
|
|
1269
1421
|
// collide-with-existing check ran first inside the mutator, so any folder
|
|
1270
|
-
// at `nodes/<
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
for (const p of
|
|
1422
|
+
// at `nodes/<p.id>/` was created by this call — safe to cascade. The
|
|
1423
|
+
// idAlreadyExists branch returns before any writeNodeFile, so the rmdir is
|
|
1424
|
+
// a no-op there.
|
|
1425
|
+
for (const p of preparedNodes) {
|
|
1274
1426
|
removeNodeDir(entry.repoPath, p.id);
|
|
1275
1427
|
}
|
|
1276
1428
|
return result;
|
|
@@ -1287,7 +1439,7 @@ export async function deleteNodeImpl(
|
|
|
1287
1439
|
flowId: string,
|
|
1288
1440
|
nodeId: string,
|
|
1289
1441
|
): Promise<DeleteNodeOutcome> {
|
|
1290
|
-
const entry = deps.registry.
|
|
1442
|
+
const entry = deps.registry.resolve(flowId);
|
|
1291
1443
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1292
1444
|
|
|
1293
1445
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1330,7 +1482,7 @@ export async function moveNodeImpl(
|
|
|
1330
1482
|
nodeId: string,
|
|
1331
1483
|
position: PositionBody,
|
|
1332
1484
|
): Promise<MoveNodeOutcome> {
|
|
1333
|
-
const entry = deps.registry.
|
|
1485
|
+
const entry = deps.registry.resolve(flowId);
|
|
1334
1486
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1335
1487
|
|
|
1336
1488
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1366,7 +1518,7 @@ export async function patchNodeImpl(
|
|
|
1366
1518
|
nodeId: string,
|
|
1367
1519
|
updates: NodePatchBody,
|
|
1368
1520
|
): Promise<PatchNodeOutcome> {
|
|
1369
|
-
const entry = deps.registry.
|
|
1521
|
+
const entry = deps.registry.resolve(flowId);
|
|
1370
1522
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1371
1523
|
|
|
1372
1524
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1426,7 +1578,7 @@ export async function reorderNodeImpl(
|
|
|
1426
1578
|
nodeId: string,
|
|
1427
1579
|
body: ReorderBody,
|
|
1428
1580
|
): Promise<ReorderNodeOutcome> {
|
|
1429
|
-
const entry = deps.registry.
|
|
1581
|
+
const entry = deps.registry.resolve(flowId);
|
|
1430
1582
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1431
1583
|
|
|
1432
1584
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1458,7 +1610,7 @@ export async function addConnectorImpl(
|
|
|
1458
1610
|
flowId: string,
|
|
1459
1611
|
connBody: Record<string, unknown>,
|
|
1460
1612
|
): Promise<AddConnectorOutcome> {
|
|
1461
|
-
const entry = deps.registry.
|
|
1613
|
+
const entry = deps.registry.resolve(flowId);
|
|
1462
1614
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1463
1615
|
|
|
1464
1616
|
const newConn = { ...connBody };
|
|
@@ -1482,64 +1634,6 @@ export async function addConnectorImpl(
|
|
|
1482
1634
|
return result;
|
|
1483
1635
|
}
|
|
1484
1636
|
|
|
1485
|
-
// Bulk add — N connectors in one read-validate-write-broadcast cycle. Same
|
|
1486
|
-
// transactional shape as addNodesBulkImpl: any single connector failing the
|
|
1487
|
-
// post-mutation ResolvedFlowSchema parse (dangling source/target, missing
|
|
1488
|
-
// kind-specific field) rolls back the whole batch. No per-item externalization
|
|
1489
|
-
// to manage — connectors don't own per-node folders.
|
|
1490
|
-
export async function addConnectorsBulkImpl(
|
|
1491
|
-
deps: OperationsDeps,
|
|
1492
|
-
flowId: string,
|
|
1493
|
-
body: ConnectorsBulkBody,
|
|
1494
|
-
): Promise<AddConnectorsBulkOutcome> {
|
|
1495
|
-
const entry = deps.registry.getById(flowId);
|
|
1496
|
-
if (!entry) return { kind: 'flowNotFound' };
|
|
1497
|
-
|
|
1498
|
-
const prepared: Array<{ id: string; conn: Record<string, unknown> }> = [];
|
|
1499
|
-
const idsInBatch = new Set<string>();
|
|
1500
|
-
for (const item of body.connectors) {
|
|
1501
|
-
const newConn = { ...item };
|
|
1502
|
-
if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
|
|
1503
|
-
newConn.id = `conn-${shortId()}`;
|
|
1504
|
-
}
|
|
1505
|
-
if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
|
|
1506
|
-
newConn.kind = 'default';
|
|
1507
|
-
}
|
|
1508
|
-
const newId = newConn.id as string;
|
|
1509
|
-
if (idsInBatch.has(newId)) return { kind: 'duplicateIdInBatch', id: newId };
|
|
1510
|
-
idsInBatch.add(newId);
|
|
1511
|
-
prepared.push({ id: newId, conn: newConn });
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1515
|
-
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1516
|
-
|
|
1517
|
-
const result = await mutateMergedFlowAndBroadcast<{ kind: 'idAlreadyExists'; id: string }>(
|
|
1518
|
-
deps,
|
|
1519
|
-
flowId,
|
|
1520
|
-
fullPath,
|
|
1521
|
-
(flow) => {
|
|
1522
|
-
const existing = new Set(
|
|
1523
|
-
flow.connectors
|
|
1524
|
-
.map((c) => (typeof c.id === 'string' ? c.id : null))
|
|
1525
|
-
.filter((id): id is string => id !== null),
|
|
1526
|
-
);
|
|
1527
|
-
for (const p of prepared) {
|
|
1528
|
-
if (existing.has(p.id)) return { kind: 'idAlreadyExists', id: p.id };
|
|
1529
|
-
}
|
|
1530
|
-
for (const p of prepared) {
|
|
1531
|
-
flow.connectors.push(p.conn);
|
|
1532
|
-
}
|
|
1533
|
-
return { kind: 'ok' };
|
|
1534
|
-
},
|
|
1535
|
-
);
|
|
1536
|
-
|
|
1537
|
-
if (result.kind === 'ok') {
|
|
1538
|
-
return { kind: 'ok', data: { connectors: prepared.map((p) => ({ id: p.id })) } };
|
|
1539
|
-
}
|
|
1540
|
-
return result;
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
1637
|
// Apply a partial PATCH body to a single connector. Mutation runs against
|
|
1544
1638
|
// the raw parsed JSON (so unknown forward-compat fields survive a round-trip).
|
|
1545
1639
|
// When `kind` changes, the previous kind's payload fields are dropped first
|
|
@@ -1555,7 +1649,7 @@ export async function patchConnectorImpl(
|
|
|
1555
1649
|
connectorId: string,
|
|
1556
1650
|
updates: ConnectorPatchBody,
|
|
1557
1651
|
): Promise<PatchConnectorOutcome> {
|
|
1558
|
-
const entry = deps.registry.
|
|
1652
|
+
const entry = deps.registry.resolve(flowId);
|
|
1559
1653
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1560
1654
|
|
|
1561
1655
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1582,7 +1676,7 @@ export async function deleteConnectorImpl(
|
|
|
1582
1676
|
flowId: string,
|
|
1583
1677
|
connectorId: string,
|
|
1584
1678
|
): Promise<DeleteConnectorOutcome> {
|
|
1585
|
-
const entry = deps.registry.
|
|
1679
|
+
const entry = deps.registry.resolve(flowId);
|
|
1586
1680
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1587
1681
|
|
|
1588
1682
|
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1704,7 +1798,7 @@ export async function applyLayoutImpl(
|
|
|
1704
1798
|
flowId: string,
|
|
1705
1799
|
options: LayoutOptions | undefined,
|
|
1706
1800
|
): Promise<ApplyLayoutOutcome> {
|
|
1707
|
-
const entry = deps.registry.
|
|
1801
|
+
const entry = deps.registry.resolve(flowId);
|
|
1708
1802
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1709
1803
|
|
|
1710
1804
|
const flowAbs = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
@@ -1774,10 +1868,6 @@ export interface Operations {
|
|
|
1774
1868
|
getFlowGraph(id: string): ReturnType<typeof getFlowGraphImpl>;
|
|
1775
1869
|
getNode(flowId: string, nodeId: string): ReturnType<typeof getNodeImpl>;
|
|
1776
1870
|
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
1871
|
patchNode(
|
|
1782
1872
|
flowId: string,
|
|
1783
1873
|
nodeId: string,
|
|
@@ -1795,16 +1885,16 @@ export interface Operations {
|
|
|
1795
1885
|
): ReturnType<typeof reorderNodeImpl>;
|
|
1796
1886
|
deleteNode(flowId: string, nodeId: string): ReturnType<typeof deleteNodeImpl>;
|
|
1797
1887
|
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
1888
|
patchConnector(
|
|
1803
1889
|
flowId: string,
|
|
1804
1890
|
connectorId: string,
|
|
1805
1891
|
body: Parameters<typeof patchConnectorImpl>[3],
|
|
1806
1892
|
): ReturnType<typeof patchConnectorImpl>;
|
|
1807
1893
|
deleteConnector(flowId: string, connectorId: string): ReturnType<typeof deleteConnectorImpl>;
|
|
1894
|
+
addBulk(
|
|
1895
|
+
flowId: string,
|
|
1896
|
+
body: Parameters<typeof addFlowBulkImpl>[2],
|
|
1897
|
+
): ReturnType<typeof addFlowBulkImpl>;
|
|
1808
1898
|
registerFlow(body: Parameters<typeof registerFlowImpl>[1]): ReturnType<typeof registerFlowImpl>;
|
|
1809
1899
|
createProject(
|
|
1810
1900
|
body: Parameters<typeof createProjectImpl>[1],
|
|
@@ -1825,16 +1915,15 @@ export function createOperations(deps: OperationsDeps): Operations {
|
|
|
1825
1915
|
getFlowGraph: (id) => getFlowGraphImpl(deps, id),
|
|
1826
1916
|
getNode: (flowId, nodeId) => getNodeImpl(deps, flowId, nodeId),
|
|
1827
1917
|
addNode: (flowId, body) => addNodeImpl(deps, flowId, body),
|
|
1828
|
-
addNodesBulk: (flowId, body) => addNodesBulkImpl(deps, flowId, body),
|
|
1829
1918
|
patchNode: (flowId, nodeId, body) => patchNodeImpl(deps, flowId, nodeId, body),
|
|
1830
1919
|
moveNode: (flowId, nodeId, body) => moveNodeImpl(deps, flowId, nodeId, body),
|
|
1831
1920
|
reorderNode: (flowId, nodeId, body) => reorderNodeImpl(deps, flowId, nodeId, body),
|
|
1832
1921
|
deleteNode: (flowId, nodeId) => deleteNodeImpl(deps, flowId, nodeId),
|
|
1833
1922
|
addConnector: (flowId, body) => addConnectorImpl(deps, flowId, body),
|
|
1834
|
-
addConnectorsBulk: (flowId, body) => addConnectorsBulkImpl(deps, flowId, body),
|
|
1835
1923
|
patchConnector: (flowId, connectorId, body) =>
|
|
1836
1924
|
patchConnectorImpl(deps, flowId, connectorId, body),
|
|
1837
1925
|
deleteConnector: (flowId, connectorId) => deleteConnectorImpl(deps, flowId, connectorId),
|
|
1926
|
+
addBulk: (flowId, body) => addFlowBulkImpl(deps, flowId, body),
|
|
1838
1927
|
registerFlow: (body) => registerFlowImpl(deps, body),
|
|
1839
1928
|
createProject: (body) => createProjectImpl(deps, body),
|
|
1840
1929
|
deleteFlow: (id) => deleteFlowImpl(deps, id),
|
package/src/registry.ts
CHANGED
|
@@ -31,6 +31,9 @@ export interface Registry {
|
|
|
31
31
|
list(): FlowEntry[];
|
|
32
32
|
getById(id: string): FlowEntry | undefined;
|
|
33
33
|
getBySlug(slug: string): FlowEntry | undefined;
|
|
34
|
+
/** Resolve by id, falling back to slug. The canonical lookup for CLI/API
|
|
35
|
+
* paths that document `<flowId>` as "Flow id or slug". */
|
|
36
|
+
resolve(idOrSlug: string): FlowEntry | undefined;
|
|
34
37
|
getByRepoPath(repoPath: string): FlowEntry | undefined;
|
|
35
38
|
getByRepoPathAndFlowPath(repoPath: string, flowPath: string): FlowEntry | undefined;
|
|
36
39
|
upsert(input: RegisterInput): FlowEntry;
|
|
@@ -182,6 +185,12 @@ export function createRegistry(options: { path?: string } = {}): Registry {
|
|
|
182
185
|
refreshIfStale();
|
|
183
186
|
return [...entries.values()].find((e) => e.slug === slug);
|
|
184
187
|
},
|
|
188
|
+
resolve: (idOrSlug) => {
|
|
189
|
+
refreshIfStale();
|
|
190
|
+
const byId = entries.get(idOrSlug);
|
|
191
|
+
if (byId) return byId;
|
|
192
|
+
return [...entries.values()].find((e) => e.slug === idOrSlug);
|
|
193
|
+
},
|
|
185
194
|
getByRepoPath: (repoPath) => {
|
|
186
195
|
refreshIfStale();
|
|
187
196
|
return findByRepoPath(repoPath);
|