@tuongaz/seeflow 0.1.41 → 0.1.47

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 (39) hide show
  1. package/README.md +2 -15
  2. package/dist/web/assets/{index-C029S3KL.js → index-BYeYJkCQ.js} +1541 -1541
  3. package/dist/web/assets/{index-BwdVgB2y.css → index-DSfixlbD.css} +1 -1
  4. package/dist/web/assets/{index.es-Ylk3HlXb.js → index.es-CqkMwhBu.js} +1 -1
  5. package/dist/web/assets/{jspdf.es.min-Bf66gPs3.js → jspdf.es.min-DLHTB6Rk.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 +2 -1
  12. package/src/api.ts +101 -14
  13. package/src/atomic-write.ts +16 -0
  14. package/src/cli-e2e.ts +424 -0
  15. package/src/cli-helpers.ts +65 -0
  16. package/src/cli.ts +371 -17
  17. package/src/file-ref.ts +27 -16
  18. package/src/mcp.ts +116 -23
  19. package/src/merge.ts +1 -1
  20. package/src/node-files.ts +48 -0
  21. package/src/operations.ts +325 -105
  22. package/src/proxy.ts +35 -6
  23. package/src/registry.ts +2 -1
  24. package/src/schema.ts +31 -25
  25. package/src/short-id.ts +24 -0
  26. package/src/status-runner.ts +9 -8
  27. package/src/watcher.ts +14 -14
  28. /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
  29. /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
  30. /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
  31. /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
  32. /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
  33. /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
  34. /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
  35. /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
  36. /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
  37. /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
  38. /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
  39. /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
@@ -604,13 +658,27 @@ export async function mutateMergedFlow<E extends { kind: string }>(
604
658
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
605
659
  }
606
660
 
607
- const snap: FlowSnapshot = {
608
- flow: finalParse.data as ResolvedFlow,
609
- valid: true,
610
- error: null,
611
- filePath: flowPath,
612
- parsedAt: Date.now(),
613
- };
661
+ // Re-read through readMergedFlow so the snapshot we hand to notifyWritten
662
+ // carries file://-resolved content (detail.md, view.html, …). The in-memory
663
+ // `merged` tree above still holds raw `file://<name>` strings — broadcasting
664
+ // it would clobber the watcher's resolved seed and ship unresolved refs to
665
+ // every SSE subscriber until the next reparse.
666
+ const reread = readMergedFlow(flowPath);
667
+ const snap: FlowSnapshot = reread.valid
668
+ ? {
669
+ flow: reread.flow,
670
+ valid: true,
671
+ error: null,
672
+ filePath: flowPath,
673
+ parsedAt: Date.now(),
674
+ }
675
+ : {
676
+ flow: finalParse.data as ResolvedFlow,
677
+ valid: true,
678
+ error: null,
679
+ filePath: flowPath,
680
+ parsedAt: Date.now(),
681
+ };
614
682
  return { kind: 'ok', snap, flowContent, styleContent };
615
683
  }
616
684
 
@@ -889,7 +957,7 @@ export async function addNodeImpl(
889
957
 
890
958
  const newNode = { ...nodeBody };
891
959
  if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
892
- newNode.id = `node-${crypto.randomUUID()}`;
960
+ newNode.id = `node-${shortId()}`;
893
961
  }
894
962
  const newId = newNode.id as string;
895
963
  // Default position so the post-merge ResolvedFlowSchema parse passes. Position lives
@@ -898,32 +966,27 @@ export async function addNodeImpl(
898
966
  newNode.position = { x: 0, y: 0 };
899
967
  }
900
968
 
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') {
969
+ // Generic spec-driven externalization. For each spec entry that applies to
970
+ // this node type, capture inbound content (default empty string), replace
971
+ // data[field] with the file:// ref, and queue a write inside the mutator
972
+ // below so flow.json only commits when the write succeeded.
973
+ const externalized: Array<{ absPath: string; content: string }> = [];
974
+ {
907
975
  const dataIsRecord =
908
976
  newNode.data !== null && typeof newNode.data === 'object' && !Array.isArray(newNode.data);
909
- const existingData: Record<string, unknown> = dataIsRecord
977
+ const data: Record<string, unknown> = dataIsRecord
910
978
  ? { ...(newNode.data as Record<string, unknown>) }
911
979
  : {};
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;
980
+ for (const { field, fileName } of externalizedFieldsForNodeType(newNode.type)) {
981
+ const incoming = data[field];
982
+ const content = typeof incoming === 'string' ? incoming : '';
983
+ data[field] = nodeFileRef(newId, fileName);
984
+ externalized.push({
985
+ absPath: nodeFileAbsPath(entry.repoPath, newId, fileName),
986
+ content,
987
+ });
926
988
  }
989
+ newNode.data = data;
927
990
  }
928
991
 
929
992
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
@@ -935,10 +998,9 @@ export async function addNodeImpl(
935
998
  fullPath,
936
999
  (flow) => {
937
1000
  flow.nodes.push(newNode);
938
- if (starterFile) {
1001
+ for (const ext of externalized) {
939
1002
  try {
940
- mkdirSync(dirname(starterFile.absPath), { recursive: true });
941
- writeFileAtomic(starterFile.absPath, starterFile.content);
1003
+ writeNodeFile(ext.absPath, ext.content);
942
1004
  } catch (err) {
943
1005
  return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
944
1006
  }
@@ -951,26 +1013,115 @@ export async function addNodeImpl(
951
1013
  return result;
952
1014
  }
953
1015
 
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
- `;
1016
+ // Bulk add N nodes in one read-validate-write-broadcast cycle. Transactional:
1017
+ // any single item failing the post-mutation ResolvedFlowSchema parse rolls
1018
+ // back the whole batch (nothing on flow.json, no per-node folders created).
1019
+ // Per-node externalization runs per item exactly like the singular path; the
1020
+ // queued file writes all happen inside the mutator so a writeFailed on item
1021
+ // K leaves items 0..K-1 with stranded folders same shape as the singular
1022
+ // path's writeFailed, but amplified by N. Caller is expected to retry.
1023
+ export async function addNodesBulkImpl(
1024
+ deps: OperationsDeps,
1025
+ flowId: string,
1026
+ body: NodesBulkBody,
1027
+ ): Promise<AddNodesBulkOutcome> {
1028
+ const entry = deps.registry.getById(flowId);
1029
+ if (!entry) return { kind: 'flowNotFound' };
1030
+
1031
+ // Pre-allocate ids + capture externalization writes per item. Doing this
1032
+ // outside the mutator means the duplicateIdInBatch check runs before any
1033
+ // disk IO; the collide-with-existing check happens inside the mutator where
1034
+ // it can see the freshly-read flow.nodes.
1035
+ const prepared: Array<{
1036
+ id: string;
1037
+ node: Record<string, unknown>;
1038
+ externalized: Array<{ absPath: string; content: string }>;
1039
+ }> = [];
1040
+ const idsInBatch = new Set<string>();
1041
+ for (const item of body.nodes) {
1042
+ const newNode = { ...item };
1043
+ if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
1044
+ newNode.id = `node-${shortId()}`;
1045
+ }
1046
+ const newId = newNode.id as string;
1047
+ if (idsInBatch.has(newId)) return { kind: 'duplicateIdInBatch', id: newId };
1048
+ idsInBatch.add(newId);
1049
+ if (!newNode.position || typeof newNode.position !== 'object') {
1050
+ newNode.position = { x: 0, y: 0 };
1051
+ }
1052
+ const externalized: Array<{ absPath: string; content: string }> = [];
1053
+ const dataIsRecord =
1054
+ newNode.data !== null && typeof newNode.data === 'object' && !Array.isArray(newNode.data);
1055
+ const data: Record<string, unknown> = dataIsRecord
1056
+ ? { ...(newNode.data as Record<string, unknown>) }
1057
+ : {};
1058
+ for (const { field, fileName } of externalizedFieldsForNodeType(newNode.type)) {
1059
+ const incoming = data[field];
1060
+ const content = typeof incoming === 'string' ? incoming : '';
1061
+ data[field] = nodeFileRef(newId, fileName);
1062
+ externalized.push({
1063
+ absPath: nodeFileAbsPath(entry.repoPath, newId, fileName),
1064
+ content,
1065
+ });
1066
+ }
1067
+ newNode.data = data;
1068
+ prepared.push({ id: newId, node: newNode, externalized });
1069
+ }
1070
+
1071
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1072
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1073
+
1074
+ const result = await mutateMergedFlowAndBroadcast<
1075
+ { kind: 'idAlreadyExists'; id: string } | { kind: 'writeFailed'; message: string }
1076
+ >(deps, flowId, fullPath, (flow) => {
1077
+ const existing = new Set(
1078
+ flow.nodes
1079
+ .map((n) => (typeof n.id === 'string' ? n.id : null))
1080
+ .filter((id): id is string => id !== null),
1081
+ );
1082
+ for (const p of prepared) {
1083
+ if (existing.has(p.id)) return { kind: 'idAlreadyExists', id: p.id };
1084
+ }
1085
+ for (const p of prepared) {
1086
+ flow.nodes.push(p.node);
1087
+ }
1088
+ for (const p of prepared) {
1089
+ for (const ext of p.externalized) {
1090
+ try {
1091
+ writeNodeFile(ext.absPath, ext.content);
1092
+ } catch (err) {
1093
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1094
+ }
1095
+ }
1096
+ }
1097
+ return { kind: 'ok' };
1098
+ });
1099
+
1100
+ if (result.kind === 'ok') {
1101
+ return {
1102
+ kind: 'ok',
1103
+ data: { nodes: prepared.map((p) => ({ id: p.id, node: p.node })) },
1104
+ };
1105
+ }
1106
+
1107
+ // Non-ok branch: the post-mutation ResolvedFlowSchema parse (or a later
1108
+ // writeFailed) ran AFTER the mutator already wrote per-node folders. The
1109
+ // collide-with-existing check ran first inside the mutator, so any folder
1110
+ // at `nodes/<prepared.id>/` was created by this call — safe to cascade.
1111
+ // The idAlreadyExists branch returns before any writeNodeFile, so we still
1112
+ // try to remove (it's a no-op when the folder doesn't exist).
1113
+ for (const p of prepared) {
1114
+ removeNodeDir(entry.repoPath, p.id);
1115
+ }
1116
+ return result;
1117
+ }
966
1118
 
967
1119
  // Remove a node and cascade-delete every connector touching it in a single
968
1120
  // atomic write. Final ResolvedFlowSchema parse stays in place so a pre-existing
969
1121
  // 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").
1122
+ // After the flow.json write, `removeNodeDir` cascades the node's whole
1123
+ // `<project>/.seeflow/nodes/<id>/` folder covering detail.md, view.html,
1124
+ // and any imageNode upload that lived there.
974
1125
  export async function deleteNodeImpl(
975
1126
  deps: OperationsDeps,
976
1127
  flowId: string,
@@ -982,8 +1133,6 @@ export async function deleteNodeImpl(
982
1133
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
983
1134
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
984
1135
 
985
- let managedHtmlAbsPath: string | undefined;
986
-
987
1136
  const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(
988
1137
  deps,
989
1138
  flowId,
@@ -991,8 +1140,6 @@ export async function deleteNodeImpl(
991
1140
  (flow) => {
992
1141
  const idx = flow.nodes.findIndex((n) => n.id === nodeId);
993
1142
  if (idx < 0) return { kind: 'unknownNode' };
994
- const removed = flow.nodes[idx];
995
- managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
996
1143
  flow.nodes.splice(idx, 1);
997
1144
  flow.connectors = flow.connectors.filter(
998
1145
  (cn) => cn.source !== nodeId && cn.target !== nodeId,
@@ -1001,41 +1148,19 @@ export async function deleteNodeImpl(
1001
1148
  },
1002
1149
  );
1003
1150
 
1004
- if (result.kind === 'ok' && managedHtmlAbsPath) {
1151
+ if (result.kind === 'ok') {
1005
1152
  try {
1006
- unlinkSync(managedHtmlAbsPath);
1153
+ removeNodeDir(entry.repoPath, nodeId);
1007
1154
  } 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
- }
1155
+ // Best-effort: flow.json is already written and the orphan folder is
1156
+ // recoverable manually. ids are random so a future add_node won't collide.
1157
+ console.error(`[seeflow] failed to remove nodes/${nodeId}/`, err);
1016
1158
  }
1017
1159
  }
1018
1160
 
1019
1161
  return result;
1020
1162
  }
1021
1163
 
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
1164
  // Move a single node by writing { x, y } back to its `position` on disk.
1040
1165
  // Mutates the *raw* parsed JSON so any unknown forward-compat fields the
1041
1166
  // schema doesn't yet recognize survive the round-trip untouched.
@@ -1087,10 +1212,47 @@ export async function patchNodeImpl(
1087
1212
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1088
1213
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1089
1214
 
1090
- return mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(deps, flowId, fullPath, (flow) => {
1215
+ return mutateMergedFlowAndBroadcast<
1216
+ { kind: 'unknownNode' } | { kind: 'writeFailed'; message: string }
1217
+ >(deps, flowId, fullPath, (flow) => {
1091
1218
  const node = flow.nodes.find((n) => n.id === nodeId);
1092
1219
  if (!node) return { kind: 'unknownNode' };
1220
+ const externalizedWrites: Array<{
1221
+ absPath: string;
1222
+ ref: string;
1223
+ field: string;
1224
+ content: string;
1225
+ }> = [];
1226
+ for (const { field, fileName } of externalizedFieldsForNodeType(node.type)) {
1227
+ const incoming = (updates as Record<string, unknown>)[field];
1228
+ if (incoming === undefined) continue;
1229
+ externalizedWrites.push({
1230
+ absPath: nodeFileAbsPath(entry.repoPath, nodeId, fileName),
1231
+ ref: nodeFileRef(nodeId, fileName),
1232
+ field,
1233
+ content: typeof incoming === 'string' ? incoming : '',
1234
+ });
1235
+ }
1093
1236
  mergeNodeUpdates(node, updates);
1237
+ if (externalizedWrites.length > 0) {
1238
+ const dataAny = node.data;
1239
+ const data: Record<string, unknown> =
1240
+ dataAny && typeof dataAny === 'object' && !Array.isArray(dataAny)
1241
+ ? (dataAny as Record<string, unknown>)
1242
+ : {};
1243
+ for (const w of externalizedWrites) {
1244
+ try {
1245
+ writeNodeFile(w.absPath, w.content);
1246
+ } catch (err) {
1247
+ return {
1248
+ kind: 'writeFailed',
1249
+ message: err instanceof Error ? err.message : String(err),
1250
+ };
1251
+ }
1252
+ data[w.field] = w.ref;
1253
+ }
1254
+ node.data = data;
1255
+ }
1094
1256
  return { kind: 'ok' };
1095
1257
  });
1096
1258
  }
@@ -1141,7 +1303,7 @@ export async function addConnectorImpl(
1141
1303
 
1142
1304
  const newConn = { ...connBody };
1143
1305
  if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1144
- newConn.id = `conn-${crypto.randomUUID()}`;
1306
+ newConn.id = `conn-${shortId()}`;
1145
1307
  }
1146
1308
  if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1147
1309
  newConn.kind = 'default';
@@ -1160,6 +1322,64 @@ export async function addConnectorImpl(
1160
1322
  return result;
1161
1323
  }
1162
1324
 
1325
+ // Bulk add — N connectors in one read-validate-write-broadcast cycle. Same
1326
+ // transactional shape as addNodesBulkImpl: any single connector failing the
1327
+ // post-mutation ResolvedFlowSchema parse (dangling source/target, missing
1328
+ // kind-specific field) rolls back the whole batch. No per-item externalization
1329
+ // to manage — connectors don't own per-node folders.
1330
+ export async function addConnectorsBulkImpl(
1331
+ deps: OperationsDeps,
1332
+ flowId: string,
1333
+ body: ConnectorsBulkBody,
1334
+ ): Promise<AddConnectorsBulkOutcome> {
1335
+ const entry = deps.registry.getById(flowId);
1336
+ if (!entry) return { kind: 'flowNotFound' };
1337
+
1338
+ const prepared: Array<{ id: string; conn: Record<string, unknown> }> = [];
1339
+ const idsInBatch = new Set<string>();
1340
+ for (const item of body.connectors) {
1341
+ const newConn = { ...item };
1342
+ if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1343
+ newConn.id = `conn-${shortId()}`;
1344
+ }
1345
+ if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1346
+ newConn.kind = 'default';
1347
+ }
1348
+ const newId = newConn.id as string;
1349
+ if (idsInBatch.has(newId)) return { kind: 'duplicateIdInBatch', id: newId };
1350
+ idsInBatch.add(newId);
1351
+ prepared.push({ id: newId, conn: newConn });
1352
+ }
1353
+
1354
+ const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1355
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1356
+
1357
+ const result = await mutateMergedFlowAndBroadcast<{ kind: 'idAlreadyExists'; id: string }>(
1358
+ deps,
1359
+ flowId,
1360
+ fullPath,
1361
+ (flow) => {
1362
+ const existing = new Set(
1363
+ flow.connectors
1364
+ .map((c) => (typeof c.id === 'string' ? c.id : null))
1365
+ .filter((id): id is string => id !== null),
1366
+ );
1367
+ for (const p of prepared) {
1368
+ if (existing.has(p.id)) return { kind: 'idAlreadyExists', id: p.id };
1369
+ }
1370
+ for (const p of prepared) {
1371
+ flow.connectors.push(p.conn);
1372
+ }
1373
+ return { kind: 'ok' };
1374
+ },
1375
+ );
1376
+
1377
+ if (result.kind === 'ok') {
1378
+ return { kind: 'ok', data: { connectors: prepared.map((p) => ({ id: p.id })) } };
1379
+ }
1380
+ return result;
1381
+ }
1382
+
1163
1383
  // Apply a partial PATCH body to a single connector. Mutation runs against
1164
1384
  // the raw parsed JSON (so unknown forward-compat fields survive a round-trip).
1165
1385
  // 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',