Compare commits
19 Commits
2bb6faa121
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec9e308ba | |||
| d11ca36764 | |||
| de7725bfb2 | |||
| bc83df3ed0 | |||
| 7ec4ffad37 | |||
| 620fefc51b | |||
| ed99334201 | |||
| 6eb77d862f | |||
| 78456c7902 | |||
| 2156cf4673 | |||
| 087b1a6d14 | |||
| a64f5026e3 | |||
| b04c5cbb11 | |||
| f810efbc04 | |||
| c5d17d1d04 | |||
| 245415e7c6 | |||
| 33d811fa68 | |||
| 0847e11713 | |||
| 06d0ab5e2d |
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| Agent | Client id | Trigger |
|
| Agent | Client id | Trigger |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Approvisionnement | `classic-models-agent-approvisionnement` | event-driven, plus a periodic ordering run |
|
| the supply agent | `classic-models-agent-supply` | event-driven, plus a periodic ordering run |
|
||||||
| Recouvrement | `classic-models-agent-recouvrement` | weekly, Monday |
|
| Recouvrement | `classic-models-agent-recouvrement` | weekly, Monday |
|
||||||
|
|
||||||
They share a repo because they are not code: n8n workflow exports, same tool,
|
They share a repo because they are not code: n8n workflow exports, same tool,
|
||||||
@@ -19,7 +19,7 @@ conversational agents.
|
|||||||
|
|
||||||
They remain full A2A agents: they publish an Agent Card, accept tasks, answer.
|
They remain full A2A agents: they publish an Agent Card, accept tasks, answer.
|
||||||
|
|
||||||
## Approvisionnement
|
## Supply
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -118,56 +118,49 @@ Recouvrement's dunning is the candidate, and it is a human-validated step
|
|||||||
already. The `classic-models-mandate` client is left in the realm, unused, for
|
already. The `classic-models-mandate` client is left in the realm, unused, for
|
||||||
that case.
|
that case.
|
||||||
|
|
||||||
## Backlog
|
## Events over Kafka — built, and one rule about them
|
||||||
|
|
||||||
**Events over Kafka, emitted by `classic-models-api`.** The target for the
|
Built 2026-08-31. `classic-models-api` emits domain events through a
|
||||||
reactive half. The estate emits no domain event today — the API has no signals
|
transactional outbox; a relay publishes them; `supply-stock-watch`
|
||||||
and no outbound webhook, and there is no bus in the cluster — which is what made
|
consumes `product.stock_changed` and is the reactive half this repo had been
|
||||||
a nightly sweep look like the only option.
|
waiting on. The contract is `classic-models-api/docs/DOMAIN_EVENTS.md` — read it
|
||||||
|
before writing a consumer.
|
||||||
|
|
||||||
The emission belongs **in the API**, not in a database CDC and not in a gateway
|
**An event carries no actor, and that is deliberate.**
|
||||||
plugin. Only the API knows which change is a business event rather than a row
|
|
||||||
update, and only it holds the identity that made it — `sub` and `azp` from the
|
|
||||||
caller's token, which the event has to carry if a downstream agent is to act
|
|
||||||
under the same authority as the person who triggered it.
|
|
||||||
|
|
||||||
Two facts about that codebase shape the work:
|
An earlier draft of this file said the opposite — that the event had to carry
|
||||||
|
`sub` and `azp` from the caller's token so a downstream agent could act under
|
||||||
|
the same authority. The user overruled it:
|
||||||
|
|
||||||
- Every write goes through one `BaseModelViewSet` in
|
> *"Nous gérons ici des événements, pas des 'commandes'. L'agent s'exécute avec
|
||||||
`api/v1/classicmodels/views.py`. One hook there covers products, orders,
|
> sa propre identité machine, on se fout de savoir qui est à l'origine de
|
||||||
payments and the rest — Django signals scattered across models would be the
|
> l'émission de l'événement."*
|
||||||
harder version of the same thing.
|
|
||||||
- **The models are `managed = False`.** The tables are the pre-existing
|
|
||||||
classicmodels schema and Django owns no migrations for them. A transactional
|
|
||||||
outbox means introducing the estate's first managed table beside unmanaged
|
|
||||||
ones — which works, and is worth deciding deliberately rather than
|
|
||||||
discovering.
|
|
||||||
|
|
||||||
An outbox is the part that matters: emitting from a viewset after `save()` drops
|
The reasoning, kept because it generalises: an event is a **fact**, not a
|
||||||
the event if the process dies between the two, and an agent that never hears
|
command. A consumer acts under its own identity. An actor field would have had
|
||||||
about a stock movement is worse than one that hears late.
|
no consumer and one obvious misuse — anyone able to write to the topic could name
|
||||||
|
any actor and have an agent act as them. A signed token is proof; a JSON field
|
||||||
|
is not. Causality travels as `traceparent`, in the system that exists for it.
|
||||||
|
|
||||||
Until this exists, detection stays unbuilt rather than being approximated by a
|
The emission still belongs **in the API** rather than in a database CDC or a
|
||||||
periodic scan: a batch that pretends to be reactive is worse than an absent
|
gateway plugin: only the API knows which change is a business event rather than
|
||||||
feature, because it looks finished.
|
a row update.
|
||||||
|
|
||||||
The periodic ordering run does not wait on any of it.
|
|
||||||
|
|
||||||
## Two controls that become mandatory here
|
## Two controls that become mandatory here
|
||||||
|
|
||||||
They were deferrable with a human in the loop. They are not, once an agent
|
Both **built** 2026-08-31, before the first agent looped unattended.
|
||||||
loops on its own.
|
|
||||||
|
|
||||||
- **Read volume caps per `(azp, sub)`**, enforced on Kong. An agent reads a
|
- **Read volume caps per `(azp, sub)`** — a scope authorises a read and says
|
||||||
thousand records in seconds where a human clicks ten; a scope authorises the
|
nothing about volume; an agent reads a thousand records where a human clicks
|
||||||
read but says nothing about volume.
|
ten.
|
||||||
- **Authorisation on tool-call parameters**, not just the tool name. `write`
|
- **Authorisation on tool-call parameters**, not just the tool name. `write`
|
||||||
authorises writing, not "this €40k transfer".
|
authorises writing, not "this €40k transfer".
|
||||||
|
|
||||||
An autonomous agent hitting a rule that requires approval must **fail cleanly
|
The rules that stay true whatever is built next: an autonomous agent hitting a
|
||||||
and escalate**, never decide. And any approval screen must be rendered from the
|
rule that requires approval must **fail cleanly and escalate**, never decide.
|
||||||
structured tool-call parameters — never from a sentence the model produced, or a
|
And any approval screen must be rendered from the structured tool-call
|
||||||
prompt injection writes what the human validates.
|
parameters — never from a sentence the model produced, or a prompt injection
|
||||||
|
writes what the human validates.
|
||||||
|
|
||||||
## Before writing workflows
|
## Before writing workflows
|
||||||
|
|
||||||
@@ -176,3 +169,26 @@ endpoints and the known blockers.
|
|||||||
|
|
||||||
Deliverables in English. Export workflows as JSON and commit them; do not rely
|
Deliverables in English. Export workflows as JSON and commit them; do not rely
|
||||||
on the n8n instance as the source of truth.
|
on the n8n instance as the source of truth.
|
||||||
|
|
||||||
|
## The Kafka credential is created by hand
|
||||||
|
|
||||||
|
`kafka-classic-models` in n8n holds the SCRAM password of the
|
||||||
|
`classic-models-agent-supply` KafkaUser and the Strimzi cluster CA.
|
||||||
|
It is not in this repo and must not be: n8n encrypts stored credentials with
|
||||||
|
`N8N_ENCRYPTION_KEY`, and the committed workflow JSON references it by id only.
|
||||||
|
|
||||||
|
To recreate it after a rebuild:
|
||||||
|
|
||||||
|
```
|
||||||
|
kubectl -n kafka get secret classic-models-agent-supply \
|
||||||
|
-o jsonpath='{.data.password}' | base64 -d
|
||||||
|
kubectl -n kafka get secret kafka-cluster-ca-cert \
|
||||||
|
-o jsonpath='{.data.ca\.crt}' | base64 -d
|
||||||
|
```
|
||||||
|
|
||||||
|
then POST to `/rest/credentials` with type `kafka`, `ssl: true`, the CA in the
|
||||||
|
`ca` field, `saslMechanism: scram-sha-512`, brokers
|
||||||
|
`kafka-kafka-bootstrap.kafka.svc.cluster.local:9093`. The workflow's
|
||||||
|
`credentials.kafka.id` then needs the new id.
|
||||||
|
|
||||||
|
The KafkaUser itself lives in `infra-kafka/kafka-users.yaml`.
|
||||||
|
|||||||
+20
-1
@@ -32,7 +32,7 @@ completed task is the wrong shape for "a human has to look at this".
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ADAPTER_AGENT_NAME` | `approvisionnement` |
|
| `ADAPTER_AGENT_NAME` | `supply` |
|
||||||
| `ADAPTER_AGENT_DESCRIPTION` | what the card says it is |
|
| `ADAPTER_AGENT_DESCRIPTION` | what the card says it is |
|
||||||
| `ADAPTER_SKILL_ID` | `check_availability` |
|
| `ADAPTER_SKILL_ID` | `check_availability` |
|
||||||
| `ADAPTER_SKILL_NAME`, `_DESCRIPTION`, `_EXAMPLES` | the card's one skill; examples are newline-separated |
|
| `ADAPTER_SKILL_NAME`, `_DESCRIPTION`, `_EXAMPLES` | the card's one skill; examples are newline-separated |
|
||||||
@@ -42,3 +42,22 @@ completed task is the wrong shape for "a human has to look at this".
|
|||||||
|
|
||||||
One skill per instance, deliberately. A second one is a card change, which is
|
One skill per instance, deliberately. A second one is a card change, which is
|
||||||
the moment to ask whether the agent has quietly become a data proxy.
|
the moment to ask whether the agent has quietly become a data proxy.
|
||||||
|
|
||||||
|
## Four outcomes, and only one of them completes
|
||||||
|
|
||||||
|
HTTP 200 from n8n does not mean the workflow answered. It returns 200 with an
|
||||||
|
**empty body** when a node throws before the Respond node runs — a refused token
|
||||||
|
exchange is the common way in. The adapter used to read that as a completed task
|
||||||
|
carrying "(the workflow returned nothing)", which told the caller the task had
|
||||||
|
succeeded: the one thing certainly false.
|
||||||
|
|
||||||
|
| `Outcome` | A2A task state |
|
||||||
|
|---|---|
|
||||||
|
| `ANSWERED` — the contract, `escalate: false` | `completed` |
|
||||||
|
| `ESCALATED` — the contract, `escalate: true` | `failed` |
|
||||||
|
| `SILENT` — 200 with an empty body | `failed` |
|
||||||
|
| `OFF_CONTRACT` — a body that is not `{"answer", "escalate"}` | `failed` |
|
||||||
|
|
||||||
|
Off contract is returned **raw** so a workflow that has drifted is visible — but
|
||||||
|
never as agreement. Escalation is `failed` for the older reason: an autonomous
|
||||||
|
agent meeting a rule that needs approval must fail cleanly, never decide.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Both n8n agents get the same image and differ only by environment. That is the
|
Both n8n agents get the same image and differ only by environment. That is the
|
||||||
point: the A2A surface is identical for every agent in the estate, and what
|
point: the A2A surface is identical for every agent in the estate, and what
|
||||||
distinguishes Approvisionnement from Recouvrement is the workflow behind the
|
distinguishes the supply agent from the collections agent is the workflow behind the
|
||||||
webhook, not the protocol in front of it.
|
webhook, not the protocol in front of it.
|
||||||
|
|
||||||
Nothing here is a secret. The adapter forwards the end user's token and holds
|
Nothing here is a secret. The adapter forwards the end user's token and holds
|
||||||
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_prefix="ADAPTER_", env_file=".env")
|
model_config = SettingsConfigDict(env_prefix="ADAPTER_", env_file=".env")
|
||||||
|
|
||||||
# --- who this instance is ------------------------------------------------
|
# --- who this instance is ------------------------------------------------
|
||||||
agent_name: str = Field(..., description="e.g. approvisionnement")
|
agent_name: str = Field(..., description="e.g. supply")
|
||||||
agent_description: str = Field(...)
|
agent_description: str = Field(...)
|
||||||
agent_version: str = "0.1.0"
|
agent_version: str = "0.1.0"
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
from enum import Enum, auto
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -152,12 +153,14 @@ class WorkflowExecutor(AgentExecutor):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
answer, escalate = _read(response)
|
answer, outcome = _read(response)
|
||||||
if escalate:
|
if outcome is not Outcome.ANSWERED:
|
||||||
# An autonomous agent that meets a rule needing approval must fail
|
# Three different failures, one honest shape. An agent that met a
|
||||||
# cleanly and escalate, never decide. The caller sees a task that
|
# rule needing approval must escalate rather than decide; a workflow
|
||||||
# did not complete, which is the honest shape for "a human has to
|
# that answered nothing did not answer; a workflow that answered off
|
||||||
# look at this".
|
# contract cannot be read as agreement. `completed` on any of them
|
||||||
|
# tells the caller the task succeeded, which is the one thing that is
|
||||||
|
# certainly false.
|
||||||
await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)]))
|
await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)]))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -167,23 +170,46 @@ class WorkflowExecutor(AgentExecutor):
|
|||||||
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
|
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
|
||||||
|
|
||||||
|
|
||||||
def _read(response: httpx.Response) -> tuple[str, bool]:
|
class Outcome(Enum):
|
||||||
"""What the workflow said, and whether it is escalating.
|
"""What came back, which is not the same question as whether HTTP worked."""
|
||||||
|
|
||||||
A workflow answers with `{"answer": "...", "escalate": false}`. Anything else
|
#: The contract was honoured and the workflow is standing behind its answer.
|
||||||
is returned as raw text rather than guessed at — a workflow that has drifted
|
ANSWERED = auto()
|
||||||
from the contract should be visible, not silently reinterpreted.
|
#: The workflow answered, and is handing the decision to a human.
|
||||||
|
ESCALATED = auto()
|
||||||
|
#: HTTP 200 with an empty body. n8n does that when a node throws before the
|
||||||
|
#: Respond node runs — a failed token exchange is the common way in. It is a
|
||||||
|
#: failure that looks exactly like a success from the status code alone.
|
||||||
|
SILENT = auto()
|
||||||
|
#: A body that is not the contract. Returned raw rather than guessed at: a
|
||||||
|
#: workflow that has drifted should be visible, not reinterpreted.
|
||||||
|
OFF_CONTRACT = auto()
|
||||||
|
|
||||||
|
|
||||||
|
def _read(response: httpx.Response) -> tuple[str, Outcome]:
|
||||||
|
"""What the workflow said, and what kind of thing it said.
|
||||||
|
|
||||||
|
A workflow answers with `{"answer": "...", "escalate": false}`.
|
||||||
"""
|
"""
|
||||||
|
body = response.text.strip()
|
||||||
|
if not body:
|
||||||
|
return (
|
||||||
|
"The workflow produced no answer. It most often means a node threw "
|
||||||
|
"before the reply was built — a refused token exchange, usually.",
|
||||||
|
Outcome.SILENT,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return response.text.strip() or "(the workflow returned nothing)", False
|
return body, Outcome.OFF_CONTRACT
|
||||||
if isinstance(payload, list) and payload:
|
if isinstance(payload, list) and payload:
|
||||||
# n8n returns an array when the workflow's last node emits items.
|
# n8n returns an array when the workflow's last node emits items.
|
||||||
payload = payload[0]
|
payload = payload[0]
|
||||||
if isinstance(payload, dict) and "answer" in payload:
|
if isinstance(payload, dict) and "answer" in payload:
|
||||||
return str(payload["answer"]), bool(payload.get("escalate", False))
|
answer = str(payload["answer"])
|
||||||
return response.text.strip() or "(the workflow returned nothing)", False
|
return answer, Outcome.ESCALATED if payload.get("escalate", False) else Outcome.ANSWERED
|
||||||
|
return body, Outcome.OFF_CONTRACT
|
||||||
|
|
||||||
|
|
||||||
def _subject_token(context: RequestContext) -> str | None:
|
def _subject_token(context: RequestContext) -> str | None:
|
||||||
|
|||||||
Binary file not shown.
@@ -11,12 +11,12 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from a2a_n8n_adapter.config import Settings
|
from a2a_n8n_adapter.config import Settings
|
||||||
from a2a_n8n_adapter.server import _read, build_agent_card
|
from a2a_n8n_adapter.server import Outcome, _read, build_agent_card
|
||||||
|
|
||||||
|
|
||||||
def settings(**over) -> Settings:
|
def settings(**over) -> Settings:
|
||||||
base = dict(
|
base = dict(
|
||||||
agent_name="approvisionnement",
|
agent_name="supply",
|
||||||
agent_description="Supply decisions.",
|
agent_description="Supply decisions.",
|
||||||
skill_id="check_availability",
|
skill_id="check_availability",
|
||||||
skill_name="Check availability",
|
skill_name="Check availability",
|
||||||
@@ -35,27 +35,35 @@ def response(payload, status=200) -> httpx.Response:
|
|||||||
|
|
||||||
class TestReadingTheWorkflow:
|
class TestReadingTheWorkflow:
|
||||||
def test_the_contracted_shape(self) -> None:
|
def test_the_contracted_shape(self) -> None:
|
||||||
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", False)
|
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", Outcome.ANSWERED)
|
||||||
|
|
||||||
def test_n8n_wraps_its_last_node_in_an_array(self) -> None:
|
def test_n8n_wraps_its_last_node_in_an_array(self) -> None:
|
||||||
assert _read(response([{"answer": "yes"}])) == ("yes", False)
|
assert _read(response([{"answer": "yes"}])) == ("yes", Outcome.ANSWERED)
|
||||||
|
|
||||||
def test_escalation_is_carried_not_swallowed(self) -> None:
|
def test_escalation_is_carried_not_swallowed(self) -> None:
|
||||||
# An autonomous agent meeting a rule that needs approval must escalate.
|
# An autonomous agent meeting a rule that needs approval must escalate.
|
||||||
# Losing this flag would turn "a human must look" into "done".
|
# Losing this flag would turn "a human must look" into "done".
|
||||||
assert _read(response({"answer": "needs approval", "escalate": True})) == (
|
assert _read(response({"answer": "needs approval", "escalate": True})) == (
|
||||||
"needs approval",
|
"needs approval",
|
||||||
True,
|
Outcome.ESCALATED,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_an_off_contract_reply_is_returned_raw(self) -> None:
|
def test_an_off_contract_reply_is_returned_raw(self) -> None:
|
||||||
# Visible rather than reinterpreted: a workflow that has drifted should
|
# Visible rather than reinterpreted: a workflow that has drifted should
|
||||||
# look wrong, not be quietly guessed at.
|
# look wrong, not be quietly guessed at.
|
||||||
text, escalate = _read(response("plain text"))
|
# Off contract is not agreement. Returned raw so the drift is visible,
|
||||||
assert text == "plain text" and escalate is False
|
# but never as a completed answer.
|
||||||
|
text, outcome = _read(response("plain text"))
|
||||||
|
assert text == "plain text" and outcome is Outcome.OFF_CONTRACT
|
||||||
|
|
||||||
def test_an_empty_reply_says_so(self) -> None:
|
def test_an_empty_reply_says_so(self) -> None:
|
||||||
assert _read(response("")) == ("(the workflow returned nothing)", False)
|
# HTTP 200 with an empty body is what n8n returns when a node throws
|
||||||
|
# before the reply is built — a refused token exchange, usually. Reading
|
||||||
|
# it as a completed answer told the caller the task succeeded, which was
|
||||||
|
# the one thing certainly false.
|
||||||
|
text, outcome = _read(response(""))
|
||||||
|
assert outcome is Outcome.SILENT
|
||||||
|
assert "no answer" in text
|
||||||
|
|
||||||
|
|
||||||
class TestAgentCard:
|
class TestAgentCard:
|
||||||
@@ -72,8 +80,8 @@ class TestAgentCard:
|
|||||||
assert len(card.skills[0].examples) == 2
|
assert len(card.skills[0].examples) == 2
|
||||||
|
|
||||||
def test_the_card_advertises_the_public_url_not_the_bind_address(self) -> None:
|
def test_the_card_advertises_the_public_url_not_the_bind_address(self) -> None:
|
||||||
card = build_agent_card(settings(public_url="http://approvisionnement.agents:8080/"))
|
card = build_agent_card(settings(public_url="http://supply-agent.agents:8080/"))
|
||||||
assert card.supported_interfaces[0].url == "http://approvisionnement.agents:8080/"
|
assert card.supported_interfaces[0].url == "http://supply-agent.agents:8080/"
|
||||||
|
|
||||||
|
|
||||||
class TestSubjectToken:
|
class TestSubjectToken:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ spec:
|
|||||||
target:
|
target:
|
||||||
name: n8n-agent-secrets
|
name: n8n-agent-secrets
|
||||||
data:
|
data:
|
||||||
- secretKey: APPROVISIONNEMENT_CLIENT_SECRET
|
- secretKey: SUPPLY_CLIENT_SECRET
|
||||||
remoteRef:
|
remoteRef:
|
||||||
key: keycloak/classic-models-realm
|
key: keycloak/classic-models-realm
|
||||||
property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT
|
property: KC_SECRET_CLASSIC_MODELS_AGENT_SUPPLY
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Reaching this agent from outside the cluster.
|
||||||
|
#
|
||||||
|
# Until now nothing did: an agent answered only what could call its Service, so
|
||||||
|
# every conversation with it went through `kubectl run curl`. An orchestrator
|
||||||
|
# nobody can talk to is not finished.
|
||||||
|
#
|
||||||
|
# Peer traffic does **not** come through here — supply calls, and is called by,
|
||||||
|
# other agents on their in-cluster Service names. This is the human door.
|
||||||
|
#
|
||||||
|
# ## It is not an open door
|
||||||
|
#
|
||||||
|
# A2A carries the end user's token in `Authorization`, and this agent forwards it
|
||||||
|
# and exchanges it; a forged one fails at the realm. But "fails downstream" is
|
||||||
|
# not the same as "refused", and an unauthenticated caller could still make the
|
||||||
|
# agent do work — inference costs money and a request that reaches an LLM is a
|
||||||
|
# prompt-injection surface. So the same authentication subrequest that guards the
|
||||||
|
# n8n endpoints guards this one: Keycloak's userinfo answers 200 for a valid
|
||||||
|
# bearer in the realm and 401 otherwise, which is the auth-request contract
|
||||||
|
# exactly.
|
||||||
|
#
|
||||||
|
# It authenticates, it does not authorise. What the caller may actually reach is
|
||||||
|
# decided further down, by the exchange and by the roles in their token.
|
||||||
|
#
|
||||||
|
# **The agent card is behind it too.** A2A discovery is normally public; here it
|
||||||
|
# is not, because a client that cannot send an `Authorization` header cannot use
|
||||||
|
# the agent anyway, and the card names the skills.
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: supply-agent-tls
|
||||||
|
namespace: agents
|
||||||
|
spec:
|
||||||
|
secretName: supply-agent-tls
|
||||||
|
dnsNames:
|
||||||
|
- supply-agent.sttlab.local
|
||||||
|
issuerRef:
|
||||||
|
name: sttlab-local-ca
|
||||||
|
kind: ClusterIssuer
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: supply-agent
|
||||||
|
namespace: agents
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/auth-url: http://keycloak.keycloak.svc.cluster.local:8080/realms/classic-models/protocol/openid-connect/userinfo
|
||||||
|
# A2A streams a task's progress; buffering it defeats that.
|
||||||
|
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
||||||
|
# An agent turn is a model call and several hops. The nginx default of 60s
|
||||||
|
# cuts a working answer off mid-flight.
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- supply-agent.sttlab.local
|
||||||
|
secretName: supply-agent-tls
|
||||||
|
rules:
|
||||||
|
- host: supply-agent.sttlab.local
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: supply-agent
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- approvisionnement.yaml
|
- supply.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# The A2A facade for Approvisionnement. It lives in the `agents` namespace with
|
# The A2A facade for Supply. It lives in the `agents` namespace with
|
||||||
# the other agents, not with n8n: what it is, is an agent — the workflow behind
|
# the other agents, not with n8n: what it is, is an agent — the workflow behind
|
||||||
# it is an implementation detail its callers never see.
|
# it is an implementation detail its callers never see.
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: approvisionnement-agent
|
name: supply-agent
|
||||||
namespace: agents
|
namespace: agents
|
||||||
spec:
|
spec:
|
||||||
# The adapter is stateless — every task is one webhook call — so this is a
|
# The adapter is stateless — every task is one webhook call — so this is a
|
||||||
@@ -12,11 +12,11 @@ spec:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: approvisionnement-agent
|
app: supply-agent
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: approvisionnement-agent
|
app: supply-agent
|
||||||
spec:
|
spec:
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
@@ -25,13 +25,13 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: adapter
|
- name: adapter
|
||||||
image: gitea.sttlab.eu/stt/a2a-n8n-adapter:v0.1.0
|
image: gitea.sttlab.eu/stt/a2a-n8n-adapter:v0.2.0
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
env:
|
env:
|
||||||
- name: ADAPTER_AGENT_NAME
|
- name: ADAPTER_AGENT_NAME
|
||||||
value: approvisionnement
|
value: supply
|
||||||
- name: ADAPTER_AGENT_DESCRIPTION
|
- name: ADAPTER_AGENT_DESCRIPTION
|
||||||
value: >-
|
value: >-
|
||||||
Supply decisions for the Classic Models catalog. Answers whether a
|
Supply decisions for the Classic Models catalog. Answers whether a
|
||||||
@@ -53,7 +53,7 @@ spec:
|
|||||||
- name: ADAPTER_WEBHOOK_URL
|
- name: ADAPTER_WEBHOOK_URL
|
||||||
value: http://n8n.n8n.svc.cluster.local/webhook/check-availability
|
value: http://n8n.n8n.svc.cluster.local/webhook/check-availability
|
||||||
- name: ADAPTER_PUBLIC_URL
|
- name: ADAPTER_PUBLIC_URL
|
||||||
value: http://approvisionnement-agent.agents.svc.cluster.local:8080/
|
value: http://supply-agent.agents.svc.cluster.local:8080/
|
||||||
- name: ADAPTER_CA_BUNDLE
|
- name: ADAPTER_CA_BUNDLE
|
||||||
value: /etc/sttlab-ca/ca.crt
|
value: /etc/sttlab-ca/ca.crt
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
@@ -92,11 +92,11 @@ spec:
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: approvisionnement-agent
|
name: supply-agent
|
||||||
namespace: agents
|
namespace: agents
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: approvisionnement-agent
|
app: supply-agent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
port: 8080
|
port: 8080
|
||||||
@@ -27,6 +27,16 @@ spec:
|
|||||||
TIMEZONE: Europe/Paris
|
TIMEZONE: Europe/Paris
|
||||||
INGRESS_CLASS: nginx
|
INGRESS_CLASS: nginx
|
||||||
INGRESS_BODY_SIZE: "50m"
|
INGRESS_BODY_SIZE: "50m"
|
||||||
|
# Every trigger endpoint is gated by this. Keycloak's userinfo answers 200
|
||||||
|
# for a valid bearer in the realm and 401 otherwise, which is exactly the
|
||||||
|
# auth-request contract, so nothing had to be built for it. In-cluster,
|
||||||
|
# over http: the subrequest never leaves the cluster and needs no CA.
|
||||||
|
#
|
||||||
|
# It authenticates, it does not authorise: any token this realm issued
|
||||||
|
# gets through, including an agent's own service account. The finer grain
|
||||||
|
# belongs to the workflow, which exchanges the caller's token and is
|
||||||
|
# refused by the domain if the caller may not read it.
|
||||||
|
N8N_WEBHOOK_AUTH_URL: http://keycloak.keycloak.svc.cluster.local:8080/realms/classic-models/protocol/openid-connect/userinfo
|
||||||
TLS_ISSUER_NAME: sttlab-local-ca
|
TLS_ISSUER_NAME: sttlab-local-ca
|
||||||
TLS_ISSUER_KIND: ClusterIssuer
|
TLS_ISSUER_KIND: ClusterIssuer
|
||||||
VAULT_SERVER: https://vault.sttlab.eu
|
VAULT_SERVER: https://vault.sttlab.eu
|
||||||
@@ -41,5 +51,9 @@ spec:
|
|||||||
# "false" so a workflow can read $env.<AGENT>_CLIENT_SECRET. It means any
|
# "false" so a workflow can read $env.<AGENT>_CLIENT_SECRET. It means any
|
||||||
# workflow on this instance can read any variable in the pod — acceptable
|
# workflow on this instance can read any variable in the pod — acceptable
|
||||||
# while it runs only this estate's agents, and not a moment longer.
|
# while it runs only this estate's agents, and not a moment longer.
|
||||||
BLOCK_ENV_ACCESS: "false"
|
BLOCK_ENV_ACCESS: '"false"'
|
||||||
EXECUTIONS_MAX_AGE_HOURS: "336"
|
# Quoted inside the value on purpose: these land in a ConfigMap, whose
|
||||||
|
# values must be strings, and the template cannot force it — kustomize
|
||||||
|
# strips quotes before substitution runs. Without the inner quotes the
|
||||||
|
# apply fails on the type.
|
||||||
|
EXECUTIONS_MAX_AGE_HOURS: '"336"'
|
||||||
|
|||||||
+254
-2
@@ -21,7 +21,82 @@ POST /rest/workflows/<id>/activate {"versionId": "<current>"}
|
|||||||
`PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently:
|
`PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently:
|
||||||
activation has its own endpoint.
|
activation has its own endpoint.
|
||||||
|
|
||||||
## approvisionnement-check-availability
|
## supply-procurement
|
||||||
|
|
||||||
|
The service's own supplier, simulated. Reacts to `reorder.requested` (emitted
|
||||||
|
by the periodic run below, once it finds a reference under threshold),
|
||||||
|
decides — a fixed lead time, or a 15% chance the supplier cannot fulfil —
|
||||||
|
answers with `reorder.confirmed`/`reorder.rejected`, and on the confirmed
|
||||||
|
delivery date writes the stock itself.
|
||||||
|
|
||||||
|
```
|
||||||
|
reorder.requested {productcode, quantity, requested_at}
|
||||||
|
→ reorder.confirmed {deliver_on} — 85% of the time
|
||||||
|
→ reorder.rejected {reason: "supplier stockout"} — 15%
|
||||||
|
```
|
||||||
|
|
||||||
|
No purchase-order entity, no supplier entity, no negotiation — the whole model
|
||||||
|
is one probability and one lead time, on purpose: this simulates *that*
|
||||||
|
fulfilment can fail, not *why* a given supplier would.
|
||||||
|
|
||||||
|
### One service, two capacities — not two identities
|
||||||
|
|
||||||
|
This is still the supply agent, not a separate "procurement" component. It
|
||||||
|
both requests (the periodic run) and answers its own request (this workflow),
|
||||||
|
under the same Kafka account and the same Keycloak client — the 2026-08-31
|
||||||
|
decision that réapprovisionnement is one service, not a split into a demand
|
||||||
|
side and a supply side.
|
||||||
|
|
||||||
|
### The write goes through the front door
|
||||||
|
|
||||||
|
On the delivery date, this workflow reads the product's current stock and
|
||||||
|
PATCHes it — through Kong, through the same `patch-product` MCP tool a human
|
||||||
|
product manager would use, under a **new, narrow role**: `restocker` may PATCH
|
||||||
|
a product and nothing else, not create, not replace, not delete. It sends
|
||||||
|
`X-Stock-Change-Reason: delivery:<reorder-id>`, which `classic-models-api`
|
||||||
|
only honours in that exact shape; anything else falls back to
|
||||||
|
`manual-adjustment`. The resulting `product.stock_changed` event is the *only*
|
||||||
|
stock-update event — there is deliberately no second, n8n-emitted fact
|
||||||
|
claiming a delivery happened, because nothing would certify it against the
|
||||||
|
one that actually changed the database. See `classic-models-api`'s commit
|
||||||
|
"Accept a delivery reason on a stock PATCH" for the backend side.
|
||||||
|
|
||||||
|
### Two triggers, one shared state
|
||||||
|
|
||||||
|
`$getWorkflowStaticData('global').pendingDeliveries`, keyed by reorder id —
|
||||||
|
the same pattern the stock-watch workflow already uses for its watchlist, now
|
||||||
|
shared across **two** trigger chains in this one workflow: the Kafka-triggered
|
||||||
|
decision writes an entry when the supplier confirms, and a daily schedule
|
||||||
|
(06:15, deliberately after the periodic run's 06:00) reads what is due and
|
||||||
|
applies it. A pending entry is deleted only once the PATCH is confirmed by the
|
||||||
|
API's own response — a failed write leaves it in place so tomorrow's run
|
||||||
|
retries it, rather than losing a delivery nobody applied.
|
||||||
|
|
||||||
|
### A bug this shipped with, and how it was found
|
||||||
|
|
||||||
|
`Apply delivery` sits between `Read current stock` and `Update stock`, and its
|
||||||
|
first version wrote `{ ...$json, new_quantity }` — but `$json` at that point is
|
||||||
|
`Read current stock`'s own output, not the due-delivery record two nodes
|
||||||
|
earlier. `reorder_id`/`productcode`/`quantity` were silently dropped, and
|
||||||
|
`Update stock` failed two nodes later with `"path_productcode" is a required
|
||||||
|
property` — a symptom far from its cause. Fixed by naming the source
|
||||||
|
explicitly: `$('Due deliveries').item.json` for what the delivery *is*, `$json`
|
||||||
|
for what the catalog *says now*. The same rule `check_availability` already
|
||||||
|
follows for `Recent movement` — an implicit `$json` is only ever the
|
||||||
|
*immediately preceding* node, never "whichever upstream node has the field I
|
||||||
|
want."
|
||||||
|
|
||||||
|
### Manual test runs do not persist static data
|
||||||
|
|
||||||
|
Verified the hard way: triggering this workflow via `/rest/workflows/{id}/run`
|
||||||
|
executes for real (the write happens, the event is genuine) but the
|
||||||
|
`pendingDeliveries` mutation is **not** saved back to the workflow — the entry
|
||||||
|
reappears on the next check. Only a genuine trigger firing (a real Kafka
|
||||||
|
message, the real schedule) persists it. A manual run is fine for proving the
|
||||||
|
logic; it is not fine for proving the state machine, and the two were verified
|
||||||
|
separately here.
|
||||||
|
|
||||||
|
## supply-check-availability
|
||||||
|
|
||||||
Working. `POST /webhook/check-availability` with the end user's token in
|
Working. `POST /webhook/check-availability` with the end user's token in
|
||||||
`X-Subject-Token`:
|
`X-Subject-Token`:
|
||||||
@@ -89,7 +164,74 @@ and through the A2A facade, `escalate: true` becomes a task in state **failed**
|
|||||||
rather than a completed one — the honest shape for "a human has to look at
|
rather than a completed one — the honest shape for "a human has to look at
|
||||||
this".
|
this".
|
||||||
|
|
||||||
## approvisionnement-periodic-reorder
|
### Deducting open orders would double-count
|
||||||
|
|
||||||
|
The answer used to end with *"quantities committed on open orders are not
|
||||||
|
deducted"*. That caveat is now **wrong**, and acting on it would be worse than
|
||||||
|
leaving it: placing an order line decrements stock in the same transaction, so
|
||||||
|
an open order has already taken what it needs. Subtracting it again counts it
|
||||||
|
twice.
|
||||||
|
|
||||||
|
The API gained the filters that would have made the deduction possible —
|
||||||
|
`list-order-details` takes `productcode` and `ordernumber__status` — and the
|
||||||
|
right conclusion was that the deduction is no longer the thing to build.
|
||||||
|
|
||||||
|
What remains true, and is said once: the order lines that came with the seed
|
||||||
|
data predate the stock movement and never decremented anything. They are
|
||||||
|
history, not commitments.
|
||||||
|
|
||||||
|
### It now knows what the reactive half saw
|
||||||
|
|
||||||
|
`check-availability` reads the watchlist before answering, so a crossing that
|
||||||
|
happened twenty minutes ago is part of the answer:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Peut-on promettre S12_1099 ?"
|
||||||
|
→ "No — 1968 Ford Mustang (S12_1099) is down to 8 … It crossed the threshold
|
||||||
|
at 06:18:24 (driven by orders), from 68."
|
||||||
|
```
|
||||||
|
|
||||||
|
"It fell below this afternoon, under demand" is a different decision from "it
|
||||||
|
has been sitting below for a month", and only the event carries that.
|
||||||
|
|
||||||
|
The watchlist is read over an in-cluster call to the stock-watch workflow's own
|
||||||
|
webhook — its data lives in that workflow and there is no other way in. The node
|
||||||
|
is `onError: continueRegularOutput`: an availability answer must not depend on
|
||||||
|
the watchlist being up.
|
||||||
|
|
||||||
|
### A stale read once made it contradict itself
|
||||||
|
|
||||||
|
The agent published this, in one sentence:
|
||||||
|
|
||||||
|
```
|
||||||
|
"68 in stock, above the reorder threshold of 50 … It crossed the threshold, from 68."
|
||||||
|
```
|
||||||
|
|
||||||
|
Both halves were internally consistent and the answer was wrong. The catalog
|
||||||
|
domain was caching reads for **60 seconds** at the gateway, so an agent woken by
|
||||||
|
an event read the figure from before the event.
|
||||||
|
|
||||||
|
The first fix was in this workflow — prefer the event's dated figure over the
|
||||||
|
undated read. It has been **removed**, because the cache was removed instead:
|
||||||
|
caching a read-write API buys little and costs correctness on every mutable
|
||||||
|
resource, since the gateway has no invalidation on write. Working around it here
|
||||||
|
would have left four other domains lying to whatever reads them next.
|
||||||
|
|
||||||
|
Measured after: a `PATCH` to 300 followed three seconds later by the same
|
||||||
|
question answers **300**.
|
||||||
|
|
||||||
|
The lesson worth keeping is not about caches. **Do not treat a single read as
|
||||||
|
current unless something guarantees it** — and an agent cannot tell.
|
||||||
|
|
||||||
|
### The crossing is mentioned only while it still holds
|
||||||
|
|
||||||
|
An entry stays on the watchlist until a human clears it, so a reference that has
|
||||||
|
since been restocked still has one. Saying "300 in stock … it crossed the
|
||||||
|
threshold, from 68" reads as a contradiction even though both halves are true,
|
||||||
|
so the context is appended only while the reference is actually below the
|
||||||
|
threshold.
|
||||||
|
|
||||||
|
## supply-periodic-reorder
|
||||||
|
|
||||||
The periodic half. A schedule trigger at 06:00, deliberately periodic rather
|
The periodic half. A schedule trigger at 06:00, deliberately periodic rather
|
||||||
than a batch standing in for events: grouping references into a purchase
|
than a batch standing in for events: grouping references into a purchase
|
||||||
@@ -106,6 +248,44 @@ scanned 110 of 110, complete
|
|||||||
escalate: true
|
escalate: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### It answers with a date
|
||||||
|
|
||||||
|
The other half of what filtering unlocked. `check_availability` now consults
|
||||||
|
the sales domain too: `list-order-details` filtered by product **and**
|
||||||
|
status (`In Process`, `On Hold`) — `Orderdetail` carries the order's
|
||||||
|
`requireddate` and `order_status` denormalised on the line itself, so this is
|
||||||
|
one call per status, not one per order.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Peut-on promettre S18_1749 ?"
|
||||||
|
→ "Yes … 2720 in stock, above the reorder threshold of 50. Basis: stock on
|
||||||
|
hand, already net of order lines placed through the API. 2 open order(s)
|
||||||
|
already commit to this reference; the nearest is due 2005-05-04
|
||||||
|
(order 10407, On Hold)."
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **not** the deduction that turned out to be wrong two sessions ago —
|
||||||
|
that would have subtracted these quantities from stock a second time. This is
|
||||||
|
additive context only: what already-open orders expect, and by when. Silent
|
||||||
|
when there is none; never blocks the answer if the sales domain cannot be
|
||||||
|
reached.
|
||||||
|
|
||||||
|
### Two token exchanges, on purpose
|
||||||
|
|
||||||
|
Catalog and sales are different audiences, so this workflow now holds two
|
||||||
|
narrow tokens rather than widening the one it already had — the same reason
|
||||||
|
every agent in this estate exchanges per callee rather than requesting a
|
||||||
|
broad token once.
|
||||||
|
|
||||||
|
### A straight chain, not two branches joining
|
||||||
|
|
||||||
|
The obvious shape was two parallel paths — catalog and sales — converging on
|
||||||
|
`Answer`. Not built that way: whether a node with two incoming connections
|
||||||
|
waits for both before running is exactly the kind of thing worth being certain
|
||||||
|
of rather than assuming, and this endpoint answers rarely enough that a few
|
||||||
|
hundred extra milliseconds from running sequentially is free. One straight
|
||||||
|
chain, ten nodes, nothing to get wrong about ordering.
|
||||||
|
|
||||||
### It acts under its own machine identity
|
### It acts under its own machine identity
|
||||||
|
|
||||||
`client_credentials`, not a mandate. An agent running on its own account acts
|
`client_credentials`, not a mandate. An agent running on its own account acts
|
||||||
@@ -147,3 +327,75 @@ looks exactly like one computed from all of it, so the difference has to be in
|
|||||||
the output. A catalog that outgrows one page needs a paging loop **and** the
|
the output. A catalog that outgrows one page needs a paging loop **and** the
|
||||||
read-volume cap this repo already lists as mandatory for autonomous agents.
|
read-volume cap this repo already lists as mandatory for autonomous agents.
|
||||||
|
|
||||||
|
|
||||||
|
## supply-stock-watch
|
||||||
|
|
||||||
|
The reactive half, and the one the design had been waiting on. It is woken by
|
||||||
|
`product.stock_changed` on `classic-models.catalog` rather than by a clock, so a
|
||||||
|
reference that runs low at 14:32 is noticed at 14:32 instead of being reported
|
||||||
|
at 06:00 the next morning.
|
||||||
|
|
||||||
|
Verified end to end against the live catalog:
|
||||||
|
|
||||||
|
```
|
||||||
|
S12_1099 68 → 38 detected: "fell from 68 to 38, below the threshold of 50"
|
||||||
|
38 → 30 not detected — already below, figures refreshed instead
|
||||||
|
30 → 0 detected: "is out of stock. Nothing can be promised."
|
||||||
|
0 → 68 not detected, entry refreshed (a correction, not demand)
|
||||||
|
POST {"clear":["S12_1099"]} → watching: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Only a crossing is news
|
||||||
|
|
||||||
|
`before >= threshold && after < threshold`. Without that test, every further
|
||||||
|
order line on an already-low reference raises the same alarm again, and an agent
|
||||||
|
that repeats itself is one a human learns to ignore.
|
||||||
|
|
||||||
|
A stock-out is treated as its own fact even when the reference was already below
|
||||||
|
the threshold: *low* and *nothing left to promise* are different problems.
|
||||||
|
|
||||||
|
A movement that changes nothing newsworthy still **refreshes** an entry already
|
||||||
|
being watched. Otherwise the watchlist would show the figure from the moment of
|
||||||
|
detection, which is the number least likely to still be true.
|
||||||
|
|
||||||
|
`reason` carries `order:<n>` for demand and `manual-adjustment` for someone
|
||||||
|
correcting the books. Both can justify a purchase order; only demand says
|
||||||
|
anything about the future, so the distinction is recorded rather than filtered
|
||||||
|
on.
|
||||||
|
|
||||||
|
### The detection has somewhere to go
|
||||||
|
|
||||||
|
`$getWorkflowStaticData('global')`, keyed by reference, readable over
|
||||||
|
`GET /webhook/stock-watchlist` and cleared with
|
||||||
|
`POST {"clear": ["S24_2000"]}`. The purchase *proposal* still has nowhere to be
|
||||||
|
written — the domain has no purchase order and no supplier entity — but a
|
||||||
|
detection that only reached an execution log would be lost between two nightly
|
||||||
|
runs, which would make the reactive half pointless.
|
||||||
|
|
||||||
|
The obvious next consumer is `check-availability`: "can we promise X?" is a
|
||||||
|
better answer when it knows X crossed the threshold at 14:32.
|
||||||
|
|
||||||
|
### Kafka details worth keeping
|
||||||
|
|
||||||
|
- **The offset moves only on success** (`resolveOffset: onCompletion`,
|
||||||
|
`allowedStatuses: ["success"]`). A failed execution replays its event rather
|
||||||
|
than losing it — for an autonomous agent a detection dropped in silence is
|
||||||
|
worse than one made twice.
|
||||||
|
- **`fromBeginning: false`.** Otherwise every restart replays the whole
|
||||||
|
retention window and re-detects crossings dealt with days ago.
|
||||||
|
- Most messages on the topic are not `stock_changed`, and the workflow says so
|
||||||
|
explicitly and succeeds. A workflow that threw on an uninteresting event would
|
||||||
|
replay it forever.
|
||||||
|
- The consumer has its **own** SCRAM account,
|
||||||
|
`classic-models-agent-supply`: Read on that one topic and that one
|
||||||
|
consumer-group prefix. It cannot read sales or payments, and it cannot write —
|
||||||
|
so a compromised workflow can raise no event another agent would act on.
|
||||||
|
|
||||||
|
### Two traps
|
||||||
|
|
||||||
|
- **n8n's Kafka credential has a `ca` field.** The cluster CA goes there, so
|
||||||
|
there was no need to touch `NODE_EXTRA_CA_CERTS` or to disable verification.
|
||||||
|
Not obvious from the node's UI, which shows it only once `ssl` is on.
|
||||||
|
- **A webhook declaring several methods has one output per method**, in the
|
||||||
|
order declared. Wiring only the first silently drops every POST: the execution
|
||||||
|
reports **success** and n8n answers `No item to return was found`.
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Approvisionnement \u2014 check_availability",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"httpMethod": "POST",
|
|
||||||
"path": "check-availability",
|
|
||||||
"responseMode": "responseNode",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "webhook",
|
|
||||||
"name": "Webhook",
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"webhookId": "check-availability"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token",
|
|
||||||
"sendBody": true,
|
|
||||||
"contentType": "form-urlencoded",
|
|
||||||
"bodyParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "grant_type",
|
|
||||||
"value": "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "client_id",
|
|
||||||
"value": "classic-models-agent-approvisionnement"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "client_secret",
|
|
||||||
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "subject_token",
|
|
||||||
"value": "={{ $('Webhook').item.json.headers['x-subject-token'] }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "subject_token_type",
|
|
||||||
"value": "urn:ietf:params:oauth:token-type:access_token"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "scope",
|
|
||||||
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "exchange-token",
|
|
||||||
"name": "Exchange token",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
220,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
|
|
||||||
"sendHeaders": true,
|
|
||||||
"headerParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "Authorization",
|
|
||||||
"value": "=Bearer {{ $json.access_token }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Accept",
|
|
||||||
"value": "application/json, text/event-stream"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sendBody": true,
|
|
||||||
"specifyBody": "json",
|
|
||||||
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement\", \"version\": \"0.1.0\"}}}",
|
|
||||||
"options": {
|
|
||||||
"response": {
|
|
||||||
"response": {
|
|
||||||
"fullResponse": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": "mcp-initialize",
|
|
||||||
"name": "MCP initialize",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
440,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
|
|
||||||
"sendHeaders": true,
|
|
||||||
"headerParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "Authorization",
|
|
||||||
"value": "=Bearer {{ $('Exchange token').item.json.access_token }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Accept",
|
|
||||||
"value": "application/json, text/event-stream"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mcp-session-id",
|
|
||||||
"value": "={{ $json.headers['mcp-session-id'] }}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sendBody": true,
|
|
||||||
"specifyBody": "json",
|
|
||||||
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'get-product', arguments: { path_productcode: $('Decide the product').item.json.productcode } } }) }}",
|
|
||||||
"options": {
|
|
||||||
"response": {
|
|
||||||
"response": {
|
|
||||||
"neverError": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": "read-stock",
|
|
||||||
"name": "Read stock",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
660,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"alwaysOutputData": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"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",
|
|
||||||
"name": "Decide the product",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
220,
|
|
||||||
180
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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 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\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = 'stock on hand; quantities committed on open orders are not deducted';\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 + `Basis: ${basis}.`\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 + `Basis: ${basis}.`,\n escalate: false,\n}}];\n"
|
|
||||||
},
|
|
||||||
"id": "answer",
|
|
||||||
"name": "Answer",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
880,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={{ JSON.stringify($json) }}",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "respond",
|
|
||||||
"name": "Respond",
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.1,
|
|
||||||
"position": [
|
|
||||||
1100,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"connections": {
|
|
||||||
"Webhook": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Decide the product",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Decide the product": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Exchange token",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Exchange token": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "MCP initialize",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"MCP initialize": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Read stock",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Read stock": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Answer",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Answer": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
+40
-5
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Approvisionnement \u2014 periodic reorder proposal",
|
"name": "Supply \u2014 periodic reorder proposal",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_id",
|
"name": "client_id",
|
||||||
"value": "classic-models-agent-approvisionnement"
|
"value": "classic-models-agent-supply"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_secret",
|
"name": "client_secret",
|
||||||
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
|
"value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "scope",
|
"name": "scope",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"specifyBody": "json",
|
||||||
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement-periodic\", \"version\": \"0.1.0\"}}}",
|
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"supply-periodic\", \"version\": \"0.1.0\"}}}",
|
||||||
"options": {
|
"options": {
|
||||||
"response": {
|
"response": {
|
||||||
"response": {
|
"response": {
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "// Which references need a purchase order, and why.\n//\n// The proposal is computed and goes no further, because there is nowhere for it\n// to go: the classicmodels schema has no purchase order, no supplier entity \u2014\n// `productvendor` is a string on the product \u2014 and the catalog's only write\n// tool is create-product, which is the Catalogue agent's business, not this\n// one's. So this run escalates rather than writing, which is also what an\n// autonomous agent meeting a decision it cannot take is supposed to do.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\nreturn [{ json: {\n proposals,\n scanned,\n // Loud when the scan was partial: a proposal set computed from part of the\n // catalog looks exactly like one computed from all of it.\n complete: scanned >= total,\n escalate: proposals.length > 0 || scanned < total,\n reason: scanned < total\n ? `Scanned ${scanned} of ${total} references; the rest were not read.`\n : (proposals.length\n ? `${proposals.length} reference(s) below the threshold of ${THRESHOLD}. `\n + 'Nowhere to record a purchase order \u2014 the domain has no such concept \u2014 '\n + 'so this needs a human.'\n : 'Nothing below the threshold.'),\n}}];\n"
|
"jsCode": "// Which references need a purchase order, and why.\n//\n// A proposal becomes a request event, not a write: the domain still has no\n// purchase-order entity, and this run does not decide a delivery date or a\n// supplier's answer \u2014 that is the procurement half of this same service,\n// reacting to reorder.requested on its own schedule.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\n// Each proposal becomes a request, not a purchase order \u2014 the domain still has\n// no such entity, and this run is still not the one that decides a delivery\n// date or a supplier's answer. It raises a fact and hands the decision to the\n// same service's own procurement half, over Kafka, exactly the way any other\n// consumer would be handed it.\nconst now = new Date().toISOString();\nconst requests = proposals.map((p) => ({\n json: {\n reorder_id: `${Date.now().toString(36)}-${p.productcode}-${Math.random().toString(36).slice(2, 8)}`,\n productcode: p.productcode,\n quantity: p.propose_order,\n requested_at: now,\n },\n}));\n\n// Nothing to request is a normal outcome, not a diagnostic \u2014 this workflow has\n// no reader downstream of an \"escalate\" field the way check_availability's\n// webhook does, so there is nowhere for a warning to usefully go beyond n8n's\n// own execution log. A partial scan (scanned < total) is visible there:\n// `results.length` on the raw page ends up smaller than `count`.\nreturn requests;\n"
|
||||||
},
|
},
|
||||||
"id": "propose-reorders",
|
"id": "propose-reorders",
|
||||||
"name": "Propose reorders",
|
"name": "Propose reorders",
|
||||||
@@ -149,6 +149,30 @@
|
|||||||
880,
|
880,
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"topic": "classic-models.procurement",
|
||||||
|
"sendInputData": false,
|
||||||
|
"message": "={{ JSON.stringify({ specversion: '1.0', type: 'com.sttlab.classicmodels.reorder.requested.v1', source: '/supply-agent/procurement', subject: $json.reorder_id, id: $json.reorder_id, time: $json.requested_at, datacontenttype: 'application/json', data: { reorder_id: $json.reorder_id, productcode: $json.productcode, quantity: $json.quantity, requested_at: $json.requested_at } }) }}",
|
||||||
|
"useKey": true,
|
||||||
|
"key": "={{ $json.reorder_id }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "emit-reorder-requests",
|
||||||
|
"name": "Emit reorder requests",
|
||||||
|
"type": "n8n-nodes-base.kafka",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"kafka": {
|
||||||
|
"id": "8qNZXLzvdx5rWQ0V",
|
||||||
|
"name": "kafka-classic-models"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
@@ -195,6 +219,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Propose reorders": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Emit reorder requests",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
{
|
||||||
|
"name": "Supply \u2014 procurement",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"topic": "classic-models.procurement",
|
||||||
|
"groupId": "supply-procurement",
|
||||||
|
"resolveOffset": "onCompletion",
|
||||||
|
"allowedStatuses": [
|
||||||
|
"success"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"jsonParseMessage": true,
|
||||||
|
"onlyMessage": true,
|
||||||
|
"fromBeginning": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.kafkaTrigger",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "procurement-events",
|
||||||
|
"name": "Procurement events",
|
||||||
|
"credentials": {
|
||||||
|
"kafka": {
|
||||||
|
"id": "8qNZXLzvdx5rWQ0V",
|
||||||
|
"name": "kafka-classic-models"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Every fact on this topic passes through here \u2014 requests, and this\n// service's own answers to them. Only a request is this node's job; an\n// answer re-arriving (a replay, or this consumer's own message) is not\n// something to decide on a second time.\nif ($json.type !== 'com.sttlab.classicmodels.reorder.requested.v1') return [];\nreturn [{ json: $json.data ?? {} }];"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "filter-requested",
|
||||||
|
"name": "Only requests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// The supplier's answer to one request \u2014 decided once, here, never revisited.\n//\n// A random stockout is the honest way to simulate \"sometimes a supplier\n// cannot fulfil a request\" without pretending to model a real one: no lead\n// time table, no supplier entity, no negotiation. `STOCKOUT_CHANCE` is the\n// whole model.\nconst LEAD_TIME_DAYS = 7;\nconst STOCKOUT_CHANCE = 0.15;\n\nconst req = $json;\nif (!req.reorder_id || !req.productcode) {\n // Not our envelope, or unreadable. Every message on this topic is either a\n // request or an answer to one \u2014 this node only runs on requests (filtered\n // upstream), so a shape this far off the contract is not this run's job.\n return [];\n}\n\nconst rejected = Math.random() < STOCKOUT_CHANCE;\nconst now = new Date();\n\nif (rejected) {\n return [{ json: {\n outcome: 'rejected',\n reorder_id: req.reorder_id,\n productcode: req.productcode,\n quantity: req.quantity,\n decided_at: now.toISOString(),\n reason: 'supplier stockout',\n }}];\n}\n\nconst deliverOn = new Date(now);\ndeliverOn.setUTCDate(deliverOn.getUTCDate() + LEAD_TIME_DAYS);\n\nreturn [{ json: {\n outcome: 'confirmed',\n reorder_id: req.reorder_id,\n productcode: req.productcode,\n quantity: req.quantity,\n decided_at: now.toISOString(),\n deliver_on: deliverOn.toISOString().slice(0, 10),\n}}];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "decide-request",
|
||||||
|
"name": "Decide the request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// The envelope for whichever answer \"Decide the request\" produced. One node\n// covers both event types on purpose: they share every field except the ones\n// that differ (deliver_on vs reason), and a caller distinguishes them by\n// `type`, the way any consumer here already does.\nconst d = $json;\nconst type = d.outcome === 'confirmed'\n ? 'com.sttlab.classicmodels.reorder.confirmed.v1'\n : 'com.sttlab.classicmodels.reorder.rejected.v1';\n\nconst data = d.outcome === 'confirmed'\n ? { reorder_id: d.reorder_id, productcode: d.productcode, quantity: d.quantity, deliver_on: d.deliver_on }\n : { reorder_id: d.reorder_id, productcode: d.productcode, quantity: d.quantity, reason: d.reason };\n\nreturn [{ json: {\n envelope: JSON.stringify({\n specversion: '1.0',\n type,\n source: '/supply-agent/procurement',\n subject: d.reorder_id,\n id: `${d.reorder_id}-${d.outcome}`,\n time: d.decided_at,\n datacontenttype: 'application/json',\n data,\n }),\n reorder_id: d.reorder_id,\n}}];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
-100
|
||||||
|
],
|
||||||
|
"id": "build-answer",
|
||||||
|
"name": "Build the answer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"topic": "classic-models.procurement",
|
||||||
|
"sendInputData": false,
|
||||||
|
"message": "={{ $json.envelope }}",
|
||||||
|
"useKey": true,
|
||||||
|
"key": "={{ $json.reorder_id }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.kafka",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
-100
|
||||||
|
],
|
||||||
|
"id": "publish-answer",
|
||||||
|
"name": "Publish the answer",
|
||||||
|
"credentials": {
|
||||||
|
"kafka": {
|
||||||
|
"id": "8qNZXLzvdx5rWQ0V",
|
||||||
|
"name": "kafka-classic-models"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Remembering what to deliver, and when \u2014 the same staticData pattern the\n// stock-watch workflow already uses for its own pending state, so this is not\n// a second mechanism to reason about, just a second use of one.\nif ($json.outcome !== 'confirmed') {\n // A rejection has nothing further to do: no delivery, nothing to schedule.\n return [];\n}\nconst store = $getWorkflowStaticData('global');\nstore.pendingDeliveries = store.pendingDeliveries ?? {};\nstore.pendingDeliveries[$json.reorder_id] = {\n productcode: $json.productcode,\n quantity: $json.quantity,\n deliver_on: $json.deliver_on,\n};\nreturn [{ json: { scheduled: true, reorder_id: $json.reorder_id } }];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"id": "schedule-delivery",
|
||||||
|
"name": "Schedule the delivery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "days",
|
||||||
|
"triggerAtHour": 6,
|
||||||
|
"triggerAtMinute": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "every-morning-delivery",
|
||||||
|
"name": "Every morning (delivery)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token",
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "form-urlencoded",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "grant_type",
|
||||||
|
"value": "client_credentials"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client_id",
|
||||||
|
"value": "classic-models-agent-supply"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client_secret",
|
||||||
|
"value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scope",
|
||||||
|
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "machine-token-delivery",
|
||||||
|
"name": "Machine token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $json.access_token }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json, text/event-stream"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"supply-procurement\", \"version\": \"0.1.0\"}}}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"fullResponse": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "mcp-initialize-delivery",
|
||||||
|
"name": "MCP initialize"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// What is due today or earlier \u2014 anything scheduled for a date already past\n// is delivered on the next run rather than lost, since this only runs once a\n// day and a missed day must not mean a missed delivery.\nconst store = $getWorkflowStaticData('global');\nstore.pendingDeliveries = store.pendingDeliveries ?? {};\n\nconst today = new Date().toISOString().slice(0, 10);\nconst due = Object.entries(store.pendingDeliveries)\n .filter(([, d]) => d.deliver_on <= today)\n .map(([reorder_id, d]) => ({ json: { reorder_id, ...d } }));\n\nreturn due;\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "due-deliveries",
|
||||||
|
"name": "Due deliveries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json, text/event-stream"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mcp-session-id",
|
||||||
|
"value": "={{ $('MCP initialize').item.json.headers['mcp-session-id'] }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'get-product', arguments: { path_productcode: $json.productcode } } }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "read-current-stock",
|
||||||
|
"name": "Read current stock",
|
||||||
|
"alwaysOutputData": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// The new stock figure, read fresh rather than trusted from whenever this was\n// scheduled \u2014 a delivery adds to whatever is on hand *now*, not to a number\n// that may be a week stale.\n//\n// Two upstream nodes matter here, not one: \"Due deliveries\" carries what this\n// delivery IS (reorder_id, productcode, quantity), \"Read current stock\" carries\n// what the catalog says right now. `$json` at this point is the LATTER \u2014 using\n// it for the former was the bug that shipped first: it silently dropped\n// reorder_id/productcode/quantity, and the write two nodes later failed on\n// \"path_productcode is a required property\" with no due-delivery context left\n// to explain why.\nconst due = $('Due deliveries').item.json;\n\nconst raw = $json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) return [{ json: { ...due, applied: false, why: 'unreadable catalog reply' } }];\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { ...due, applied: false, why: 'unreadable catalog reply' } }]; }\nif (rpc.error || rpc.result?.isError) {\n return [{ json: { ...due, applied: false, why: 'read failed' } }];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst newQuantity = Number(product.quantityinstock) + Number(due.quantity);\n\nreturn [{ json: { ...due, new_quantity: newQuantity } }];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "apply-delivery",
|
||||||
|
"name": "Apply delivery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json, text/event-stream"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mcp-session-id",
|
||||||
|
"value": "={{ $('MCP initialize').item.json.headers['mcp-session-id'] }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'patch-product', arguments: { path_productcode: $json.productcode, ['header_X-Stock-Change-Reason']: 'delivery:' + $json.reorder_id, body: { quantityinstock: $json.new_quantity } } } }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "update-stock",
|
||||||
|
"name": "Update stock",
|
||||||
|
"alwaysOutputData": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Only clear the pending entry once the write is actually confirmed \u2014 the\n// classic-models-api response is the proof, never an assumption. A failed\n// PATCH here must leave the entry in place so tomorrow's run retries it,\n// rather than silently losing a delivery nobody applied.\nconst raw = $json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\n\nlet ok = false;\ntry {\n const rpc = JSON.parse(line.slice(5).trim());\n ok = !rpc.error && !rpc.result?.isError;\n} catch (e) {\n ok = false;\n}\n\nconst pending = $('Apply delivery').item.json;\nif (!ok) {\n return [{ json: { delivered: false, reorder_id: pending.reorder_id, why: 'the write did not confirm; retrying tomorrow' } }];\n}\n\nconst store = $getWorkflowStaticData('global');\nif (store.pendingDeliveries) delete store.pendingDeliveries[pending.reorder_id];\nreturn [{ json: { delivered: true, reorder_id: pending.reorder_id, productcode: pending.productcode, new_quantity: pending.new_quantity } }];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"id": "clear-delivery",
|
||||||
|
"name": "Confirm delivery"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Procurement events": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Only requests",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Only requests": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Decide the request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Decide the request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build the answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Schedule the delivery",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build the answer": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Publish the answer",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Every morning (delivery)": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Machine token",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Machine token": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "MCP initialize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MCP initialize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Due deliveries",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Due deliveries": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Read current stock",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Read current stock": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Apply delivery",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Apply delivery": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Update stock",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Update stock": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Confirm delivery",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"name": "Supply \u2014 stock watch",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"topic": "classic-models.catalog",
|
||||||
|
"groupId": "supply-stock-watch",
|
||||||
|
"resolveOffset": "onCompletion",
|
||||||
|
"allowedStatuses": [
|
||||||
|
"success"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"jsonParseMessage": true,
|
||||||
|
"onlyMessage": true,
|
||||||
|
"fromBeginning": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.kafkaTrigger",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "kafka-trigger-stock-watch",
|
||||||
|
"name": "Catalog events",
|
||||||
|
"credentials": {
|
||||||
|
"kafka": {
|
||||||
|
"id": "8qNZXLzvdx5rWQ0V",
|
||||||
|
"name": "kafka-classic-models"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// A stock movement, judged the moment it happens.\n//\n// This is the reactive half of Supply. The 06:00 run proposes\n// purchase orders on a buying cycle, which is genuinely periodic; noticing that\n// a reference is about to run out is not, and a nightly sweep would report at\n// 06:00 what was true at 14:32 the previous afternoon.\n//\n// What it reacts to is a fact the API published, not a row it went looking for.\nconst THRESHOLD = 50;\nconst COVER = 3; // a proposal covers three times the threshold\nconst STOCK_CHANGED = 'com.sttlab.classicmodels.product.stock_changed.v1';\n\nconst event = $json ?? {};\n\n// The topic carries every catalog event, so most messages are not ours. Saying\n// so explicitly keeps the offset moving: a workflow that threw on an\n// uninteresting event would replay it forever.\nif (event.type !== STOCK_CHANGED) {\n return [{ json: { considered: false, type: event.type ?? 'unreadable' } }];\n}\n\nconst data = event.data ?? {};\nconst before = Number(data.before?.quantityinstock);\nconst after = Number(data.after?.quantityinstock);\nif (!Number.isFinite(before) || !Number.isFinite(after)) {\n return [{ json: {\n considered: false,\n escalate: true,\n reason: `${event.subject}: the movement carried no readable quantities.`,\n }}];\n}\n\n// Only a *crossing* is news. Without this, every further order line on an\n// already-low reference raises the same alarm again, and an agent that repeats\n// itself is one a human learns to ignore.\nconst crossed = before >= THRESHOLD && after < THRESHOLD;\n// A stock-out is a different fact from a low stock, even when the reference was\n// already below the threshold \u2014 nothing can be promised at all.\nconst emptied = after === 0 && before > 0;\n\n// `order:` means demand; anything else is someone correcting the books. Both\n// justify a purchase order, but only demand is a signal about the future.\nconst fromDemand = String(data.reason ?? '').startsWith('order:');\n\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\nif (!crossed && !emptied) {\n // Not news, but if this reference is already being watched its figures have\n // just moved. Refresh them rather than leaving a human reading the number it\n // held at the moment of detection, which is the number least likely to still\n // be true.\n const watched = store.watchlist[event.subject];\n if (watched) {\n watched.on_hand = after;\n watched.propose_order = THRESHOLD * COVER - after;\n watched.last_movement_at = event.time ?? new Date().toISOString();\n }\n return [{ json: {\n considered: true,\n detected: false,\n watching: Boolean(watched),\n productcode: event.subject,\n on_hand: after,\n }}];\n}\n\nconst state = { detected: true, escalate: true,\n productcode: event.subject,\n on_hand: after,\n was: before,\n threshold: THRESHOLD,\n stock_out: after === 0,\n driver: fromDemand ? 'demand' : 'correction',\n movement: data.reason ?? null,\n propose_order: THRESHOLD * COVER - after,\n observed_at: event.time ?? new Date().toISOString(),\n // Kept so this detection can be joined to the request that caused it. It is\n // the only link back \u2014 the event deliberately names no actor.\n traceparent: event.traceparent ?? null,\n};\n\nstate.reason = state.stock_out\n ? `${state.productcode} is out of stock (was ${before}). Nothing can be promised.`\n : `${state.productcode} fell from ${before} to ${after}, below the threshold of ${THRESHOLD}.`;\n\n// The watchlist is the destination, and it is a real one: the proposal still\n// has nowhere to be written \u2014 the domain has no purchase order and no supplier\n// entity \u2014 so a detection that only logged would be lost between two nightly\n// runs. Here it survives, keyed by reference so a reference cannot appear twice.\nstore.watchlist[state.productcode] = state;\n\nreturn [{ json: state }];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "detect-crossing",
|
||||||
|
"name": "Judge the movement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": [
|
||||||
|
"GET",
|
||||||
|
"POST"
|
||||||
|
],
|
||||||
|
"path": "stock-watchlist",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"options": {},
|
||||||
|
"multipleMethods": true
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"id": "watchlist-read",
|
||||||
|
"name": "Watchlist",
|
||||||
|
"webhookId": "stock-watchlist-get"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// What has been detected and not yet dealt with.\n//\n// A detection that goes only into an execution log is one nobody reads. This\n// makes the watchlist readable \u2014 by the periodic run, which can start from what\n// was already noticed rather than rediscovering it, and by a human.\n//\n// GET /webhook/stock-watchlist \u2192 the current list\n// POST /webhook/stock-watchlist {\"clear\": [\"S24_2000\"]} \u2192 drop entries\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\n// GET carries no body; the node still runs, and clear is then empty.\nconst body = $json.body ?? {};\nconst clear = Array.isArray(body.clear) ? body.clear : [];\nfor (const code of clear) delete store.watchlist[code];\n\nconst entries = Object.values(store.watchlist);\nreturn [{ json: {\n watching: entries.length,\n cleared: clear,\n // Worst first: an out-of-stock reference is not the same problem as one that\n // merely crossed the threshold.\n entries: entries.sort((a, b) => a.on_hand - b.on_hand),\n}}];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"id": "watchlist-render",
|
||||||
|
"name": "Render the watchlist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Catalog events": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Judge the movement",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Watchlist": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Render the watchlist",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Render the watchlist",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user