@tuongaz/seeflow 0.1.40 → 0.1.42

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.
Files changed (38) hide show
  1. package/README.md +2 -15
  2. package/dist/web/assets/{index-DTNk6GGk.js → index-BPUoNIBm.js} +1541 -1541
  3. package/dist/web/assets/{index-BwdVgB2y.css → index-BlkUOp7f.css} +1 -1
  4. package/dist/web/assets/{index.es-D_iCCj4R.js → index.es-mje3R_63.js} +1 -1
  5. package/dist/web/assets/{jspdf.es.min-C9FG4HQT.js → jspdf.es.min-DX3imOs2.js} +3 -3
  6. package/dist/web/index.html +2 -2
  7. package/examples/ecommerce-platform/.seeflow/flow.json +47 -47
  8. package/examples/ecommerce-platform/.seeflow/style.json +10 -10
  9. package/examples/order-pipeline/.seeflow/flow.json +17 -17
  10. package/examples/order-pipeline/.seeflow/style.json +4 -4
  11. package/package.json +1 -1
  12. package/src/api.ts +101 -14
  13. package/src/atomic-write.ts +16 -0
  14. package/src/cli-e2e.ts +420 -0
  15. package/src/cli-helpers.ts +65 -0
  16. package/src/cli.ts +371 -17
  17. package/src/mcp.ts +116 -23
  18. package/src/merge.ts +1 -1
  19. package/src/node-files.ts +45 -0
  20. package/src/operations.ts +304 -98
  21. package/src/proxy.ts +35 -6
  22. package/src/registry.ts +2 -1
  23. package/src/schema.ts +31 -25
  24. package/src/short-id.ts +24 -0
  25. package/src/status-runner.ts +9 -8
  26. package/src/watcher.ts +14 -14
  27. /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
  28. /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
  29. /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
  30. /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
  31. /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
  32. /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
  33. /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
  34. /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
  35. /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
  36. /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
  37. /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
  38. /package/examples/order-pipeline/.seeflow/{details/fulfillment-service.md → nodes/node-zUIH7WFnhK/detail.md} +0 -0
package/src/operations.ts CHANGED
@@ -7,18 +7,19 @@
7
7
  // Helpers extracted in US-003: node lifecycle (add/delete/move/reorder).
8
8
  // Future stories add patch_node + connector helpers alongside these.
9
9
 
10
- import {
11
- existsSync,
12
- mkdirSync,
13
- readFileSync,
14
- renameSync,
15
- statSync,
16
- unlinkSync,
17
- writeFileSync,
18
- } from 'node:fs';
10
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
19
11
  import { dirname, isAbsolute, join } from 'node:path';
20
12
  import { type ZodIssue, z } from 'zod';
13
+ import { writeFileAtomic } from './atomic-write.ts';
21
14
  import { mergeFlowAndStyle, splitFlow } from './merge.ts';
15
+ import {
16
+ EXTERNALIZED_NODE_FIELDS,
17
+ externalizedFieldsForNodeType,
18
+ nodeFileAbsPath,
19
+ nodeFileRef,
20
+ removeNodeDir,
21
+ writeNodeFile,
22
+ } from './node-files.ts';
22
23
  import { seeflowHome } from './paths.ts';
23
24
  import { type Registry, slugify } from './registry.ts';
24
25
  import {
@@ -26,13 +27,18 @@ import {
26
27
  EdgePinSchema,
27
28
  type Flow,
28
29
  FlowSchema,
30
+ PlayActionSchema,
29
31
  type ResolvedFlow,
30
32
  ResolvedFlowSchema,
33
+ ShapeKindSchema,
31
34
  SourceHandleIdSchema,
35
+ StateSourceSchema,
36
+ StatusActionSchema,
32
37
  StyleSchema,
33
38
  TargetHandleIdSchema,
34
39
  } from './schema.ts';
35
40
  import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
41
+ import { shortId } from './short-id.ts';
36
42
  import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
37
43
 
38
44
  const DEFAULT_FLOW_RELATIVE_PATH = '.seeflow/flow.json';
@@ -90,7 +96,7 @@ export const NodePatchBodySchema = z
90
96
  // sizes the wrapper around it. mergeNodeUpdates enforces the invariant
91
97
  // that autoSize:true never coexists with persisted width/height.
92
98
  autoSize: z.boolean().optional(),
93
- shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
99
+ shape: ShapeKindSchema.optional(),
94
100
  // iconNode-only: stroke color token. Lands at data.color; ResolvedFlowSchema's
95
101
  // post-merge reparse gates that this is only valid on an iconNode.
96
102
  color: ColorTokenSchema.optional(),
@@ -110,6 +116,17 @@ export const NodePatchBodySchema = z
110
116
  // serialize signal — `mergeNodeUpdates` strips the key from disk.
111
117
  description: z.string().optional(),
112
118
  detail: z.string().optional(),
119
+ // htmlNode-only: inline HTML content. Externalized to
120
+ // `<project>/.seeflow/nodes/<id>/view.html` by patchNodeImpl; the file://
121
+ // ref on the node persists. Empty string empties the file but keeps the ref.
122
+ html: z.string().optional(),
123
+ // P5 overlay attach: lets the skill (or any consumer) wire executable
124
+ // behaviour onto a previously-created node without re-issuing it. Final
125
+ // validity is enforced by the post-merge ResolvedFlowSchema reparse —
126
+ // e.g. statusAction is only valid on playNode / stateNode.
127
+ playAction: PlayActionSchema.optional(),
128
+ statusAction: StatusActionSchema.optional(),
129
+ stateSource: StateSourceSchema.optional(),
113
130
  })
114
131
  .strict();
115
132
  export type NodePatchBody = z.infer<typeof NodePatchBodySchema>;
@@ -138,8 +155,14 @@ const NODE_DATA_PATCH_KEYS = [
138
155
  'icon',
139
156
  'description',
140
157
  'detail',
158
+ 'html',
159
+ 'playAction',
160
+ 'statusAction',
161
+ 'stateSource',
141
162
  ] as const satisfies ReadonlyArray<keyof NodePatchBody>;
142
163
 
164
+ const EXTERNALIZED_FIELD_NAMES = new Set<string>(EXTERNALIZED_NODE_FIELDS.map((e) => e.field));
165
+
143
166
  export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePatchBody): void => {
144
167
  if (updates.position !== undefined) {
145
168
  node.position = updates.position;
@@ -152,6 +175,10 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
152
175
  let touchedData = false;
153
176
  for (const key of NODE_DATA_PATCH_KEYS) {
154
177
  if (updates[key] === undefined) continue;
178
+ // Externalized fields (detail, ...) flow through patchNodeImpl's own
179
+ // pre-process — keep merge out of it so the file:// ref on disk stays
180
+ // stable and the file is the source of truth for content.
181
+ if (EXTERNALIZED_FIELD_NAMES.has(key)) continue;
155
182
  // Empty string on the two free-text metadata fields is the documented
156
183
  // clear-on-serialize signal — strip the key instead of writing "" to disk
157
184
  // so flow.json stays compact and round-tripping a cleared node doesn't
@@ -286,6 +313,21 @@ export type AddNodeOutcome =
286
313
  | { kind: 'badSchema'; issues: ZodIssue[] }
287
314
  | { kind: 'writeFailed'; message: string };
288
315
 
316
+ // Bulk add: ok payload carries every created node so the caller can read
317
+ // server-assigned ids back. duplicateIdInBatch fires when two items in the
318
+ // same request share an id; idAlreadyExists fires when a request id collides
319
+ // with a node already on disk. Both are pre-write rejections, no rollback
320
+ // needed. writeFailed/badSchema cover the post-mutation failure modes.
321
+ export type AddNodesBulkOutcome =
322
+ | { kind: 'ok'; data: { nodes: Array<{ id: string; node: Record<string, unknown> }> } }
323
+ | { kind: 'flowNotFound' }
324
+ | { kind: 'fileNotFound'; path: string }
325
+ | { kind: 'badJson'; message: string }
326
+ | { kind: 'badSchema'; issues: ZodIssue[] }
327
+ | { kind: 'duplicateIdInBatch'; id: string }
328
+ | { kind: 'idAlreadyExists'; id: string }
329
+ | { kind: 'writeFailed'; message: string };
330
+
289
331
  export type DeleteNodeOutcome =
290
332
  | { kind: 'ok' }
291
333
  | { kind: 'flowNotFound' }
@@ -401,6 +443,21 @@ export const mergeConnectorUpdates = (
401
443
  }
402
444
  };
403
445
 
446
+ // Bulk-add envelopes. Body shape gated here; per-item shape is implicit and
447
+ // enforced by ResolvedFlowSchema after the whole batch is merged in — same
448
+ // pattern the singular add endpoints already rely on. The 100-item cap keeps
449
+ // one SSE broadcast payload reasonable; the LLM caller is meant to chunk if
450
+ // it ever needs more.
451
+ const BULK_MAX_ITEMS = 100;
452
+ export const NodesBulkBodySchema = z.object({
453
+ nodes: z.array(z.record(z.unknown())).min(1).max(BULK_MAX_ITEMS),
454
+ });
455
+ export type NodesBulkBody = z.infer<typeof NodesBulkBodySchema>;
456
+ export const ConnectorsBulkBodySchema = z.object({
457
+ connectors: z.array(z.record(z.unknown())).min(1).max(BULK_MAX_ITEMS),
458
+ });
459
+ export type ConnectorsBulkBody = z.infer<typeof ConnectorsBulkBodySchema>;
460
+
404
461
  export type AddConnectorOutcome =
405
462
  | { kind: 'ok'; data: { id: string } }
406
463
  | { kind: 'flowNotFound' }
@@ -409,6 +466,16 @@ export type AddConnectorOutcome =
409
466
  | { kind: 'badSchema'; issues: ZodIssue[] }
410
467
  | { kind: 'writeFailed'; message: string };
411
468
 
469
+ export type AddConnectorsBulkOutcome =
470
+ | { kind: 'ok'; data: { connectors: Array<{ id: string }> } }
471
+ | { kind: 'flowNotFound' }
472
+ | { kind: 'fileNotFound'; path: string }
473
+ | { kind: 'badJson'; message: string }
474
+ | { kind: 'badSchema'; issues: ZodIssue[] }
475
+ | { kind: 'duplicateIdInBatch'; id: string }
476
+ | { kind: 'idAlreadyExists'; id: string }
477
+ | { kind: 'writeFailed'; message: string };
478
+
412
479
  export type PatchConnectorOutcome =
413
480
  | { kind: 'ok' }
414
481
  | { kind: 'flowNotFound' }
@@ -454,20 +521,7 @@ export const withFlowWriteLock = <T>(flowId: string, fn: () => Promise<T>): Prom
454
521
  * editor diffs clean (single fs.watch event for the rename) and means a crash
455
522
  * during write can never corrupt the original.
456
523
  */
457
- export const writeFileAtomic = (filePath: string, content: string): void => {
458
- const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
459
- try {
460
- writeFileSync(tempPath, content);
461
- renameSync(tempPath, filePath);
462
- } catch (err) {
463
- try {
464
- if (existsSync(tempPath)) unlinkSync(tempPath);
465
- } catch {
466
- // best-effort cleanup
467
- }
468
- throw err;
469
- }
470
- };
524
+ export { writeFileAtomic } from './atomic-write.ts';
471
525
 
472
526
  /**
473
527
  * Read flow.json + optional style.json, return the raw parsed JSON so
@@ -889,7 +943,7 @@ export async function addNodeImpl(
889
943
 
890
944
  const newNode = { ...nodeBody };
891
945
  if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
892
- newNode.id = `node-${crypto.randomUUID()}`;
946
+ newNode.id = `node-${shortId()}`;
893
947
  }
894
948
  const newId = newNode.id as string;
895
949
  // Default position so the post-merge ResolvedFlowSchema parse passes. Position lives
@@ -898,32 +952,27 @@ export async function addNodeImpl(
898
952
  newNode.position = { x: 0, y: 0 };
899
953
  }
900
954
 
901
- // US-015: for htmlNode without a client-supplied htmlPath, allocate the
902
- // studio-managed `blocks/<id>.html` path and queue a starter-file write.
903
- // Client-supplied htmlPath wins and we skip the starter file (symmetric
904
- // with US-016's hand-edited-path leave-alone rule).
905
- let starterFile: { absPath: string; content: string } | undefined;
906
- if (newNode.type === 'htmlNode') {
955
+ // Generic spec-driven externalization. For each spec entry that applies to
956
+ // this node type, capture inbound content (default empty string), replace
957
+ // data[field] with the file:// ref, and queue a write inside the mutator
958
+ // below so flow.json only commits when the write succeeded.
959
+ const externalized: Array<{ absPath: string; content: string }> = [];
960
+ {
907
961
  const dataIsRecord =
908
962
  newNode.data !== null && typeof newNode.data === 'object' && !Array.isArray(newNode.data);
909
- const existingData: Record<string, unknown> = dataIsRecord
963
+ const data: Record<string, unknown> = dataIsRecord
910
964
  ? { ...(newNode.data as Record<string, unknown>) }
911
965
  : {};
912
- const clientProvidedHtmlPath =
913
- typeof existingData.htmlPath === 'string' && existingData.htmlPath.length > 0;
914
- if (!clientProvidedHtmlPath) {
915
- const htmlPath = `blocks/${newId}.html`;
916
- existingData.htmlPath = htmlPath;
917
- newNode.data = existingData;
918
- starterFile = {
919
- absPath: join(entry.repoPath, '.seeflow', htmlPath),
920
- content: buildHtmlNodeStarter(newId),
921
- };
922
- } else if (!dataIsRecord) {
923
- // Coerce non-object data into the spread'd record so the schema parse
924
- // sees the right shape — shouldn't happen in practice but keeps types honest.
925
- newNode.data = existingData;
966
+ for (const { field, fileName } of externalizedFieldsForNodeType(newNode.type)) {
967
+ const incoming = data[field];
968
+ const content = typeof incoming === 'string' ? incoming : '';
969
+ data[field] = nodeFileRef(newId, fileName);
970
+ externalized.push({
971
+ absPath: nodeFileAbsPath(entry.repoPath, newId, fileName),
972
+ content,
973
+ });
926
974
  }
975
+ newNode.data = data;
927
976
  }
928
977
 
929
978
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
@@ -935,10 +984,9 @@ export async function addNodeImpl(
935
984
  fullPath,
936
985
  (flow) => {
937
986
  flow.nodes.push(newNode);
938
- if (starterFile) {
987
+ for (const ext of externalized) {
939
988
  try {
940
- mkdirSync(dirname(starterFile.absPath), { recursive: true });
941
- writeFileAtomic(starterFile.absPath, starterFile.content);
989
+ writeNodeFile(ext.absPath, ext.content);
942
990
  } catch (err) {
943
991
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
944
992
  }
@@ -951,26 +999,115 @@ export async function addNodeImpl(
951
999
  return result;
952
1000
  }
953
1001
 
954
- // US-015: starter HTML content for studio-created htmlNodes. Centered 'Edit me'
955
- // card with a `blocks/<id>.html` subtitle matches the design's Section 6
956
- // markup exactly so the renderer paints a useful first impression while the
957
- // author hasn't yet edited the file.
958
- const buildHtmlNodeStarter = (nodeId: string): string =>
959
- `<div class="flex h-full w-full items-center justify-center rounded-lg border border-slate-300 bg-white p-4 text-slate-900">
960
- <div class="text-center">
961
- <div class="font-semibold">Edit me</div>
962
- <div class="text-xs text-slate-500">blocks/${nodeId}.html</div>
963
- </div>
964
- </div>
965
- `;
1002
+ // Bulk add N nodes in one read-validate-write-broadcast cycle. Transactional:
1003
+ // any single item failing the post-mutation ResolvedFlowSchema parse rolls
1004
+ // back the whole batch (nothing on flow.json, no per-node folders created).
1005
+ // Per-node externalization runs per item exactly like the singular path; the
1006
+ // queued file writes all happen inside the mutator so a writeFailed on item
1007
+ // K leaves items 0..K-1 with stranded folders same shape as the singular
1008
+ // path's writeFailed, but amplified by N. Caller is expected to retry.
1009
+ export async function addNodesBulkImpl(
1010
+ deps: OperationsDeps,
1011
+ flowId: string,
1012
+ body: NodesBulkBody,
1013
+ ): Promise<AddNodesBulkOutcome> {
1014
+ const entry = deps.registry.getById(flowId);
1015
+ if (!entry) return { kind: 'flowNotFound' };
1016
+
1017
+ // Pre-allocate ids + capture externalization writes per item. Doing this
1018
+ // outside the mutator means the duplicateIdInBatch check runs before any
1019
+ // disk IO; the collide-with-existing check happens inside the mutator where
1020
+ // it can see the freshly-read flow.nodes.
1021
+ const prepared: Array<{
1022
+ id: string;
1023
+ node: Record<string, unknown>;
1024
+ externalized: Array<{ absPath: string; content: string }>;
1025
+ }> = [];
1026
+ const idsInBatch = new Set<string>();
1027
+ for (const item of body.nodes) {
1028
+ const newNode = { ...item };
1029
+ if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
1030
+ newNode.id = `node-${shortId()}`;
1031
+ }
1032
+ const newId = newNode.id as string;
1033
+ if (idsInBatch.has(newId)) return { kind: 'duplicateIdInBatch', id: newId };
1034
+ idsInBatch.add(newId);
1035
+ if (!newNode.position || typeof newNode.position !== 'object') {
1036
+ newNode.position = { x: 0, y: 0 };
1037
+ }
1038
+ const externalized: Array<{ absPath: string; content: string }> = [];
1039
+ const dataIsRecord =
1040
+ newNode.data !== null && typeof newNode.data === 'object' && !Array.isArray(newNode.data);
1041
+ const data: Record<string, unknown> = dataIsRecord
1042
+ ? { ...(newNode.data as Record<string, unknown>) }
1043
+ : {};
1044
+ for (const { field, fileName } of externalizedFieldsForNodeType(newNode.type)) {
1045
+ const incoming = data[field];
1046
+ const content = typeof incoming === 'string' ? incoming : '';
1047
+ data[field] = nodeFileRef(newId, fileName);
1048
+ externalized.push({
1049
+ absPath: nodeFileAbsPath(entry.repoPath, newId, fileName),
1050
+ content,
1051
+ });
1052
+ }
1053
+ newNode.data = data;
1054
+ prepared.push({ id: newId, node: newNode, externalized });
1055
+ }
1056
+
1057
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1058
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1059
+
1060
+ const result = await mutateMergedFlowAndBroadcast<
1061
+ { kind: 'idAlreadyExists'; id: string } | { kind: 'writeFailed'; message: string }
1062
+ >(deps, flowId, fullPath, (flow) => {
1063
+ const existing = new Set(
1064
+ flow.nodes
1065
+ .map((n) => (typeof n.id === 'string' ? n.id : null))
1066
+ .filter((id): id is string => id !== null),
1067
+ );
1068
+ for (const p of prepared) {
1069
+ if (existing.has(p.id)) return { kind: 'idAlreadyExists', id: p.id };
1070
+ }
1071
+ for (const p of prepared) {
1072
+ flow.nodes.push(p.node);
1073
+ }
1074
+ for (const p of prepared) {
1075
+ for (const ext of p.externalized) {
1076
+ try {
1077
+ writeNodeFile(ext.absPath, ext.content);
1078
+ } catch (err) {
1079
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1080
+ }
1081
+ }
1082
+ }
1083
+ return { kind: 'ok' };
1084
+ });
1085
+
1086
+ if (result.kind === 'ok') {
1087
+ return {
1088
+ kind: 'ok',
1089
+ data: { nodes: prepared.map((p) => ({ id: p.id, node: p.node })) },
1090
+ };
1091
+ }
1092
+
1093
+ // Non-ok branch: the post-mutation ResolvedFlowSchema parse (or a later
1094
+ // writeFailed) ran AFTER the mutator already wrote per-node folders. The
1095
+ // collide-with-existing check ran first inside the mutator, so any folder
1096
+ // at `nodes/<prepared.id>/` was created by this call — safe to cascade.
1097
+ // The idAlreadyExists branch returns before any writeNodeFile, so we still
1098
+ // try to remove (it's a no-op when the folder doesn't exist).
1099
+ for (const p of prepared) {
1100
+ removeNodeDir(entry.repoPath, p.id);
1101
+ }
1102
+ return result;
1103
+ }
966
1104
 
967
1105
  // Remove a node and cascade-delete every connector touching it in a single
968
1106
  // atomic write. Final ResolvedFlowSchema parse stays in place so a pre-existing
969
1107
  // schema violation surfaces honestly instead of being silently papered over.
970
- // US-016: when the removed node is an htmlNode whose data.htmlPath matches the
971
- // studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
972
- // the flow.json write succeeds. Hand-edited paths are left alone (symmetric
973
- // with US-015's "client-supplied htmlPath wins, no starter file written").
1108
+ // After the flow.json write, `removeNodeDir` cascades the node's whole
1109
+ // `<project>/.seeflow/nodes/<id>/` folder covering detail.md, view.html,
1110
+ // and any imageNode upload that lived there.
974
1111
  export async function deleteNodeImpl(
975
1112
  deps: OperationsDeps,
976
1113
  flowId: string,
@@ -982,8 +1119,6 @@ export async function deleteNodeImpl(
982
1119
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
983
1120
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
984
1121
 
985
- let managedHtmlAbsPath: string | undefined;
986
-
987
1122
  const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(
988
1123
  deps,
989
1124
  flowId,
@@ -991,8 +1126,6 @@ export async function deleteNodeImpl(
991
1126
  (flow) => {
992
1127
  const idx = flow.nodes.findIndex((n) => n.id === nodeId);
993
1128
  if (idx < 0) return { kind: 'unknownNode' };
994
- const removed = flow.nodes[idx];
995
- managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
996
1129
  flow.nodes.splice(idx, 1);
997
1130
  flow.connectors = flow.connectors.filter(
998
1131
  (cn) => cn.source !== nodeId && cn.target !== nodeId,
@@ -1001,41 +1134,19 @@ export async function deleteNodeImpl(
1001
1134
  },
1002
1135
  );
1003
1136
 
1004
- if (result.kind === 'ok' && managedHtmlAbsPath) {
1137
+ if (result.kind === 'ok') {
1005
1138
  try {
1006
- unlinkSync(managedHtmlAbsPath);
1139
+ removeNodeDir(entry.repoPath, nodeId);
1007
1140
  } catch (err) {
1008
- const code = (err as NodeJS.ErrnoException | undefined)?.code;
1009
- if (code !== 'ENOENT') {
1010
- console.warn(
1011
- `[operations] failed to remove managed htmlNode file ${managedHtmlAbsPath}: ${
1012
- err instanceof Error ? err.message : String(err)
1013
- }`,
1014
- );
1015
- }
1141
+ // Best-effort: flow.json is already written and the orphan folder is
1142
+ // recoverable manually. ids are random so a future add_node won't collide.
1143
+ console.error(`[seeflow] failed to remove nodes/${nodeId}/`, err);
1016
1144
  }
1017
1145
  }
1018
1146
 
1019
1147
  return result;
1020
1148
  }
1021
1149
 
1022
- // US-016: only delete the companion file when the htmlPath matches the
1023
- // studio-managed shape `blocks/<id>.html` exactly. Hand-edited paths
1024
- // (`custom/hero.html`, an absolute path, anything else) are left alone so
1025
- // authors don't lose work they pointed the node at.
1026
- const managedHtmlNodePath = (
1027
- repoPath: string,
1028
- nodeId: string,
1029
- removed: Record<string, unknown> | undefined,
1030
- ): string | undefined => {
1031
- if (!removed || removed.type !== 'htmlNode') return undefined;
1032
- const data = removed.data;
1033
- if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
1034
- const htmlPath = (data as Record<string, unknown>).htmlPath;
1035
- if (htmlPath !== `blocks/${nodeId}.html`) return undefined;
1036
- return join(repoPath, '.seeflow', htmlPath);
1037
- };
1038
-
1039
1150
  // Move a single node by writing { x, y } back to its `position` on disk.
1040
1151
  // Mutates the *raw* parsed JSON so any unknown forward-compat fields the
1041
1152
  // schema doesn't yet recognize survive the round-trip untouched.
@@ -1087,10 +1198,47 @@ export async function patchNodeImpl(
1087
1198
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1088
1199
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1089
1200
 
1090
- return mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(deps, flowId, fullPath, (flow) => {
1201
+ return mutateMergedFlowAndBroadcast<
1202
+ { kind: 'unknownNode' } | { kind: 'writeFailed'; message: string }
1203
+ >(deps, flowId, fullPath, (flow) => {
1091
1204
  const node = flow.nodes.find((n) => n.id === nodeId);
1092
1205
  if (!node) return { kind: 'unknownNode' };
1206
+ const externalizedWrites: Array<{
1207
+ absPath: string;
1208
+ ref: string;
1209
+ field: string;
1210
+ content: string;
1211
+ }> = [];
1212
+ for (const { field, fileName } of externalizedFieldsForNodeType(node.type)) {
1213
+ const incoming = (updates as Record<string, unknown>)[field];
1214
+ if (incoming === undefined) continue;
1215
+ externalizedWrites.push({
1216
+ absPath: nodeFileAbsPath(entry.repoPath, nodeId, fileName),
1217
+ ref: nodeFileRef(nodeId, fileName),
1218
+ field,
1219
+ content: typeof incoming === 'string' ? incoming : '',
1220
+ });
1221
+ }
1093
1222
  mergeNodeUpdates(node, updates);
1223
+ if (externalizedWrites.length > 0) {
1224
+ const dataAny = node.data;
1225
+ const data: Record<string, unknown> =
1226
+ dataAny && typeof dataAny === 'object' && !Array.isArray(dataAny)
1227
+ ? (dataAny as Record<string, unknown>)
1228
+ : {};
1229
+ for (const w of externalizedWrites) {
1230
+ try {
1231
+ writeNodeFile(w.absPath, w.content);
1232
+ } catch (err) {
1233
+ return {
1234
+ kind: 'writeFailed',
1235
+ message: err instanceof Error ? err.message : String(err),
1236
+ };
1237
+ }
1238
+ data[w.field] = w.ref;
1239
+ }
1240
+ node.data = data;
1241
+ }
1094
1242
  return { kind: 'ok' };
1095
1243
  });
1096
1244
  }
@@ -1141,7 +1289,7 @@ export async function addConnectorImpl(
1141
1289
 
1142
1290
  const newConn = { ...connBody };
1143
1291
  if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1144
- newConn.id = `conn-${crypto.randomUUID()}`;
1292
+ newConn.id = `conn-${shortId()}`;
1145
1293
  }
1146
1294
  if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1147
1295
  newConn.kind = 'default';
@@ -1160,6 +1308,64 @@ export async function addConnectorImpl(
1160
1308
  return result;
1161
1309
  }
1162
1310
 
1311
+ // Bulk add — N connectors in one read-validate-write-broadcast cycle. Same
1312
+ // transactional shape as addNodesBulkImpl: any single connector failing the
1313
+ // post-mutation ResolvedFlowSchema parse (dangling source/target, missing
1314
+ // kind-specific field) rolls back the whole batch. No per-item externalization
1315
+ // to manage — connectors don't own per-node folders.
1316
+ export async function addConnectorsBulkImpl(
1317
+ deps: OperationsDeps,
1318
+ flowId: string,
1319
+ body: ConnectorsBulkBody,
1320
+ ): Promise<AddConnectorsBulkOutcome> {
1321
+ const entry = deps.registry.getById(flowId);
1322
+ if (!entry) return { kind: 'flowNotFound' };
1323
+
1324
+ const prepared: Array<{ id: string; conn: Record<string, unknown> }> = [];
1325
+ const idsInBatch = new Set<string>();
1326
+ for (const item of body.connectors) {
1327
+ const newConn = { ...item };
1328
+ if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1329
+ newConn.id = `conn-${shortId()}`;
1330
+ }
1331
+ if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1332
+ newConn.kind = 'default';
1333
+ }
1334
+ const newId = newConn.id as string;
1335
+ if (idsInBatch.has(newId)) return { kind: 'duplicateIdInBatch', id: newId };
1336
+ idsInBatch.add(newId);
1337
+ prepared.push({ id: newId, conn: newConn });
1338
+ }
1339
+
1340
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1341
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1342
+
1343
+ const result = await mutateMergedFlowAndBroadcast<{ kind: 'idAlreadyExists'; id: string }>(
1344
+ deps,
1345
+ flowId,
1346
+ fullPath,
1347
+ (flow) => {
1348
+ const existing = new Set(
1349
+ flow.connectors
1350
+ .map((c) => (typeof c.id === 'string' ? c.id : null))
1351
+ .filter((id): id is string => id !== null),
1352
+ );
1353
+ for (const p of prepared) {
1354
+ if (existing.has(p.id)) return { kind: 'idAlreadyExists', id: p.id };
1355
+ }
1356
+ for (const p of prepared) {
1357
+ flow.connectors.push(p.conn);
1358
+ }
1359
+ return { kind: 'ok' };
1360
+ },
1361
+ );
1362
+
1363
+ if (result.kind === 'ok') {
1364
+ return { kind: 'ok', data: { connectors: prepared.map((p) => ({ id: p.id })) } };
1365
+ }
1366
+ return result;
1367
+ }
1368
+
1163
1369
  // Apply a partial PATCH body to a single connector. Mutation runs against
1164
1370
  // the raw parsed JSON (so unknown forward-compat fields survive a round-trip).
1165
1371
  // When `kind` changes, the previous kind's payload fields are dropped first
package/src/proxy.ts CHANGED
@@ -17,6 +17,7 @@ import { join, resolve, sep } from 'node:path';
17
17
  import type { EventBus } from './events.ts';
18
18
  import { type ProcessSpawner, type SpawnHandle, defaultProcessSpawner } from './process-spawner.ts';
19
19
  import type { PlayAction, ResetAction } from './schema.ts';
20
+ import { shortId } from './short-id.ts';
20
21
 
21
22
  export interface PlayResult {
22
23
  /** Correlates this run across SSE events + the synchronous response. */
@@ -47,9 +48,35 @@ const SCRIPT_PATH_ESCAPE = 'scriptPath escapes project root';
47
48
 
48
49
  type Resolved = { ok: true; absPath: string } | { ok: false };
49
50
 
50
- // Resolve `<cwd>/.seeflow/<scriptPath>` and verify via realpath it stays inside
51
- // the `.seeflow` root. Mirrors `resolveProjectFile` in api.ts.
52
- function resolveScript(cwd: string, scriptPath: string): Resolved {
51
+ // Resolve `<cwd>/.seeflow/nodes/<nodeId>/<scriptPath>` and verify via realpath
52
+ // it stays inside the node folder. The per-node anchor means scriptPath is
53
+ // "scripts/play.ts" no node id leaks into its own path.
54
+ function resolveScript(cwd: string, nodeId: string, scriptPath: string): Resolved {
55
+ const nodeRoot = join(cwd, '.seeflow', 'nodes', nodeId);
56
+ let realRoot: string;
57
+ try {
58
+ realRoot = realpathSync(nodeRoot);
59
+ } catch {
60
+ return { ok: false };
61
+ }
62
+ const target = resolve(nodeRoot, scriptPath);
63
+ let realTarget: string;
64
+ try {
65
+ realTarget = realpathSync(target);
66
+ } catch {
67
+ return { ok: false };
68
+ }
69
+ const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
70
+ if (realTarget !== realRoot && !realTarget.startsWith(rootWithSep)) {
71
+ return { ok: false };
72
+ }
73
+ return { ok: true, absPath: realTarget };
74
+ }
75
+
76
+ // Legacy anchor for resetAction (kept until resetAction gets its own design
77
+ // round). Same realpath escape check as resolveScript, but rooted at
78
+ // <cwd>/.seeflow/ rather than a per-node folder.
79
+ function resolveResetScript(cwd: string, scriptPath: string): Resolved {
53
80
  const seeflowRoot = join(cwd, '.seeflow');
54
81
  let realRoot: string;
55
82
  try {
@@ -154,9 +181,9 @@ export async function stopAllPlays(flowId: string): Promise<void> {
154
181
  export async function runPlay(options: RunPlayOptions): Promise<PlayResult> {
155
182
  const { events, flowId, nodeId, cwd, action } = options;
156
183
  const spawner = options.spawner ?? defaultProcessSpawner;
157
- const runId = crypto.randomUUID();
184
+ const runId = shortId();
158
185
 
159
- const resolved = resolveScript(cwd, action.scriptPath);
186
+ const resolved = resolveScript(cwd, nodeId, action.scriptPath);
160
187
  if (!resolved.ok) {
161
188
  events.broadcast({
162
189
  type: 'node:error',
@@ -303,7 +330,9 @@ export async function runReset(options: RunResetOptions): Promise<ResetResult> {
303
330
  const { events, flowId, cwd, action } = options;
304
331
  const spawner = options.spawner ?? defaultProcessSpawner;
305
332
 
306
- const resolved = resolveScript(cwd, action.scriptPath);
333
+ // resetAction stays anchored at .seeflow/ for now — design defers per-node
334
+ // resetAction to a later round (decision #7). Mirrors the previous behaviour.
335
+ const resolved = resolveResetScript(cwd, action.scriptPath);
307
336
  if (!resolved.ok) {
308
337
  events.broadcast({
309
338
  type: 'demo:reset',
package/src/registry.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { seeflowHome } from './paths.ts';
4
+ import { shortId } from './short-id.ts';
4
5
 
5
6
  export interface FlowEntry {
6
7
  id: string;
@@ -121,7 +122,7 @@ export function createRegistry(options: { path?: string } = {}): Registry {
121
122
  persist();
122
123
  return updated;
123
124
  }
124
- const id = crypto.randomUUID();
125
+ const id = shortId();
125
126
  const slug = uniqueSlug(slugify(input.name));
126
127
  const entry: FlowEntry = {
127
128
  id,