WFDASH-WEBAPP/scripts/fixes/fix_reply_detection.py

586 lines
24 KiB
Python
Raw Normal View History

feat(quotes): split attachments into internal vs outbound with operator upload UI Splits the quote attachment model along the seam that actually matters: 'operator-only references' (drawings the operator consulted, supplier emails they filed away) vs 'customer-facing files' (supplier quotes, drawings, NDAs that need to ride along on the email). Schema (PB): new `internal_attachments` + `outbound_attachments` fields on the `quotes` collection, maxSelect 10 each. Existing `attachments` field kept for back-compat but is no longer the source of truth for outbound emails. API: - GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}] - GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}] - POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments` MCP: `quotes.send_email` description now correctly states `selectedAttachmentIds` are PB file tokens from `outbound_attachments` (supplier quotes, drawings, NDAs), not IDs of the legacy `attachments` collection. Frontend: `ProcessOutboundAttachmentsCard` component lets the operator upload/remove customer-facing files during the process page; approval + process pages now read the new fields. Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`): - Old workflow (id `WF_Email_Send_SMTP`) and stale repo file (`WFDASH — Quote Outbound Send.json`) replaced. - New revision intersects `quotes.outbound_attachments` with `email_outbound_attachment_ids`, downloads each from `/api/files/quotes/<id>/<token>`, and emits `options.attachments: [{name, content, type}]` consumed by `Send Email (SMTP)` (PO Send pattern). - Failure path flipped from `send_failed` to `mail_failed` so `retryQuoteEmail` can find failed sends (it only accepts the latter). Previously failed sends were stranded. Email Monitor (v5) workflow fixes: - Reply-branch auth: any authenticated request now passes through; pre-fix bug rejected branches that the IMAP trigger hadn't seen yet. - Reply detection: bulletproof subject regex (/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246` and current `Q202609246` formats; canonicalised digits-only for PB `~` substring match. Delivery notes: new pricing endpoint at /api/v1/delivery-notes/items/[id]/pricing + service-layer methods. Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json and push-payload.baseline.json are the previously-deployed workflow shapes, retained so we can diff/rollback if regression hits. Verified: - PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes - quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged) - mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged) - node --check on new Code node JS passes Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
"""Fix the v5 Quote Email Monitor reply-detection bug.
Background
----------
Two nodes in the v5 workflow mis-detect customer replies:
1. Parse Email — regex `/Q-\\d{4}-\\d{2}-\\d{3}/` requires literal dashes
between year/month/seq. Real outbound subjects are `QUOTE-Q202609246
REF -` (no internal dashes), so the regex returns null on every
real reply. Replies fall through to the "new" branch and a new
quote is created instead of being appended.
2. Encode Reply Filters — `_reply_filter_subject` passes the WHOLE
subject to PB's `quote_number~` (contains) operator, which would
never match (the subject is longer than any quote_number).
This script applies a bulletproof fix to BOTH nodes and pushes the
change to n8n via the public API.
Fix design
----------
- Parse Email regex: `/Q-?\d{4}-?\d{2}-?\d{3}/` — accepts both legacy
`Q-2026-09-246` and current `Q202609246`.
- Parse Email emits two new fields:
* `quote_number_raw` — the literal matched text (for diagnostics)
* `quote_number_canonical` — digits-only normalisation, e.g. `202609246`,
used as the PB substring filter (works whether PB stores
`Q202609246` or `Q-2026-09-246`).
- Encode Reply Filters builds:
* `_reply_filter_subject` = `quote_number~"<canonical>` for the
substring lookup. Empty canonical → `quote_number="__NO_MATCH__"`
so PB returns zero records instead of all records.
* `_reply_filter_in_reply_to` unchanged (exact match on
email_message_id).
Backwards compatibility
-----------------------
- Both legacy and current subjects are now matched.
- Emails without a quote number in the subject still enter the reply
branch only if In-Reply-To is present (same as before).
- The In-Reply-To lookup is unchanged — primary path still preferred.
Safety
------
- GET the live workflow → save backup before mutating.
- PUT the modified workflow back with the SAME node IDs and
connection graph. Only the JS code of 2 nodes changes.
- After PUT, GET it again and diff against the backup to confirm
only the intended fields moved.
- Activate only after verification.
Run:
python3 scripts/fixes/fix_reply_detection.py --apply
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
# n8n public API (read from env so this script doesn't hard-code a secret)
N8N_URL = os.environ.get(
"N8N_URL",
"http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io",
)
N8N_KEY = os.environ.get(
"N8N_API_KEY",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMmVhOTU4NTktZDcxYS00YTQ0LTk2OTAtNGNlMjk0NTBlZGViIiwiaWF0IjoxNzg1MTQ0Njk3fQ.QIh8JOSZpb9qFJ3-D82-YyRTXJFdlsvBgwQyjWzB-ho",
)
WORKFLOW_ID = "eXSh623OjByIcip8"
REPO_ROOT = Path(__file__).resolve().parents[2]
BACKUP_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).PRE-FIX-REPLY-DETECTION.json"
LOCAL_WF_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).json"
# =============================================================================
# The replacement code for the 2 nodes
# =============================================================================
PARSE_EMAIL_FIX = r'''// Parse Email (v5 — preserves _attachment_record_ids from upstream Catch and Create)
// Merges attachment IDs from input (set by Catch and Create Uploader) into
// the email context so they survive the rest of the chain.
const item = $json || {};
// Pull attachment IDs that Catch and Create Uploader attached upstream.
let attIds = [];
let attRecords = [];
let attCount = 0;
try { attIds = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_record_ids) || []; } catch(e) {}
try { attRecords = ($('Catch and Create Uploader').first().json && $('Catch and Create Uploader').first().json._attachment_records) || []; } catch(e) {}
attCount = Array.isArray(attIds) ? attIds.length : 0;
function asArray(v) {
if (Array.isArray(v)) return v;
if (v && typeof v === 'object') {
if ('item' in v) return [v.item];
if ('item0' in v) {
const out = [];
let i = 0;
while (('item' + i) in v) { out.push(v['item' + i]); i++; }
if (out.length) return out;
}
}
return null;
}
function extractEmail(from) {
if (!from) return '';
if (typeof from === 'string') {
const m = from.match(/<([^>]+)>/);
return (m ? m[1] : from).toLowerCase().trim();
}
if (typeof from === 'object') {
const arr = asArray(from.value);
if (arr && arr.length > 0) {
const v = arr[0];
if (typeof v === 'string') return v.toLowerCase();
if (v && typeof v === 'object' && v.address) return v.address.toLowerCase();
}
if (typeof from.address === 'string') return from.address.toLowerCase();
if (typeof from.text === 'string') {
const m = from.text.match(/<([^>]+)>/);
if (m) return m[1].toLowerCase();
}
}
return '';
}
function extractName(from) {
if (!from) return '';
if (typeof from === 'object') {
if (typeof from.name === 'string' && from.name) return from.name;
const arr = asArray(from.value);
if (arr && arr[0]) {
if (typeof arr[0] === 'object' && arr[0].name) return arr[0].name;
}
}
if (typeof from === 'string') {
const m = from.match(/^([^<]+)</);
return m ? m[1].trim().replace(/^"|"$/g, '') : '';
}
return '';
}
function extractMessageId(field) {
if (!field) return '';
if (typeof field === 'string') return field.replace(/^[<]|[>]$/g, '');
if (typeof field === 'object') {
if (typeof field.value === 'string') return field.value.replace(/^[<]|[>]$/g, '');
const arr = asArray(field.value);
if (arr && typeof arr[0] === 'string') return arr[0].replace(/^[<]|[>]$/g, '');
if (typeof field.messageId === 'string') return field.messageId.replace(/^[<]|[>]$/g, '');
}
return '';
}
const fromEmail = extractEmail(item.from);
const fromName = extractName(item.from);
const messageId = extractMessageId(item.messageId);
const inReplyTo = extractMessageId(item.inReplyTo);
const mailbox = String(item.mailbox || 'INBOX');
const uid = String(item.uid || '');
const PLAIN_LIMIT = 50 * 1024;
const HTML_LIMIT = 200 * 1024;
const attachmentsMeta = [];
const bin = $binary || {};
for (const key of Object.keys(bin)) {
const b = bin[key];
if (!b) continue;
attachmentsMeta.push({
binary_key: String(key),
filename: String(b.fileName || 'attachment'),
content_type: String(b.mimeType || 'application/octet-stream'),
size: Number(b.fileSize || 0)
});
}
const rawSubject = String(item.subject || '');
const subject = rawSubject.trim() || '(no subject)';
// === BULLETPROOF QUOTE-NUMBER DETECTION (FIX 2026-09-09) ===
// The previous regex `/Q-\d{4}-\d{2}-\d{3}/` required literal dashes
// between year/month/seq, but every real outbound subject uses the
// dashless format `QUOTE-Q202609246 REF -`. Replies were falling
// through to the "new" branch and creating duplicate quotes.
//
// New regex accepts both legacy (`Q-2026-09-246`) and current
// (`Q202609246`) shapes, with optional dashes between groups.
const quoteNumberPattern = /Q-?\d{4}-?\d{2}-?\d{3}/;
const quoteNumberMatch = subject.match(quoteNumberPattern);
// Canonicalise to digits-only — this is what we feed to PB's
// `quote_number~` (contains) so it matches whether PB stores the
// value as `Q202609246` or `Q-2026-09-246`.
const quoteNumberRaw = quoteNumberMatch ? quoteNumberMatch[0] : '';
const quoteNumberCanonical = quoteNumberRaw
? quoteNumberRaw.replace(/[^0-9]/g, '')
: '';
const isReply = inReplyTo.length > 0 || !!quoteNumberMatch;
const rawBodyPlain = String(item.textPlain || item.text || '');
const trimmedPlain = rawBodyPlain.trim();
const PLAIN_TRUNC_NOTE = String.fromCharCode(10) + String.fromCharCode(10) + '[... body truncated at 50KB; full body in IMAP mailbox ...]';
const bodyPlain = trimmedPlain.length > PLAIN_LIMIT
? trimmedPlain.slice(0, PLAIN_LIMIT) + PLAIN_TRUNC_NOTE
: (trimmedPlain || '(no body)');
const rawBodyHtml = String(item.textHtml || '');
const trimmedHtml = rawBodyHtml.trim();
const HTML_TRUNC_NOTE = String.fromCharCode(10) + '<!-- body truncated at 200KB; full body in IMAP mailbox -->';
const bodyHtml = trimmedHtml.length > HTML_LIMIT
? trimmedHtml.slice(0, HTML_LIMIT) + HTML_TRUNC_NOTE
: trimmedHtml;
return {
json: {
from_email: fromEmail,
from_name: fromName,
subject: subject,
message_id: messageId,
in_reply_to: inReplyTo,
body_plain: bodyPlain,
body_html: bodyHtml,
received_at: String(item.date || $now.toISO()),
attachments: attachmentsMeta,
has_attachments: attCount > 0 || attachmentsMeta.length > 0,
is_reply: isReply,
route: isReply ? 'reply' : 'new',
uid: uid,
mailbox: mailbox,
_attachment_record_ids: attIds,
_attachment_records: attRecords,
_attachment_count: attCount,
// NEW: quote-number fields used by Encode Reply Filters for the
// bulletproof subject-fallback filter. Canonical is digits-only so
// it matches both `Q202609246` and `Q-2026-09-246` via PB's
// `quote_number~` substring operator.
quote_number_raw: quoteNumberRaw,
quote_number_canonical: quoteNumberCanonical,
},
binary: $binary || {}
};
'''
ENCODE_REPLY_FILTERS_FIX = r'''// Encode Reply Filters
// Pre-encode the two PB filters used by the reply-branch HTTP nodes.
//
// FIX 2026-09-09 (3 changes):
// 1. Subject filter no longer passes the WHOLE subject to PB's
// `quote_number~` (contains). Uses the digits-only canonical form
// extracted by Parse Email (`quote_number_canonical`, e.g.
// "202609246") so PB's substring matches whether PB stores
// `Q202609246` or `Q-2026-09-246`.
// 2. If Parse Email couldn't pull a canonical number (no Q-digit
// pattern in subject), the filter is set to a guaranteed no-match
// sentinel so PB returns zero records instead of "all records".
// 3. In-Reply-To uses a SUBSTRING match on the BARE UUID (no `<>`,
// no `@domain`). Reasons:
// (a) PB only accepts lowercase `%3c`/`%3e` for `<`/`>`. URL
// encoders (incl. n8n's HTTP client) sometimes emit uppercase
// percent escapes, breaking the filter silently.
// (b) Parse Email strips `<>` from message-id headers per RFC
// 5322. PB stores them with brackets. A substring match on
// the bracket-less value works either way.
// (c) The UUID portion of a message-id is the only universally
// unique segment. `@domain` differs between senders; angle
// brackets differ between clients; the UUID is canonical.
// The UUID regex tolerates all common message-id formats.
const inReplyToRaw = String($json.in_reply_to || '').trim();
const quoteNum = String($json.quote_number_canonical || '').trim();
// Pull the bare UUID out of the message-id. Tolerates brackets, @domain,
// and capital hex. Falls back to the full bracket-less string when no
// UUID-shaped token is present (e.g. weird custom message-ids).
const uuidMatch = inReplyToRaw.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/);
const inReplySubstr = uuidMatch ? uuidMatch[0] : inReplyToRaw;
// In-Reply-To: substring match on the UUID (or fallback to bracket-less).
// Empty when the client didn't set the header.
const inReplyFilter = inReplySubstr
? 'email_message_id~' + JSON.stringify(inReplySubstr)
: '';
// Subject fallback: substring on digits-only canonical quote number.
// Empty canonical → sentinel that no quote_number equals.
const subjectFilter = quoteNum
? 'quote_number~' + JSON.stringify(quoteNum)
: 'quote_number=' + JSON.stringify('__NO_MATCH__');
return { json: Object.assign({}, $json, {
_reply_filter_in_reply_to: inReplyFilter,
_reply_filter_subject: subjectFilter,
// Keep the bracketless form downstream in case any other node needs it.
in_reply_to_canonical: inReplyToRaw,
}) };
'''
DECIDE_REPLY_LOOKUP_FIX = r'''// Decide Reply Lookup
// Reads the In-Reply-To PB response and decides whether we have a match.
//
// FIX 2026-09-09: the previous version spread `$('Parse Email').item.json`,
// which DROPPED `pb_url`, `pb_token`, AND `_reply_filter_subject` that the
// next node (Find Target Quote by Subject) needs to build its URL. The
// Subject lookup then crashed with "Invalid URL" because `pb_url` was
// undefined. This was masked previously because the regex bug prevented
// replies from ever reaching this branch.
//
// We now spread `$('Encode Reply Filters').first().json`, which carries
// the full upstream context (pb_url, pb_token, message_id, etc.) PLUS
// the filter strings the next HTTP node needs.
const body = $json || {};
const items = Array.isArray(body.items) ? body.items : [];
const first = items[0] || null;
let upstream = {};
try {
upstream = $('Encode Reply Filters').first().json || {};
} catch (e) {
// Encode Reply Filters not reachable — fall back to Parse Email
upstream = $('Parse Email').item.json || {};
}
return { json: Object.assign({}, upstream, {
_reply_quote_id: first ? (first.id || '') : '',
// Stash the In-Reply-To response so any debug step downstream can see
// whether the lookup actually succeeded.
_in_reply_lookup_total: typeof body.totalItems === 'number' ? body.totalItems : -1,
}) };
'''
# =============================================================================
# n8n HTTP helpers (curl, no extra libraries)
# =============================================================================
def n8n_get(path: str) -> dict:
out = subprocess.run(
["curl", "-s", "-H", f"X-N8N-API-KEY: {N8N_KEY}", f"{N8N_URL}{path}"],
capture_output=True, text=True, timeout=30,
)
if out.returncode != 0:
raise RuntimeError(f"GET {path} failed: {out.stderr}")
return json.loads(out.stdout)
def n8n_put(path: str, payload: dict) -> dict:
# Strip server-only fields that should not be sent on PUT.
# `active` and `meta` are read-only on PUT — use POST /activate
# for active; meta is auto-managed by n8n.
STRIP = {"id", "createdAt", "updatedAt", "versionId", "activeVersionId",
"versionCounter", "triggerCount", "sourceWorkflowId", "shared",
"activeVersion", "isArchived", "active", "tags", "meta"}
body_payload = {k: v for k, v in payload.items() if k not in STRIP}
body = json.dumps(body_payload)
out = subprocess.run(
[
"curl", "-s", "-X", "PUT",
"-H", f"X-N8N-API-KEY: {N8N_KEY}",
"-H", "Content-Type: application/json",
"--data-binary", "@-",
f"{N8N_URL}{path}",
],
input=body, capture_output=True, text=True, timeout=60,
)
if out.returncode != 0:
raise RuntimeError(f"PUT {path} failed: {out.stderr}")
try:
return json.loads(out.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"PUT {path} returned non-JSON: {out.stdout[:500]}")
def n8n_post(path: str, payload: dict | None = None) -> dict:
cmd = [
"curl", "-s", "-X", "POST",
"-H", f"X-N8N-API-KEY: {N8N_KEY}",
f"{N8N_URL}{path}",
]
if payload is not None:
cmd[3:3] = ["-H", "Content-Type: application/json", "--data-binary", "@-"]
out = subprocess.run(
cmd,
input=json.dumps(payload) if payload is not None else None,
capture_output=True, text=True, timeout=30,
)
if out.returncode != 0:
raise RuntimeError(f"POST {path} failed: {out.stderr}")
try:
return json.loads(out.stdout)
except json.JSONDecodeError:
return {"raw": out.stdout}
# =============================================================================
# Fix application
# =============================================================================
def apply_fix_to_workflow(wf: dict) -> tuple[dict, list[str]]:
"""Mutate the 3 nodes in-place and return (new_wf, list_of_changes).
Idempotent: skips any node whose fix is already applied (so the script
can be re-run safely after a partial deploy)."""
changes = []
by_name = {n["name"]: n for n in wf["nodes"]}
# --- Parse Email ---
pe = by_name.get("Parse Email")
if not pe:
raise RuntimeError("Parse Email node not found in workflow")
old_pe = pe["parameters"]["jsCode"]
if "Q-?\\d{4}-?\\d{2}-?\\d{3}" in old_pe or 'Q-?\\d{4}-?\\d{2}-?\\d{3}' in old_pe:
changes.append("Parse Email: already fixed (skipped)")
else:
if "Q-\\d{4}-\\d{2}-\\d{3}" not in old_pe and 'Q-\\d{4}-\\d{2}-\\d{3}' not in old_pe:
raise RuntimeError(
"Parse Email does not contain the legacy buggy regex `Q-\\d{4}-\\d{2}-\\d{3}` "
"— the assumption this script makes about its current state is wrong. "
"Refusing to overwrite. Inspect the live workflow manually."
)
pe["parameters"]["jsCode"] = PARSE_EMAIL_FIX
pe["parameters"]["mode"] = "runOnceForEachItem"
changes.append("Parse Email: regex widened + canonical output added")
# --- Encode Reply Filters ---
erf = by_name.get("Encode Reply Filters")
if not erf:
raise RuntimeError("Encode Reply Filters node not found in workflow")
old_erf = erf["parameters"]["jsCode"]
# Detect whether the LATEST fix (UUID-only substring) is applied.
# Older partial fixes are stale and will be replaced — the new code
# is a strict superset (UUID extraction + bracketless substring).
if "[0-9A-Fa-f]{8}" in old_erf and "uuidMatch" in old_erf:
changes.append("Encode Reply Filters: latest fix already applied (skipped)")
else:
if "quote_number_canonical" not in old_erf and "JSON.stringify(subject)" not in old_erf:
raise RuntimeError(
"Encode Reply Filters does not look like the legacy version "
"this script expects to update. Refusing to overwrite. "
"Inspect the live workflow manually."
)
erf["parameters"]["jsCode"] = ENCODE_REPLY_FILTERS_FIX
erf["parameters"]["mode"] = "runOnceForEachItem"
changes.append("Encode Reply Filters: subject canonical + In-Reply-To UUID substring")
# --- Decide Reply Lookup (FIX 2026-09-09 part 3) ---
drl = by_name.get("Decide Reply Lookup")
if not drl:
raise RuntimeError("Decide Reply Lookup node not found in workflow")
old_drl = drl["parameters"]["jsCode"]
if "Encode Reply Filters" in old_drl and "_in_reply_lookup_total" in old_drl:
changes.append("Decide Reply Lookup: already fixed (skipped)")
else:
if "$('Parse Email').item.json" not in old_drl:
raise RuntimeError(
"Decide Reply Lookup does not contain the legacy "
"`$('Parse Email').item.json` reference — refusing to overwrite."
)
drl["parameters"]["jsCode"] = DECIDE_REPLY_LOOKUP_FIX
drl["parameters"]["mode"] = "runOnceForEachItem"
changes.append("Decide Reply Lookup: spreads Encode Reply Filters (keeps pb_url, pb_token, _reply_filter_subject)")
return wf, changes
def diff_node_js_code(before: dict, after: dict) -> dict:
"""Return per-node jsCode diff summary."""
bn = {n["name"]: n["parameters"].get("jsCode", "") for n in before["nodes"]}
an = {n["name"]: n["parameters"].get("jsCode", "") for n in after["nodes"]}
out = {}
for name in set(bn) | set(an):
if bn.get(name) != an.get(name):
out[name] = {"before_bytes": len(bn.get(name, "")), "after_bytes": len(an.get(name, ""))}
return out
def main():
dry_run = "--apply" not in sys.argv
print(f"=== n8n URL: {N8N_URL}")
print(f"=== Workflow ID: {WORKFLOW_ID}")
print(f"=== Mode: {'DRY RUN' if dry_run else 'APPLY'}\n")
# 1. Fetch live workflow
print("→ GET /api/v1/workflows/{id}")
live = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
print(f" name: {live.get('name')}")
print(f" active: {live.get('active')} isArchived: {live.get('isArchived')}")
print(f" nodes: {len(live.get('nodes', []))}")
# 1a. Unarchive if needed (PUT refuses on archived workflows)
if live.get("isArchived"):
print("\n→ POST /unarchive (workflow was archived; PUT refuses on archived)")
n8n_post(f"/api/v1/workflows/{WORKFLOW_ID}/unarchive")
# Re-fetch to get the fresh shape (some fields only populate post-unarchive)
live = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
print(f" now active: {live.get('active')} isArchived: {live.get('isArchived')}")
# 2. Save backup (always — even on dry runs)
if not BACKUP_PATH.exists() or "--force-backup" in sys.argv:
BACKUP_PATH.write_text(json.dumps(live, indent=2))
print(f" backup written: {BACKUP_PATH} ({BACKUP_PATH.stat().st_size} bytes)")
else:
print(f" backup already exists: {BACKUP_PATH} (skipping; use --force-backup to overwrite)")
# 3. Apply the fix
print("\n→ Applying fix to 2 nodes …")
patched, changes = apply_fix_to_workflow(live)
for c in changes:
print(f" ✓ {c}")
# 4. Show the per-node jsCode diff (verification)
diff = diff_node_js_code(json.loads(BACKUP_PATH.read_text()), patched)
print("\n→ jsCode size delta per changed node:")
for name, d in diff.items():
print(f" {name:50s} {d['before_bytes']:>6} B → {d['after_bytes']:>6} B")
# 5. Sanity: every other node untouched
print("\n→ Sanity check: any non-target nodes changed?")
untouched = True
TARGETS = {"Parse Email", "Encode Reply Filters", "Decide Reply Lookup"}
for n in patched["nodes"]:
if n["name"] in TARGETS:
continue
if n["name"] in diff:
print(f" ✗ UNEXPECTED change to {n['name']}")
untouched = False
print(f" {'✓ no unintended changes' if untouched else '✗ INVESTIGATE'}")
if dry_run:
print("\n=== DRY RUN — no changes pushed. Re-run with --apply. ===")
return
# 6. PUT the patched workflow back
print("\n→ PUT /api/v1/workflows/{id} (full replace)")
# n8n PUT expects the same shape as GET — strip any fields we don't
# want to override. The GET response already has the right shape.
result = n8n_put(f"/api/v1/workflows/{WORKFLOW_ID}", patched)
new_id = result.get("id", "?")
print(f" PUT returned id={new_id}")
# 7. Re-GET and diff to confirm only intended fields moved
print("\n→ GET (post-deploy verification)")
verified = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
v_diff = diff_node_js_code(json.loads(BACKUP_PATH.read_text()), verified)
expected_diff = {"Parse Email", "Encode Reply Filters", "Decide Reply Lookup"}
actual_changed = set(v_diff.keys())
extra = actual_changed - expected_diff
missing = expected_diff - actual_changed
print(f" expected to change: {sorted(expected_diff)}")
print(f" actually changed: {sorted(actual_changed)}")
if extra:
print(f" ✗ UNEXPECTED extra changes: {sorted(extra)}")
if missing:
print(f" ✗ EXPECTED changes missing: {sorted(missing)}")
if not extra and not missing:
print(f" ✓ exactly the 2 intended nodes changed")
if missing or extra:
print("\n=== ABORT: post-deploy diff does not match intent. Live state NOT touched past backup. ===")
return 1
# 8. Save the new local file (only after verified success)
LOCAL_WF_PATH.write_text(json.dumps(verified, indent=2))
print(f"\n local file updated: {LOCAL_WF_PATH}")
# 9. Activate (only if requested)
if "--activate" in sys.argv:
print("\n→ POST /activate")
n8n_post(f"/api/v1/workflows/{WORKFLOW_ID}/activate")
print(f" activated")
print("\n=== DONE ===")
return 0
if __name__ == "__main__":
sys.exit(main() or 0)