@tuongaz/seeflow 0.1.55 → 0.1.57

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
@@ -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
- // Bulk add: ok payload carries every created node so the caller can read
365
- // server-assigned ids back. duplicateIdInBatch fires when two items in the
366
- // same request share an id; idAlreadyExists fires when a request id collides
367
- // with a node already on disk. Both are pre-write rejections, no rollback
368
- // needed. writeFailed/badSchema cover the post-mutation failure modes.
369
- export type AddNodesBulkOutcome =
370
- | { kind: 'ok'; data: { nodes: Array<{ id: string; node: Record<string, unknown> }> } }
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
- // Bulk-add envelopes. Body shape gated here; per-item shape is implicit and
495
- // enforced by ResolvedFlowSchema after the whole batch is merged in — same
496
- // pattern the singular add endpoints already rely on. The 100-item cap keeps
497
- // one SSE broadcast payload reasonable; the LLM caller is meant to chunk if
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 NodesBulkBodySchema = z.object({
501
- nodes: z.array(z.record(z.unknown())).min(1).max(BULK_MAX_ITEMS),
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 type NodesBulkBody = z.infer<typeof NodesBulkBodySchema>;
504
- export const ConnectorsBulkBodySchema = z.object({
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 ConnectorsBulkBody = z.infer<typeof ConnectorsBulkBodySchema>;
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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.getById(idOrSlug) ?? registry.getBySlug(idOrSlug);
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.getById(flowId);
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 cycle. Transactional:
1177
- // any single item failing the post-mutation ResolvedFlowSchema parse rolls
1178
- // back the whole batch (nothing on flow.json, no per-node folders created).
1179
- // Per-node externalization runs per item exactly like the singular path; the
1180
- // queued file writes all happen inside the mutator so a writeFailed on item
1181
- // K leaves items 0..K-1 with stranded folders same shape as the singular
1182
- // path's writeFailed, but amplified by N. Caller is expected to retry.
1183
- export async function addNodesBulkImpl(
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: NodesBulkBody,
1187
- ): Promise<AddNodesBulkOutcome> {
1188
- const entry = deps.registry.getById(flowId);
1295
+ body: FlowBulkBody,
1296
+ ): Promise<FlowBulkOutcome> {
1297
+ const entry = deps.registry.resolve(flowId);
1189
1298
  if (!entry) return { kind: 'flowNotFound' };
1190
1299
 
1191
- // Pre-allocate ids + capture externalization writes per item. Doing this
1192
- // outside the mutator means the duplicateIdInBatch check runs before any
1193
- // disk IO; the collide-with-existing check happens inside the mutator where
1194
- // it can see the freshly-read flow.nodes.
1195
- const prepared: Array<{
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 idsInBatch = new Set<string>();
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 (idsInBatch.has(newId)) return { kind: 'duplicateIdInBatch', id: newId };
1208
- idsInBatch.add(newId);
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
- prepared.push({ id: newId, node: newNode, externalized });
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
- const result = await mutateMergedFlowAndBroadcast<
1235
- { kind: 'idAlreadyExists'; id: string } | { kind: 'writeFailed'; message: string }
1236
- >(deps, flowId, fullPath, (flow) => {
1237
- const existing = new Set(
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 prepared) {
1243
- if (existing.has(p.id)) return { kind: 'idAlreadyExists', id: p.id };
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
- for (const p of prepared) {
1246
- flow.nodes.push(p.node);
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 prepared) {
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 { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
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: { nodes: prepared.map((p) => ({ id: p.id, node: p.node })) },
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/<prepared.id>/` was created by this call — safe to cascade.
1271
- // The idAlreadyExists branch returns before any writeNodeFile, so we still
1272
- // try to remove (it's a no-op when the folder doesn't exist).
1273
- for (const p of prepared) {
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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.getById(flowId);
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);