@tuongaz/seeflow 0.1.41 → 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.
- package/README.md +2 -15
- package/dist/web/assets/{index-C029S3KL.js → index-BPUoNIBm.js} +1541 -1541
- package/dist/web/assets/{index-BwdVgB2y.css → index-BlkUOp7f.css} +1 -1
- package/dist/web/assets/{index.es-Ylk3HlXb.js → index.es-mje3R_63.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-Bf66gPs3.js → jspdf.es.min-DX3imOs2.js} +3 -3
- package/dist/web/index.html +2 -2
- package/examples/ecommerce-platform/.seeflow/flow.json +47 -47
- package/examples/ecommerce-platform/.seeflow/style.json +10 -10
- package/examples/order-pipeline/.seeflow/flow.json +17 -17
- package/examples/order-pipeline/.seeflow/style.json +4 -4
- package/package.json +1 -1
- package/src/api.ts +101 -14
- package/src/atomic-write.ts +16 -0
- package/src/cli-e2e.ts +420 -0
- package/src/cli-helpers.ts +65 -0
- package/src/cli.ts +371 -17
- package/src/mcp.ts +116 -23
- package/src/merge.ts +1 -1
- package/src/node-files.ts +45 -0
- package/src/operations.ts +304 -98
- package/src/proxy.ts +35 -6
- package/src/registry.ts +2 -1
- package/src/schema.ts +31 -25
- package/src/short-id.ts +24 -0
- package/src/status-runner.ts +9 -8
- package/src/watcher.ts +14 -14
- /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
- /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:
|
|
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
|
|
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-${
|
|
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
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
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
|
|
963
|
+
const data: Record<string, unknown> = dataIsRecord
|
|
910
964
|
? { ...(newNode.data as Record<string, unknown>) }
|
|
911
965
|
: {};
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
987
|
+
for (const ext of externalized) {
|
|
939
988
|
try {
|
|
940
|
-
|
|
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
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
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'
|
|
1137
|
+
if (result.kind === 'ok') {
|
|
1005
1138
|
try {
|
|
1006
|
-
|
|
1139
|
+
removeNodeDir(entry.repoPath, nodeId);
|
|
1007
1140
|
} catch (err) {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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<
|
|
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-${
|
|
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
|
|
51
|
-
// the
|
|
52
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
125
|
+
const id = shortId();
|
|
125
126
|
const slug = uniqueSlug(slugify(input.name));
|
|
126
127
|
const entry: FlowEntry = {
|
|
127
128
|
id,
|