@manny-est/node-red-flowpilot 0.5.1 → 0.5.2

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.
@@ -28,7 +28,21 @@
28
28
  // throwing) AND on disk (each string serializes with id
29
29
  // undefined -> null, confirmed via a live flows.json). Group
30
30
  // membership must only ever change through applyGroupChanges().
31
- nodes: 1
31
+ nodes: 1,
32
+ // Node appearance/documentation fields and node-type-specific
33
+ // internal defaults that models hallucinate sentinel placeholders
34
+ // for during build-loop reviews. None of these are meaningfully
35
+ // settable via a Modify patch — they're either Appearance-tab
36
+ // metadata or type-internal runtime flags. Adding them here
37
+ // prevents false "redaction" blocks with no actionable remediation.
38
+ // Appearance/docs (all node types):
39
+ info: 1, inputLabels: 1, outputLabels: 1, icon: 1,
40
+ // debug node internal display flags:
41
+ console: 1, tostatus: 1, targetType: 1, statusVal: 1, statusType: 1,
42
+ // function node internal flags:
43
+ noerr: 1, initialize: 1, finalize: 1,
44
+ // mqtt in/out retain handling:
45
+ rh: 1
32
46
  };
33
47
 
34
48
  // Sentinel strings written by sanitizeNode (and redactDebugValue) for
@@ -113,7 +127,7 @@
113
127
  // internally for t:"add" events) and pushes one compound history entry so
114
128
  // a single Ctrl+Z removes both the new nodes and their wires together.
115
129
  function applyInsertions(newNodes, newWires, contextNodeIds) {
116
- if (!newNodes || !newNodes.length) { return; }
130
+ if ((!newNodes || !newNodes.length) && (!newWires || !newWires.length)) { return; }
117
131
 
118
132
  // Determine z (flow-tab id) from the active workspace.
119
133
  var z = "";
@@ -246,6 +260,57 @@
246
260
  });
247
261
  });
248
262
 
263
+ // Collision-avoidance stagger: independent components that happen to
264
+ // share the same anchor (e.g. several standalone new nodes each
265
+ // wired from/to the SAME existing node — "add 5 debug taps after
266
+ // this http request node") compute identical target coordinates
267
+ // above and land exactly on top of each other. Detect a component
268
+ // whose origin collides with an earlier one and nudge the WHOLE
269
+ // component (preserving its own internal layout) into a small grid
270
+ // offset from the first occupant, so multi-node insertions never
271
+ // pile invisibly at one point.
272
+ var occupiedOrigins = [];
273
+ function collidesWithOccupied(x, y) {
274
+ return occupiedOrigins.some(function (p) {
275
+ return Math.abs(p.x - x) < 20 && Math.abs(p.y - y) < 20;
276
+ });
277
+ }
278
+ Object.keys(componentAnchors).forEach(function (c) {
279
+ var members = laid.filter(function (n) { return componentOf[n.id] === c; });
280
+ if (!members.length) { return; }
281
+ var baseX = members.reduce(function (m, n) { return Math.min(m, n.x); }, members[0].x);
282
+ var baseY = members.reduce(function (m, n) { return Math.min(m, n.y); }, members[0].y);
283
+ // Walk a single-column grid outward from the natural origin
284
+ // until we find a slot nothing else already occupies — one
285
+ // node per row, never two at the same y (CLAUDE-012: Manny's
286
+ // original spec, which the earlier 2-wide grid still violated
287
+ // by putting a pair at the same vertical level). After 5 nodes
288
+ // in a column, wrap to a new column further right than the
289
+ // first node in the previous one, so wires stay visually
290
+ // distinguishable instead of stacking on top of each other.
291
+ var slot = 0;
292
+ var x = baseX, y = baseY;
293
+ while (collidesWithOccupied(x, y) && slot < 200) {
294
+ slot++;
295
+ x = baseX + Math.floor(slot / 5) * 250;
296
+ y = baseY + (slot % 5) * 90;
297
+ }
298
+ if (slot > 0) {
299
+ var dx = x - baseX, dy = y - baseY;
300
+ members.forEach(function (n) { n.x += dx; n.y += dy; });
301
+ }
302
+ occupiedOrigins.push({ x: x, y: y });
303
+ });
304
+
305
+ // B3: snapshot placeholder→type before ids are overwritten, so wire
306
+ // failure messages can name the placeholder id and type rather than the
307
+ // opaque real hex id the node was never given (node not inserted = id
308
+ // exists in idMap but never landed in the live graph).
309
+ var placeholderToType = {};
310
+ laid.forEach(function (n) {
311
+ if (placeholderIds[n.id]) { placeholderToType[n.id] = n.type || "?"; }
312
+ });
313
+
249
314
  // Build placeholder-id → real-id map and assign real ids + z.
250
315
  var idMap = {};
251
316
  laid.forEach(function (n) {
@@ -338,6 +403,15 @@
338
403
  ? typeDef.outputs
339
404
  : (Array.isArray(n.wires) ? n.wires.length : 0);
340
405
  }
406
+ // The model's "wires" array can disagree with the node type's
407
+ // real port count (e.g. a hallucinated single empty port on a
408
+ // 0-output type like http response) — the editor draws one
409
+ // anchor per n.wires entry regardless of n.outputs, so a
410
+ // mismatch renders a phantom port. Reconcile the array length
411
+ // to the authoritative n.outputs count now that it's resolved.
412
+ if (!Array.isArray(n.wires)) { n.wires = []; }
413
+ while (n.wires.length < n.outputs) { n.wires.push([]); }
414
+ if (n.wires.length > n.outputs) { n.wires.length = n.outputs; }
341
415
  // Apply type-definition defaults for any property the model omitted.
342
416
  // This covers required fields (e.g. statusVal/statusType on debug)
343
417
  // that oneditsave would normally set, preventing a spurious triangle.
@@ -396,7 +470,16 @@
396
470
  var fromNode = findLiveNode(fromId);
397
471
  var toNode = findLiveNode(toId);
398
472
  if (!fromNode || !toNode) {
399
- addMessage("error", "Cannot wire — node not found: " + (!fromNode ? fromId : toId));
473
+ var _failRef = !fromNode ? wire.from : wire.to;
474
+ var _failMsg;
475
+ if (placeholderIds[_failRef]) {
476
+ _failMsg = "Cannot wire — referenced node was not inserted (" +
477
+ _failRef + " / type: " + (placeholderToType[_failRef] || "?") + ")";
478
+ } else {
479
+ _failMsg = "Cannot wire — node not found in editor: " +
480
+ (!fromNode ? fromId : toId);
481
+ }
482
+ addMessage("error", _failMsg);
400
483
  return;
401
484
  }
402
485
  var fromPort = wire.fromPort || 0;
@@ -524,12 +607,13 @@
524
607
  var propertyChanges = [];
525
608
  var wiresChanged = false;
526
609
  var redactionSkips = 0;
610
+ var redactedKeys = [];
527
611
  Object.keys(modNode).forEach(function (k) {
528
612
  if (DIFF_SKIP[k]) { return; }
529
613
  var newRaw = modNode[k];
530
614
  // If the model echoed a sanitizer sentinel, the field is opaque —
531
615
  // we can't meaningfully compare or apply it, so skip entirely.
532
- if (isSanitizeSentinel(newRaw)) { redactionSkips++; return; }
616
+ if (isSanitizeSentinel(newRaw)) { redactionSkips++; redactedKeys.push(k); return; }
533
617
  var oldRaw = liveNode ? liveNode[k] : undefined;
534
618
  var oldStr, newStr;
535
619
  try { oldStr = JSON.stringify(oldRaw); } catch (e) { oldStr = String(oldRaw); }
@@ -538,7 +622,7 @@
538
622
  if (k === "wires") { wiresChanged = true; return; }
539
623
  propertyChanges.push({ key: k, oldVal: oldRaw, newVal: newRaw });
540
624
  });
541
- return { propertyChanges: propertyChanges, wiresChanged: wiresChanged, redactionSkips: redactionSkips };
625
+ return { propertyChanges: propertyChanges, wiresChanged: wiresChanged, redactionSkips: redactionSkips, redactedKeys: redactedKeys };
542
626
  }
543
627
 
544
628
  // Diff outgoing wires for an existing node: compare what's live in the graph
@@ -642,6 +726,7 @@
642
726
  propertyChanges: diff.propertyChanges,
643
727
  wiresChanged: diff.wiresChanged,
644
728
  redactionSkips: diff.redactionSkips,
729
+ redactedKeys: diff.redactedKeys,
645
730
  wiresDiff: wiresDiff,
646
731
  name: modNode.name || (liveNode && liveNode.name) || "",
647
732
  type: modNode.type || (liveNode && liveNode.type) || ""
@@ -662,7 +747,7 @@
662
747
  var hasNewNodes = newNodes.length > 0;
663
748
  var hasRemoveNodes = removeNodes.length > 0;
664
749
  var hasNewGroups = newGroups.length > 0;
665
- var hasAnyChanges = hasPropChanges || hasWireChanges || hasNewNodes || hasRemoveNodes || hasNewGroups;
750
+ var hasAnyChanges = hasPropChanges || hasWireChanges || hasNewNodes || hasRemoveNodes || hasNewGroups || (newWires && newWires.length > 0);
666
751
  var totalRedactionSkips = nodeDiffs.reduce(function (s, d) { return s + (d.redactionSkips || 0); }, 0);
667
752
 
668
753
  var $msg = $("<div>").addClass("fp-message fp-review");
@@ -715,15 +800,27 @@
715
800
  });
716
801
  if (redactionOnlyDiffs.length > 0) {
717
802
  var redSectionTop = hasPropChanges ? "12px" : "0";
718
- $("<div>").addClass("fp-review-count").css("margin-top", redSectionTop)
719
- .text("Not applied targets redacted value(s):")
803
+ var totalRedFields = redactionOnlyDiffs.reduce(function (s, d) {
804
+ return s + (Array.isArray(d.redactedKeys) && d.redactedKeys.length ? d.redactedKeys.length : 1);
805
+ }, 0);
806
+ var $redToggle = $("<div>").addClass("fp-review-count")
807
+ .css({ "margin-top": redSectionTop, "cursor": "pointer" })
808
+ .text("▶ Not applied — FlowPilot can’t change these directly (" + totalRedFields + " field(s))")
720
809
  .appendTo($summaryPanel);
810
+ var $redContent = $("<div>").addClass("fp-hidden").appendTo($summaryPanel);
811
+ $redToggle.on("click", function () {
812
+ var collapsed = $redContent.hasClass("fp-hidden");
813
+ $redContent.toggleClass("fp-hidden", !collapsed);
814
+ $redToggle.text((collapsed ? "▼" : "▶") + " Not applied — FlowPilot can’t change these directly (" + totalRedFields + " field(s))");
815
+ });
721
816
  redactionOnlyDiffs.forEach(function (d) {
722
- var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
817
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($redContent);
723
818
  var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
724
819
  $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
820
+ var keys = Array.isArray(d.redactedKeys) && d.redactedKeys.length ? d.redactedKeys : [];
821
+ var fieldList = keys.length ? keys.join(", ") : "one or more fields";
725
822
  $("<div>").addClass("fp-diff-warn")
726
- .text("Proposed change targets a redacted fieldedit the value directly in the node editor.")
823
+ .text("Cannot change “" + fieldList + "” FlowPilot cannot read the live value for this field (internal node config). Edit it directly in the node editor if needed.")
727
824
  .appendTo($section);
728
825
  });
729
826
  }
@@ -845,7 +942,7 @@
845
942
  if (!hasAnyChanges) {
846
943
  if (totalRedactionSkips > 0) {
847
944
  $("<div>").addClass("fp-warning")
848
- .text("All proposed changes target redacted fieldsFlowPilot can only see those values as placeholders. Edit the field(s) directly in the node editor, then retry for any remaining changes.")
945
+ .text("None of the proposed changes could be appliedall targeted fields are internal node config that FlowPilot cannot read or modify directly. See the field names above and edit them in the node editor if needed.")
849
946
  .appendTo($actions);
850
947
  } else {
851
948
  $("<div>").addClass("fp-review-hint")
@@ -894,34 +991,49 @@
894
991
  });
895
992
  $msg.attr("data-fp-record-id", _modRecord.id);
896
993
 
994
+ var hintParts = [];
995
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
996
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
997
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
998
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
999
+ if (!hasNewNodes && newWires && newWires.length > 0) { hintParts.push("connections added"); }
1000
+ var applySub = hintParts.length ? "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo." : "";
897
1001
  var $applyBtn = $("<button>")
898
- .addClass("red-ui-button red-ui-button-primary")
1002
+ .addClass("fp-chip fp-chip-card")
899
1003
  .attr("type", "button")
900
- .text(btnLabel)
901
1004
  .on("click", function () {
902
- $applyBtn.prop("disabled", true).text("Applying…");
1005
+ $applyBtn.prop("disabled", true);
1006
+ $applyTitle.text("Applying…");
903
1007
  // Insertions run FIRST so their placeholder→real-id map is
904
1008
  // available to applyModifications/applyGroupChanges — an
905
1009
  // existing node's rewired "wires" (Tier 3) or a new
906
1010
  // group's membership may point at a node being inserted
907
1011
  // in this same response.
908
1012
  var idMap = {};
909
- if (hasNewNodes) {
1013
+ var hasNewWires = newWires && newWires.length > 0;
1014
+ if (hasNewNodes || hasNewWires) {
910
1015
  idMap = applyInsertions(newNodes, newWires, nodes.map(function (n) { return n.id; })) || {};
911
1016
  }
912
- if (hasMutations && applyCallback) { applyCallback(nodeDiffs, removeNodes, null, idMap); }
1017
+ // W4 Phase 2: applyCallback also runs the verify read-back,
1018
+ // so it must fire for insertion-only Modifies too (no
1019
+ // property/wire change on an EXISTING node), not just when
1020
+ // hasMutations is true — otherwise "exists"/"wire" checks on
1021
+ // newly inserted nodes would never run.
1022
+ if ((hasMutations || hasNewNodes || hasNewWires) && applyCallback) {
1023
+ applyCallback(nodeDiffs, removeNodes, null, idMap);
1024
+ }
913
1025
  if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
914
1026
  if (_modRecord) { _modRecord.state = "applied"; }
915
- $applyBtn.text("Done ✓");
1027
+ $applyTitle.text("Done ✓");
916
1028
  });
1029
+ $("<span>").addClass("fp-chip-icon")
1030
+ .append($("<i>").addClass(buildFixInfo ? "fa fa-wrench" : "fa fa-check-circle"))
1031
+ .appendTo($applyBtn);
1032
+ var $applyBody = $("<span>").addClass("fp-chip-body").appendTo($applyBtn);
1033
+ var $applyTitle = $("<span>").addClass("fp-chip-title").text(buildFixInfo ? "Apply Fix" : btnLabel).appendTo($applyBody);
1034
+ $("<span>").addClass("fp-chip-sub").text(buildFixInfo && buildFixInfo.capReached ? "Apply final fix and stop the loop" : applySub).appendTo($applyBody);
1035
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($applyBtn);
917
1036
  $actions.append($applyBtn);
918
- var hintParts = [];
919
- if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
920
- if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
921
- if (hasNewGroups) { hintParts.push("groups are created/updated"); }
922
- if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
923
- var hintText = "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.";
924
- $("<span>").addClass("fp-review-hint").text(hintText).appendTo($actions);
925
1037
  }
926
1038
 
927
1039
  $box.append($msg);
@@ -949,7 +1061,17 @@
949
1061
 
950
1062
  var oldValues = {};
951
1063
  d.propertyChanges.forEach(function (c) { oldValues[c.key] = liveNode[c.key]; });
952
- d.propertyChanges.forEach(function (c) { liveNode[c.key] = c.newVal; });
1064
+ d.propertyChanges.forEach(function (c) {
1065
+ // Rewrite placeholder ids that point at newly-inserted config
1066
+ // nodes (e.g. an MQTT out node's broker: "fp-new-0" referring
1067
+ // to a new mqtt-broker in the same response's newNodes).
1068
+ // applyInsertions returns idMap and it flows to us via the
1069
+ // applyCallback chain — idMap[placeholder] is the real id.
1070
+ var val = (idMap && typeof c.newVal === "string" && idMap[c.newVal])
1071
+ ? idMap[c.newVal]
1072
+ : c.newVal;
1073
+ liveNode[c.key] = val;
1074
+ });
953
1075
 
954
1076
  // Switch nodes derive their port count from rules.length (the edit
955
1077
  // dialog's "outputCount" map assigns each rule its own output and
@@ -1652,19 +1774,72 @@
1652
1774
  state: "pending"
1653
1775
  });
1654
1776
  $msg.attr("data-fp-record-id", _genRecord.id);
1655
- var $addBtn = $("<button>")
1656
- .addClass("red-ui-button red-ui-button-primary")
1657
- .attr("type", "button")
1658
- .text("Add to workspace")
1659
- .on("click", function () {
1660
- $addBtn.prop("disabled", true).text("Click the canvas to place…");
1661
- if (_genRecord) { _genRecord.state = "applied"; }
1662
- importGeneratedFlow(nodes, onImported);
1663
- });
1664
- $actions.append($addBtn);
1665
- $("<span>").addClass("fp-review-hint")
1666
- .text("Opens Node-RED's normal place-at-cursor import — click the canvas to drop the nodes.")
1667
- .appendTo($actions);
1777
+ if (onImported && buildGoal) {
1778
+ // B1: primary deploy-and-verify chip (accent, full-width) + secondary
1779
+ // plain escape hatch. Only when buildGoal is set — that signals this
1780
+ // is a build-loop context, not just a plain post-import callback.
1781
+ var $primaryChip = $("<button>")
1782
+ .addClass("fp-chip fp-chip-card")
1783
+ .attr("type", "button")
1784
+ .on("click", function () {
1785
+ $primaryChip.prop("disabled", true);
1786
+ $secondaryAddBtn.prop("disabled", true);
1787
+ if (_genRecord) { _genRecord.state = "applied"; }
1788
+ importGeneratedFlow(nodes, onImported);
1789
+ });
1790
+ $("<span>").addClass("fp-chip-icon")
1791
+ .append($("<i>").addClass("fa fa-check-circle"))
1792
+ .appendTo($primaryChip);
1793
+ var $chipBody = $("<span>").addClass("fp-chip-body").appendTo($primaryChip);
1794
+ $("<span>").addClass("fp-chip-title").text("Deploy & verify this works →").appendTo($chipBody);
1795
+ $("<span>").addClass("fp-chip-sub").text("Add to canvas, deploy, then run the verification loop").appendTo($chipBody);
1796
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($primaryChip);
1797
+ $actions.append($primaryChip);
1798
+ var $secondaryAddBtn = $("<button>")
1799
+ .addClass("fp-chip fp-chip-card fp-chip-card-alt")
1800
+ .attr("type", "button")
1801
+ .on("click", function () {
1802
+ $secondaryAddBtn.prop("disabled", true);
1803
+ $primaryChip.prop("disabled", true);
1804
+ if (_genRecord) { _genRecord.state = "applied"; }
1805
+ importGeneratedFlow(nodes, null);
1806
+ });
1807
+ var $altBody = $("<span>").addClass("fp-chip-body").appendTo($secondaryAddBtn);
1808
+ $("<span>").addClass("fp-chip-title").text("Just add to canvas").appendTo($altBody);
1809
+ $("<span>").addClass("fp-chip-sub").text("Skips deploy & verification").appendTo($altBody);
1810
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($secondaryAddBtn);
1811
+ $actions.append($secondaryAddBtn);
1812
+ } else if (onImported) {
1813
+ // Has a post-import callback (e.g. verifyImportedNodes) but no build
1814
+ // loop — chip button that still fires the callback on import.
1815
+ var $addBtn = $("<button>")
1816
+ .addClass("fp-chip fp-chip-card")
1817
+ .attr("type", "button")
1818
+ .on("click", function () {
1819
+ $addBtn.prop("disabled", true);
1820
+ if (_genRecord) { _genRecord.state = "applied"; }
1821
+ importGeneratedFlow(nodes, onImported);
1822
+ });
1823
+ var $addBody = $("<span>").addClass("fp-chip-body").appendTo($addBtn);
1824
+ $("<span>").addClass("fp-chip-title").text("Add to canvas").appendTo($addBody);
1825
+ $("<span>").addClass("fp-chip-sub").text("Click the canvas to place the nodes").appendTo($addBody);
1826
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($addBtn);
1827
+ $actions.append($addBtn);
1828
+ } else {
1829
+ var $addBtn = $("<button>")
1830
+ .addClass("fp-chip fp-chip-card")
1831
+ .attr("type", "button")
1832
+ .on("click", function () {
1833
+ $addBtn.prop("disabled", true);
1834
+ if (_genRecord) { _genRecord.state = "applied"; }
1835
+ importGeneratedFlow(nodes, null);
1836
+ });
1837
+ var $addBody = $("<span>").addClass("fp-chip-body").appendTo($addBtn);
1838
+ $("<span>").addClass("fp-chip-title").text("Add to canvas").appendTo($addBody);
1839
+ $("<span>").addClass("fp-chip-sub").text("Click the canvas to place the nodes").appendTo($addBody);
1840
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($addBtn);
1841
+ $actions.append($addBtn);
1842
+ }
1668
1843
  }
1669
1844
 
1670
1845
  $box.append($msg);
@@ -1867,12 +2042,21 @@
1867
2042
  : hasMutations ? "Apply Changes"
1868
2043
  : hasNewNodes ? "Insert Nodes"
1869
2044
  : "Apply Changes";
1870
- var $applyBtn = $("<button>").addClass("red-ui-button red-ui-button-primary")
1871
- .attr("type", "button").text(btnLabel)
2045
+ var hintParts = [];
2046
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
2047
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
2048
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
2049
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
2050
+ if (!hasNewNodes && newWires && newWires.length > 0) { hintParts.push("connections added"); }
2051
+ var applySub = hintParts.length ? "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo." : "";
2052
+ var $applyBtn = $("<button>")
2053
+ .addClass("fp-chip fp-chip-card")
2054
+ .attr("type", "button")
1872
2055
  .on("click", function () {
1873
- $applyBtn.prop("disabled", true).text("Applying…");
2056
+ $applyBtn.prop("disabled", true);
2057
+ $applyTitle.text("Applying…");
1874
2058
  var idMap = {};
1875
- if (hasNewNodes) { idMap = applyInsertions(newNodes, newWires, existingIds) || {}; }
2059
+ if (hasNewNodes || (newWires && newWires.length > 0)) { idMap = applyInsertions(newNodes, newWires, existingIds) || {}; }
1876
2060
  if (isBuildFix) {
1877
2061
  applyBuildLoopFix(nodeDiffs, removeNodes, idMap, capReached);
1878
2062
  } else {
@@ -1880,19 +2064,16 @@
1880
2064
  if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
1881
2065
  }
1882
2066
  if (_newRec) { _newRec.state = "applied"; }
1883
- $applyBtn.text("Done ✓");
2067
+ $applyTitle.text("Done ✓");
1884
2068
  });
2069
+ $("<span>").addClass("fp-chip-icon")
2070
+ .append($("<i>").addClass(isBuildFix ? "fa fa-wrench" : "fa fa-check-circle"))
2071
+ .appendTo($applyBtn);
2072
+ var $applyBody = $("<span>").addClass("fp-chip-body").appendTo($applyBtn);
2073
+ var $applyTitle = $("<span>").addClass("fp-chip-title").text(isBuildFix ? "Apply Fix" : btnLabel).appendTo($applyBody);
2074
+ $("<span>").addClass("fp-chip-sub").text(isBuildFix && capReached ? "Apply final fix and stop the loop" : applySub).appendTo($applyBody);
2075
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($applyBtn);
1885
2076
  $actions.append($applyBtn);
1886
- var hintParts = [];
1887
- if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
1888
- if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
1889
- if (hasNewGroups) { hintParts.push("groups are created/updated"); }
1890
- if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
1891
- if (hintParts.length) {
1892
- $("<span>").addClass("fp-review-hint")
1893
- .text("Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.")
1894
- .appendTo($actions);
1895
- }
1896
2077
  }
1897
2078
 
1898
2079
  $box.append($msg);
package/lib/core/init.js CHANGED
@@ -1000,8 +1000,14 @@
1000
1000
  ' </div>' +
1001
1001
  ' <label>Provider Name</label>' +
1002
1002
  ' <input id="fp-provider-name" type="text" placeholder="LocalAI">' +
1003
+ ' <label>Provider type</label>' +
1004
+ ' <select id="fp-provider-type">' +
1005
+ ' <option value="openai-compatible">OpenAI-compatible (LocalAI, Ollama, SGLang, OpenAI, etc.)</option>' +
1006
+ ' <option value="anthropic">Anthropic</option>' +
1007
+ ' </select>' +
1003
1008
  ' <label>Base URL</label>' +
1004
1009
  ' <input id="fp-base-url" type="text" placeholder="http://localhost:8080">' +
1010
+ ' <div id="fp-base-url-hint" class="fp-consent-hint fp-hidden">Leave blank to use api.anthropic.com.</div>' +
1005
1011
  ' <label>API Key</label>' +
1006
1012
  ' <input id="fp-api-key" type="password" placeholder="Optional">' +
1007
1013
  ' <label>Model</label>' +
@@ -1173,6 +1179,7 @@
1173
1179
  // Nothing is sent to the backend until the user explicitly
1174
1180
  // attaches a message and sends a request.
1175
1181
  try { RED.comms.subscribe("debug", onDebugMessage); } catch (e) { /* comms unavailable */ }
1182
+ try { RED.comms.subscribe("status/#", onNodeStatus); } catch (e) { /* comms unavailable */ }
1176
1183
 
1177
1184
  // Track whether the user is scrolled to the bottom of the chat,
1178
1185
  // so "Cruising…"/streaming updates only auto-follow when they
@@ -1216,6 +1223,9 @@
1216
1223
  content.find("#fp-provider-select").on("change", function () {
1217
1224
  switchProvider($(this).val());
1218
1225
  });
1226
+ content.find("#fp-provider-type").on("change", function () {
1227
+ toggleAnthropicHint($(this).val());
1228
+ });
1219
1229
  content.find("#fp-add-provider").on("click", function () { addProvider(); });
1220
1230
  content.find("#fp-remove-provider").on("click", function () { removeProvider(); });
1221
1231
  content.find("#fp-test-provider").on("click", function () { testProvider(); });
package/lib/core/main.js CHANGED
@@ -81,6 +81,8 @@
81
81
  case "question":
82
82
  if (rec.loopCheckpoint) {
83
83
  renderLoopCheckpoint(activeBuildLoop);
84
+ } else if (rec.buildConsentGate) {
85
+ renderBuildConsentGate(rec);
84
86
  } else {
85
87
  renderClarifyingQuestion(rec.options || []);
86
88
  }
@@ -91,6 +93,9 @@
91
93
  case "buildStep":
92
94
  rerenderBuildStepRecord(rec);
93
95
  break;
96
+ case "todo":
97
+ rerenderTodoRecord(rec);
98
+ break;
94
99
  }
95
100
  }
96
101
 
@@ -141,6 +146,7 @@
141
146
  var entry = {
142
147
  id: nextDebugMessageId++,
143
148
  timestamp: Date.now(),
149
+ sourceKind: "debug",
144
150
  name: msg.name || msg.id || "(unnamed node)",
145
151
  topic: redactedTopic,
146
152
  // previewValue: short, for the scannable debug-log list only.
@@ -173,8 +179,14 @@
173
179
  // message from one trigger accumulate into attachedDebugMessages
174
180
  // before review actually runs, so the model sees the full picture
175
181
  // instead of whichever message happened to arrive first.
182
+ // WS4: a Skip decision at the build consent gate excludes this tap's
183
+ // real id from skipCheckpointTapIds — its messages still arrive here
184
+ // (still shown in the debug-log list/updateDebugStatus above) but
185
+ // don't drive the auto-attach/auto-review transition, so the loop
186
+ // falls back to manual confirmation or the honest-timeout instead.
176
187
  if (activeBuildLoop && activeBuildLoop.waypoint === "attach" &&
177
- activeBuildLoop.nodeIds.indexOf(msg.id) !== -1) {
188
+ activeBuildLoop.nodeIds.indexOf(msg.id) !== -1 &&
189
+ (activeBuildLoop.skipCheckpointTapIds || []).indexOf(msg.id) === -1) {
178
190
  attachedDebugMessages.push(entry);
179
191
  updateDebugStatus();
180
192
  if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
@@ -193,11 +205,76 @@
193
205
  }
194
206
  }
195
207
 
208
+ // W3: node-status stream evidence path.
209
+ // Fires for every deployed node that sets a status (fill/text via
210
+ // node.status() in user function code, or core nodes like http-request
211
+ // reporting "200 OK" / error text). Topic is "status/<nodeId>".
212
+ // Used as fallback evidence when the loop is waiting at "attach" but no
213
+ // debug node exists (HTTP endpoints, background workers, etc.) — gives
214
+ // the model something real to review instead of the loop hanging until
215
+ // the 20-second honest-timeout fires.
216
+ // Only used when no debug payload has arrived yet; if debug output is
217
+ // coming the richer payload data wins — let that path take over.
218
+ function onNodeStatus(topic, msg) {
219
+ if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
220
+ var nodeId = topic.split("/")[1];
221
+ if (!nodeId || activeBuildLoop.nodeIds.indexOf(nodeId) === -1) { return; }
222
+ // WS4: same Skip-decision gate as onDebugMessage, keyed on the
223
+ // side-effecting node's own real id here (status events report the
224
+ // node itself, not a debug tap wired to it).
225
+ if ((activeBuildLoop.skipCheckpointNodeIds || []).indexOf(nodeId) !== -1) { return; }
226
+ if (attachedDebugMessages.length > 0) { return; }
227
+
228
+ // Skip in-progress ("blue") statuses — e.g. http-request emits
229
+ // fill:"blue" text:"requesting" before any response arrives. Locking
230
+ // in this early status starts the debounce before the actual error or
231
+ // response has a chance to land, so the model reviews a placeholder
232
+ // rather than the real result. Only terminal states (red=error,
233
+ // green=success, or no fill) are useful evidence.
234
+ if (msg && msg.fill === "blue") { return; }
235
+
236
+ var statusText = [msg && msg.fill, msg && msg.text].filter(Boolean).join(" — ");
237
+ if (!statusText) { return; }
238
+
239
+ var entry = {
240
+ id: nextDebugMessageId++,
241
+ timestamp: Date.now(),
242
+ sourceKind: "status",
243
+ name: nodeId + " (node status)",
244
+ topic: "node-status",
245
+ previewValue: statusText,
246
+ value: "Node " + nodeId + " reported status: " + statusText
247
+ };
248
+ attachedDebugMessages.push(entry);
249
+ updateDebugStatus();
250
+
251
+ if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
252
+ if (buildLoopAttachTimer) { clearTimeout(buildLoopAttachTimer); }
253
+ buildLoopAttachTimer = setTimeout(function () {
254
+ buildLoopAttachTimer = null;
255
+ if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
256
+ activeBuildLoop.waypoint = "review";
257
+ renderLoopStepper(activeBuildLoop);
258
+ if (currentSettings.loopHoldStep) {
259
+ renderLoopCheckpoint(activeBuildLoop);
260
+ } else {
261
+ runBuildReview(activeBuildLoop);
262
+ }
263
+ }, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
264
+ }
265
+
196
266
  // The exact shape sent to the backend (and shown by "Preview debug") —
197
267
  // excludes previewValue, which exists only for the debug-log list.
198
268
  function buildDebugMessagesForSend() {
199
269
  return attachedDebugMessages.map(function (m) {
200
- return { id: m.id, timestamp: m.timestamp, name: m.name, topic: m.topic, value: m.value };
270
+ return {
271
+ id: m.id,
272
+ timestamp: m.timestamp,
273
+ sourceKind: m.sourceKind,
274
+ name: m.name,
275
+ topic: m.topic,
276
+ value: m.value
277
+ };
201
278
  });
202
279
  }
203
280
 
@@ -1042,10 +1119,19 @@
1042
1119
  if (active) { $sel.val(active.id); }
1043
1120
  }
1044
1121
 
1122
+ function toggleAnthropicHint(type) {
1123
+ var isAnthropic = type === "anthropic";
1124
+ el("#fp-base-url-hint").toggleClass("fp-hidden", !isAnthropic);
1125
+ el("#fp-base-url").attr("placeholder", isAnthropic ? "Leave blank for api.anthropic.com" : "http://localhost:8080");
1126
+ }
1127
+
1045
1128
  // Write the form fields from a given provider profile.
1046
1129
  function fillProviderFields(p) {
1047
1130
  p = p || {};
1048
1131
  el("#fp-provider-name").val(p.providerName || "");
1132
+ var type = p.type || "openai-compatible";
1133
+ el("#fp-provider-type").val(type);
1134
+ toggleAnthropicHint(type);
1049
1135
  el("#fp-base-url").val(p.baseUrl || "");
1050
1136
  el("#fp-api-key").val(p.apiKey || "");
1051
1137
  el("#fp-model").val(p.model || "");
@@ -1134,6 +1220,7 @@
1134
1220
  var ap = activeProvider();
1135
1221
  if (!ap) { return; }
1136
1222
  ap.providerName = el("#fp-provider-name").val() || "Provider";
1223
+ ap.type = el("#fp-provider-type").val() || "openai-compatible";
1137
1224
  ap.baseUrl = el("#fp-base-url").val() || "";
1138
1225
  ap.apiKey = el("#fp-api-key").val() || "";
1139
1226
  ap.model = el("#fp-model").val() || "";
@@ -1330,10 +1417,10 @@
1330
1417
  var payload = collectSettings();
1331
1418
  var list = payload.providers || [];
1332
1419
 
1333
- // Validation 1: every provider needs a base URL (the one field a
1334
- // provider cannot function without).
1420
+ // Validation 1: every non-Anthropic provider needs a base URL.
1421
+ // Anthropic providers default to api.anthropic.com when baseUrl is blank.
1335
1422
  var noUrl = list.filter(function (p) {
1336
- return !p.baseUrl || !String(p.baseUrl).trim();
1423
+ return p.type !== "anthropic" && (!p.baseUrl || !String(p.baseUrl).trim());
1337
1424
  });
1338
1425
  if (noUrl.length) {
1339
1426
  var urlNames = noUrl.map(function (p) { return p.providerName || "(unnamed)"; }).join(", ");
@@ -1774,4 +1861,3 @@
1774
1861
 
1775
1862
  renderChip("Open Settings", "fa fa-cog", showSettings);
1776
1863
  }
1777
-