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