@manny-est/node-red-flowpilot 0.5.1 → 0.6.0-beta.1
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/CHANGELOG.md +58 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +27 -14
- package/flowpilot-core.css +159 -5
- package/flowpilot.js +1353 -172
- package/lib/agent-contract.js +45 -0
- package/lib/build-core-script.js +1 -0
- package/lib/build-system-prompt.js +26 -4
- package/lib/chat-data.js +106 -0
- package/lib/core/apply-review.js +268 -64
- package/lib/core/graph-truth.js +63 -0
- package/lib/core/history.js +10 -1
- package/lib/core/init.js +148 -5
- package/lib/core/main.js +755 -21
- package/lib/core/modes.js +1523 -67
- package/lib/core/selection-context.js +31 -1
- package/lib/default-system-prompt.js +17 -12
- package/lib/document-system-prompt.js +22 -32
- package/lib/envelope.js +13 -7
- package/lib/generation-system-prompt.js +29 -44
- package/lib/modify-system-prompt.js +152 -72
- package/lib/persona-prompt.js +81 -54
- package/lib/prompt-fragments.js +56 -0
- package/lib/provider-anthropic.js +388 -0
- package/lib/provider-openai-compatible.js +49 -12
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +160 -12
- package/lib/validator.js +238 -0
- package/package.json +1 -1
package/lib/storage.js
CHANGED
|
@@ -1,10 +1,83 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
|
|
4
|
+
// Sentinel the client sends back for an apiKey field it never actually saw
|
|
5
|
+
// (see maskProviderSecrets in flowpilot.js) to mean "leave the stored key
|
|
6
|
+
// alone". Shared between the GET/POST /flowpilot/settings routes (masking)
|
|
7
|
+
// and reconcileProviderSecrets below (unmasking on save) — both must agree
|
|
8
|
+
// on the exact string or a real key could get silently overwritten.
|
|
9
|
+
const API_KEY_UNCHANGED = "__FP_KEY_UNCHANGED__";
|
|
10
|
+
|
|
4
11
|
function ensureDir(dir) {
|
|
5
12
|
fs.mkdirSync(dir, { recursive: true });
|
|
6
13
|
}
|
|
7
14
|
|
|
15
|
+
// Strips control characters (CR/LF and friends) from a freshly-typed API
|
|
16
|
+
// key before it's persisted — cheap insurance against a pasted value
|
|
17
|
+
// corrupting a future header or a log line.
|
|
18
|
+
function sanitizeApiKey(raw) {
|
|
19
|
+
return String(raw).replace(/[\x00-\x1F\x7F]/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reconciles an incoming providers list (as submitted by POST /flowpilot/
|
|
23
|
+
// settings, where the client only ever sees the masked sentinel/"" for
|
|
24
|
+
// apiKey — never a real key) against the previously-stored list, matched by
|
|
25
|
+
// provider id. A sentinel or missing apiKey keeps the stored key; an empty
|
|
26
|
+
// string clears it; anything else is a real retyped key. Also enforces the
|
|
27
|
+
// server-writes-only discipline for confirmedBaseUrl/confirmedAt (the
|
|
28
|
+
// provider-confirmation gate, B1): a client can never set or forge these,
|
|
29
|
+
// and confirmation is dropped whenever baseUrl or apiKey actually changed
|
|
30
|
+
// from what was last confirmed.
|
|
31
|
+
// trustedConfirmation: true ONLY when called from saveSettings' own internal
|
|
32
|
+
// callers (/flowpilot/test, /flowpilot/probe, right after a real passing
|
|
33
|
+
// check) — never from the public POST /settings path. Those two routes
|
|
34
|
+
// compute confirmedBaseUrl themselves (== the exact URL they just verified),
|
|
35
|
+
// so it's server-computed data at that point, not client input; every OTHER
|
|
36
|
+
// provider in the same save (anything not freshly (re)confirmed this call)
|
|
37
|
+
// still only keeps its OWN prior confirmation, and only while baseUrl/apiKey
|
|
38
|
+
// still match what was actually confirmed.
|
|
39
|
+
function reconcileProviderSecrets(incoming, existing, trustedConfirmation) {
|
|
40
|
+
const existingById = {};
|
|
41
|
+
(Array.isArray(existing) ? existing : []).forEach(function (p) {
|
|
42
|
+
if (p && p.id) { existingById[p.id] = p; }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (Array.isArray(incoming) ? incoming : []).map(function (p) {
|
|
46
|
+
const prior = existingById[p.id];
|
|
47
|
+
const next = Object.assign({}, p);
|
|
48
|
+
delete next.hasApiKey;
|
|
49
|
+
|
|
50
|
+
if (next.apiKey === API_KEY_UNCHANGED || next.apiKey === undefined) {
|
|
51
|
+
next.apiKey = prior ? (prior.apiKey || "") : "";
|
|
52
|
+
} else if (typeof next.apiKey === "string" && next.apiKey !== "") {
|
|
53
|
+
next.apiKey = sanitizeApiKey(next.apiKey);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// typeof, not truthiness: baseUrl "" is a real, documented, supported
|
|
57
|
+
// value (Anthropic's "leave blank for api.anthropic.com" convention),
|
|
58
|
+
// so confirmedBaseUrl can legitimately BE "" too — `"" && ...` would
|
|
59
|
+
// silently evaluate false and lock that configuration out of
|
|
60
|
+
// confirmation forever. Only an actually-absent field should fail.
|
|
61
|
+
if (trustedConfirmation && typeof p.confirmedBaseUrl === "string") {
|
|
62
|
+
next.confirmedBaseUrl = p.confirmedBaseUrl;
|
|
63
|
+
next.confirmedAt = p.confirmedAt;
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Untrusted path (public POST /settings): never trust what the client
|
|
68
|
+
// sent, start from the prior stored state, then drop it if baseUrl or
|
|
69
|
+
// the (now-reconciled) apiKey no longer matches what was confirmed.
|
|
70
|
+
if (prior && typeof prior.confirmedBaseUrl === "string" && next.baseUrl === prior.baseUrl && next.apiKey === prior.apiKey) {
|
|
71
|
+
next.confirmedBaseUrl = prior.confirmedBaseUrl;
|
|
72
|
+
next.confirmedAt = prior.confirmedAt;
|
|
73
|
+
} else {
|
|
74
|
+
delete next.confirmedBaseUrl;
|
|
75
|
+
delete next.confirmedAt;
|
|
76
|
+
}
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
8
81
|
function createStorage(userDir) {
|
|
9
82
|
const baseDir = path.join(userDir, "flowpilot");
|
|
10
83
|
const chatsDir = path.join(baseDir, "chats");
|
|
@@ -18,10 +91,15 @@ function createStorage(userDir) {
|
|
|
18
91
|
return {
|
|
19
92
|
id: "default",
|
|
20
93
|
providerName: "LocalAI",
|
|
94
|
+
// "openai-compatible" (default) or "anthropic"
|
|
95
|
+
type: "openai-compatible",
|
|
21
96
|
baseUrl: "http://localhost:8080",
|
|
22
97
|
apiKey: "",
|
|
23
98
|
model: "",
|
|
24
|
-
temperature: 0.2
|
|
99
|
+
temperature: 0.2,
|
|
100
|
+
// Configured context window size for this provider in tokens (0 = unknown).
|
|
101
|
+
// When set, FlowPilot warns when the assembled prompt approaches the limit.
|
|
102
|
+
numCtx: 0
|
|
25
103
|
};
|
|
26
104
|
}
|
|
27
105
|
|
|
@@ -33,7 +111,11 @@ function createStorage(userDir) {
|
|
|
33
111
|
maxContextChars: 12000,
|
|
34
112
|
defaultContextMode: "selected",
|
|
35
113
|
allowConfigContext: false,
|
|
36
|
-
|
|
114
|
+
// When true, provider turns (post-redaction messages, replies, and tool calls)
|
|
115
|
+
// are appended to debug.log
|
|
116
|
+
// (0600 perms). Auth headers/keys are never logged — only the content bytes
|
|
117
|
+
// that the provider actually received. Off by default (diagnostic tool).
|
|
118
|
+
debugLogging: false,
|
|
37
119
|
streamingEnabled: true,
|
|
38
120
|
// First-run welcome/warning shows until the user saves settings once.
|
|
39
121
|
firstRunAcknowledged: false,
|
|
@@ -50,6 +132,9 @@ function createStorage(userDir) {
|
|
|
50
132
|
// than cloud providers; users on that hardware raise this in Behavior
|
|
51
133
|
// settings rather than living with a hardcoded ceiling.
|
|
52
134
|
requestTimeoutMs: 180000,
|
|
135
|
+
// Hard output bound for each tool-capable agent turn. Classic completions
|
|
136
|
+
// remain uncapped so ordinary flow envelopes are never silently clipped.
|
|
137
|
+
agentTurnMaxTokens: 4096,
|
|
53
138
|
// Max build->deploy->test->fix cycles the /build agentic loop will run
|
|
54
139
|
// before stopping with an honest "couldn't fully verify" instead of
|
|
55
140
|
// proposing another fix. Bounds against a non-converging loop burning
|
|
@@ -62,6 +147,14 @@ function createStorage(userDir) {
|
|
|
62
147
|
// and shows a checkpoint question ("Continue with AI review, or stop?")
|
|
63
148
|
// instead of auto-advancing. Default false = original auto-advance behavior.
|
|
64
149
|
loopHoldStep: false,
|
|
150
|
+
// Routes Generate through the Phase 10 step-queue engine (graph read-back
|
|
151
|
+
// verification after import). The legacy path remains available when a
|
|
152
|
+
// user explicitly disables this setting.
|
|
153
|
+
enableStepQueue: true,
|
|
154
|
+
// W7 WRITE-tool loop. Default-off until the server/client Round 1
|
|
155
|
+
// plumbing has passed its integration and mandatory human live-test
|
|
156
|
+
// gates. This is separate from enableStepQueue (Generate checklist UI).
|
|
157
|
+
enableAgentWrite: false,
|
|
65
158
|
// Lets the user silence the recurring secrets/size reminder bar after
|
|
66
159
|
// typing an explicit acknowledgement in settings.
|
|
67
160
|
suppressContextWarnings: false,
|
|
@@ -71,12 +164,15 @@ function createStorage(userDir) {
|
|
|
71
164
|
// dedicated Node-RED credentials field is dropped by the frontend
|
|
72
165
|
// regardless of this setting, via a different, always-on mechanism.
|
|
73
166
|
redactionEnabled: true,
|
|
74
|
-
// Chat-only persona slider, 1-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
167
|
+
// Chat-only persona slider, 1-5 (CLAUDE-032: was 1-10, collapsed to 5
|
|
168
|
+
// discrete levels after live testing found the old scale's
|
|
169
|
+
// interpolated-between-anchors design produced no discernible voice
|
|
170
|
+
// difference across most of its range): 1 is a plain Node-RED engineer,
|
|
171
|
+
// 5 is a comically over-the-top airline captain who happens to be a
|
|
172
|
+
// Node-RED expert. 2 ("subtle co-pilot") is the default.
|
|
77
173
|
// See lib/persona-prompt.js — generated fresh per request, never baked
|
|
78
174
|
// into the persisted systemPrompt below.
|
|
79
|
-
personaIntensity:
|
|
175
|
+
personaIntensity: 2,
|
|
80
176
|
// User-defined intent buttons: array of { label, text }.
|
|
81
177
|
customIntents: [],
|
|
82
178
|
systemPrompt: require("./default-system-prompt")
|
|
@@ -163,6 +259,7 @@ function createStorage(userDir) {
|
|
|
163
259
|
|
|
164
260
|
if (!fs.existsSync(settingsFile)) {
|
|
165
261
|
fs.writeFileSync(settingsFile, JSON.stringify(defaultSettings, null, 2), "utf8");
|
|
262
|
+
try { fs.chmodSync(settingsFile, 0o600); } catch (e) { /* best-effort */ }
|
|
166
263
|
}
|
|
167
264
|
|
|
168
265
|
if (!fs.existsSync(auditFile)) {
|
|
@@ -191,7 +288,13 @@ function createStorage(userDir) {
|
|
|
191
288
|
}
|
|
192
289
|
}
|
|
193
290
|
|
|
194
|
-
|
|
291
|
+
// options.trustConfirmation: pass true ONLY from /flowpilot/test or
|
|
292
|
+
// /flowpilot/probe's own internal saveSettings call, right after a real
|
|
293
|
+
// passing provider check — see reconcileProviderSecrets's own comment.
|
|
294
|
+
// Every other caller (in particular the public POST /settings route)
|
|
295
|
+
// omits this, so confirmedBaseUrl/confirmedAt stay strictly
|
|
296
|
+
// server-computed and can never be set via a settings save.
|
|
297
|
+
function saveSettings(settings, options) {
|
|
195
298
|
init();
|
|
196
299
|
|
|
197
300
|
let current = {};
|
|
@@ -205,16 +308,19 @@ function createStorage(userDir) {
|
|
|
205
308
|
const merged = Object.assign({}, defaultSettings, current, settings || {});
|
|
206
309
|
merged.systemPrompt = fixStaleSystemPrompt(merged.systemPrompt);
|
|
207
310
|
delete merged._error;
|
|
208
|
-
// If the caller sent a providers list, it
|
|
209
|
-
//
|
|
311
|
+
// If the caller sent a providers list, reconcile it against what's
|
|
312
|
+
// actually stored (real apiKey/confirmedBaseUrl never come from the
|
|
313
|
+
// client — see reconcileProviderSecrets above) rather than trusting it
|
|
314
|
+
// outright the way Object.assign would.
|
|
210
315
|
if (settings && Array.isArray(settings.providers)) {
|
|
211
|
-
merged.providers = settings.providers;
|
|
316
|
+
merged.providers = reconcileProviderSecrets(settings.providers, current.providers, !!(options && options.trustConfirmation));
|
|
212
317
|
}
|
|
213
318
|
// Saving settings is an explicit user action; mark first-run complete so
|
|
214
319
|
// the welcome/warning stops showing.
|
|
215
320
|
merged.firstRunAcknowledged = true;
|
|
216
321
|
|
|
217
322
|
fs.writeFileSync(settingsFile, JSON.stringify(merged, null, 2), "utf8");
|
|
323
|
+
try { fs.chmodSync(settingsFile, 0o600); } catch (e) { /* best-effort */ }
|
|
218
324
|
|
|
219
325
|
return merged;
|
|
220
326
|
}
|
|
@@ -240,7 +346,16 @@ function createStorage(userDir) {
|
|
|
240
346
|
|
|
241
347
|
function appendTranscript(conversationId, entry) {
|
|
242
348
|
init();
|
|
243
|
-
|
|
349
|
+
const file = transcriptFile(conversationId);
|
|
350
|
+
const isNewFile = !fs.existsSync(file);
|
|
351
|
+
// Same 0600-on-append pattern as appendDebugLog below — transcripts hold
|
|
352
|
+
// full conversation content, never world-readable even on first write.
|
|
353
|
+
const fd = fs.openSync(file, "a", 0o600);
|
|
354
|
+
fs.writeSync(fd, JSON.stringify(entry) + "\n");
|
|
355
|
+
fs.closeSync(fd);
|
|
356
|
+
if (isNewFile) {
|
|
357
|
+
try { fs.chmodSync(file, 0o600); } catch (e) { /* best-effort */ }
|
|
358
|
+
}
|
|
244
359
|
}
|
|
245
360
|
|
|
246
361
|
// Removes a conversation's transcript file (e.g. user deletes it from the
|
|
@@ -290,6 +405,32 @@ function createStorage(userDir) {
|
|
|
290
405
|
return defaultSettings.systemPrompt;
|
|
291
406
|
}
|
|
292
407
|
|
|
408
|
+
const debugLogFile = path.join(baseDir, "debug.log");
|
|
409
|
+
|
|
410
|
+
// Append one JSON-lines entry to debug.log.
|
|
411
|
+
// Written 0600 — diagnostic data, never world-readable.
|
|
412
|
+
// Auth keys are NOT included (only baseUrl + model from the provider
|
|
413
|
+
// profile, never apiKey). The bytes logged are post-redaction: the same
|
|
414
|
+
// content the provider actually received.
|
|
415
|
+
function appendDebugLog(entry) {
|
|
416
|
+
init();
|
|
417
|
+
const line = JSON.stringify({
|
|
418
|
+
timestamp: new Date().toISOString(),
|
|
419
|
+
...entry
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
// Open with O_APPEND | O_CREAT, mode 0600 so secrets never land
|
|
423
|
+
// in a world-readable file even on first write.
|
|
424
|
+
const fd = fs.openSync(debugLogFile, "a", 0o600);
|
|
425
|
+
fs.writeSync(fd, line + "\n");
|
|
426
|
+
fs.closeSync(fd);
|
|
427
|
+
// Ensure 0600 regardless of umask on subsequent opens.
|
|
428
|
+
fs.chmodSync(debugLogFile, 0o600);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error("[FlowPilot] debug log write failed:", err.message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
293
434
|
init();
|
|
294
435
|
|
|
295
436
|
return {
|
|
@@ -298,11 +439,13 @@ function createStorage(userDir) {
|
|
|
298
439
|
backupsDir,
|
|
299
440
|
settingsFile,
|
|
300
441
|
auditFile,
|
|
442
|
+
debugLogFile,
|
|
301
443
|
getSettings,
|
|
302
444
|
saveSettings,
|
|
303
445
|
getActiveProvider,
|
|
304
446
|
getDefaultSystemPrompt,
|
|
305
447
|
appendAudit,
|
|
448
|
+
appendDebugLog,
|
|
306
449
|
appendTranscript,
|
|
307
450
|
readTranscript,
|
|
308
451
|
deleteTranscript,
|
|
@@ -310,4 +453,9 @@ function createStorage(userDir) {
|
|
|
310
453
|
};
|
|
311
454
|
}
|
|
312
455
|
|
|
313
|
-
|
|
456
|
+
// Static, instance-independent — flowpilot.js's route handlers need the
|
|
457
|
+
// exact same sentinel string that reconcileProviderSecrets checks against
|
|
458
|
+
// above, without needing a storage instance to get it.
|
|
459
|
+
createStorage.API_KEY_UNCHANGED = API_KEY_UNCHANGED;
|
|
460
|
+
|
|
461
|
+
module.exports = createStorage;
|
package/lib/validator.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// W2 — Class A validator / repair layer.
|
|
4
|
+
//
|
|
5
|
+
// Rules that are mechanically checkable or repairable belong here, not in
|
|
6
|
+
// the model's attention budget. For each repair implemented, the
|
|
7
|
+
// corresponding prompt rule is deleted or shortened in the same commit —
|
|
8
|
+
// the win is measured in prompt shrinkage + corpus improvement, not in
|
|
9
|
+
// code added.
|
|
10
|
+
//
|
|
11
|
+
// Server-side mirror of the client-side DIFF_SKIP in apply-review.js.
|
|
12
|
+
// When a field appears here, it is stripped from changes[].set before
|
|
13
|
+
// the diff reaches the client (rule A6). Keep this list in sync with
|
|
14
|
+
// DIFF_SKIP in apply-review.js.
|
|
15
|
+
const DIFF_SKIP_SERVER = {
|
|
16
|
+
// Appearance-tab metadata (all node types)
|
|
17
|
+
info: 1, inputLabels: 1, outputLabels: 1, icon: 1,
|
|
18
|
+
// debug node display flags
|
|
19
|
+
console: 1, tostatus: 1, targetType: 1, statusVal: 1, statusType: 1,
|
|
20
|
+
// function node internal flags
|
|
21
|
+
noerr: 1, initialize: 1, finalize: 1,
|
|
22
|
+
// mqtt in/out retain handling
|
|
23
|
+
rh: 1
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// A1: missing wires on a non-comment node → inject [].
|
|
27
|
+
// A2: comment node with non-empty wires → force empty.
|
|
28
|
+
// A3: x/y/z present on a node → strip (editor handles placement).
|
|
29
|
+
// A4: tab/subflow type → remove from array entirely.
|
|
30
|
+
function repairFlowNodes(nodes, repairs) {
|
|
31
|
+
if (!Array.isArray(nodes)) { return nodes; }
|
|
32
|
+
const out = [];
|
|
33
|
+
nodes.forEach(function (n) {
|
|
34
|
+
if (!n || typeof n !== "object") { return; }
|
|
35
|
+
|
|
36
|
+
// A4: reject tab/subflow — editor types, not importable nodes
|
|
37
|
+
if (n.type === "tab" || n.type === "subflow") {
|
|
38
|
+
repairs.push({ rule: "A4", detail: "removed " + n.type + " node id=" + (n.id || "?") });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fixed = Object.assign({}, n);
|
|
43
|
+
|
|
44
|
+
// A3: strip x/y/z (editor assigns positions on import; if present
|
|
45
|
+
// they cause nodes to pile up at exact coordinates instead of
|
|
46
|
+
// being auto-arranged)
|
|
47
|
+
const stripped = [];
|
|
48
|
+
["x", "y", "z"].forEach(function (k) {
|
|
49
|
+
if (k in fixed) { delete fixed[k]; stripped.push(k); }
|
|
50
|
+
});
|
|
51
|
+
if (stripped.length) {
|
|
52
|
+
repairs.push({ rule: "A3", detail: "stripped " + stripped.join(",") + " from id=" + (n.id || "?") });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (n.type === "comment") {
|
|
56
|
+
// A2: comment nodes are passive annotations — wires must be empty
|
|
57
|
+
if (Array.isArray(n.wires) && n.wires.some(function (p) { return Array.isArray(p) && p.length > 0; })) {
|
|
58
|
+
fixed.wires = [];
|
|
59
|
+
repairs.push({ rule: "A2", detail: "forced wires:[] on comment id=" + (n.id || "?") });
|
|
60
|
+
} else if (!Array.isArray(n.wires)) {
|
|
61
|
+
fixed.wires = [];
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// A1: every non-comment node must have a wires array
|
|
65
|
+
if (!Array.isArray(n.wires)) {
|
|
66
|
+
fixed.wires = [];
|
|
67
|
+
repairs.push({ rule: "A1", detail: "injected wires:[] on id=" + (n.id || "?") + " type=" + (n.type || "?") });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
out.push(fixed);
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A5: http-request node headers in {key, value} shape → transform to
|
|
77
|
+
// {keyType, keyValue, valueType, valueValue}. The flat shape is silently
|
|
78
|
+
// ignored by Node-RED; the editor uses the keyed shape exclusively.
|
|
79
|
+
function repairHttpHeaders(headers) {
|
|
80
|
+
if (!Array.isArray(headers)) { return headers; }
|
|
81
|
+
return headers.map(function (h) {
|
|
82
|
+
if (h && typeof h === "object" &&
|
|
83
|
+
"key" in h && "value" in h &&
|
|
84
|
+
!("keyType" in h)) {
|
|
85
|
+
return {
|
|
86
|
+
keyType: "other", keyValue: String(h.key || ""),
|
|
87
|
+
valueType: "other", valueValue: String(h.value || "")
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return h;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A6: forbidden fields in changes[].set → strip (server-side DIFF_SKIP).
|
|
95
|
+
// A7: group inside set → strip (group membership changes via newGroups only).
|
|
96
|
+
// A8: http-request headers {key,value} shape → transform.
|
|
97
|
+
// A9: same id in changes AND removeNodes → drop from changes.
|
|
98
|
+
//
|
|
99
|
+
// Note: redaction-placeholder stripping (formerly A8 in planning notes)
|
|
100
|
+
// was implemented as W0.2 (stripRedactionPlaceholders in flowpilot.js)
|
|
101
|
+
// and runs after this function — no duplication needed here.
|
|
102
|
+
function repairChanges(changes, removeNodes, repairs) {
|
|
103
|
+
if (!Array.isArray(changes)) { return changes; }
|
|
104
|
+
const removeSet = new Set(Array.isArray(removeNodes) ? removeNodes : []);
|
|
105
|
+
|
|
106
|
+
return changes.filter(function (entry) {
|
|
107
|
+
if (!entry || typeof entry !== "object") { return false; }
|
|
108
|
+
// A9: id appears in both changes and removeNodes — removeNodes wins
|
|
109
|
+
if (entry.id && removeSet.has(entry.id)) {
|
|
110
|
+
repairs.push({ rule: "A9", detail: "dropped id=" + entry.id + " from changes (also in removeNodes)" });
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}).map(function (entry) {
|
|
115
|
+
if (!entry.set || typeof entry.set !== "object") { return entry; }
|
|
116
|
+
const cleanSet = {};
|
|
117
|
+
const droppedA6 = [];
|
|
118
|
+
let droppedGroup = false;
|
|
119
|
+
|
|
120
|
+
Object.keys(entry.set).forEach(function (k) {
|
|
121
|
+
// A6: forbidden internal fields
|
|
122
|
+
if (DIFF_SKIP_SERVER[k]) { droppedA6.push(k); return; }
|
|
123
|
+
// A7: group is informational context, not settable via changes
|
|
124
|
+
if (k === "group") { droppedGroup = true; return; }
|
|
125
|
+
|
|
126
|
+
let v = entry.set[k];
|
|
127
|
+
// A5: http-request node headers on the modify path
|
|
128
|
+
if (k === "headers") { v = repairHttpHeaders(v); }
|
|
129
|
+
|
|
130
|
+
cleanSet[k] = v;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (droppedA6.length) {
|
|
134
|
+
repairs.push({ rule: "A6", detail: "stripped " + droppedA6.join(",") + " from set on id=" + entry.id });
|
|
135
|
+
}
|
|
136
|
+
if (droppedGroup) {
|
|
137
|
+
repairs.push({ rule: "A7", detail: "stripped group from set on id=" + entry.id });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Object.assign({}, entry, { set: cleanSet });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A10: newWires entries using fromId/toId → rename to from/to. The Modify
|
|
145
|
+
// finalizer consumes only from/to, so leaving the common model-produced
|
|
146
|
+
// aliases in place would make both endpoint references appear missing.
|
|
147
|
+
function repairNewWires(newWires, repairs) {
|
|
148
|
+
if (!Array.isArray(newWires)) { return newWires; }
|
|
149
|
+
return newWires.map(function (wire, index) {
|
|
150
|
+
if (!wire || typeof wire !== "object") { return wire; }
|
|
151
|
+
const fixed = Object.assign({}, wire);
|
|
152
|
+
const renamed = [];
|
|
153
|
+
if (!("from" in fixed) && "fromId" in fixed) {
|
|
154
|
+
fixed.from = fixed.fromId;
|
|
155
|
+
delete fixed.fromId;
|
|
156
|
+
renamed.push("fromId->from");
|
|
157
|
+
}
|
|
158
|
+
if (!("to" in fixed) && "toId" in fixed) {
|
|
159
|
+
fixed.to = fixed.toId;
|
|
160
|
+
delete fixed.toId;
|
|
161
|
+
renamed.push("toId->to");
|
|
162
|
+
}
|
|
163
|
+
if (renamed.length) {
|
|
164
|
+
repairs.push({ rule: "A10", detail: "renamed " + renamed.join(",") + " on newWires[" + index + "]" });
|
|
165
|
+
}
|
|
166
|
+
return fixed;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Switch rules/wires mismatch — detect, do not repair. The rule
|
|
171
|
+
// alignment is a semantic contract (rules[i] corresponds to output port
|
|
172
|
+
// i); auto-repairing the wrong choice would silently misroute messages.
|
|
173
|
+
// Instead, surface as a targeted retry so the model sees exactly what's
|
|
174
|
+
// wrong and can fix it in one shot.
|
|
175
|
+
// Returns an array of { id, rulesLen, wiresLen } — empty when all clean.
|
|
176
|
+
function detectSwitchMismatches(changes) {
|
|
177
|
+
const mismatches = [];
|
|
178
|
+
(Array.isArray(changes) ? changes : []).forEach(function (entry) {
|
|
179
|
+
if (!entry || !entry.set) { return; }
|
|
180
|
+
const rules = entry.set.rules;
|
|
181
|
+
const wires = entry.set.wires;
|
|
182
|
+
if (Array.isArray(rules) && Array.isArray(wires) && rules.length !== wires.length) {
|
|
183
|
+
mismatches.push({ id: entry.id, rulesLen: rules.length, wiresLen: wires.length });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return mismatches;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Top-level entry point. Takes a parsed envelope object and returns:
|
|
190
|
+
// { envelope, repairs, switchMismatches }
|
|
191
|
+
//
|
|
192
|
+
// - envelope: repaired copy (original not mutated)
|
|
193
|
+
// - repairs: array of { rule, detail } for each repair applied
|
|
194
|
+
// - switchMismatches: array of { id, rulesLen, wiresLen } for switch
|
|
195
|
+
// nodes whose rules/wires arrays have different lengths (targeted retry
|
|
196
|
+
// candidates — callers surface these as skippedNotes or 422 bounces)
|
|
197
|
+
function repairEnvelope(parsed) {
|
|
198
|
+
if (!parsed || typeof parsed !== "object") {
|
|
199
|
+
return { envelope: parsed, repairs: [], switchMismatches: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const repairs = [];
|
|
203
|
+
const out = Object.assign({}, parsed);
|
|
204
|
+
|
|
205
|
+
// Flow array (Generate/Build): A1, A2, A3, A4
|
|
206
|
+
if (Array.isArray(out.flow)) {
|
|
207
|
+
out.flow = repairFlowNodes(out.flow, repairs);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// newNodes (Modify): A1, A2, A3, A4
|
|
211
|
+
if (Array.isArray(out.newNodes)) {
|
|
212
|
+
out.newNodes = repairFlowNodes(out.newNodes, repairs);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// changes (Modify): A5, A6, A7, A9
|
|
216
|
+
if (Array.isArray(out.changes)) {
|
|
217
|
+
out.changes = repairChanges(out.changes, out.removeNodes, repairs);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// newWires (Modify): A10
|
|
221
|
+
if (Array.isArray(out.newWires)) {
|
|
222
|
+
out.newWires = repairNewWires(out.newWires, repairs);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Switch alignment check (detect only, not repair)
|
|
226
|
+
const switchMismatches = detectSwitchMismatches(out.changes);
|
|
227
|
+
|
|
228
|
+
return { envelope: out, repairs: repairs, switchMismatches: switchMismatches };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
repairEnvelope: repairEnvelope,
|
|
233
|
+
repairFlowNodes: repairFlowNodes,
|
|
234
|
+
repairChanges: repairChanges,
|
|
235
|
+
repairNewWires: repairNewWires,
|
|
236
|
+
detectSwitchMismatches: detectSwitchMismatches,
|
|
237
|
+
DIFF_SKIP_SERVER: DIFF_SKIP_SERVER
|
|
238
|
+
};
|