834 lines
37 KiB
Python
834 lines
37 KiB
Python
|
|
"""Fix the v5 Quote Email Monitor reply-branch auth bug.
|
||
|
|
|
||
|
|
Background
|
||
|
|
----------
|
||
|
|
The reply branch HTTP nodes (`Find Target Quote (by In-Reply-To)`,
|
||
|
|
`Find Target Quote (by Subject)`, `Insert quote_replies`,
|
||
|
|
`Flip has_new_reply`) all use `Authorization: Bearer {{ $json.pb_token }}`
|
||
|
|
but `pb_token` is never set on the reply path.
|
||
|
|
|
||
|
|
The CREATE branch authenticates inline inside `Catch and Create Uploader`
|
||
|
|
(via `this.helpers.httpRequest`) and sets `pb_token` on every item it
|
||
|
|
emits, which downstream CREATE-branch nodes inherit. The reply branch
|
||
|
|
never goes through `Catch and Create Uploader`, so its items arrive at
|
||
|
|
the HTTP nodes without a token.
|
||
|
|
|
||
|
|
Both `PocketBase Admin Auth` (HTTP) and `Parse PB Auth` (Code) are
|
||
|
|
`disabled: true` in the live workflow — leftover scaffolding from a
|
||
|
|
pre-v5 design where auth was a separate pre-step.
|
||
|
|
|
||
|
|
Result: GET lookups succeed because PB's viewer rules allow listing the
|
||
|
|
`quotes` collection to anonymous callers, but writes (insert
|
||
|
|
`quote_replies`, PATCH `has_new_reply`) would 401 in production.
|
||
|
|
|
||
|
|
Fix design
|
||
|
|
----------
|
||
|
|
1. Add a new Code node `PB Auth Inline` between `PocketBase Connection`
|
||
|
|
and `Is Reply?` that does inline auth via `helpers.httpRequest`,
|
||
|
|
matching the pattern already used in `Catch and Create Uploader`.
|
||
|
|
Both branches inherit `pb_token` on the shared context.
|
||
|
|
2. Reconnect `PocketBase Connection → PB Auth Inline → Is Reply?`
|
||
|
|
(replaces the direct `PocketBase Connection → Is Reply?` link).
|
||
|
|
3. Tighten `Encode Reply Filters`: the empty-`in_reply_to` fallback is
|
||
|
|
currently `''` (empty filter), which PB interprets as "no filter"
|
||
|
|
and returns all records (with `perPage=1`, just the first). Replace
|
||
|
|
with a guaranteed no-match sentinel `id="__NO_MATCH__"` so PB
|
||
|
|
returns zero records — same shape as the subject fallback.
|
||
|
|
|
||
|
|
Backwards compatibility
|
||
|
|
-----------------------
|
||
|
|
- `Catch and Create Uploader` still does its own inline auth for the
|
||
|
|
CREATE branch (its token is newer and overwrites the upstream one
|
||
|
|
before downstream CREATE-branch HTTP nodes read it). No conflict.
|
||
|
|
- Both branches now have a valid `pb_token`; existing reply-branch
|
||
|
|
HTTP nodes continue to work without modification.
|
||
|
|
- The CREATE branch is unaffected: nothing downstream of `Is Reply?`
|
||
|
|
in the CREATE branch relies on `pb_token` being absent.
|
||
|
|
|
||
|
|
Safety
|
||
|
|
------
|
||
|
|
- GET the live workflow → save a fresh backup before mutating.
|
||
|
|
- Add exactly one node and update two connections; leave every other
|
||
|
|
node and connection untouched.
|
||
|
|
- After PUT, GET it again and diff against the backup to confirm only
|
||
|
|
the intended changes moved.
|
||
|
|
- Auth failures throw — visible in n8n UI — instead of producing an
|
||
|
|
empty token and a confusing 401 cascade downstream.
|
||
|
|
|
||
|
|
Run:
|
||
|
|
python3 scripts/fixes/fix_reply_branch_auth.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-BRANCH-AUTH.json"
|
||
|
|
LOCAL_WF_PATH = REPO_ROOT / "n8n_workfows" / "WFDASH — Quote Email Monitor (v5 catch-and-create).json"
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# The replacement code for Encode Reply Filters (tightened empty fallback)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
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.
|
||
|
|
//
|
||
|
|
// FIX 2026-09-09 (4th change — auth fix): the empty `in_reply_to`
|
||
|
|
// fallback used to be `''` (empty filter string). PB interprets an
|
||
|
|
// empty filter as "no filter" and would return the first record in
|
||
|
|
// the collection with `perPage=1`, which is the opposite of the
|
||
|
|
// intended "no match" behaviour. Now uses the same no-match sentinel
|
||
|
|
// pattern as the subject fallback: `id="__NO_MATCH__"`, which is
|
||
|
|
// guaranteed not to match any record (record ids are 15-char PB base62
|
||
|
|
// strings).
|
||
|
|
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).
|
||
|
|
// Missing/empty → sentinel that no record's id equals.
|
||
|
|
const inReplyFilter = inReplySubstr
|
||
|
|
? 'email_message_id~' + JSON.stringify(inReplySubstr)
|
||
|
|
: 'id=' + JSON.stringify('__NO_MATCH__');
|
||
|
|
|
||
|
|
// 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,
|
||
|
|
}) };
|
||
|
|
'''
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# The new PB Auth Inline node (sits between PocketBase Connection and Is Reply?)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
PB_AUTH_INLINE_NODE = {
|
||
|
|
"parameters": {
|
||
|
|
"mode": "runOnceForEachItem",
|
||
|
|
"jsCode": (
|
||
|
|
"// PB Auth Inline (v5 — reply-branch auth)\n"
|
||
|
|
"// Authenticate with PB admin once, set pb_token + pb_admin_id\n"
|
||
|
|
"// on the shared context. Both the reply branch (Find Target\n"
|
||
|
|
"// Quote, Insert quote_replies, Flip has_new_reply) and the\n"
|
||
|
|
"// create branch (Carry Email Ctx → downstream HTTP nodes)\n"
|
||
|
|
"// consume $json.pb_token.\n"
|
||
|
|
"//\n"
|
||
|
|
"// The create branch ALSO calls auth inline inside Catch and\n"
|
||
|
|
"// Create Uploader for its own attachment uploads. That's\n"
|
||
|
|
"// intentional — its token is overwritten downstream anyway\n"
|
||
|
|
"// and the duplicate call is cheap against local PB.\n"
|
||
|
|
"//\n"
|
||
|
|
"// On auth failure: throw so the operator sees the failure in\n"
|
||
|
|
"// the n8n UI rather than silently producing an empty token\n"
|
||
|
|
"// and a confusing 401 from the next HTTP node.\n"
|
||
|
|
"\n"
|
||
|
|
"const input = $json || {};\n"
|
||
|
|
"const pbUrl = String(input.pb_url || '').trim();\n"
|
||
|
|
"const pbEmail = String(input.pb_admin_email || '').trim();\n"
|
||
|
|
"const pbPassword = String(input.pb_admin_password || '').trim();\n"
|
||
|
|
"\n"
|
||
|
|
"if (!pbUrl) throw new Error('PB Auth Inline: pb_url missing on input');\n"
|
||
|
|
"if (!pbEmail) throw new Error('PB Auth Inline: pb_admin_email missing on input');\n"
|
||
|
|
"if (!pbPassword) throw new Error('PB Auth Inline: pb_admin_password missing on input');\n"
|
||
|
|
"\n"
|
||
|
|
"const subj = String(input.subject || '(no subject)');\n"
|
||
|
|
"const fromEmail = String(input.from_email || '(unknown sender)');\n"
|
||
|
|
"\n"
|
||
|
|
"let authResp;\n"
|
||
|
|
"try {\n"
|
||
|
|
" authResp = await this.helpers.httpRequest({\n"
|
||
|
|
" method: 'POST',\n"
|
||
|
|
" url: pbUrl.replace(/\\/$/, '') + '/api/collections/_superusers/auth-with-password',\n"
|
||
|
|
" headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },\n"
|
||
|
|
" body: { identity: pbEmail, password: pbPassword },\n"
|
||
|
|
" json: true,\n"
|
||
|
|
" timeout: 10000,\n"
|
||
|
|
" });\n"
|
||
|
|
"} catch (e) {\n"
|
||
|
|
" throw new Error(\n"
|
||
|
|
" 'PB Auth Inline: HTTP call to ' + pbUrl + ' failed for \"' + subj + '\" from ' + fromEmail + ': ' +\n"
|
||
|
|
" (e.message || String(e))\n"
|
||
|
|
" );\n"
|
||
|
|
"}\n"
|
||
|
|
"\n"
|
||
|
|
"const token = authResp && typeof authResp.token === 'string' ? authResp.token : '';\n"
|
||
|
|
"if (!token) {\n"
|
||
|
|
" throw new Error(\n"
|
||
|
|
" 'PB Auth Inline: ' + pbUrl + ' returned no token for \"' + subj + '\" from ' + fromEmail +\n"
|
||
|
|
" ' (response: ' + JSON.stringify(authResp).slice(0, 200) + ')'\n"
|
||
|
|
" );\n"
|
||
|
|
"}\n"
|
||
|
|
"\n"
|
||
|
|
"const adminId = authResp.admin && typeof authResp.admin.id === 'string' ? authResp.admin.id : '';\n"
|
||
|
|
"\n"
|
||
|
|
"return {\n"
|
||
|
|
" json: Object.assign({}, input, {\n"
|
||
|
|
" pb_token: token,\n"
|
||
|
|
" pb_admin_id: adminId,\n"
|
||
|
|
" _pb_auth_ok: true,\n"
|
||
|
|
" }),\n"
|
||
|
|
"};\n"
|
||
|
|
),
|
||
|
|
},
|
||
|
|
"id": "pb-auth-inline-2026-09-09",
|
||
|
|
"name": "PB Auth Inline",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
# Slot between PocketBase Connection (x=1120) and Is Reply? (x=1344)
|
||
|
|
# at y=832 (the common-path lane).
|
||
|
|
"position": [1232, 832],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Apply Subject Result node (sits between Find Target Quote (by Subject)
|
||
|
|
# and Route Reply Result (matched?) — fixes the wrapper-context loss)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
APPLY_SUBJECT_RESULT_NODE = {
|
||
|
|
"parameters": {
|
||
|
|
"mode": "runOnceForEachItem",
|
||
|
|
"jsCode": (
|
||
|
|
"// Apply Subject Result\n"
|
||
|
|
"// Merges the PB response from Find Target Quote (by Subject)\n"
|
||
|
|
"// with the wrapper context built by Decide Reply Lookup.\n"
|
||
|
|
"//\n"
|
||
|
|
"// The HTTP Request node that ran just before this one\n"
|
||
|
|
"// REPLACES $json with the PB response (e.g. {items: [...]}),\n"
|
||
|
|
"// dropping _reply_quote_id, pb_token, message_id, etc.\n"
|
||
|
|
"// Without this merge, Route Reply Result evaluates an empty\n"
|
||
|
|
"// _reply_quote_id and never fires the matched branch, even\n"
|
||
|
|
"// when the subject fallback found the right quote.\n"
|
||
|
|
"//\n"
|
||
|
|
"// Behaviour:\n"
|
||
|
|
"// - items.length > 0: override _reply_quote_id with items[0].id\n"
|
||
|
|
"// (subject fallback matched; In-Reply-To did not).\n"
|
||
|
|
"// - items.length === 0: keep _reply_quote_id from Decide Reply\n"
|
||
|
|
"// Lookup (which used the In-Reply-To result, if any).\n"
|
||
|
|
"// - Always preserve pb_token, message_id, in_reply_to, from_email,\n"
|
||
|
|
"// from_name, subject, body_plain, body_html, received_at so the\n"
|
||
|
|
"// downstream Insert quote_replies + Flip has_new_reply HTTP\n"
|
||
|
|
"// nodes have the auth token and email context they need.\n"
|
||
|
|
"\n"
|
||
|
|
"const resp = $json || {};\n"
|
||
|
|
"const items = Array.isArray(resp.items) ? resp.items : [];\n"
|
||
|
|
"const subjectHit = items[0] || null;\n"
|
||
|
|
"\n"
|
||
|
|
"// Pull the wrapper context that Decide Reply Lookup assembled.\n"
|
||
|
|
"// Fall back to Encode Reply Filters, then to Parse Email, in case\n"
|
||
|
|
"// upstream topology ever changes.\n"
|
||
|
|
"let wrapper = {};\n"
|
||
|
|
"const sources = [\n"
|
||
|
|
" () => { try { return $('Decide Reply Lookup').first().json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
" () => { try { return $('Encode Reply Filters').first().json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
" () => { try { return $('Parse Email').item.json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
"];\n"
|
||
|
|
"for (const get of sources) {\n"
|
||
|
|
" wrapper = get();\n"
|
||
|
|
" if (wrapper && (wrapper.pb_token || wrapper.message_id || wrapper.from_email)) break;\n"
|
||
|
|
"}\n"
|
||
|
|
"\n"
|
||
|
|
"const existingQuoteId = String(wrapper._reply_quote_id || '');\n"
|
||
|
|
"const subjectQuoteId = subjectHit && subjectHit.id ? String(subjectHit.id) : '';\n"
|
||
|
|
"\n"
|
||
|
|
"return { json: Object.assign({}, wrapper, {\n"
|
||
|
|
" // Subject match wins over empty In-Reply-To result; preserve\n"
|
||
|
|
" // any non-empty In-Reply-To result if subject returned nothing.\n"
|
||
|
|
" _reply_quote_id: subjectQuoteId || existingQuoteId,\n"
|
||
|
|
" _subject_lookup_total: typeof resp.totalItems === 'number' ? resp.totalItems : -1,\n"
|
||
|
|
" _subject_lookup_matched: !!subjectHit,\n"
|
||
|
|
"}) };\n"
|
||
|
|
),
|
||
|
|
},
|
||
|
|
"id": "apply-subject-result-2026-09-09",
|
||
|
|
"name": "Apply Subject Result",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
# Slot between Find Target Quote (by Subject) (x=2240) and Route Reply
|
||
|
|
# Result (matched?) (x=2464) at y=448 (the reply-branch lane).
|
||
|
|
"position": [2352, 448],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Preserve Wrapper for Flip node (sits between Insert quote_replies and
|
||
|
|
# Flip has_new_reply — same wrapper-loss pattern as Apply Subject Result)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
PRESERVE_WRAPPER_FOR_FLIP_NODE = {
|
||
|
|
"parameters": {
|
||
|
|
"mode": "runOnceForEachItem",
|
||
|
|
"jsCode": (
|
||
|
|
"// Preserve Wrapper for Flip\n"
|
||
|
|
"// Insert quote_replies is an HTTP POST that REPLACES $json\n"
|
||
|
|
"// with the new PB record (id, collectionId, etc.). That wipes\n"
|
||
|
|
"// pb_url, pb_token, _reply_quote_id, message_id, etc. — the\n"
|
||
|
|
"// fields Flip has_new_reply needs to build its PATCH URL\n"
|
||
|
|
"// (`pb_url/api/collections/quotes/records/_reply_quote_id`) and\n"
|
||
|
|
"// Authorization header.\n"
|
||
|
|
"//\n"
|
||
|
|
"// This node re-merges the wrapper context from the most recent\n"
|
||
|
|
"// upstream Code node that carries it (Apply Subject Result →\n"
|
||
|
|
"// Decide Reply Lookup → Encode Reply Filters → Parse Email,\n"
|
||
|
|
"// first one that has pb_token wins), and stashes the new\n"
|
||
|
|
"// quote_replies record id for any debug step downstream.\n"
|
||
|
|
"//\n"
|
||
|
|
"// Insert's response body is intentionally not re-emitted —\n"
|
||
|
|
"// downstream only needs the wrapper fields to build the PATCH.\n"
|
||
|
|
"\n"
|
||
|
|
"let wrapper = {};\n"
|
||
|
|
"const sources = [\n"
|
||
|
|
" () => { try { return $('Apply Subject Result').first().json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
" () => { try { return $('Decide Reply Lookup').first().json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
" () => { try { return $('Encode Reply Filters').first().json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
" () => { try { return $('Parse Email').item.json || {}; } catch (e) { return {}; } },\n"
|
||
|
|
"];\n"
|
||
|
|
"for (const get of sources) {\n"
|
||
|
|
" wrapper = get();\n"
|
||
|
|
" if (wrapper && wrapper.pb_token) break;\n"
|
||
|
|
"}\n"
|
||
|
|
"\n"
|
||
|
|
"const insResp = $json || {};\n"
|
||
|
|
"\n"
|
||
|
|
"return { json: Object.assign({}, wrapper, {\n"
|
||
|
|
" // Stash the freshly created quote_replies record id for any\n"
|
||
|
|
" // downstream debug step (Flip itself doesn't need it).\n"
|
||
|
|
" _quote_reply_record_id: insResp.id || '',\n"
|
||
|
|
"}) };\n"
|
||
|
|
),
|
||
|
|
},
|
||
|
|
"id": "preserve-wrapper-for-flip-2026-09-09",
|
||
|
|
"name": "Preserve Wrapper for Flip",
|
||
|
|
"type": "n8n-nodes-base.code",
|
||
|
|
"typeVersion": 2,
|
||
|
|
# Slot between Insert quote_replies (x=2688) and Flip has_new_reply
|
||
|
|
# (x=2976) at y=448 (the reply-branch lane).
|
||
|
|
"position": [2832, 448],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 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.
|
||
|
|
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]]:
|
||
|
|
"""Add PB Auth Inline node, rewire PocketBase Connection → it → Is Reply?,
|
||
|
|
tighten Encode Reply Filters empty in_reply_to fallback.
|
||
|
|
|
||
|
|
Idempotent: detects the already-applied state and skips each piece.
|
||
|
|
"""
|
||
|
|
changes = []
|
||
|
|
by_name = {n["name"]: n for n in wf["nodes"]}
|
||
|
|
|
||
|
|
# --- 1. Encode Reply Filters: tighten empty in_reply_to fallback ---
|
||
|
|
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 the latest tightened state: both subject and in_reply_to
|
||
|
|
# fall back to a `id="__NO_MATCH__"`-style sentinel.
|
||
|
|
# The literal in the JS code is `id="__NO_MATCH__"` (PB filter syntax).
|
||
|
|
already_tightened = 'id="__NO_MATCH__"' in old_erf
|
||
|
|
if already_tightened:
|
||
|
|
changes.append("Encode Reply Filters: empty-in_reply_to fallback already tightened (skipped)")
|
||
|
|
else:
|
||
|
|
if "[0-9A-Fa-f]{8}" not in old_erf:
|
||
|
|
raise RuntimeError(
|
||
|
|
"Encode Reply Filters does not look like the expected v5 "
|
||
|
|
"shape (missing UUID-regex `[0-9A-Fa-f]{8}`). Refusing to "
|
||
|
|
"overwrite. Inspect the live workflow manually."
|
||
|
|
)
|
||
|
|
erf["parameters"]["jsCode"] = ENCODE_REPLY_FILTERS_FIX
|
||
|
|
erf["parameters"]["mode"] = "runOnceForEachItem"
|
||
|
|
changes.append("Encode Reply Filters: empty in_reply_to fallback → no-match sentinel")
|
||
|
|
|
||
|
|
# --- 2. Add PB Auth Inline node (idempotent: skip if already present) ---
|
||
|
|
has_auth_node = "PB Auth Inline" in by_name
|
||
|
|
if has_auth_node:
|
||
|
|
changes.append("PB Auth Inline node: already present (skipped add)")
|
||
|
|
else:
|
||
|
|
wf["nodes"].append(PB_AUTH_INLINE_NODE)
|
||
|
|
changes.append(f"PB Auth Inline node: added (id={PB_AUTH_INLINE_NODE['id']})")
|
||
|
|
|
||
|
|
# Refresh by_name after potential node add
|
||
|
|
by_name = {n["name"]: n for n in wf["nodes"]}
|
||
|
|
|
||
|
|
# --- 3. Rewire connections ---
|
||
|
|
conns = wf.setdefault("connections", {})
|
||
|
|
pb_conn_out = conns.get("PocketBase Connection", {}).get("main", [])
|
||
|
|
is_reply_in = conns.get("Is Reply?", {}).get("main", [])
|
||
|
|
|
||
|
|
# 3a. Insert PB Auth Inline between PocketBase Connection and Is Reply?
|
||
|
|
# First, drop the existing direct PocketBase Connection → Is Reply?
|
||
|
|
# link so it doesn't bypass the new auth node.
|
||
|
|
new_pb_conn_out: list[list[dict]] = [[] for _ in pb_conn_out]
|
||
|
|
rewired = False
|
||
|
|
for branch in pb_conn_out:
|
||
|
|
new_branch = []
|
||
|
|
for link in branch:
|
||
|
|
if link.get("node") == "Is Reply?":
|
||
|
|
# Replace direct link with link to PB Auth Inline
|
||
|
|
new_branch.append({
|
||
|
|
"node": "PB Auth Inline",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0,
|
||
|
|
})
|
||
|
|
rewired = True
|
||
|
|
else:
|
||
|
|
new_branch.append(link)
|
||
|
|
new_pb_conn_out[branch_idx_for(pb_conn_out, branch)] = new_branch
|
||
|
|
if rewired:
|
||
|
|
conns["PocketBase Connection"]["main"] = new_pb_conn_out
|
||
|
|
changes.append("PocketBase Connection → Is Reply?: replaced with link to PB Auth Inline")
|
||
|
|
else:
|
||
|
|
changes.append("PocketBase Connection → Is Reply?: link not found (skipping)")
|
||
|
|
|
||
|
|
# 3b. Add PB Auth Inline → Is Reply? link (only if missing)
|
||
|
|
pb_auth_out = conns.get("PB Auth Inline", {}).get("main", [[]])
|
||
|
|
already_linked_to_is_reply = any(
|
||
|
|
link.get("node") == "Is Reply?"
|
||
|
|
for branch in pb_auth_out
|
||
|
|
for link in branch
|
||
|
|
)
|
||
|
|
if not already_linked_to_is_reply:
|
||
|
|
conns.setdefault("PB Auth Inline", {"main": [[]]})
|
||
|
|
# Append Is Reply? link to the first branch of PB Auth Inline
|
||
|
|
conns["PB Auth Inline"]["main"][0].append({
|
||
|
|
"node": "Is Reply?",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0,
|
||
|
|
})
|
||
|
|
changes.append("PB Auth Inline → Is Reply?: added")
|
||
|
|
else:
|
||
|
|
changes.append("PB Auth Inline → Is Reply?: already linked (skipped)")
|
||
|
|
|
||
|
|
# --- 4. Add Apply Subject Result node (idempotent) ---
|
||
|
|
by_name = {n["name"]: n for n in wf["nodes"]}
|
||
|
|
if "Apply Subject Result" in by_name:
|
||
|
|
changes.append("Apply Subject Result node: already present (skipped add)")
|
||
|
|
else:
|
||
|
|
wf["nodes"].append(APPLY_SUBJECT_RESULT_NODE)
|
||
|
|
changes.append(f"Apply Subject Result node: added (id={APPLY_SUBJECT_RESULT_NODE['id']})")
|
||
|
|
|
||
|
|
# --- 5. Rewire: Find Target Quote (by Subject) → Apply Subject Result →
|
||
|
|
# Route Reply Result (matched?) (was direct to Route Reply Result) ---
|
||
|
|
ftq_subject_out = conns.get("Find Target Quote (by Subject)", {}).get("main", [])
|
||
|
|
new_ftq_out: list[list[dict]] = [[] for _ in ftq_subject_out]
|
||
|
|
rewired2 = False
|
||
|
|
for branch in ftq_subject_out:
|
||
|
|
new_branch = []
|
||
|
|
for link in branch:
|
||
|
|
if link.get("node") == "Route Reply Result (matched?)":
|
||
|
|
# Replace direct link with link to Apply Subject Result
|
||
|
|
new_branch.append({
|
||
|
|
"node": "Apply Subject Result",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0,
|
||
|
|
})
|
||
|
|
rewired2 = True
|
||
|
|
else:
|
||
|
|
new_branch.append(link)
|
||
|
|
new_ftq_out[branch_idx_for(ftq_subject_out, branch)] = new_branch
|
||
|
|
if rewired2:
|
||
|
|
conns["Find Target Quote (by Subject)"]["main"] = new_ftq_out
|
||
|
|
changes.append("Find Target Quote (by Subject) → Route Reply Result: replaced with link to Apply Subject Result")
|
||
|
|
else:
|
||
|
|
changes.append("Find Target Quote (by Subject) → Route Reply Result: link not found (skipping)")
|
||
|
|
|
||
|
|
# 5b. Add Apply Subject Result → Route Reply Result (matched?) link
|
||
|
|
asr_out = conns.get("Apply Subject Result", {}).get("main", [[]])
|
||
|
|
already_linked = any(
|
||
|
|
link.get("node") == "Route Reply Result (matched?)"
|
||
|
|
for branch in asr_out
|
||
|
|
for link in branch
|
||
|
|
)
|
||
|
|
if not already_linked:
|
||
|
|
conns.setdefault("Apply Subject Result", {"main": [[]]})
|
||
|
|
conns["Apply Subject Result"]["main"][0].append({
|
||
|
|
"node": "Route Reply Result (matched?)",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0,
|
||
|
|
})
|
||
|
|
changes.append("Apply Subject Result → Route Reply Result: added")
|
||
|
|
else:
|
||
|
|
changes.append("Apply Subject Result → Route Reply Result: already linked (skipped)")
|
||
|
|
|
||
|
|
# --- 6. Add Preserve Wrapper for Flip node (idempotent) ---
|
||
|
|
by_name = {n["name"]: n for n in wf["nodes"]}
|
||
|
|
if "Preserve Wrapper for Flip" in by_name:
|
||
|
|
changes.append("Preserve Wrapper for Flip node: already present (skipped add)")
|
||
|
|
else:
|
||
|
|
wf["nodes"].append(PRESERVE_WRAPPER_FOR_FLIP_NODE)
|
||
|
|
changes.append(f"Preserve Wrapper for Flip node: added (id={PRESERVE_WRAPPER_FOR_FLIP_NODE['id']})")
|
||
|
|
|
||
|
|
# --- 7. Rewire: Insert quote_replies → Preserve Wrapper for Flip →
|
||
|
|
# Flip has_new_reply (was direct to Flip) ---
|
||
|
|
insert_out = conns.get("Insert quote_replies", {}).get("main", [])
|
||
|
|
new_insert_out: list[list[dict]] = [[] for _ in insert_out]
|
||
|
|
rewired3 = False
|
||
|
|
for branch in insert_out:
|
||
|
|
new_branch = []
|
||
|
|
for link in branch:
|
||
|
|
if link.get("node") == "Flip has_new_reply":
|
||
|
|
new_branch.append({
|
||
|
|
"node": "Preserve Wrapper for Flip",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0,
|
||
|
|
})
|
||
|
|
rewired3 = True
|
||
|
|
else:
|
||
|
|
new_branch.append(link)
|
||
|
|
new_insert_out[branch_idx_for(insert_out, branch)] = new_branch
|
||
|
|
if rewired3:
|
||
|
|
conns["Insert quote_replies"]["main"] = new_insert_out
|
||
|
|
changes.append("Insert quote_replies → Flip has_new_reply: replaced with link to Preserve Wrapper for Flip")
|
||
|
|
else:
|
||
|
|
changes.append("Insert quote_replies → Flip has_new_reply: link not found (skipping)")
|
||
|
|
|
||
|
|
# 7b. Add Preserve Wrapper for Flip → Flip has_new_reply link
|
||
|
|
pwf_out = conns.get("Preserve Wrapper for Flip", {}).get("main", [[]])
|
||
|
|
already_linked_pwf = any(
|
||
|
|
link.get("node") == "Flip has_new_reply"
|
||
|
|
for branch in pwf_out
|
||
|
|
for link in branch
|
||
|
|
)
|
||
|
|
if not already_linked_pwf:
|
||
|
|
conns.setdefault("Preserve Wrapper for Flip", {"main": [[]]})
|
||
|
|
conns["Preserve Wrapper for Flip"]["main"][0].append({
|
||
|
|
"node": "Flip has_new_reply",
|
||
|
|
"type": "main",
|
||
|
|
"index": 0,
|
||
|
|
})
|
||
|
|
changes.append("Preserve Wrapper for Flip → Flip has_new_reply: added")
|
||
|
|
else:
|
||
|
|
changes.append("Preserve Wrapper for Flip → Flip has_new_reply: already linked (skipped)")
|
||
|
|
|
||
|
|
return wf, changes
|
||
|
|
|
||
|
|
|
||
|
|
def branch_idx_for(outer: list, branch: list) -> int:
|
||
|
|
"""Return the index of `branch` within `outer` (linear search)."""
|
||
|
|
for i, b in enumerate(outer):
|
||
|
|
if b is branch:
|
||
|
|
return i
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def diff_workflow(before: dict, after: dict) -> dict:
|
||
|
|
"""Return a summary of structural changes (nodes added/removed/modified,
|
||
|
|
connection graph differences)."""
|
||
|
|
before_nodes = {n["name"]: n for n in before["nodes"]}
|
||
|
|
after_nodes = {n["name"]: n for n in after["nodes"]}
|
||
|
|
|
||
|
|
added = sorted(set(after_nodes) - set(before_nodes))
|
||
|
|
removed = sorted(set(before_nodes) - set(after_nodes))
|
||
|
|
|
||
|
|
modified: dict[str, dict] = {}
|
||
|
|
for name in set(before_nodes) | set(after_nodes):
|
||
|
|
b = before_nodes.get(name)
|
||
|
|
a = after_nodes.get(name)
|
||
|
|
if b is None or a is None:
|
||
|
|
continue
|
||
|
|
diffs: list[str] = []
|
||
|
|
if b.get("parameters") != a.get("parameters"):
|
||
|
|
diffs.append("parameters")
|
||
|
|
if b.get("position") != a.get("position"):
|
||
|
|
diffs.append("position")
|
||
|
|
if b.get("disabled") != a.get("disabled"):
|
||
|
|
diffs.append("disabled")
|
||
|
|
if b.get("type") != a.get("type"):
|
||
|
|
diffs.append("type")
|
||
|
|
if b.get("typeVersion") != a.get("typeVersion"):
|
||
|
|
diffs.append("typeVersion")
|
||
|
|
if diffs:
|
||
|
|
modified[name] = {"fields": diffs}
|
||
|
|
|
||
|
|
conn_diff: list[str] = []
|
||
|
|
before_conns = before.get("connections", {})
|
||
|
|
after_conns = after.get("connections", {})
|
||
|
|
if before_conns != after_conns:
|
||
|
|
# Show which keys changed
|
||
|
|
for key in set(before_conns) | set(after_conns):
|
||
|
|
if before_conns.get(key) != after_conns.get(key):
|
||
|
|
conn_diff.append(key)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"nodes_added": added,
|
||
|
|
"nodes_removed": removed,
|
||
|
|
"nodes_modified": modified,
|
||
|
|
"connections_changed": sorted(conn_diff),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
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', []))}")
|
||
|
|
|
||
|
|
if live.get("isArchived"):
|
||
|
|
print("\n→ POST /unarchive (workflow was archived; PUT refuses on archived)")
|
||
|
|
n8n_post(f"/api/v1/workflows/{WORKFLOW_ID}/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 …")
|
||
|
|
patched, changes = apply_fix_to_workflow(live)
|
||
|
|
for c in changes:
|
||
|
|
print(f" ✓ {c}")
|
||
|
|
|
||
|
|
# 4. Diff against backup
|
||
|
|
print("\n→ Structural diff vs backup:")
|
||
|
|
diff = diff_workflow(json.loads(BACKUP_PATH.read_text()), patched)
|
||
|
|
print(f" nodes added: {diff['nodes_added']}")
|
||
|
|
print(f" nodes removed: {diff['nodes_removed']}")
|
||
|
|
for name, info in diff["nodes_modified"].items():
|
||
|
|
print(f" nodes modified: {name} (fields: {info['fields']})")
|
||
|
|
print(f" connections changed: {diff['connections_changed']}")
|
||
|
|
|
||
|
|
# 5. Sanity: nothing unexpected.
|
||
|
|
# The auth-fix portion (PB Auth Inline + Encode Reply Filters tightening
|
||
|
|
# + those two connections) and the merge-fix portion (Apply Subject
|
||
|
|
# Result + those two connections) are idempotent and may already be
|
||
|
|
# live, so we allow them to be EITHER in this diff OR already present.
|
||
|
|
auth_fix_already_live = (
|
||
|
|
"PB Auth Inline" not in diff["nodes_added"]
|
||
|
|
and "PB Auth Inline" in {n["name"] for n in live["nodes"]}
|
||
|
|
)
|
||
|
|
erf_already_fixed = (
|
||
|
|
"Encode Reply Filters" not in diff["nodes_modified"]
|
||
|
|
)
|
||
|
|
merge_fix_already_live = (
|
||
|
|
"Apply Subject Result" not in diff["nodes_added"]
|
||
|
|
and "Apply Subject Result" in {n["name"] for n in live["nodes"]}
|
||
|
|
)
|
||
|
|
|
||
|
|
expected_added_after_auth_fix = set()
|
||
|
|
expected_conn_changes_after_auth_fix = set()
|
||
|
|
|
||
|
|
# Apply Subject Result portion
|
||
|
|
if not merge_fix_already_live:
|
||
|
|
expected_added_after_auth_fix.add("Apply Subject Result")
|
||
|
|
expected_conn_changes_after_auth_fix.update({
|
||
|
|
"Find Target Quote (by Subject)",
|
||
|
|
"Apply Subject Result",
|
||
|
|
})
|
||
|
|
|
||
|
|
# Preserve Wrapper for Flip portion — always fresh in this diff
|
||
|
|
if "Preserve Wrapper for Flip" in diff["nodes_added"]:
|
||
|
|
expected_added_after_auth_fix.add("Preserve Wrapper for Flip")
|
||
|
|
expected_conn_changes_after_auth_fix.update({
|
||
|
|
"Insert quote_replies",
|
||
|
|
"Preserve Wrapper for Flip",
|
||
|
|
})
|
||
|
|
|
||
|
|
# PB Auth Inline / Encode Reply Filters portion (first-time auth fix)
|
||
|
|
if not auth_fix_already_live:
|
||
|
|
expected_added_after_auth_fix.add("PB Auth Inline")
|
||
|
|
expected_conn_changes_after_auth_fix.update({
|
||
|
|
"PocketBase Connection",
|
||
|
|
"PB Auth Inline",
|
||
|
|
})
|
||
|
|
if not erf_already_fixed:
|
||
|
|
expected_conn_changes_after_auth_fix.add("PocketBase Connection")
|
||
|
|
|
||
|
|
actual_added = set(diff["nodes_added"])
|
||
|
|
actual_modified = set(diff["nodes_modified"])
|
||
|
|
actual_conn_changed = set(diff["connections_changed"])
|
||
|
|
|
||
|
|
extra_added = actual_added - expected_added_after_auth_fix
|
||
|
|
missing_added = expected_added_after_auth_fix - actual_added
|
||
|
|
extra_modified = actual_modified # No modifications expected (encode reply filters already fixed)
|
||
|
|
missing_modified = set()
|
||
|
|
extra_conn = actual_conn_changed - expected_conn_changes_after_auth_fix
|
||
|
|
|
||
|
|
problems = []
|
||
|
|
if extra_added:
|
||
|
|
problems.append(f"unexpected nodes added: {sorted(extra_added)}")
|
||
|
|
if missing_added:
|
||
|
|
problems.append(f"expected add missing: {sorted(missing_added)}")
|
||
|
|
if extra_modified:
|
||
|
|
problems.append(f"unexpected nodes modified: {sorted(extra_modified)}")
|
||
|
|
if missing_modified:
|
||
|
|
problems.append(f"expected modifications missing: {sorted(missing_modified)}")
|
||
|
|
if extra_conn:
|
||
|
|
problems.append(f"unexpected connection changes: {sorted(extra_conn)}")
|
||
|
|
|
||
|
|
if problems:
|
||
|
|
print("\n ✗ UNEXPECTED CHANGES:")
|
||
|
|
for p in problems:
|
||
|
|
print(f" - {p}")
|
||
|
|
print("\n=== ABORT: structural diff does not match intent. ===")
|
||
|
|
return 1
|
||
|
|
print(" ✓ structural diff matches intent exactly")
|
||
|
|
|
||
|
|
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)")
|
||
|
|
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 verify diff against backup
|
||
|
|
print("\n→ GET (post-deploy verification)")
|
||
|
|
verified = n8n_get(f"/api/v1/workflows/{WORKFLOW_ID}")
|
||
|
|
v_diff = diff_workflow(json.loads(BACKUP_PATH.read_text()), verified)
|
||
|
|
print(f" nodes added (post): {v_diff['nodes_added']}")
|
||
|
|
print(f" nodes modified (post): {sorted(v_diff['nodes_modified'])}")
|
||
|
|
print(f" connections changed (post): {v_diff['connections_changed']}")
|
||
|
|
|
||
|
|
actual_post = set(v_diff["nodes_added"]) | set(v_diff["nodes_modified"])
|
||
|
|
# For post-deploy verification, only the NEW additions this run should appear.
|
||
|
|
expected_post = expected_added_after_auth_fix
|
||
|
|
if actual_post != expected_post:
|
||
|
|
print(f"\n ✗ POST-DEPLOY DIFF MISMATCH")
|
||
|
|
print(f" expected: {sorted(expected_post)}")
|
||
|
|
print(f" actual: {sorted(actual_post)}")
|
||
|
|
print("\n=== ABORT: live state may have diverged from intent. ===")
|
||
|
|
return 1
|
||
|
|
print(" ✓ exactly the intended changes are live")
|
||
|
|
|
||
|
|
# 8. Save the new local file
|
||
|
|
LOCAL_WF_PATH.write_text(json.dumps(verified, indent=2))
|
||
|
|
print(f"\n local file updated: {LOCAL_WF_PATH}")
|
||
|
|
|
||
|
|
print("\n=== DONE ===")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main() or 0)
|