@bian-womp/spark-workbench 0.2.9 → 0.2.11

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 (43) hide show
  1. package/lib/cjs/index.cjs +337 -146
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/AbstractWorkbench.d.ts +2 -2
  4. package/lib/cjs/src/core/AbstractWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +2 -2
  6. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  7. package/lib/cjs/src/core/contracts.d.ts +2 -2
  8. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/DefaultNode.d.ts.map +1 -1
  10. package/lib/cjs/src/misc/NodeContextMenu.d.ts +1 -1
  11. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  14. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +7 -0
  15. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  16. package/lib/cjs/src/runtime/IGraphRunner.d.ts +7 -0
  17. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  18. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts +12 -0
  19. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  20. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +7 -0
  21. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  22. package/lib/esm/index.js +337 -146
  23. package/lib/esm/index.js.map +1 -1
  24. package/lib/esm/src/core/AbstractWorkbench.d.ts +2 -2
  25. package/lib/esm/src/core/AbstractWorkbench.d.ts.map +1 -1
  26. package/lib/esm/src/core/InMemoryWorkbench.d.ts +2 -2
  27. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  28. package/lib/esm/src/core/contracts.d.ts +2 -2
  29. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  30. package/lib/esm/src/misc/DefaultNode.d.ts.map +1 -1
  31. package/lib/esm/src/misc/NodeContextMenu.d.ts +1 -1
  32. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  33. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  34. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  35. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +7 -0
  36. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  37. package/lib/esm/src/runtime/IGraphRunner.d.ts +7 -0
  38. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  39. package/lib/esm/src/runtime/LocalGraphRunner.d.ts +12 -0
  40. package/lib/esm/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  41. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +7 -0
  42. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  43. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -169,6 +169,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
169
169
  change: { type: "addNode", nodeId: id },
170
170
  });
171
171
  this.refreshValidation();
172
+ return id;
172
173
  }
173
174
  removeNode(nodeId) {
174
175
  this.def.nodes = this.def.nodes.filter((n) => n.nodeId !== nodeId);
@@ -193,6 +194,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
193
194
  change: { type: "connect", edgeId: id },
194
195
  });
195
196
  this.refreshValidation();
197
+ return id;
196
198
  }
197
199
  disconnect(edgeId) {
198
200
  this.def.edges = this.def.edges.filter((e) => e.id !== edgeId);
@@ -409,6 +411,21 @@ class AbstractGraphRunner {
409
411
  class LocalGraphRunner extends AbstractGraphRunner {
410
412
  constructor(registry) {
411
413
  super(registry, { kind: "local" });
414
+ this.setEnvironment = (env, opts) => {
415
+ if (!this.runtime)
416
+ return;
417
+ if (opts?.merge) {
418
+ const current = this.runtime.getEnvironment();
419
+ const next = { ...(current || {}), ...(env || {}) };
420
+ this.runtime.setEnvironment(next);
421
+ }
422
+ else {
423
+ this.runtime.setEnvironment(env);
424
+ }
425
+ };
426
+ this.getEnvironment = () => {
427
+ return this.runtime?.getEnvironment?.();
428
+ };
412
429
  this.emit("transport", { state: "local" });
413
430
  }
414
431
  build(def) {
@@ -507,17 +524,36 @@ class LocalGraphRunner extends AbstractGraphRunner {
507
524
  const runtimeInputs = this.runtime
508
525
  ? this.runtime.getNodeData?.(n.nodeId)?.inputs ?? {}
509
526
  : {};
510
- if (this.isRunning()) {
511
- out[n.nodeId] = runtimeInputs;
512
- }
513
- else {
514
- const merged = { ...runtimeInputs, ...staged };
515
- if (Object.keys(merged).length > 0)
516
- out[n.nodeId] = merged;
517
- }
527
+ const merged = { ...runtimeInputs, ...staged };
528
+ if (Object.keys(merged).length > 0)
529
+ out[n.nodeId] = merged;
518
530
  }
519
531
  return out;
520
532
  }
533
+ async snapshotFull() {
534
+ const def = undefined; // UI will supply def/positions on download for local
535
+ const inputs = this.getInputs(this.runtime
536
+ ? {
537
+ nodes: Array.from(this.runtime.getNodeIds()).map((id) => ({ nodeId: id, typeId: "" })),
538
+ edges: [],
539
+ }
540
+ : { nodes: [], edges: [] });
541
+ const outputs = this.getOutputs(this.runtime
542
+ ? {
543
+ nodes: Array.from(this.runtime.getNodeIds()).map((id) => ({ nodeId: id, typeId: "" })),
544
+ edges: [],
545
+ }
546
+ : { nodes: [], edges: [] });
547
+ const environment = this.getEnvironment() || {};
548
+ return { def, environment, inputs, outputs };
549
+ }
550
+ async applySnapshotFull(payload) {
551
+ if (payload.def)
552
+ this.build(payload.def);
553
+ this.setEnvironment?.(payload.environment || {}, { merge: false });
554
+ // Hydrate via runtime for exact restore and re-emit
555
+ this.runtime?.hydrate({ inputs: payload.inputs || {}, outputs: payload.outputs || {} }, { reemit: true });
556
+ }
521
557
  dispose() {
522
558
  super.dispose();
523
559
  this.runtime = undefined;
@@ -689,6 +725,36 @@ class RemoteGraphRunner extends AbstractGraphRunner {
689
725
  return value;
690
726
  }
691
727
  }
728
+ async snapshotFull() {
729
+ const runner = await this.ensureRemoteRunner();
730
+ try {
731
+ return await runner.snapshotFull();
732
+ }
733
+ catch {
734
+ return { def: undefined, environment: {}, inputs: {}, outputs: {} };
735
+ }
736
+ }
737
+ async applySnapshotFull(payload) {
738
+ const runner = await this.ensureRemoteRunner();
739
+ await runner.applySnapshotFull(payload);
740
+ }
741
+ setEnvironment(env, opts) {
742
+ const t = this.transport;
743
+ if (!t)
744
+ return;
745
+ t.request({
746
+ message: {
747
+ type: "SetEnvironment",
748
+ payload: { environment: env, merge: opts?.merge },
749
+ },
750
+ }).catch(() => { });
751
+ }
752
+ getEnvironment() {
753
+ // Fetch from remote via lightweight command
754
+ // Note: returns undefined synchronously; callers needing value should use snapshotFull or call runner directly
755
+ // For now, we expose an async helper on RemoteRunner. Keep sync signature per interface.
756
+ return undefined;
757
+ }
692
758
  getOutputs(def) {
693
759
  const out = {};
694
760
  const cache = this.valueCache;
@@ -722,7 +788,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
722
788
  if (rec && rec.io === "input")
723
789
  cur[h] = rec.value;
724
790
  }
725
- const merged = this.isRunning() ? cur : { ...cur, ...staged };
791
+ const merged = { ...cur, ...staged };
726
792
  if (Object.keys(merged).length > 0)
727
793
  out[n.nodeId] = merged;
728
794
  }
@@ -2083,7 +2149,7 @@ function DefaultNodeHeader({ id, title, status, validation, right, onInvalidate,
2083
2149
  .join("; ") })), jsxRuntime.jsxs("span", { className: "text-[10px] opacity-70", children: ["(", id, ")"] })] })] }));
2084
2150
  }
2085
2151
  function DefaultNodeContent({ id, data, isConnectable, }) {
2086
- const ctx = useWorkbenchContext();
2152
+ useWorkbenchContext();
2087
2153
  const { showValues, inputValues, outputValues, toString } = data;
2088
2154
  const inputEntries = data.inputHandles ?? [];
2089
2155
  const outputEntries = data.outputHandles ?? [];
@@ -2093,98 +2159,6 @@ function DefaultNodeContent({ id, data, isConnectable, }) {
2093
2159
  outputs: []};
2094
2160
  const isRunning = !!status.activeRuns;
2095
2161
  const pct = Math.round(Math.max(0, Math.min(1, Number(status.progress) || 0)) * 100);
2096
- const handleBake = React.useCallback(async (handleId) => {
2097
- try {
2098
- const typeId = ctx.outputTypesMap?.[id]?.[handleId];
2099
- const rawValue = ctx.outputsMap?.[id]?.[handleId];
2100
- if (!typeId || rawValue === undefined)
2101
- return;
2102
- const unwrap = (v) => sparkGraph.isTypedOutput(v) ? sparkGraph.getTypedOutputValue(v) : v;
2103
- const clone = (v) => typeof structuredClone === "function"
2104
- ? structuredClone(v)
2105
- : JSON.parse(JSON.stringify(v));
2106
- const coerceIfNeeded = async (fromType, toType, value) => {
2107
- if (!toType || toType === fromType || !ctx.runner?.coerce)
2108
- return value;
2109
- try {
2110
- return await ctx.runner.coerce(fromType, toType, value);
2111
- }
2112
- catch {
2113
- return value;
2114
- }
2115
- };
2116
- const positions = ctx.wb.getPositions();
2117
- const pos = positions[id] || { x: 0, y: 0 };
2118
- const isArray = typeId.endsWith("[]");
2119
- const baseTypeId = isArray ? typeId.slice(0, -2) : typeId;
2120
- const tArr = isArray ? ctx.registry?.types.get(typeId) : undefined;
2121
- const tElem = ctx.registry?.types.get(baseTypeId);
2122
- const singleTarget = !isArray ? tElem?.bakeTarget : undefined;
2123
- const arrTarget = isArray ? tArr?.bakeTarget : undefined;
2124
- const elemTarget = isArray ? tElem?.bakeTarget : undefined;
2125
- const makeTargetInfo = (bt) => {
2126
- if (!bt)
2127
- return undefined;
2128
- const node = ctx.registry?.nodes.get(String(bt.nodeTypeId));
2129
- const inType = sparkGraph.getInputTypeId(node?.inputs, String(bt.inputHandle || "Value"));
2130
- return { bt, inType };
2131
- };
2132
- // Plan and execute
2133
- if (singleTarget) {
2134
- const info = makeTargetInfo(singleTarget);
2135
- const v = unwrap(rawValue);
2136
- const coerced = await coerceIfNeeded(typeId, info?.inType, v);
2137
- ctx.wb.addNode({
2138
- nodeId: undefined,
2139
- typeId: String(singleTarget.nodeTypeId),
2140
- position: { x: pos.x + 180, y: pos.y },
2141
- params: {},
2142
- initialInputs: {
2143
- [String(singleTarget.inputHandle || "Value")]: clone(coerced),
2144
- },
2145
- });
2146
- return;
2147
- }
2148
- if (isArray && arrTarget) {
2149
- const info = makeTargetInfo(arrTarget);
2150
- const v = unwrap(rawValue);
2151
- const coerced = await coerceIfNeeded(typeId, info?.inType, v);
2152
- ctx.wb.addNode({
2153
- nodeId: undefined,
2154
- typeId: String(arrTarget.nodeTypeId),
2155
- position: { x: pos.x + 180, y: pos.y },
2156
- params: {},
2157
- initialInputs: {
2158
- [String(arrTarget.inputHandle || "Value")]: clone(coerced),
2159
- },
2160
- });
2161
- return;
2162
- }
2163
- if (isArray && elemTarget && Array.isArray(rawValue)) {
2164
- const info = makeTargetInfo(elemTarget);
2165
- const items = rawValue.map(unwrap);
2166
- const coercedItems = await Promise.all(items.map((v) => coerceIfNeeded(baseTypeId, info?.inType, v)));
2167
- const COLS = 4;
2168
- const DX = 180;
2169
- const DY = 160;
2170
- coercedItems.forEach((cv, idx) => {
2171
- const col = idx % COLS;
2172
- const row = Math.floor(idx / COLS);
2173
- ctx.wb.addNode({
2174
- nodeId: undefined,
2175
- typeId: String(elemTarget.nodeTypeId),
2176
- position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
2177
- params: {},
2178
- initialInputs: {
2179
- [String(elemTarget.inputHandle || "Value")]: clone(cv),
2180
- },
2181
- });
2182
- });
2183
- return;
2184
- }
2185
- }
2186
- catch { }
2187
- }, [ctx, id]);
2188
2162
  return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: cx("h-px", (isRunning || pct > 0) && "bg-blue-200 dark:bg-blue-900"), children: jsxRuntime.jsx("div", { className: cx("h-px transition-all", (isRunning || pct > 0) && "bg-blue-500"), style: { width: isRunning || pct > 0 ? `${pct}%` : 0 } }) }), jsxRuntime.jsx(NodeHandles, { data: data, isConnectable: isConnectable, getClassName: ({ kind, id }) => {
2189
2163
  const vIssues = (kind === "input" ? validation.inputs : validation.outputs).filter((v) => v.handle === id);
2190
2164
  const hasAny = vIssues.length > 0;
@@ -2212,26 +2186,7 @@ function DefaultNodeContent({ id, data, isConnectable, }) {
2212
2186
  const txt = toString(resolved.typeId, resolved.value);
2213
2187
  return typeof txt === "string" ? txt : String(txt);
2214
2188
  })();
2215
- const tId = ctx.outputTypesMap?.[id]?.[handleId];
2216
- let canBake = false;
2217
- if (tId?.endsWith("[]")) {
2218
- const base = tId.slice(0, -2);
2219
- const tArr = ctx.registry?.types.get(tId);
2220
- const tElem = ctx.registry?.types.get(base);
2221
- const arrTarget = tArr?.bakeTarget;
2222
- const elemTarget = tElem?.bakeTarget;
2223
- canBake = !!((arrTarget && ctx.registry?.nodes?.has?.(arrTarget.nodeTypeId)) ||
2224
- (elemTarget && ctx.registry?.nodes?.has?.(elemTarget.nodeTypeId)));
2225
- }
2226
- else if (tId) {
2227
- const t = ctx.registry?.types.get(tId);
2228
- const target = t?.bakeTarget;
2229
- canBake = !!(target && ctx.registry?.nodes?.has?.(target.nodeTypeId));
2230
- }
2231
- return (jsxRuntime.jsxs("span", { className: "flex items-center gap-1 w-full", children: [kind === "output" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [canBake && (jsxRuntime.jsx("button", { onClick: (e) => {
2232
- e.stopPropagation();
2233
- handleBake(handleId);
2234
- }, title: "Bake value", className: "pointer-events-auto border border-gray-300 rounded px-1 py-0.5 text-[10px] bg-white/80 hover:bg-white mr-2", children: "Bake" })), valueText !== undefined && (jsxRuntime.jsx("span", { className: "opacity-60 truncate pl-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText })), jsxRuntime.jsx("span", { className: "truncate shrink-0", style: { maxWidth: "40%" }, children: handleId })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: "truncate shrink-0", style: { maxWidth: "40%" }, children: handleId }), valueText !== undefined && (jsxRuntime.jsx("span", { className: "opacity-60 truncate pr-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText }))] })), hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "shrink-0", title: title }))] }));
2189
+ return (jsxRuntime.jsxs("span", { className: "flex items-center gap-1 w-full", children: [kind === "output" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [valueText !== undefined && (jsxRuntime.jsx("span", { className: "opacity-60 truncate pl-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText })), jsxRuntime.jsx("span", { className: "truncate shrink-0", style: { maxWidth: "40%" }, children: handleId })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: "truncate shrink-0", style: { maxWidth: "40%" }, children: handleId }), valueText !== undefined && (jsxRuntime.jsx("span", { className: "opacity-60 truncate pr-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText }))] })), hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "shrink-0", title: title }))] }));
2235
2190
  } })] }));
2236
2191
  }
2237
2192
 
@@ -2322,7 +2277,7 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
2322
2277
  }
2323
2278
 
2324
2279
  function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
2325
- const { wb, runner, engineKind } = useWorkbenchContext();
2280
+ const { wb, runner, engineKind, registry, outputsMap, outputTypesMap } = useWorkbenchContext();
2326
2281
  const ref = React.useRef(null);
2327
2282
  // outside click + ESC
2328
2283
  React.useEffect(() => {
@@ -2349,47 +2304,182 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
2349
2304
  if (open)
2350
2305
  ref.current?.focus();
2351
2306
  }, [open]);
2352
- if (!open || !clientPos || !nodeId)
2353
- return null;
2354
- // clamp
2355
- const MENU_MIN_WIDTH = 180;
2356
- const PADDING = 16;
2357
- const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
2358
- (MENU_MIN_WIDTH + PADDING));
2359
- const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
2307
+ // Bake helpers
2308
+ const getBakeableOutputs = () => {
2309
+ try {
2310
+ const def = wb.export();
2311
+ const node = def.nodes.find((n) => n.nodeId === nodeId);
2312
+ if (!node)
2313
+ return [];
2314
+ const desc = registry.nodes.get(node.typeId);
2315
+ const handles = Object.keys(desc?.outputs || {});
2316
+ const out = [];
2317
+ for (const h of handles) {
2318
+ const tId = outputTypesMap?.[nodeId]?.[h];
2319
+ if (!tId)
2320
+ continue;
2321
+ if (tId.endsWith("[]")) {
2322
+ const base = tId.slice(0, -2);
2323
+ const tArr = registry.types.get(tId);
2324
+ const tElem = registry.types.get(base);
2325
+ const arrT = tArr?.bakeTarget;
2326
+ const elemT = tElem?.bakeTarget;
2327
+ if ((arrT && registry.nodes.has(arrT.nodeTypeId)) ||
2328
+ (elemT && registry.nodes.has(elemT.nodeTypeId)))
2329
+ out.push(h);
2330
+ }
2331
+ else {
2332
+ const t = registry.types.get(tId);
2333
+ const bt = t?.bakeTarget;
2334
+ if (bt && registry.nodes.has(bt.nodeTypeId))
2335
+ out.push(h);
2336
+ }
2337
+ }
2338
+ return out;
2339
+ }
2340
+ catch {
2341
+ return [];
2342
+ }
2343
+ };
2344
+ const doBake = async (handleId) => {
2345
+ try {
2346
+ const typeId = outputTypesMap?.[nodeId]?.[handleId];
2347
+ const raw = outputsMap?.[nodeId]?.[handleId];
2348
+ if (!typeId || raw === undefined)
2349
+ return;
2350
+ const unwrap = (v) => sparkGraph.isTypedOutput(v) ? sparkGraph.getTypedOutputValue(v) : v;
2351
+ const clone = (v) => typeof structuredClone === "function"
2352
+ ? structuredClone(v)
2353
+ : JSON.parse(JSON.stringify(v));
2354
+ const coerceIfNeeded = async (fromType, toType, value) => {
2355
+ if (!toType || toType === fromType || !runner?.coerce)
2356
+ return value;
2357
+ try {
2358
+ return await runner.coerce(fromType, toType, value);
2359
+ }
2360
+ catch {
2361
+ return value;
2362
+ }
2363
+ };
2364
+ const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
2365
+ const isArray = typeId.endsWith("[]");
2366
+ const baseTypeId = isArray ? typeId.slice(0, -2) : typeId;
2367
+ const tArr = isArray ? registry.types.get(typeId) : undefined;
2368
+ const tElem = registry.types.get(baseTypeId);
2369
+ const singleTarget = !isArray ? tElem?.bakeTarget : undefined;
2370
+ const arrTarget = isArray ? tArr?.bakeTarget : undefined;
2371
+ const elemTarget = isArray ? tElem?.bakeTarget : undefined;
2372
+ if (singleTarget) {
2373
+ const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
2374
+ const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
2375
+ const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
2376
+ const newId = wb.addNode({
2377
+ typeId: singleTarget.nodeTypeId,
2378
+ position: { x: pos.x + 180, y: pos.y },
2379
+ params: {},
2380
+ });
2381
+ runner.update(wb.export());
2382
+ await runner.whenIdle();
2383
+ runner.setInputs(newId, { [singleTarget.inputHandle]: coerced });
2384
+ return;
2385
+ }
2386
+ if (isArray && arrTarget) {
2387
+ const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
2388
+ const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
2389
+ const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
2390
+ const newId = `n${Math.random().toString(36).slice(2, 8)}`;
2391
+ wb.addNode({
2392
+ nodeId: newId,
2393
+ typeId: arrTarget.nodeTypeId,
2394
+ position: { x: pos.x + 180, y: pos.y },
2395
+ params: {},
2396
+ });
2397
+ runner.update(wb.export());
2398
+ await runner.whenIdle();
2399
+ runner.setInputs(newId, { [arrTarget.inputHandle]: coerced });
2400
+ return;
2401
+ }
2402
+ if (isArray && elemTarget) {
2403
+ const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
2404
+ const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
2405
+ const src = unwrap(raw);
2406
+ const items = Array.isArray(src) ? src : [src];
2407
+ const coercedItems = await Promise.all(items.map((v) => coerceIfNeeded(baseTypeId, inType, v)));
2408
+ const COLS = 4;
2409
+ const DX = 180;
2410
+ const DY = 160;
2411
+ for (let idx = 0; idx < coercedItems.length; idx++) {
2412
+ const cv = coercedItems[idx];
2413
+ const col = idx % COLS;
2414
+ const row = Math.floor(idx / COLS);
2415
+ const newId = wb.addNode({
2416
+ typeId: elemTarget.nodeTypeId,
2417
+ position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
2418
+ params: {},
2419
+ initialInputs: { [elemTarget.inputHandle]: clone(cv) },
2420
+ });
2421
+ runner.update(wb.export());
2422
+ await runner.whenIdle();
2423
+ runner.setInputs(newId, { [elemTarget.inputHandle]: cv });
2424
+ }
2425
+ return;
2426
+ }
2427
+ }
2428
+ catch { }
2429
+ };
2360
2430
  // actions
2361
- const handleDelete = () => {
2431
+ const handleDelete = React.useCallback(() => {
2362
2432
  wb.removeNode(nodeId);
2363
2433
  onClose();
2364
- };
2365
- const handleDuplicate = () => {
2434
+ }, [nodeId, wb, onClose]);
2435
+ const handleDuplicate = React.useCallback(() => {
2366
2436
  const def = wb.export();
2367
2437
  const n = def.nodes.find((n) => n.nodeId === nodeId);
2368
2438
  if (!n)
2369
2439
  return onClose();
2370
2440
  const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
2371
- wb.addNode({ typeId: n.typeId, params: n.params, position: { x: pos.x + 24, y: pos.y + 24 } });
2441
+ wb.addNode({
2442
+ typeId: n.typeId,
2443
+ params: n.params,
2444
+ position: { x: pos.x + 24, y: pos.y + 24 },
2445
+ });
2372
2446
  onClose();
2373
- };
2374
- const handleCopyId = async () => {
2447
+ }, [nodeId, wb, onClose]);
2448
+ React.useCallback(async (handleId) => {
2449
+ await doBake(handleId);
2450
+ onClose();
2451
+ }, [doBake, onClose]);
2452
+ const handleCopyId = React.useCallback(async () => {
2375
2453
  try {
2376
2454
  await navigator.clipboard.writeText(nodeId);
2377
2455
  }
2378
2456
  catch { }
2379
2457
  onClose();
2380
- };
2381
- const canRunPull = engineKind()?.toString() === "pull";
2382
- const handleRunPull = async () => {
2458
+ }, [nodeId, onClose]);
2459
+ const handleRunPull = React.useCallback(async () => {
2383
2460
  try {
2384
2461
  await runner.computeNode(nodeId);
2385
2462
  }
2386
2463
  catch { }
2387
2464
  onClose();
2388
- };
2465
+ }, [nodeId, runner, onClose]);
2466
+ if (!open || !clientPos || !nodeId)
2467
+ return null;
2468
+ // clamp
2469
+ const MENU_MIN_WIDTH = 180;
2470
+ const PADDING = 16;
2471
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
2472
+ (MENU_MIN_WIDTH + PADDING));
2473
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
2474
+ const canRunPull = engineKind()?.toString() === "pull";
2475
+ const outs = getBakeableOutputs();
2389
2476
  return (jsxRuntime.jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
2390
2477
  e.preventDefault();
2391
2478
  e.stopPropagation();
2392
- }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDuplicate, children: "Duplicate" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleCopyId, children: "Copy Node ID" })] }));
2479
+ }, children: [jsxRuntime.jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDelete, children: "Delete" }), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDuplicate, children: "Duplicate" }), canRunPull && (jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleRunPull, children: "Run (pull)" })), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" }), outs.length > 0 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), outs.map((h) => (jsxRuntime.jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: async () => {
2480
+ await doBake(h);
2481
+ onClose();
2482
+ }, children: ["Bake: ", h] }, h))), jsxRuntime.jsx("div", { className: "h-px bg-gray-200 my-1" })] })), jsxRuntime.jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleCopyId, children: "Copy Node ID" })] }));
2393
2483
  }
2394
2484
 
2395
2485
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
@@ -2430,6 +2520,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
2430
2520
  outputValues: n.data.outputValues,
2431
2521
  status: n.data.status,
2432
2522
  validation: n.data.validation,
2523
+ inputConnected: n.data.inputConnected,
2433
2524
  },
2434
2525
  });
2435
2526
  return isEqual(pick(a), pick(b));
@@ -2619,10 +2710,21 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
2619
2710
  setNodeMenuOpen(false);
2620
2711
  }
2621
2712
  };
2622
- const addNodeAt = (typeId, pos) => {
2713
+ const addNodeAt = React.useCallback((typeId, pos) => {
2623
2714
  wb.addNode({ typeId, position: pos });
2624
- };
2625
- return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsx(react.ReactFlowProvider, { children: jsxRuntime.jsxs(react.ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, selectionOnDrag: true, onInit: (inst) => (rfInstanceRef.current = inst), onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", fitView: true, children: [jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 }), jsxRuntime.jsx(react.MiniMap, {}), jsxRuntime.jsx(react.Controls, {}), jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, onAdd: addNodeAt, onClose: () => setMenuOpen(false) }), jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, onClose: () => setNodeMenuOpen(false) })] }) }) }));
2715
+ }, [wb]);
2716
+ React.useCallback((inst) => {
2717
+ rfInstanceRef.current = inst;
2718
+ }, []);
2719
+ const onCloseMenu = React.useCallback(() => {
2720
+ setMenuOpen(false);
2721
+ }, []);
2722
+ const onCloseNodeMenu = React.useCallback(() => {
2723
+ setNodeMenuOpen(false);
2724
+ }, []);
2725
+ return (jsxRuntime.jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsxRuntime.jsx(react.ReactFlowProvider, { children: jsxRuntime.jsxs(react.ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, selectionOnDrag: true, onInit: (inst) => {
2726
+ rfInstanceRef.current = inst;
2727
+ }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", fitView: true, children: [jsxRuntime.jsx(react.Background, { id: "workbench-canvas-background", variant: react.BackgroundVariant.Dots, gap: 12, size: 1 }), jsxRuntime.jsx(react.MiniMap, {}), jsxRuntime.jsx(react.Controls, {}), jsxRuntime.jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, onAdd: addNodeAt, onClose: onCloseMenu }), !!nodeAtMenu && (jsxRuntime.jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, onClose: onCloseNodeMenu }))] }) }) }));
2626
2728
  });
2627
2729
 
2628
2730
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
@@ -2677,6 +2779,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2677
2779
  const lastAutoLaunched = React.useRef(undefined);
2678
2780
  const autoLayoutRan = React.useRef(false);
2679
2781
  const canvasRef = React.useRef(null);
2782
+ const uploadInputRef = React.useRef(null);
2680
2783
  // Expose init callback with setInitialGraph helper
2681
2784
  const initCalled = React.useRef(false);
2682
2785
  React.useEffect(() => {
@@ -2781,6 +2884,66 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
2781
2884
  alert(String(err?.message ?? err));
2782
2885
  }
2783
2886
  }, [wb, runner]);
2887
+ const onUploadPicked = React.useCallback(async (e) => {
2888
+ try {
2889
+ const file = e.target.files?.[0];
2890
+ if (!file)
2891
+ return;
2892
+ const text = await file.text();
2893
+ const parsed = JSON.parse(text);
2894
+ // Support both Graph and Snapshot payloads
2895
+ const isSnapshot = parsed &&
2896
+ typeof parsed === "object" &&
2897
+ (parsed.def || parsed.inputs || parsed.outputs || parsed.environment);
2898
+ if (isSnapshot) {
2899
+ const def = parsed.def;
2900
+ const positions = parsed.positions || {};
2901
+ const environment = parsed.environment || {};
2902
+ const inputs = parsed.inputs || {};
2903
+ if (def) {
2904
+ // Remote exact restore path
2905
+ await runner.applySnapshotFull({
2906
+ def,
2907
+ environment,
2908
+ inputs,
2909
+ outputs: parsed.outputs || {},
2910
+ });
2911
+ await wb.load(def);
2912
+ if (positions && typeof positions === "object")
2913
+ wb.setPositions(positions);
2914
+ }
2915
+ }
2916
+ else {
2917
+ const def = parsed?.def ?? parsed;
2918
+ const inputs = parsed?.inputs ?? {};
2919
+ await wb.load(def);
2920
+ try {
2921
+ runner.build(wb.export());
2922
+ }
2923
+ catch { }
2924
+ if (inputs && typeof inputs === "object") {
2925
+ for (const [nodeId, map] of Object.entries(inputs)) {
2926
+ try {
2927
+ runner.setInputs(nodeId, map);
2928
+ }
2929
+ catch { }
2930
+ }
2931
+ }
2932
+ }
2933
+ runAutoLayout();
2934
+ }
2935
+ catch (err) {
2936
+ alert(String(err?.message ?? err));
2937
+ }
2938
+ finally {
2939
+ // reset input so same file can be picked again
2940
+ if (uploadInputRef.current)
2941
+ uploadInputRef.current.value = "";
2942
+ }
2943
+ }, [wb, runner, runAutoLayout]);
2944
+ const triggerUpload = React.useCallback(() => {
2945
+ uploadInputRef.current?.click();
2946
+ }, []);
2784
2947
  const hydrateFromBackend = React.useCallback(async (kind, base) => {
2785
2948
  try {
2786
2949
  const transport = kind === "remote-http"
@@ -3078,7 +3241,35 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3078
3241
  catch (err) {
3079
3242
  alert(String(err?.message ?? err));
3080
3243
  }
3081
- }, disabled: !engine, children: "Start" })), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded px-2 py-1.5", onClick: runAutoLayout, children: "Auto Layout" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: "Fit View" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: downloadGraph, children: "Download Graph" }), jsxRuntime.jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx("span", { children: "Debug events" })] }), jsxRuntime.jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx("span", { children: "Show values in nodes" })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, toElement: toElement, contextPanel: overrides?.contextPanel })] })] }));
3244
+ }, disabled: !engine, children: "Start" })), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded px-2 py-1.5", onClick: runAutoLayout, children: "Auto Layout" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: "Fit View" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: downloadGraph, children: "Download Graph" }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: async () => {
3245
+ try {
3246
+ const def = wb.export();
3247
+ const positions = wb.getPositions();
3248
+ const snapshot = await runner.snapshotFull();
3249
+ const payload = {
3250
+ ...snapshot,
3251
+ def,
3252
+ positions,
3253
+ schemaVersion: 1,
3254
+ };
3255
+ const pretty = JSON.stringify(payload, null, 2);
3256
+ const blob = new Blob([pretty], { type: "application/json" });
3257
+ const url = URL.createObjectURL(blob);
3258
+ const a = document.createElement("a");
3259
+ const d = new Date();
3260
+ const pad = (n) => String(n).padStart(2, "0");
3261
+ const ts = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
3262
+ a.href = url;
3263
+ a.download = `spark-snapshot-${ts}.json`;
3264
+ document.body.appendChild(a);
3265
+ a.click();
3266
+ a.remove();
3267
+ URL.revokeObjectURL(url);
3268
+ }
3269
+ catch (err) {
3270
+ alert(String(err?.message ?? err));
3271
+ }
3272
+ }, children: "Download Snapshot" }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: triggerUpload, children: "Upload Graph/Snapshot" }), jsxRuntime.jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx("span", { children: "Debug events" })] }), jsxRuntime.jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx("span", { children: "Show values in nodes" })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, toElement: toElement, contextPanel: overrides?.contextPanel })] })] }));
3082
3273
  }
3083
3274
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, overrides, onInit, onChange, }) {
3084
3275
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());