@manny-est/node-red-flowpilot 0.5.0 → 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.
package/lib/core/modes.js CHANGED
@@ -144,62 +144,131 @@
144
144
  if (isChat) { pushHistory("user", prompt + note); }
145
145
  if (!promptOverride) { $promptBox.val(""); }
146
146
 
147
- var ap = activeProvider();
148
- var isAgentLoop = isChat && ap && ap.supportsTools;
149
-
150
- setBusy(true);
151
- showPending(isAgentLoop);
152
- var payload = {
153
- prompt: prompt,
154
- context: context,
155
- history: historyPayload.messages,
156
- historyTruncated: historyPayload.truncated,
157
- conversationId: conversationId
158
- };
159
-
160
- function handleSendResult(data) {
161
- hidePending();
162
- var message = data.message || JSON.stringify(data, null, 2);
163
- // Test Provider also reports tool-calling support, used by
164
- // the agentic path.
165
- if (data.capability && data.capability.label) {
166
- message += "\n\n" + data.capability.label;
147
+ function dispatch() {
148
+ var ap = activeProvider();
149
+ var isAgentLoop = isChat && ap && ap.supportsTools;
150
+
151
+ setBusy(true);
152
+ showPending(isAgentLoop);
153
+ var payload = {
154
+ prompt: prompt,
155
+ context: context,
156
+ history: historyPayload.messages,
157
+ historyTruncated: historyPayload.truncated,
158
+ conversationId: conversationId
159
+ };
160
+
161
+ function handleSendResult(data) {
162
+ hidePending();
163
+ // Render a collapsed thinking block for non-streaming reasoning models
164
+ // (the streaming path handles this live in sendChatStream instead).
165
+ if (data.reasoningContent) {
166
+ var $box = el("#fp-messages");
167
+ var approxTokens = Math.round(data.reasoningContent.length / 4);
168
+ var $thinking = $("<details>").addClass("fp-thinking");
169
+ var $summary = $("<summary>").appendTo($thinking);
170
+ $("<span>").text("Thinking").appendTo($summary);
171
+ $("<span>").addClass("fp-thinking-tokens").text(approxTokens + " tokens").appendTo($summary);
172
+ $("<div>").addClass("fp-thinking-body").text(data.reasoningContent).appendTo($thinking);
173
+ $box.append($thinking);
174
+ }
175
+ var message = data.message || JSON.stringify(data, null, 2);
176
+ // Test Provider also reports tool-calling support, used by
177
+ // the agentic path. Mirror the probe results into currentSettings
178
+ // so the auto-preflight condition (probedModel !== model) has a
179
+ // baseline to compare against without requiring a page reload.
180
+ if (data.capability && data.capability.label) {
181
+ message += "\n\n" + data.capability.label;
182
+ }
183
+ if (endpoint === "test" && data.capability && data.capability.probedModel) {
184
+ var testAp = activeProvider();
185
+ if (testAp && currentSettings && Array.isArray(currentSettings.providers)) {
186
+ currentSettings.providers = currentSettings.providers.map(function(p) {
187
+ return p.id === testAp.id ? Object.assign({}, p, {
188
+ supportsTools: data.capability.supportsTools,
189
+ isReasoningModel: data.capability.isReasoningModel,
190
+ probedModel: data.capability.probedModel
191
+ }) : p;
192
+ });
193
+ }
194
+ }
195
+ if (endpoint === "test") {
196
+ message += "\n\nAll set — try `/help` for the full briefing and shortcut list.";
197
+ }
198
+ addMessage("assistant", message);
199
+ if (isChat) {
200
+ pushHistory("assistant", data.message || "");
201
+ renderActionChip(data.suggestedAction);
202
+ renderClarifyingQuestion(data.questionOptions);
203
+ }
204
+ setBusy(false);
205
+ updateSelectionStatus();
167
206
  }
168
- if (endpoint === "test") {
169
- message += "\n\nAll set — try `/help` for the full briefing and shortcut list.";
207
+
208
+ function handleSendError(msg) {
209
+ hidePending();
210
+ if (isChat) { popDanglingUserHistory(); }
211
+ addMessage("error", msg);
212
+ setBusy(false);
170
213
  }
171
- addMessage("assistant", message);
172
- if (isChat) {
173
- pushHistory("assistant", data.message || "");
174
- renderActionChip(data.suggestedAction);
175
- renderClarifyingQuestion(data.questionOptions);
214
+
215
+ // When the active provider supports tool/function calling,
216
+ // the chat turn is offered the Tier-1 read tools and run through the
217
+ // bounded agent loop instead of a single request.
218
+ if (isAgentLoop) {
219
+ runAgentChat(payload, handleSendResult, handleSendError);
220
+ return;
176
221
  }
177
- setBusy(false);
178
- updateSelectionStatus();
179
- }
180
222
 
181
- function handleSendError(msg) {
182
- hidePending();
183
- if (isChat) { popDanglingUserHistory(); }
184
- addMessage("error", msg);
185
- setBusy(false);
186
- }
223
+ if (isChat && currentSettings.streamingEnabled) {
224
+ payload.stream = true;
225
+ sendChatStream(payload);
226
+ return;
227
+ }
187
228
 
188
- // When the active provider supports tool/function calling,
189
- // the chat turn is offered the Tier-1 read tools and run through the
190
- // bounded agent loop instead of a single request.
191
- if (isAgentLoop) {
192
- runAgentChat(payload, handleSendResult, handleSendError);
193
- return;
229
+ ajaxJson("POST", "flowpilot/" + endpoint, payload, handleSendResult, handleSendError);
194
230
  }
195
231
 
196
- if (isChat && currentSettings.streamingEnabled) {
197
- payload.stream = true;
198
- sendChatStream(payload);
232
+ // Silent preflight: if the model changed since the last probe, save and
233
+ // re-probe before routing — stale supportsTools silently misroutes chat
234
+ // (agent-loop path vs streaming/non-streaming).
235
+ // Compare against the LIVE DOM value so unsaved edits trigger correctly;
236
+ // save first so the backend probes the right model.
237
+ var ap = activeProvider();
238
+ var liveModel = (el("#fp-model").length ? el("#fp-model").val() : null) || (ap && ap.model) || "";
239
+ if (isChat && ap && ap.probedModel && liveModel && liveModel !== ap.probedModel) {
240
+ setBusy(true);
241
+ showPending(false);
242
+ setAgentNarration("Pre-flight…");
243
+ saveSettings(function() {
244
+ var ap2 = activeProvider();
245
+ ajaxJson("POST", "flowpilot/probe", {}, function(result) {
246
+ if (currentSettings && Array.isArray(currentSettings.providers)) {
247
+ var targetId = (ap2 || ap).id;
248
+ currentSettings.providers = currentSettings.providers.map(function(p) {
249
+ return p.id === targetId ? Object.assign({}, p, {
250
+ supportsTools: result.supportsTools,
251
+ isReasoningModel: result.isReasoningModel,
252
+ probedModel: result.probedModel
253
+ }) : p;
254
+ });
255
+ }
256
+ hidePending();
257
+ var caps = [];
258
+ if (result.supportsTools) { caps.push("Tools ✓"); } else { caps.push("Tools ✗"); }
259
+ if (result.isReasoningModel) { caps.push("Reasoning ✓"); }
260
+ addMessage("notice", "Pre-flight: " + (result.probedModel || liveModel) + " · " + caps.join(" · "));
261
+ dispatch();
262
+ }, function() {
263
+ hidePending();
264
+ addMessage("notice", "Pre-flight failed — continuing with cached capabilities.");
265
+ dispatch();
266
+ });
267
+ });
199
268
  return;
200
269
  }
201
270
 
202
- ajaxJson("POST", "flowpilot/" + endpoint, payload, handleSendResult, handleSendError);
271
+ dispatch();
203
272
  }
204
273
 
205
274
  // ---------------------------------------------------------------------
@@ -348,25 +417,53 @@
348
417
  // wait until real content starts arriving.
349
418
  var $msg = null;
350
419
  var $text = null;
420
+ var _chatRec = null;
421
+
422
+ // Reasoning block (shown for reasoning models that emit reasoning_content).
423
+ var $thinking = null;
424
+ var $thinkingBody = null;
425
+ var $thinkingTokens = null;
426
+ var reasoningBuf = "";
351
427
 
352
428
  var fullText = "";
353
429
  var finalData = null;
354
430
 
431
+ function ensureThinkingBlock() {
432
+ if ($thinking) { return; }
433
+ hidePending();
434
+ $thinking = $("<details>").addClass("fp-thinking").attr("open", "");
435
+ var $summary = $("<summary>").appendTo($thinking);
436
+ $("<span>").text("Thinking").appendTo($summary);
437
+ $thinkingTokens = $("<span>").addClass("fp-thinking-tokens").appendTo($summary);
438
+ $thinkingBody = $("<div>").addClass("fp-thinking-body").appendTo($thinking);
439
+ $box.append($thinking);
440
+ scrollMessagesToBottom();
441
+ }
442
+
355
443
  function ensureBubble() {
356
444
  if ($text) { return; }
357
445
  hidePending();
358
- addMessage("assistant", "");
446
+ // Collapse the thinking block the moment real content starts flowing.
447
+ if ($thinking) { $thinking.prop("open", false); }
448
+ _chatRec = addMessage("assistant", "");
359
449
  $msg = $box.find(".fp-message").last();
360
450
  $text = $msg.find("div").last();
361
451
  }
362
452
 
363
453
  function finish() {
364
454
  hidePending();
455
+ // Stamp approximate token count on the thinking block once we're done.
456
+ if ($thinking && reasoningBuf) {
457
+ var approxTokens = Math.round(reasoningBuf.length / 4);
458
+ $thinkingTokens.text(approxTokens + " tokens");
459
+ }
365
460
  if (!fullText) {
366
461
  if ($msg && $msg.length) { $msg.remove(); }
462
+ if (_chatRec) { messageRecords.splice(messageRecords.indexOf(_chatRec), 1); _chatRec = null; }
367
463
  popDanglingUserHistory();
368
464
  addMessage("error", "No response received from the provider.");
369
465
  } else {
466
+ if (_chatRec) { _chatRec.text = fullText; _chatRec.streamingComplete = true; }
370
467
  pushHistory("assistant", fullText);
371
468
  if (finalData) {
372
469
  renderActionChip(finalData.suggestedAction);
@@ -392,14 +489,10 @@
392
489
  }
393
490
 
394
491
  // Shared SSE-line parser: handles `data: {"delta":"..."}` /
395
- // `data: {"final":{...}}` / `data: {"error":"..."}` / `data: [DONE]`
396
- // lines, appending deltas to fullText and rendering them. Used by
397
- // both the streaming pump() loop (called per-chunk with the trailing
398
- // partial line held back) and the non-getReader fallback (#12,
399
- // called once with the full body split into lines), so neither path
400
- // can drift or show raw SSE text. The backend withholds any trailing
401
- // <<<FLOWPILOT_DATA>>> block from `delta`s entirely and relays its
402
- // parsed suggestedAction/questionOptions as a single `final` event.
492
+ // `data: {"reasoningDelta":"..."}` / `data: {"final":{...}}` /
493
+ // `data: {"error":"..."}` / `data: [DONE]` lines. Used by both the
494
+ // streaming pump() loop and the non-getReader fallback so neither path
495
+ // can drift or show raw SSE text.
403
496
  function processSseLines(lines) {
404
497
  lines.forEach(function (line) {
405
498
  line = line.trim();
@@ -409,10 +502,17 @@
409
502
  var evt;
410
503
  try { evt = JSON.parse(dataStr); } catch (e) { return; }
411
504
  if (evt.error) { throw new Error(evt.error); }
412
- if (evt.delta) {
505
+ if (evt.reasoningDelta) {
506
+ reasoningBuf += evt.reasoningDelta;
507
+ ensureThinkingBlock();
508
+ $thinkingBody.text(reasoningBuf);
509
+ $thinkingBody[0].scrollTop = $thinkingBody[0].scrollHeight;
510
+ scrollMessagesToBottom();
511
+ } else if (evt.delta) {
413
512
  fullText += evt.delta;
414
513
  ensureBubble();
415
514
  $text.html(renderMarkdown(fullText));
515
+ if (_chatRec) { _chatRec.text = fullText; }
416
516
  scrollMessagesToBottom();
417
517
  } else if (evt.final) {
418
518
  finalData = evt.final;
@@ -580,6 +680,7 @@
580
680
  }
581
681
 
582
682
  $box.append($row);
683
+ addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
583
684
  scrollMessagesToBottom();
584
685
  }
585
686
 
@@ -667,6 +768,7 @@
667
768
  .appendTo($row);
668
769
 
669
770
  $box.append($row).append($otherRow);
771
+ addRecord("question", { options: options });
670
772
  scrollMessagesToBottom();
671
773
  }
672
774
 
@@ -710,15 +812,20 @@
710
812
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
711
813
  addMessage("assistant", data.explanation || "(no explanation returned)");
712
814
  pushHistory("assistant", data.explanation || "(no explanation returned)");
713
- addGeneratedReview(flow);
714
- // After any Generate result, offer the deploy-verify loop as a one-click
715
- // option. Only shown when no loop is already running and the original
716
- // prompt is available (it always is here goalPrompt comes from the
717
- // compose box value captured at send time via wrappedOnResult).
718
- if (goalPrompt && !activeBuildLoop) {
719
- renderActionChip({ mode: "build", prompt: goalPrompt, customTitle: "Run deploy-verify loop on this →" });
720
- }
721
- renderActionChip(data.suggestedAction);
815
+ // B1: bake the deploy-verify option into the review panel as the
816
+ // primary chip rather than a separate chip below it. Only for
817
+ // executable flows (not documentation-only comment nodes) and when no
818
+ // loop is already active. The secondary "Just add to canvas" button is
819
+ // always shown alongside it as an escape hatch.
820
+ var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
821
+ var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
822
+ ? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
823
+ : null;
824
+ addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
825
+ // Suppress a server-suggested build chip when deploy-verify is already
826
+ // the primary action inside the review panel — it would be a duplicate.
827
+ renderActionChip(_buildOnImported && data.suggestedAction && data.suggestedAction.mode === "build"
828
+ ? null : data.suggestedAction);
722
829
  setBusy(false);
723
830
  updateSelectionStatus();
724
831
  }
@@ -729,10 +836,41 @@
729
836
  hidePending();
730
837
  if (renderQuestionOrProse(data)) { return; }
731
838
 
839
+ // W4: parse and surface the Plan: block if present.
840
+ var planItems = parseTodoPlan(data.explanation || "");
841
+ var todoRec = null;
842
+ if (planItems.length) {
843
+ // Verification only produces one aggregate pass/fail result for
844
+ // the whole Modify response — mark every item active up front so
845
+ // they all resolve together instead of leaving items 2+ stuck at
846
+ // "pending" forever (only item 1 would ever flip otherwise).
847
+ planItems.forEach(function (item) { item.status = "active"; });
848
+ todoRec = addRecord("todo", { action: "modify", items: planItems });
849
+ rerenderTodoRecord(todoRec);
850
+ }
851
+
732
852
  addMessage("assistant", data.explanation || "(no explanation returned)");
733
853
  pushHistory("assistant", data.explanation || "(no explanation returned)");
734
854
  if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
735
- addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyModifications, null, data.newGroups || []);
855
+
856
+ // W4 Phase 2: wrap apply to run a real graph read-back via
857
+ // verifySteps (Track A, server) instead of unconditionally marking
858
+ // the todo done. Falls back to the Phase 1 behavior (check off with
859
+ // no verification) when the server sent no verifySteps.
860
+ var verifySteps = Array.isArray(data.verifySteps) ? data.verifySteps : [];
861
+ var applyCallback = todoRec ? function(nodeDiffs, removeNodes, $btn, idMap) {
862
+ applyModifications(nodeDiffs, removeNodes, $btn, idMap);
863
+ if (verifySteps.length) {
864
+ verifyModifySteps(verifySteps, idMap, todoRec);
865
+ } else {
866
+ todoRec.items.forEach(function(item) {
867
+ if (item.status === "active") { item.status = "done"; }
868
+ });
869
+ rerenderTodoRecord(todoRec);
870
+ }
871
+ } : applyModifications;
872
+
873
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
736
874
  renderActionChip(data.suggestedAction);
737
875
  setBusy(false);
738
876
  updateSelectionStatus();
@@ -953,8 +1091,272 @@
953
1091
  ajaxJson("POST", fullEndpoint, payload, wrappedOnResult, onError);
954
1092
  }
955
1093
 
1094
+ // W4: parse a "Plan:" block from the model's explanation field.
1095
+ // Returns an array of { text, status } items, or [] if none found.
1096
+ // Each numbered/bulleted line under "Plan:" up to the first blank line
1097
+ // becomes one item. Status starts as "pending" for all items — the
1098
+ // caller sets the first to "active" before rendering.
1099
+ function parseTodoPlan(explanation) {
1100
+ if (!explanation || typeof explanation !== "string") { return []; }
1101
+ var planStart = explanation.indexOf("Plan:");
1102
+ if (planStart === -1) { return []; }
1103
+ var afterPlan = explanation.slice(planStart + 5);
1104
+ var planBlock = afterPlan.split(/\n\n/)[0];
1105
+ var lines = planBlock.split("\n");
1106
+ var items = [];
1107
+ lines.forEach(function (line) {
1108
+ var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1109
+ if (stripped) { items.push({ text: stripped, status: "pending" }); }
1110
+ });
1111
+ return items;
1112
+ }
1113
+
1114
+ // W4: render or re-render a "todo" record. For a 1-item plan, renders
1115
+ // as a compact status line (one chip). For N>1 items, renders as a
1116
+ // checklist card. Updates in place when the record already has a
1117
+ // data-fp-todo-id element in the message box (e.g. on verify check-off).
1118
+ function rerenderTodoRecord(rec) {
1119
+ if (!rec || !rec.items) { return; }
1120
+ var $box = el("#fp-messages");
1121
+ if (!$box.length) { return; }
1122
+ var items = rec.items;
1123
+ var $existing = $box.find("[data-fp-todo-id='" + rec.id + "']");
1124
+
1125
+ var $wrap;
1126
+ if (items.length === 1) {
1127
+ var item = items[0];
1128
+ var icon = item.status === "done" ? "✓" : item.status === "failed" ? "✗" : "▶";
1129
+ $wrap = $("<div>")
1130
+ .addClass("fp-todo-status fp-todo-" + item.status)
1131
+ .attr("data-fp-todo-id", rec.id)
1132
+ .text(icon + " " + item.text);
1133
+ } else {
1134
+ $wrap = $("<div>")
1135
+ .addClass("fp-todo-card")
1136
+ .attr("data-fp-todo-id", rec.id);
1137
+ var $ul = $("<ul>").addClass("fp-todo-list");
1138
+ items.forEach(function (item) {
1139
+ var icon = item.status === "done" ? "✓" :
1140
+ item.status === "failed" ? "✗" :
1141
+ item.status === "active" ? "▶" : "○";
1142
+ $("<li>")
1143
+ .addClass("fp-todo-item fp-todo-item-" + item.status)
1144
+ .text(icon + " " + item.text)
1145
+ .appendTo($ul);
1146
+ });
1147
+ $wrap.append($ul);
1148
+ }
1149
+
1150
+ if ($existing.length) {
1151
+ $existing.replaceWith($wrap);
1152
+ } else {
1153
+ $box.append($wrap);
1154
+ }
1155
+ }
1156
+
1157
+ // Step-queue path for Generate (opt-in via enableStepQueue setting).
1158
+ // After the user clicks "Add to workspace", performs a synchronous graph
1159
+ // read-back — calls RED.nodes.node(id) for every node that landed on the
1160
+ // canvas — and surfaces the result as a verification notice. This is the
1161
+ // structural verification step for Generate: "did the import actually land?"
1162
+ // not "does it do the thing?" (that's the semantic loop, Build's domain).
1163
+ // Skips comment and group nodes — those aren't addressable via RED.nodes.node.
1164
+ // todoRec: optional todo record to check off (or fail) after verification.
1165
+ function verifyImportedNodes(importResult, todoRec) {
1166
+ if (!importResult || !importResult.nodeMap) { return; }
1167
+ var nodeMap = importResult.nodeMap;
1168
+ var total = 0, found = 0, missing = [];
1169
+ // Config nodes (e.g. an http-request's TLS config, an mqtt broker
1170
+ // config) ride along in nodeMap whenever the model's own `flow`
1171
+ // array included them, but they aren't part of what the user asked
1172
+ // for — RED.nodes.node() resolves them same as regular nodes (it
1173
+ // checks configNodes[id] before falling back), so left uncounted
1174
+ // they'd silently inflate the headline total (e.g. "8" instead of
1175
+ // "5"). Track and verify them separately instead.
1176
+ var configTotal = 0, configFound = 0;
1177
+ // Node-RED's own RED.nodes.import (the generateIds:true path used
1178
+ // for every Generate/Build import) keys nodeMap TWICE per imported
1179
+ // node: once under the model's own placeholder id (assigned while
1180
+ // constructing the node, before it's added to the live registry)
1181
+ // and again under the freshly-generated real editor id (assigned in
1182
+ // the final addNode/addGroup/addJunction registration loop) — both
1183
+ // entries point to the same live node object. Left undeduped this
1184
+ // doubles every count here (visible AND config alike), independent
1185
+ // of the config/visible split above. Confirmed by reading
1186
+ // @node-red/editor-client/public/red/red.js's importNodes directly.
1187
+ var seenLiveIds = {};
1188
+ Object.keys(nodeMap).forEach(function (pid) {
1189
+ var liveNode = nodeMap[pid];
1190
+ if (!liveNode || !liveNode.id) { return; }
1191
+ if (seenLiveIds[liveNode.id]) { return; }
1192
+ seenLiveIds[liveNode.id] = true;
1193
+ if (liveNode.type === "comment" || liveNode.type === "group") { return; }
1194
+ if (liveNode._def && liveNode._def.category === "config") {
1195
+ configTotal++;
1196
+ if (RED.nodes.node(liveNode.id)) { configFound++; }
1197
+ return;
1198
+ }
1199
+ total++;
1200
+ if (RED.nodes.node(liveNode.id)) {
1201
+ found++;
1202
+ } else {
1203
+ missing.push(liveNode.type || pid);
1204
+ }
1205
+ });
1206
+ var configMissing = configTotal - configFound;
1207
+ var allGood = total > 0 && missing.length === 0 && configMissing === 0;
1208
+ if (total === 0) {
1209
+ // Nothing user-visible to verify (only comments/groups/config
1210
+ // nodes) — skip notice.
1211
+ } else if (allGood) {
1212
+ var configSuffix = configTotal > 0
1213
+ ? " (+" + configTotal + " supporting config node(s))"
1214
+ : "";
1215
+ addMessage("fp-notice", "✓ Verified: all " + found + " node(s) confirmed on canvas" + configSuffix + ".");
1216
+ } else {
1217
+ var allMissing = missing.slice();
1218
+ if (configMissing > 0) { allMissing.push(configMissing + " config node(s)"); }
1219
+ addMessage("fp-notice", "⚠ Verification: " + found + "/" + total + " node(s) on canvas — " +
1220
+ allMissing.length + " not found after import (" + allMissing.join(", ") + "). " +
1221
+ "These may be uninstalled node types that were silently dropped.");
1222
+ }
1223
+ // Check off (or fail) the active todo item.
1224
+ if (todoRec && todoRec.items) {
1225
+ todoRec.items.forEach(function (item) {
1226
+ if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
1227
+ });
1228
+ rerenderTodoRecord(todoRec);
1229
+ }
1230
+ }
1231
+
1232
+ // W4 Phase 2: real graph read-back verification for Modify, using the
1233
+ // server-derived verifySteps (Track A — property/exists/absent/wire
1234
+ // checks; see finalizeModifyResult in flowpilot.js). Mirrors
1235
+ // verifyImportedNodes's design for Generate: aggregate pass/fail across
1236
+ // all steps, surface one notice, and check off (or fail) the active
1237
+ // todo item. idMap resolves the response-time placeholder ids that
1238
+ // existence/wire checks on newly-inserted nodes carry (the server can't
1239
+ // know the browser-assigned id at response time — applyInsertions
1240
+ // assigns it and returns idMap). Property/absent checks already use
1241
+ // real existing-node ids, so idMap[id] simply misses and falls through
1242
+ // to the id unchanged.
1243
+ function verifyModifySteps(verifySteps, idMap, todoRec) {
1244
+ if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
1245
+ idMap = idMap || {};
1246
+ function resolve(id) { return (idMap && idMap[id]) || id; }
1247
+
1248
+ var total = 0, passed = 0, failures = [];
1249
+ verifySteps.forEach(function (step) {
1250
+ var ok = false;
1251
+ switch (step.check) {
1252
+ case "property": {
1253
+ var pNode = RED.nodes.node(resolve(step.nodeId));
1254
+ ok = !!pNode && pNode[step.prop] === step.expected;
1255
+ if (!ok) { failures.push((step.prop || "property") + " on " + step.nodeId); }
1256
+ break;
1257
+ }
1258
+ case "exists": {
1259
+ ok = !!RED.nodes.node(resolve(step.nodeId));
1260
+ if (!ok) { failures.push(step.nodeId + " missing"); }
1261
+ break;
1262
+ }
1263
+ case "absent": {
1264
+ ok = !RED.nodes.node(resolve(step.nodeId));
1265
+ if (!ok) { failures.push(step.nodeId + " still present"); }
1266
+ break;
1267
+ }
1268
+ case "wire": {
1269
+ // Read from the live link registry (RED.nodes.eachLink), not
1270
+ // node.wires — RED.nodes.addLink/removeLink never re-sync a
1271
+ // live node's own .wires array mid-session (it's only set at
1272
+ // import and recomputed at export), so fromNode.wires[port] is
1273
+ // stale for any wire added/removed during the current editing
1274
+ // session. Mirrors computeWireDiff (apply-review.js).
1275
+ var fromId = resolve(step.fromId);
1276
+ var toId = resolve(step.toId);
1277
+ var port = step.fromPort || 0;
1278
+ ok = false;
1279
+ RED.nodes.eachLink(function (l) {
1280
+ if (ok) { return; }
1281
+ if (l.source && l.source.id === fromId &&
1282
+ (l.sourcePort || 0) === port &&
1283
+ l.target && l.target.id === toId) {
1284
+ ok = true;
1285
+ }
1286
+ });
1287
+ if (!ok) { failures.push("wire " + step.fromId + " → " + step.toId); }
1288
+ break;
1289
+ }
1290
+ default:
1291
+ return; // unrecognized check type — don't count it either way
1292
+ }
1293
+ total++;
1294
+ if (ok) { passed++; }
1295
+ });
1296
+
1297
+ if (total === 0) { return; }
1298
+ var allGood = failures.length === 0;
1299
+ if (allGood) {
1300
+ addMessage("fp-notice", "✓ Verified: all " + passed + " change(s) confirmed on canvas.");
1301
+ } else {
1302
+ addMessage("fp-notice", "⚠ Verification: " + passed + "/" + total + " change(s) confirmed — " +
1303
+ failures.length + " did not land as expected (" + failures.join(", ") + ").");
1304
+ }
1305
+ if (todoRec && todoRec.items) {
1306
+ todoRec.items.forEach(function (item) {
1307
+ if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
1308
+ });
1309
+ rerenderTodoRecord(todoRec);
1310
+ }
1311
+ }
1312
+
1313
+ function handleStepQueueGenerateResult(data, goalPrompt) {
1314
+ hidePending();
1315
+ if (renderQuestionOrProse(data)) { return; }
1316
+ var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1317
+
1318
+ // Build the todo plan. Parse "Plan:" from explanation if present;
1319
+ // fall back to an implicit single item from the goal prompt.
1320
+ var planItems = parseTodoPlan(data.explanation || "");
1321
+ if (!planItems.length) {
1322
+ planItems = [{ text: goalPrompt || "Generate flow", status: "pending" }];
1323
+ }
1324
+ // Same aggregate-verification reasoning as the Modify path: mark
1325
+ // every item active up front so a multi-item plan resolves together.
1326
+ planItems.forEach(function (item) { item.status = "active"; });
1327
+ var todoRec = addRecord("todo", { action: "generate", items: planItems });
1328
+ rerenderTodoRecord(todoRec);
1329
+
1330
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1331
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
1332
+ // B1: when a build loop is appropriate, bake deploy-verify into the
1333
+ // primary chip (same as handleSimpleGenerationResult). The callback
1334
+ // also runs verifyImportedNodes so the todo record still gets checked
1335
+ // off. Without a build loop, fall back to a plain "Add to canvas"
1336
+ // button that still fires the verify callback.
1337
+ var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
1338
+ var _wantLoop = goalPrompt && !activeBuildLoop && _hasDeployable;
1339
+ var _onImported = _wantLoop
1340
+ ? function (importResult) {
1341
+ verifyImportedNodes(importResult, todoRec);
1342
+ startBuildLoop(goalPrompt, flow, importResult);
1343
+ }
1344
+ : function (importResult) { verifyImportedNodes(importResult, todoRec); };
1345
+ addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
1346
+ // Suppress a server-suggested build chip when deploy-verify is already
1347
+ // the primary action inside the review panel.
1348
+ renderActionChip(_wantLoop && data.suggestedAction && data.suggestedAction.mode === "build"
1349
+ ? null : data.suggestedAction);
1350
+ setBusy(false);
1351
+ updateSelectionStatus();
1352
+ }
1353
+
956
1354
  function generate() {
957
- runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1355
+ if (currentSettings.enableStepQueue) {
1356
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleStepQueueGenerateResult);
1357
+ } else {
1358
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1359
+ }
958
1360
  }
959
1361
 
960
1362
  // /build's first step. Reuses Generate's pipeline wholesale for the
@@ -1063,6 +1465,127 @@
1063
1465
  updateSelectionStatus();
1064
1466
  }
1065
1467
 
1468
+ // WS4: real consent gate for side-effecting build steps. Renders one
1469
+ // combined chip covering every side-effecting node this step's
1470
+ // classification found (classifyFlowNodes in flowpilot.js, server-side)
1471
+ // — a single decision rather than per-node chips, which is sufficient
1472
+ // because only the FIRST /flowpilot/build response carries
1473
+ // stepNodeClasses today (fix iterations via /flowpilot/modify don't, so
1474
+ // there's exactly one consent point per loop lifetime under the current
1475
+ // limitation — see the handleBuildResult call site).
1476
+ //
1477
+ // Reconstructed entirely from `src` (plain data, never a live closure)
1478
+ // on every call — the initial render from handleBuildResult AND every
1479
+ // later rerender (refresh, pop-out reopen, the W0A idle/focus
1480
+ // auto-refresh) via rerenderRecord's buildConsentGate branch below.
1481
+ // Mirrors rerenderReviewRecord's pattern in apply-review.js: a fresh
1482
+ // record is added each time from the source's stored fields (including
1483
+ // `decision`, once made), rather than relying on an in-memory callback
1484
+ // surviving a refresh. That in-memory-callback version is exactly what
1485
+ // broke before this fix — a refresh mid-decision fell through to
1486
+ // renderClarifyingQuestion's generic path, whose buttons send the
1487
+ // clicked label as a new chat message instead of resolving Proceed/Skip,
1488
+ // permanently stranding the loop.
1489
+ //
1490
+ // src fields: sideEffecting, flow, goalPrompt, fpUidManifest,
1491
+ // suggestedAction — everything runBuildConsentDecision needs — plus,
1492
+ // once resolved, `decision` ("proceed"|"skip") so a later rerender shows
1493
+ // a settled state instead of re-offering an already-made choice.
1494
+ function renderBuildConsentGate(src) {
1495
+ var $box = el("#fp-messages");
1496
+ if (!$box.length) {
1497
+ if (!src.decision) { runBuildConsentDecision(src, true); }
1498
+ return;
1499
+ }
1500
+
1501
+ var _rec = addRecord("question", {
1502
+ buildConsentGate: true,
1503
+ options: ["Auto-verify", "I'll check myself"],
1504
+ sideEffecting: src.sideEffecting,
1505
+ flow: src.flow,
1506
+ goalPrompt: src.goalPrompt,
1507
+ fpUidManifest: src.fpUidManifest,
1508
+ suggestedAction: src.suggestedAction,
1509
+ decision: src.decision
1510
+ });
1511
+
1512
+ var sideEffecting = Array.isArray(_rec.sideEffecting) ? _rec.sideEffecting : [];
1513
+ var labels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
1514
+
1515
+ if (_rec.decision) {
1516
+ // Already resolved before this render (e.g. resolved earlier in
1517
+ // the session, now showing again after a refresh) — settled
1518
+ // state, not an interactive choice.
1519
+ addMessage("assistant", "This step calls an external service: " + labels + ".");
1520
+ var $settledRow = $("<div>").addClass("fp-chip-row fp-question-row");
1521
+ $("<button>")
1522
+ .addClass("fp-consent-chip")
1523
+ .addClass(_rec.decision === "proceed" ? "fp-consent-chip-primary" : "fp-consent-chip-alt")
1524
+ .attr("type", "button").prop("disabled", true)
1525
+ .text(_rec.decision === "proceed" ? "Auto-verify ✓" : "Checking myself ✓")
1526
+ .appendTo($settledRow);
1527
+ $box.append($settledRow);
1528
+ scrollMessagesToBottom();
1529
+ return;
1530
+ }
1531
+
1532
+ addMessage("assistant", "This step calls an external service: " + labels +
1533
+ ". Want it verified automatically once triggered, or would you rather check it yourself?");
1534
+
1535
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
1536
+
1537
+ function decide(proceed) {
1538
+ $row.find("button").prop("disabled", true);
1539
+ _rec.decision = proceed ? "proceed" : "skip";
1540
+ runBuildConsentDecision(_rec, proceed);
1541
+ }
1542
+
1543
+ $("<button>")
1544
+ .addClass("fp-consent-chip fp-consent-chip-primary")
1545
+ .attr("type", "button")
1546
+ .text("Auto-verify")
1547
+ .on("click", function () { decide(true); })
1548
+ .appendTo($row);
1549
+ $("<button>")
1550
+ .addClass("fp-consent-chip fp-consent-chip-alt")
1551
+ .attr("type", "button")
1552
+ .text("I'll check myself")
1553
+ .on("click", function () { decide(false); })
1554
+ .appendTo($row);
1555
+
1556
+ $box.append($row);
1557
+ scrollMessagesToBottom();
1558
+ }
1559
+
1560
+ // The actual "proceed with review + loop" action, factored out so both
1561
+ // the fresh (no side-effecting nodes) and gated (decision made) paths in
1562
+ // handleBuildResult, and a rerendered consent-gate record's decide(),
1563
+ // all run identical logic sourced from plain data — never a captured
1564
+ // closure. consentGranted=false builds the Skip consent object
1565
+ // (skippedNodeIds/fpUidManifest) that startBuildLoop resolves into real
1566
+ // ids via importResult.nodeMap — see startBuildLoop's own comment.
1567
+ function runBuildConsentDecision(src, consentGranted) {
1568
+ var sideEffecting = Array.isArray(src.sideEffecting) ? src.sideEffecting : [];
1569
+ var flow = src.flow;
1570
+ var goalPrompt = src.goalPrompt;
1571
+ var fpUidManifest = Array.isArray(src.fpUidManifest) ? src.fpUidManifest : [];
1572
+
1573
+ if (sideEffecting.length > 0) {
1574
+ var sideLabels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
1575
+ addMessage("fp-notice", consentGranted
1576
+ ? "⚠ External calls: " + sideLabels + " — the deploy-test loop will auto-verify these once triggered."
1577
+ : "⚠ External calls: " + sideLabels + " — auto-verify skipped for these node(s); confirm the result yourself.");
1578
+ }
1579
+ var consent = consentGranted ? null : {
1580
+ skippedNodeIds: sideEffecting.map(function (n) { return n.id; }),
1581
+ fpUidManifest: fpUidManifest
1582
+ };
1583
+ addGeneratedReview(flow, function (importResult) {
1584
+ startBuildLoop(goalPrompt, flow, importResult, consent);
1585
+ }, goalPrompt);
1586
+ renderActionChip(src.suggestedAction);
1587
+ }
1588
+
1066
1589
  function handleBuildResult(data, goalPrompt) {
1067
1590
  hidePending();
1068
1591
  if (renderQuestionOrProse(data)) { return; }
@@ -1071,8 +1594,24 @@
1071
1594
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1072
1595
  addMessage("assistant", data.explanation || "(no explanation returned)");
1073
1596
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1074
- addGeneratedReview(flow, function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }, goalPrompt);
1075
- renderActionChip(data.suggestedAction);
1597
+
1598
+ var nodeClasses = data.stepNodeClasses;
1599
+ var sideEffecting = (nodeClasses && Array.isArray(nodeClasses.sideEffecting))
1600
+ ? nodeClasses.sideEffecting : [];
1601
+ var fpUidManifest = Array.isArray(data.fpUidManifest) ? data.fpUidManifest : [];
1602
+ var consentSrc = {
1603
+ sideEffecting: sideEffecting,
1604
+ flow: flow,
1605
+ goalPrompt: goalPrompt,
1606
+ fpUidManifest: fpUidManifest,
1607
+ suggestedAction: data.suggestedAction
1608
+ };
1609
+
1610
+ if (sideEffecting.length > 0) {
1611
+ renderBuildConsentGate(consentSrc);
1612
+ } else {
1613
+ runBuildConsentDecision(consentSrc, true);
1614
+ }
1076
1615
  setBusy(false);
1077
1616
  updateSelectionStatus();
1078
1617
  }
@@ -1163,6 +1702,8 @@
1163
1702
  addMessage("error", "Describe what you want to change.");
1164
1703
  return;
1165
1704
  }
1705
+ var existingNodeIds = context.nodes.map(function (n) { return n.id; });
1706
+
1166
1707
  var label = "Modify: " + instruction + contextAttachmentNote(context);
1167
1708
  addMessage("user", label);
1168
1709
  // Snapshot history before pushing this turn (see send()).
@@ -1187,6 +1728,10 @@
1187
1728
  handleExecuteError(msg, raw);
1188
1729
  }
1189
1730
 
1731
+ function onModifyResult(data) {
1732
+ handleModifyResult(data);
1733
+ }
1734
+
1190
1735
  // Explore-then-propose, same as generate(). The
1191
1736
  // model may call read tools (e.g. to re-check the selected node's
1192
1737
  // current config) before producing the modify envelope; the final
@@ -1194,7 +1739,7 @@
1194
1739
  if (isAgentLoop) {
1195
1740
  runAgentLoop("flowpilot/modify", payload,
1196
1741
  { mode: "modify", context: context, prompt: instruction },
1197
- handleModifyResult, onModifyError);
1742
+ onModifyResult, onModifyError);
1198
1743
  return;
1199
1744
  }
1200
1745
 
@@ -1202,11 +1747,11 @@
1202
1747
  // generate() for details.
1203
1748
  if (currentSettings.streamingEnabled) {
1204
1749
  payload.stream = true;
1205
- sendExecuteStream("modify", payload, handleModifyResult);
1750
+ sendExecuteStream("modify", payload, onModifyResult);
1206
1751
  return;
1207
1752
  }
1208
1753
 
1209
- ajaxJson("POST", "flowpilot/modify", payload, handleModifyResult, onModifyError);
1754
+ ajaxJson("POST", "flowpilot/modify", payload, onModifyResult, onModifyError);
1210
1755
  }
1211
1756
 
1212
1757
  // Render generated flow JSON in a preformatted, copyable block. Used for
@@ -1255,6 +1800,10 @@
1255
1800
  // the review — see onDebugMessage for why (a forked/split flow can
1256
1801
  // fire its debug node more than once per trigger).
1257
1802
  var BUILD_LOOP_ATTACH_DEBOUNCE_MS = 1200;
1803
+ // W0.3: how many times the model can bail (emit a prose reply with a
1804
+ // suggestedAction mode-redirect) before the loop gives up with an
1805
+ // honest-timeout instead of silently treating the bail as success.
1806
+ var BUILD_LOOP_MAX_BAILS = 2;
1258
1807
  var buildLoopAttachTimer = null;
1259
1808
  // Fires when "attach" waits too long with no debug — surfaces a prompt
1260
1809
  // for flows that don't produce automatic debug output (HTTP endpoints, etc).
@@ -1304,9 +1853,59 @@
1304
1853
  .appendTo($row);
1305
1854
 
1306
1855
  $box.append($row);
1856
+ addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
1307
1857
  scrollMessagesToBottom();
1308
1858
  }
1309
1859
 
1860
+ // WS3: remove FP-UID checkpoint tap nodes that the build prompt placed
1861
+ // on the canvas as FlowPilot scaffolding. These are debug nodes named
1862
+ // FP-UID001, FP-UID002, etc. — wired in parallel to external-call nodes
1863
+ // so the loop can attribute debug messages to specific checkpoints.
1864
+ // Called at loop end (any outcome) to clean up before returning control
1865
+ // to the user. Replicates the remove-and-history pattern from
1866
+ // applyModifications in apply-review.js (confirmed group-cleanup bookkeeping).
1867
+ function removeFpUidTaps(loop) {
1868
+ if (!loop || !Array.isArray(loop.nodeIds) || !loop.nodeIds.length) { return 0; }
1869
+ var FP_UID_RE = /^FP-UID\d+$/;
1870
+ var removed = 0;
1871
+ loop.nodeIds.forEach(function (id) {
1872
+ var liveNode = RED.nodes.node(id);
1873
+ if (!liveNode || !FP_UID_RE.test(liveNode.name)) { return; }
1874
+ var connectedLinks = [];
1875
+ RED.nodes.eachLink(function (l) {
1876
+ if ((l.source && l.source.id === id) || (l.target && l.target.id === id)) {
1877
+ connectedLinks.push(l);
1878
+ }
1879
+ });
1880
+ try { RED.nodes.remove(liveNode.id); } catch (e) { return; }
1881
+ if (liveNode.g && RED.nodes.group) {
1882
+ var ownerGroup = RED.nodes.group(liveNode.g);
1883
+ if (ownerGroup) {
1884
+ var idx = ownerGroup.nodes.indexOf(liveNode);
1885
+ if (idx !== -1) { ownerGroup.nodes.splice(idx, 1); }
1886
+ RED.group.markDirty(ownerGroup);
1887
+ }
1888
+ }
1889
+ RED.history.push({
1890
+ t: "delete",
1891
+ nodes: [liveNode],
1892
+ links: connectedLinks,
1893
+ groups: [],
1894
+ junctions: [],
1895
+ subflow: { id: undefined, instances: [] },
1896
+ subflowInputs: [],
1897
+ subflowOutputs: [],
1898
+ dirty: RED.nodes.dirty()
1899
+ });
1900
+ removed++;
1901
+ });
1902
+ if (removed) {
1903
+ RED.nodes.dirty(true);
1904
+ RED.view.redraw(true);
1905
+ }
1906
+ return removed;
1907
+ }
1908
+
1310
1909
  // The single exit point for every way a build loop ends — Touchdown,
1311
1910
  // the cap being reached, pausing on a clarifying question, or the user
1312
1911
  // clicking Stop. Releases Build mode and its pinned selection too: once
@@ -1317,6 +1916,7 @@
1317
1916
  // visible as a completion badge. success=false (default): remove the stepper
1318
1917
  // (user stop, cap reached, paused for question).
1319
1918
  function stopBuildLoop(note, success) {
1919
+ var tapCount = activeBuildLoop ? removeFpUidTaps(activeBuildLoop) : 0;
1320
1920
  if (success && activeBuildLoop) {
1321
1921
  activeBuildLoop.waypoint = "done";
1322
1922
  renderLoopStepper(activeBuildLoop);
@@ -1327,6 +1927,7 @@
1327
1927
  if (!success) { el("#fp-loop-stepper").remove(); }
1328
1928
  disarmExecuteAction();
1329
1929
  if (note) { addMessage("assistant", note); }
1930
+ if (tapCount) { addMessage("assistant", "Removed " + tapCount + " FP-UID checkpoint tap(s) from the canvas."); }
1330
1931
  }
1331
1932
 
1332
1933
  // Applies a build-loop review's fix envelope, then keeps the loop's
@@ -1334,7 +1935,7 @@
1334
1935
  // handleBuildReviewResult's addModifyReview callback (rather than left
1335
1936
  // as an inline closure) so the EXACT same logic can run whether the
1336
1937
  // Apply click happened in the main window or was relayed from the
1337
- // pop-out — see the "applyBuildFix" handler in initMainWindow.
1938
+ // pop-out — see the applyByRecordId handler in initMainWindow (Phase 10 0B).
1338
1939
  function applyBuildLoopFix(nodeDiffs, removeNodesArg, idMap, capReached) {
1339
1940
  applyModifications(nodeDiffs, removeNodesArg, null, idMap);
1340
1941
  if (!activeBuildLoop) { return; }
@@ -1389,7 +1990,21 @@
1389
1990
  if (loop.waypoint === "apply") {
1390
1991
  hint = "Click the canvas to place the new node(s), then Deploy — I'll move on automatically once you deploy.";
1391
1992
  } else if (loop.waypoint === "attach") {
1392
- hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
1993
+ var eps = loop.httpEndpoints;
1994
+ if (eps && eps.length > 0) {
1995
+ var ep = eps[0];
1996
+ var baseUrl = (typeof window !== "undefined" && window.location)
1997
+ ? window.location.origin : "";
1998
+ var curlMethod = ep.method === "GET" ? "" : " -X " + ep.method;
1999
+ hint = "Send " + ep.method + " " + ep.url + " to trigger the flow — " +
2000
+ "e.g. curl" + curlMethod + " " + baseUrl + ep.url +
2001
+ ". I’ll attach the debug output automatically.";
2002
+ if (eps.length > 1) {
2003
+ hint += " (" + (eps.length - 1) + " more endpoint(s) in this flow.)";
2004
+ }
2005
+ } else {
2006
+ hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
2007
+ }
1393
2008
  } else if (loop.waypoint === "review") {
1394
2009
  hint = "Debug output attached — reviewing against the goal…";
1395
2010
  }
@@ -1403,10 +2018,32 @@
1403
2018
  .appendTo($actions);
1404
2019
  }
1405
2020
 
2021
+ // Replace any prior buildStep snapshot — only the latest waypoint matters.
2022
+ messageRecords = messageRecords.filter(function (r) { return r.kind !== "buildStep"; });
2023
+ addRecord("buildStep", {
2024
+ waypoint: loop.waypoint,
2025
+ iteration: loop.iteration,
2026
+ maxIterations: loop.maxIterations,
2027
+ goal: loop.goal,
2028
+ nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : [],
2029
+ httpEndpoints: Array.isArray(loop.httpEndpoints) ? loop.httpEndpoints.slice() : []
2030
+ });
2031
+
1406
2032
  $box.append($msg);
1407
2033
  scrollMessagesToBottom();
1408
2034
  }
1409
2035
 
2036
+ function rerenderBuildStepRecord(rec) {
2037
+ renderLoopStepper({
2038
+ waypoint: rec.waypoint || "done",
2039
+ iteration: rec.iteration || 1,
2040
+ maxIterations: rec.maxIterations || 5,
2041
+ goal: rec.goal || "",
2042
+ nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : [],
2043
+ httpEndpoints: Array.isArray(rec.httpEndpoints) ? rec.httpEndpoints : []
2044
+ });
2045
+ }
2046
+
1410
2047
  // Called once the first build proposal is actually imported (not on a
1411
2048
  // clarifying question or prose-only reply — see handleBuildResult). goal
1412
2049
  // is the original prompt text, kept verbatim so the review step can
@@ -1419,12 +2056,22 @@
1419
2056
  // end up on the canvas — importResult.nodeMap maps each placeholder id
1420
2057
  // to the real live node object, which is the only way later review/fix
1421
2058
  // requests can target the right nodes via collectSelectionContext.
1422
- function startBuildLoop(goal, nodeIdsOrNodes, importResult) {
2059
+ //
2060
+ // consent (WS4, optional): { skippedNodeIds, fpUidManifest } from a
2061
+ // Skip decision at the build consent gate (see handleBuildResult /
2062
+ // renderBuildConsentGate) — skippedNodeIds are the side-effecting
2063
+ // nodes' own PLACEHOLDER ids, fpUidManifest maps each FP-UID debug tap's
2064
+ // placeholder id to the placeholder id of the node it's wired from
2065
+ // (wiredFrom). Resolved here into two REAL-id sets: the skipped nodes
2066
+ // themselves (onNodeStatus's status/<nodeId> path checks against these)
2067
+ // and the taps wired to them (onDebugMessage's msg.id check does) — so
2068
+ // both auto-verify evidence paths honor the same Skip decision.
2069
+ function startBuildLoop(goal, nodeIdsOrNodes, importResult, consent) {
1423
2070
  var nodeIds = [];
2071
+ var nodeMap = importResult && importResult.nodeMap;
1424
2072
  if (importResult) {
1425
2073
  // Fresh build: map placeholder ids from the proposal to the real
1426
2074
  // ids importNodes assigned on the canvas.
1427
- var nodeMap = importResult.nodeMap;
1428
2075
  if (nodeMap && Array.isArray(nodeIdsOrNodes)) {
1429
2076
  nodeIdsOrNodes.forEach(function (n) {
1430
2077
  var real = n && n.id && nodeMap[n.id];
@@ -1435,13 +2082,43 @@
1435
2082
  // Existing-flow build: ids are already resolved real canvas ids.
1436
2083
  nodeIds = nodeIdsOrNodes.filter(function (id) { return typeof id === "string" && id; });
1437
2084
  }
2085
+ // Detect HTTP-in endpoints so the "attach" step can show a specific
2086
+ // trigger hint instead of the generic "trigger the flow" message.
2087
+ var httpEndpoints = [];
2088
+ nodeIds.forEach(function (id) {
2089
+ var n = RED.nodes.node(id);
2090
+ if (n && n.type === "http in" && n.url) {
2091
+ httpEndpoints.push({ method: (n.method || "get").toUpperCase(), url: n.url });
2092
+ }
2093
+ });
2094
+
2095
+ var skipCheckpointNodeIds = [];
2096
+ var skipCheckpointTapIds = [];
2097
+ var skippedPlaceholderIds = consent && Array.isArray(consent.skippedNodeIds) ? consent.skippedNodeIds : [];
2098
+ if (skippedPlaceholderIds.length && nodeMap) {
2099
+ skippedPlaceholderIds.forEach(function (placeholderId) {
2100
+ var real = nodeMap[placeholderId];
2101
+ if (real && real.id) { skipCheckpointNodeIds.push(real.id); }
2102
+ });
2103
+ var manifest = Array.isArray(consent.fpUidManifest) ? consent.fpUidManifest : [];
2104
+ manifest.forEach(function (tap) {
2105
+ if (!tap || skippedPlaceholderIds.indexOf(tap.wiredFrom) === -1) { return; }
2106
+ var realTap = nodeMap[tap.id];
2107
+ if (realTap && realTap.id) { skipCheckpointTapIds.push(realTap.id); }
2108
+ });
2109
+ }
2110
+
1438
2111
  activeBuildLoop = {
1439
2112
  goal: goal,
1440
2113
  nodeIds: nodeIds,
1441
2114
  iteration: 1,
1442
2115
  maxIterations: getAgentLoopMaxIterations(),
1443
2116
  waypoint: "apply",
1444
- conversationId: conversationId
2117
+ conversationId: conversationId,
2118
+ bailCount: 0,
2119
+ httpEndpoints: httpEndpoints,
2120
+ skipCheckpointNodeIds: skipCheckpointNodeIds,
2121
+ skipCheckpointTapIds: skipCheckpointTapIds
1445
2122
  };
1446
2123
  renderLoopStepper(activeBuildLoop);
1447
2124
  }
@@ -1457,7 +2134,47 @@
1457
2134
  function runBuildReview(loop) {
1458
2135
  var context = collectSelectionContext(loop.nodeIds);
1459
2136
  context = attachDebugContext(context);
1460
- var instruction = "Review the attached debug output against this build goal: \"" +
2137
+ var reviewEvidence = context && Array.isArray(context.debugMessages)
2138
+ ? context.debugMessages : [];
2139
+ var statusOnlyEvidence = reviewEvidence.length > 0 &&
2140
+ reviewEvidence.every(function (entry) {
2141
+ return entry && entry.sourceKind === "status";
2142
+ });
2143
+ // W0.3: framing block — suppresses the Modify escape hatch
2144
+ // (suggestedAction mode-redirect) inside the build loop context.
2145
+ // The code-side handler (handleBuildReviewResult) also detects and
2146
+ // counts bail attempts so N bails trigger an honest-timeout instead
2147
+ // of silently treating a redirect as success.
2148
+ var instruction = "CONTEXT: You are the fix engine inside a build-test-fix loop. " +
2149
+ "Your only valid responses are: (1) plain text when the goal is fully " +
2150
+ "satisfied, or (2) a {\"explanation\", \"changes\", ...} fix envelope " +
2151
+ "when something needs patching." +
2152
+ (statusOnlyEvidence
2153
+ ? " (3) Because the attached evidence contains ONLY coarse node-status " +
2154
+ "lines, you may instead return the atomic {\"question\", " +
2155
+ "\"questionOptions\"} envelope described below."
2156
+ : "") +
2157
+ " Do NOT use the <<<FLOWPILOT_DATA>>> " +
2158
+ "block or suggest switching to chat/generate/document — you are already " +
2159
+ "in the right context and any mode-redirect will be ignored. If you are " +
2160
+ "genuinely uncertain what to fix, " +
2161
+ (statusOnlyEvidence
2162
+ ? "use the status-only confirmation question below."
2163
+ : "describe the uncertainty inside \"explanation\" in a fix envelope.") +
2164
+ "\n\nEach attached evidence object has sourceKind. sourceKind:\"debug\" " +
2165
+ "is real message content emitted by a debug node. sourceKind:\"status\" " +
2166
+ "is only a coarse connection/status line synthesized from node status; " +
2167
+ "never treat it as proof of message payload content or successful " +
2168
+ "end-to-end behavior. " +
2169
+ (statusOnlyEvidence
2170
+ ? "STATUS-ONLY FALLBACK: if the coarse status does not prove whether " +
2171
+ "the deployed node is actually connected/working, do not guess or " +
2172
+ "assert failure. Ask one concrete yes/no confirmation such as " +
2173
+ "\"Does the node show connected after deploy?\" by returning ONLY " +
2174
+ "{\"question\":\"...\",\"questionOptions\":[\"Yes\",\"No\"]}. "
2175
+ : "") +
2176
+ "\n\n" +
2177
+ "Review the attached debug output against this build goal: \"" +
1461
2178
  loop.goal + "\". Before concluding anything, list out every distinct " +
1462
2179
  "piece of data or behavior the goal actually requires, then check the " +
1463
2180
  "attached debug payload(s) contain EACH one — a payload that's merely " +
@@ -1465,7 +2182,22 @@
1465
2182
  "goal asked to combine two things but the payload only shows one), " +
1466
2183
  "does NOT fully satisfy it. If more than one debug message is " +
1467
2184
  "attached, treat them together as the full picture from one trigger, " +
1468
- "not as separate independent attempts. If it fully satisfies the goal, " +
2185
+ "not as separate independent attempts. " +
2186
+ "SPECIAL CASE — network errors: if the debug output shows ONLY a " +
2187
+ "network-level error (EHOSTUNREACH, ECONNREFUSED, ETIMEDOUT, " +
2188
+ "ENOTFOUND, getaddrinfo ENOTFOUND, EAI_AGAIN, EAI_NODATA), the " +
2189
+ "flow MIGHT be correctly built — BUT you MUST first check the node " +
2190
+ "context: if any http-request node has an empty url field, a " +
2191
+ "placeholder, or a clearly malformed url (no hostname, no protocol, " +
2192
+ "etc.), the DNS or connection error is a CONFIGURATION problem — " +
2193
+ "fix the url field, do NOT declare it an infrastructure issue. Only " +
2194
+ "apply this special case when the url is a real, non-empty, " +
2195
+ "well-formed URL and the external service is simply unreachable. In " +
2196
+ "that case reply in plain text acknowledging the flow is structurally " +
2197
+ "correct and the network error is an infrastructure issue outside the " +
2198
+ "flow. Do NOT propose any changes; this error cannot be resolved by " +
2199
+ "modifying the flow. " +
2200
+ "If it fully satisfies the goal, " +
1469
2201
  "say so in plain text — no changes needed. If something's wrong " +
1470
2202
  "(including a node that never fired, or a value that's missing/empty " +
1471
2203
  "when the goal needed it), propose the fix directly as a patch in " +
@@ -1521,6 +2253,26 @@
1521
2253
  // diff-then-Apply pipeline as a manual Modify, then the loop advances
1522
2254
  // back to "apply" for the next deploy/test cycle, or stops if the
1523
2255
  // iteration cap is reached).
2256
+ // Returns false when all of data's proposed changes are sentinel-echoed
2257
+ // with no insertions, removals, or wire changes. Used by
2258
+ // handleBuildReviewResult to avoid showing an all-blocked review panel
2259
+ // when the model said "no changes needed" but still emitted a modify
2260
+ // envelope (a common model behavior after a build-loop review).
2261
+ function reviewHasRealDiffs(data) {
2262
+ if ((data.newNodes && data.newNodes.length) ||
2263
+ (data.removeNodes && data.removeNodes.length) ||
2264
+ (data.newWires && data.newWires.length) ||
2265
+ (data.newGroups && data.newGroups.length)) { return true; }
2266
+ var nodes = Array.isArray(data.flow) ? data.flow : [];
2267
+ return nodes.some(function (modNode) {
2268
+ if (!modNode || !modNode.id) { return false; }
2269
+ var liveNode = findLiveNode(modNode.id);
2270
+ if (!liveNode) { return false; }
2271
+ var diff = computeNodeDiff(liveNode, modNode);
2272
+ return diff.propertyChanges.length > 0 || diff.wiresChanged;
2273
+ });
2274
+ }
2275
+
1524
2276
  function handleBuildReviewResult(data) {
1525
2277
  hidePending();
1526
2278
  var loop = activeBuildLoop;
@@ -1544,6 +2296,51 @@
1544
2296
  }
1545
2297
 
1546
2298
  if (data.prose) {
2299
+ var explanation = data.explanation || "(no content returned)";
2300
+
2301
+ // W0.3: bail detection — a prose reply with a mode-redirect
2302
+ // suggestedAction means the model tried to exit the loop
2303
+ // context via the Modify escape hatch. Count it and retry or
2304
+ // honest-timeout rather than treating it as success.
2305
+ var sa = data.suggestedAction;
2306
+ var isBail = sa && (sa.mode === "chat" || sa.mode === "generate" || sa.mode === "document");
2307
+ if (isBail) {
2308
+ loop.bailCount = (loop.bailCount || 0) + 1;
2309
+ console.warn("[FlowPilot] build-loop bail #" + loop.bailCount +
2310
+ " mode=" + sa.mode + ": " + explanation);
2311
+ addMessage("assistant", explanation);
2312
+ pushHistory("assistant", explanation);
2313
+ if (loop.bailCount >= BUILD_LOOP_MAX_BAILS) {
2314
+ stopBuildLoop("Build loop could not assess the debug output — the AI kept redirecting instead of reviewing. Try attaching more debug context or continuing manually with Modify.", false);
2315
+ setBusy(false);
2316
+ updateSelectionStatus();
2317
+ } else {
2318
+ addMessage("fp-notice", "Build-loop: review redirected to " + sa.mode +
2319
+ " — staying in build context and retrying (bail " + loop.bailCount +
2320
+ "/" + BUILD_LOOP_MAX_BAILS + ").");
2321
+ runBuildReview(loop);
2322
+ }
2323
+ return;
2324
+ }
2325
+
2326
+ addMessage("assistant", explanation);
2327
+ pushHistory("assistant", explanation);
2328
+ renderActionChip(data.suggestedAction);
2329
+ var stopMsg = /EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EAI_NODATA|getaddrinfo|unreachable|infrastructure/i
2330
+ .test(explanation)
2331
+ ? "Build complete — the flow is correctly structured, but the external endpoint was unreachable during testing (infrastructure issue, not a flow problem)."
2332
+ : "Touchdown — the debug output matches the goal.";
2333
+ stopBuildLoop(stopMsg, true);
2334
+ setBusy(false);
2335
+ updateSelectionStatus();
2336
+ return;
2337
+ }
2338
+
2339
+ // Modify envelope where every proposed change is a sentinel echo —
2340
+ // the model emitted a changes object but all fields are redacted
2341
+ // placeholders with no insertions, removals, or wire changes. Treat
2342
+ // it as "no changes needed" rather than showing an all-blocked panel.
2343
+ if (!reviewHasRealDiffs(data)) {
1547
2344
  addMessage("assistant", data.explanation || "(no content returned)");
1548
2345
  pushHistory("assistant", data.explanation || "");
1549
2346
  renderActionChip(data.suggestedAction);