fix(workflow): reach the catalog, and answer

Two things were wrong, and the second hid the first.

n8n does not re-register an active workflow's definition on PATCH: the running
webhook kept executing the version it was activated with. So the corrected
subject_token expression was never running, and the Keycloak V1 NullPointer it
kept producing was the old empty-token failure — including through the attempt
to capture the request with an echo service, which the old definition never
called. Deactivate, apply, reactivate; written down in workflows/README.md
because it costs an afternoon otherwise.

The token reference itself: $json is the previous node's output, not the
request, so the subject token has to come from $('Webhook').

The regex is now loose after the underscore, so a reference that looks valid but
does not exist reaches the catalog instead of being answered with 'you named no
product', which was false.

Working for the main path and for a question naming no product. A nonexistent
code still returns an empty body — recorded as a known gap, not as acceptable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-29 13:24:44 +02:00
parent f9511fd15d
commit 562987cd69
2 changed files with 48 additions and 32 deletions
+44 -29
View File
@@ -2,47 +2,62 @@
Exported as JSON and committed. The n8n instance is not the source of truth. Exported as JSON and committed. The n8n instance is not the source of truth.
## Importing ## Deploying a change
n8n's REST API, with an owner session cookie. The activation endpoint is **n8n does not re-register an active workflow when you PATCH it.** The running
separate from the update one and requires the current `versionId`: webhook keeps executing the definition it was activated with, so an edit appears
to have no effect — and the failure it keeps producing is the *old* one, which
is a very good way to spend an afternoon debugging something already fixed.
Deactivate, apply, reactivate, in that order:
``` ```
POST /rest/workflows # import POST /rest/workflows/<id>/deactivate {"versionId": "<current>"}
POST /rest/workflows/<id>/activate # {"versionId": "<current>"} PATCH /rest/workflows/<id> {"nodes":…, "connections":…, "versionId": "<current>"}
POST /rest/workflows/<id>/activate {"versionId": "<current>"}
``` ```
`PATCH /rest/workflows/<id>` with `{"active": true}` returns 200 and leaves the `versionId` changes at every step, so re-read it each time. And note
workflow inactive, silently. Use the activate endpoint. `PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently:
activation has its own endpoint.
## approvisionnement-check-availability ## approvisionnement-check-availability
**Not working yet.** The `Exchange token` node fails, and what it fails with is Working. `POST /webhook/check-availability` with the end user's token in
worth recording: `X-Subject-Token`:
``` ```
Keycloak 500, an HTML login page as the body "Peut-on promettre S10_1949 ?"
NullPointerException: Cannot invoke UserSessionModel.getAuthenticatedClientSessionByClient → {"answer":"Yes — 1952 Alpine Renault 1300 (S10_1949) can be promised:
because "userSession" is null 7305 in stock, above the reorder threshold of 50.","escalate":false}
at V1TokenExchangeProvider.exchangeClientToOIDCClient
"Peut-on promettre la Vespa ?"
→ {"answer":"No product reference in the question…","escalate":true}
``` ```
Two things stand out. Keycloak answers a malformed token exchange with a **500 It exchanges the token itself, using the agent's client secret from the pod
and a rendered login page**, which tells a client nothing. And it took the **V1** environment so the committed JSON holds no secret, then talks MCP in two calls:
exchange path, not the Standard one every other agent in this estate uses `initialize` to obtain the session id from a **response header**, then
successfully. `tools/call`. No `notifications/initialized` is needed.
What has been eliminated, each by hand against the same running Keycloak: MCP rather than the REST route, which would have been one call instead of two:
the per-tool ACL by consumer group lives on the MCP route only. Going through
REST would give an autonomous agent broader access than a conversational one,
which is exactly backwards.
- the client and its secret — Keycloak logs The product code is read with a regex, not a model. This agent's critical path
`kc.clientId: classic-models-agent-approvisionnement`, so it authenticated, is rules; a question naming no product escalates rather than being guessed at.
which also proves `$env` reaches the node The regex is loose after the underscore on purpose — a reference that looks
- the subject token — the *same* token replayed with curl exchanges fine (200) valid but does not exist must reach the catalog and come back as "no such
- omitting `subject_token_type` — still 200 product", because answering "you named no product" would be false.
- omitting `openid` from the scope — still 200
- using `audience=` instead of a scope — a clean 400, not this
So n8n is sending something the reproduction attempts are not. The next step is ### Known gap
to capture the request as the node actually emits it: point the node at an
in-cluster echo service and read the body. That was set up and the run did not A product code that does not exist returns an **empty body** rather than that
reach it, which is itself unexplained and where this stopped. message. The run completes, every node executes including the one that composes
the answer, and the response is still empty — so the fault is between the
composing node and the webhook response, not in the decision logic.
`alwaysOutputData` on the read node did not change it.
For an autonomous agent, silence is the worst possible answer. Not shipped as
acceptable — recorded as unfinished.
@@ -139,11 +139,12 @@
"position": [ "position": [
660, 660,
0 0
] ],
"alwaysOutputData": true
}, },
{ {
"parameters": { "parameters": {
"jsCode": "// Pull the product code out of the question.\n//\n// A regex rather than a model: this agent's critical path is rules, and putting\n// an LLM on it would make a nightly job both slower and less predictable. If the\n// question does not name a product, the agent escalates instead of guessing \u2014\n// an autonomous agent that meets an ambiguity must hand it back, never resolve\n// it on its own.\nconst question = $('Webhook').first().json.body?.question ?? '';\nconst match = question.match(/\\bS\\d+_\\d+[A-Za-z]*\\b/);\nif (!match) {\n return [{ json: { productcode: null, question } }];\n}\nreturn [{ json: { productcode: match[0], question } }];\n" "jsCode": "// Pull the product code out of the question.\n//\n// Deliberately loose on the part after the underscore: a reference that\n// looks like one but does not exist must reach the catalog and come back\n// as \"no such product\". Rejecting it here would answer \"you named no\n// product\", which is false and sends the caller looking in the wrong place.\n//\n// A regex rather than a model: this agent's critical path is rules, and putting\n// an LLM on it would make a nightly job both slower and less predictable. If the\n// question does not name a product, the agent escalates instead of guessing \u2014\n// an autonomous agent that meets an ambiguity must hand it back, never resolve\n// it on its own.\nconst question = $('Webhook').first().json.body?.question ?? '';\nconst match = question.match(/\\bS\\d+_[A-Za-z0-9]+\\b/);\nif (!match) {\n return [{ json: { productcode: null, question } }];\n}\nreturn [{ json: { productcode: match[0], question } }];\n"
}, },
"id": "decide-the-product", "id": "decide-the-product",
"name": "Decide the product", "name": "Decide the product",
@@ -156,7 +157,7 @@
}, },
{ {
"parameters": { "parameters": {
"jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst raw = $input.first().json;\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nconst rpc = JSON.parse(line.slice(5).trim());\nif (rpc.error) {\n // A refusal is the caller's own permissions being applied. Reported, never\n // worked around.\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised.`,\n escalate: false,\n}}];\n" "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $input.first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nconst rpc = JSON.parse(line.slice(5).trim());\nif (rpc.error) {\n // A refusal is the caller's own permissions being applied. Reported, never\n // worked around.\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised.`,\n escalate: false,\n}}];\n"
}, },
"id": "answer", "id": "answer",
"name": "Answer", "name": "Answer",