Compare commits
12 Commits
90b2c89ff1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eadc1f6a6 | |||
| b758a0e09d | |||
| 242b0c0f1c | |||
| 097b76c7ef | |||
| b7151b3bc7 | |||
| 8a631f5ea4 | |||
| 1851b38c62 | |||
| 660c62c32c | |||
| a1acb781b0 | |||
| 54a6849563 | |||
| 6f54d765c4 | |||
| 5ec2990347 |
@@ -64,7 +64,10 @@ src/
|
||||
│ ├── workflow-service.js Workflows, lazy loading + cache
|
||||
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
||||
│ ├── wms-query-service.js Construction d'expressions LINQ
|
||||
│ └── log-service.js Lecture et recherche dans les fichiers de logs
|
||||
│ ├── single-flight.js Déduplication des chargements + génération (D27)
|
||||
│ ├── ad-envelope.js Enveloppe { entities } des API AD : vide anormal = erreur (D27)
|
||||
│ ├── log-service.js Lecture et recherche dans les fichiers de logs
|
||||
│ └── response-limit.js Plafond de taille commun aux outils de requête (D24)
|
||||
└── tools/ 23 outils MCP
|
||||
├── wms-query-tools.js query_wms_entities, count_wms_entities,
|
||||
│ get_entity_schema, search_wms_data
|
||||
@@ -105,7 +108,11 @@ serveur au démarrage.
|
||||
[MONITORING.md](MONITORING.md) §2.
|
||||
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
|
||||
structurée `{ success: false, error, tool }` avec `isError: true` — le
|
||||
wrapper est dans le handler `tools/call` de `src/index.js`.
|
||||
wrapper est dans le handler `tools/call` de `src/index.js`. Les enveloppes
|
||||
construites **localement** dans `src/tools/` portent le champ `tool` elles
|
||||
aussi : le wrapper ne les voit pas, et une erreur sans `tool` sort du
|
||||
contrat. Les champs supplémentaires utiles (`warning` de résolution,
|
||||
`profiles`, `hint`…) viennent après.
|
||||
4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
|
||||
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
|
||||
disponibles : … »).
|
||||
@@ -150,6 +157,11 @@ actif à chaque appel.
|
||||
de `search_logs` — au-delà, des résultats entiers sont écartés et signalés
|
||||
(`truncated`, D24).
|
||||
|
||||
**`MAX_QUERY_RESPONSE_CHARS`** (défaut 25 000) : même plafond pour
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` — au-delà, des
|
||||
lignes entières sont écartées et signalées (D24). `count_wms_entities` n'est
|
||||
pas concerné.
|
||||
|
||||
**Au runtime.** `profile-manager` est un singleton d'état global. Les services
|
||||
s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant :
|
||||
|
||||
@@ -181,15 +193,62 @@ cache incluent l'application pour éviter toute pollution croisée (D26).
|
||||
| `ad-service` | **un par (application, type)** (20 types) | `AD_ELEMENT_TYPES` : `View` 200, `Workflow` 5000, `Resource` 15000, autres 100000 |
|
||||
|
||||
**Ne préchargez jamais les 9 applications** : seule l'application demandée est
|
||||
chargée (D26).
|
||||
chargée (D26). `search_workflows` et `search_ad_elements` rappellent toujours
|
||||
l'application interrogée et, sur résultat **vide**, ajoutent un `hint` nommant
|
||||
les autres — construit depuis la liste d'applications **déjà en cache**, jamais
|
||||
par un appel réseau (D26).
|
||||
|
||||
Les tailles de page par type viennent de l'observation des timeouts serveur —
|
||||
ne les augmentez pas à l'aveugle.
|
||||
|
||||
Les chargements sont **dédupliqués par clé de cache** : sous appels concurrents,
|
||||
une seule chaîne de fetch part par clé et les autres appelants la rejoignent
|
||||
(D27) — deux applications différentes se chargent toujours en parallèle. Un
|
||||
fetch parti avant une invalidation ne repeuple plus le cache après elle : la
|
||||
publication passe par un `commit` gardé par un compteur de génération. **Ne
|
||||
remettez jamais d'écriture de cache dans une fonction de chargement.**
|
||||
|
||||
Une réponse d'API AD **hors enveloppe** `{ entities: [...] }` lève au lieu de
|
||||
passer pour un tableau vide : sinon un cache vide s'installe pour tout le TTL
|
||||
(D27). Un `entities: []` **réel** reste cachable — des applications sont
|
||||
légitimement vides.
|
||||
|
||||
`get_application_summary` expose l'état des caches sans redémarrage.
|
||||
|
||||
---
|
||||
|
||||
## Sorties bornées (D24)
|
||||
|
||||
Une réponse d'outil de plus de ~70 000 caractères est **rejetée par le client
|
||||
MCP**. Les outils qui peuvent dépasser ce seuil bornent et **signalent** :
|
||||
`truncated: true` (jamais `false`), `hint` actionnable, `returned`, et le total
|
||||
avant la coupe. Réutilisez ce vocabulaire, n'en inventez pas un second.
|
||||
|
||||
**`get_workflow_details` fenêtre le blob `data`** (la définition EasyBuilder :
|
||||
71 512 caractères sur un StackerCrane, 92 362 sur un gros `CST_*`) :
|
||||
`max_data_chars` (défaut 20 000) et `data_offset` (défaut 0). Les métadonnées
|
||||
restent complètes, `dataTotalChars` est porté par toute réponse, et la tranche
|
||||
est **verbatim** — concaténer les tranches dans l'ordre des offsets reconstitue
|
||||
la définition à l'octet près. Ne la résumez pas, ne la « parsez » pas.
|
||||
|
||||
**Les trois outils de requête plafonnent leur volume** via
|
||||
`src/services/response-limit.js` (`MAX_QUERY_RESPONSE_CHARS`) : au-delà, des
|
||||
lignes entières sont écartées, jamais coupées au milieu. Sous le plafond, la
|
||||
réponse est inchangée **octet pour octet** — c'est la contrainte à préserver si
|
||||
vous y touchez.
|
||||
|
||||
| Outil | Unité écartée | Total porté |
|
||||
|---|---|---|
|
||||
| `query_wms_entities` | une ligne | `count` (déjà présent) |
|
||||
| `call_query_api` | une ligne | `totalRows` (ajouté à la coupe) |
|
||||
| `search_wms_data` | un résultat, réparti en tourniquet entre les entités | `totalFound` (déjà présent) |
|
||||
|
||||
Cas limite réel : **une seule ligne Writing dépasse le plafond** (95 288
|
||||
caractères mesurés) — la réponse est alors `returned: 0`, `omitted: 1`,
|
||||
`truncated: true`, avec un hint qui renvoie vers Reading.
|
||||
|
||||
---
|
||||
|
||||
## Écrire une requête WMS
|
||||
|
||||
```js
|
||||
|
||||
+177
-3
@@ -458,15 +458,69 @@ sa réponse et **signale** la coupe. Le signal est commun :
|
||||
| `truncated: true` | présent **uniquement** quand la réponse a été coupée — jamais `truncated: false` |
|
||||
| `hint` | présent ssi `truncated` ; actionnable : dit comment continuer (`offset` suivant) ou réduire (filtres, `context_lines`…) |
|
||||
| `returned` | nombre d'éléments effectivement renvoyés |
|
||||
| total (`totalParameters`, `totalResults`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
|
||||
| total (`totalParameters`, `totalResults`, `dataTotalChars`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
|
||||
|
||||
Les mécanismes restent **volontairement locaux**, car ils diffèrent :
|
||||
`get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu,
|
||||
on continue avec l'offset suivant) ; `search_logs` plafonne le volume
|
||||
(`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats
|
||||
**entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce
|
||||
en plus `omitted`, le compte écarté. Pas de helper partagé : le factoriser
|
||||
forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
|
||||
factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
Les **trois outils de requête**, eux, partagent le même mécanisme — ils
|
||||
partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère
|
||||
est le mécanisme, pas le nombre d'appelants.
|
||||
|
||||
**Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient
|
||||
encore le seuil, toutes mesurées sur `LIMAGRAI2512` :
|
||||
|
||||
`get_workflow_details` **fenêtre le blob `data`** (`max_data_chars`, défaut
|
||||
20 000 ; `data_offset`, défaut 0) — 79 092 caractères pour un StackerCrane
|
||||
(dont 71 512 de blob), 101 816 pour `CST_SendRejectContainersToPK` (92 362 de
|
||||
blob), ramenés à ~23 000. La tranche est **verbatim** : découpe de chaîne, rien
|
||||
d'autre. Ne jamais résumer, reformuler ni « parser » cette définition
|
||||
EasyBuilder — la concaténation des tranches dans l'ordre des offsets doit la
|
||||
reconstituer à l'octet près (vérifié : 20 000 + 20 000 + 20 000 + 11 512 =
|
||||
71 512, concaténation identique au blob d'origine). Les métadonnées du workflow
|
||||
restent complètes dans chaque tranche ; seul `data` est fenêtré, et
|
||||
`dataTotalChars` est porté par **toute** réponse — y compris non tronquée, où
|
||||
la seule différence avec l'ancienne réponse est ces trois champs de fenêtre
|
||||
(+65 caractères mesurés).
|
||||
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur
|
||||
volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que
|
||||
`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper
|
||||
commun `src/services/response-limit.js` (recherche dichotomique : ~8
|
||||
constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de
|
||||
~1 Mo) :
|
||||
|
||||
| Appel | Avant | Après |
|
||||
|---|---:|---:|
|
||||
| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) |
|
||||
| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) |
|
||||
| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 |
|
||||
|
||||
Trois points de cadrage, tous vérifiés en exécution :
|
||||
|
||||
- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique
|
||||
**octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`,
|
||||
`call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond).
|
||||
`MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés :
|
||||
le correctif est le bornage signalé, pas une réduction silencieuse.
|
||||
- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing —
|
||||
`call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`,
|
||||
`omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing
|
||||
et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux
|
||||
qu'un rejet client opaque.
|
||||
- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les
|
||||
entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans
|
||||
cela, une entité volumineuse placée en tête consommerait tout le budget et
|
||||
les suivantes reviendraient à zéro résultat sans que rien ne le dise —
|
||||
exactement le faux négatif que corrige L5.4.
|
||||
|
||||
`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un
|
||||
scalaire), et son `query_type` ne porte donc pas l'avertissement de volume
|
||||
ajouté aux deux autres.
|
||||
|
||||
Deux garde-fous de cadrage :
|
||||
|
||||
@@ -568,3 +622,123 @@ Règles associées :
|
||||
ne détaille que les entrées **effectivement en cache** : la sortie reste
|
||||
bornée quel que soit le nombre d'applications interrogées (D24). Il expose
|
||||
aussi les caches de workflows par application.
|
||||
|
||||
**Le paramètre ne suffisait pas : il faut que la réponse le dise** (lot 5,
|
||||
25/08/2026). Cas réel : une session Cowork cherchant des workflows `CST_*` sans
|
||||
passer `application: "CustomApp"` a conclu que l'AD n'en contenait aucun — alors
|
||||
que `CST_PickingTasksSequencing_PR` et `CST_ChooseDestinationFromPS` existent.
|
||||
Le paramètre était disponible et documenté ; ce qui manquait, c'est que **rien
|
||||
dans la réponse ne disait qu'on n'avait regardé qu'une application sur neuf**.
|
||||
Un défaut silencieux se lit comme une exhaustivité.
|
||||
|
||||
`search_workflows` et `search_ad_elements` rappellent donc **toujours**
|
||||
l'application effectivement interrogée (plus seulement quand le paramètre a été
|
||||
passé), et ajoutent un `hint` quand la recherche revient **vide** :
|
||||
|
||||
- Seuil à **0 résultat**, pas « peu ». Toute valeur non nulle produirait un hint
|
||||
parasite sur une recherche légitimement étroite, et le mode d'échec observé
|
||||
est bien le zéro pris pour une absence.
|
||||
- Le hint nomme les autres applications depuis la liste allégée **déjà en
|
||||
cache** ; sans elle, il reste générique et renvoie vers
|
||||
`list_workflow_categories`. **Jamais de fetch pour construire un hint** —
|
||||
ce serait précisément le préchargement que cette décision interdit.
|
||||
- Il nomme `CustomApp` en clair, sauf quand c'est déjà l'application
|
||||
interrogée : c'est une connaissance statique, déjà portée par les
|
||||
descriptions d'outils, pas une donnée à aller chercher.
|
||||
|
||||
---
|
||||
|
||||
## D27 — Chargements paresseux sous concurrence : single-flight + génération
|
||||
|
||||
**Contexte (mesuré le 25/08/2026, `LIMAGRAI2512`).** Le serveur traite les
|
||||
`tools/call` **en concurrence** : une rafale d'appels dans une même session
|
||||
s'exécute en parallèle. Les trois services à cache chargeaient paresseusement
|
||||
sans se coordonner — le premier appelant qui trouve le cache invalide lance le
|
||||
fetch, et tous ceux qui arrivent pendant ce fetch trouvent le cache **encore**
|
||||
invalide et lancent le leur. Mesures avant correction :
|
||||
|
||||
| Rafale | Résultat |
|
||||
|---|---|
|
||||
| 6 × `search_workflows` (CustomApp) | 6 × `fetching from API` pour une seule clé |
|
||||
| 6 × `query_wms_entities` (Container) | 6 × `[EntityResolver] Cache expired or empty` — soit 30 GET Metadata |
|
||||
| 14 appels mixtes | l'API AD répond **HTTP 500** sur `Workflow/GetByApplication` (EasyWMS, ~4 000 workflows) — les 4 appels EasyWMS échouent, les mêmes passent en séquentiel |
|
||||
|
||||
La dernière ligne est le vrai coût : la duplication ne gaspille pas seulement
|
||||
des appels, elle **surcharge l'API AD au point de la faire échouer**.
|
||||
|
||||
**Décision.** Un motif unique, `src/services/single-flight.js`, partagé par
|
||||
`workflow-service`, `ad-service` et `entity-resolver` — une `Map` de promesses,
|
||||
pas une dépendance externe :
|
||||
|
||||
- **Une clé de single-flight par entrée de cache** : `workflows::<app>` et
|
||||
`applications` pour les workflows, `<app>::<type>` pour l'AD, une clé unique
|
||||
pour le resolver. Deux clés distinctes se chargent toujours **en parallèle** —
|
||||
le single-flight ne sérialise rien au-delà de la clé demandée, et
|
||||
n'introduit aucun préchargement (D26 intact).
|
||||
- **La promesse est retirée au règlement, succès *ou* échec.** Un fetch en
|
||||
erreur ne reste pas coincé dans la Map : l'appel suivant refetche. Les
|
||||
appelants joints reçoivent la même erreur, et rien n'est mis en cache.
|
||||
- **Le log de fetch reste l'observable** (D6) : une ligne `fetching from API`
|
||||
/ `Cache expired or empty` par chargement **réel**. Les appelants joints
|
||||
émettent une ligne distincte (`Fetch already in flight … joining it`) — ne
|
||||
fusionnez pas les deux, c'est ce qui rend la déduplication vérifiable depuis
|
||||
stderr.
|
||||
|
||||
**Garde de génération.** Un fetch parti *avant* une invalidation terminait
|
||||
*après* elle et écrivait quand même son résultat : le cache repartait peuplé
|
||||
avec les données de l'ancien tenant, timestamp neuf, `valid: true`. Défaut
|
||||
latent avant le single-flight, **déterministe après** (la promesse en vol
|
||||
survit à l'invalidation). D'où :
|
||||
|
||||
- Un **compteur de génération par service**, incrémenté à chaque invalidation
|
||||
(`clearCache()` / `invalidateCache()`, toujours déclenchées par
|
||||
`onSwitch()` — D8 inchangé). Le fetch capture la génération au départ.
|
||||
- **Les fonctions de chargement n'écrivent plus rien en cache** : la
|
||||
publication est un `commit` passé à `singleFlight.run`, appelé *seulement*
|
||||
si la génération n'a pas bougé. C'est structurel, pas conventionnel — un
|
||||
fetch ne peut plus publier par inadvertance.
|
||||
- L'invalidation vide aussi la Map des promesses en vol. **L'appelant reçoit
|
||||
quand même son résultat** — il l'a demandé avant la bascule ; c'est sa mise
|
||||
en cache qui est refusée, tracée par
|
||||
`Result for "…" discarded, not cached`.
|
||||
|
||||
Mesuré sur `[search_workflows(EasyWMS), switch_wms_profile(EUROTRAFIC)]` envoyé
|
||||
d'un bloc, puis `get_application_summary` en séquentiel : **3/3 avant**, le
|
||||
cache EasyWMS de LIMAGRAIN (3 944 workflows) survit à la bascule avec un
|
||||
timestamp neuf ; **3/3 après**, aucun cache peuplé.
|
||||
|
||||
**Vide anormal ≠ vide réel.** Les services lisaient `response?.entities || []`
|
||||
sur les réponses de l'API AD (enveloppe `{ entities: [...] }`, D4). Toute
|
||||
réponse d'une **autre forme** devenait donc un tableau vide, indistinguable
|
||||
d'une page finale légitime — et mise en cache avec un timestamp valide : un
|
||||
cache vide empoisonné pour tout le TTL, sans message. C'est la cause probable
|
||||
du `count: 0` mesuré sous rafale, et le mode d'échec le plus coûteux du lot :
|
||||
il se lit comme une réponse.
|
||||
|
||||
`src/services/ad-envelope.js` porte le contrat pour les trois sites
|
||||
(`Workflow/GetByApplication`, `Application/GetAll`, `<Type>/GetByApplication`) :
|
||||
|
||||
| Réponse | Traitement |
|
||||
|---|---|
|
||||
| `{ entities: [...] }`, `[]` réel compris | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow ; 3 types AD valides mais vides, D17) |
|
||||
| toute autre forme | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche |
|
||||
|
||||
**Pas de retry, pas de résilience.** L'anomalie doit être **visible et non
|
||||
persistante** ; la rattraper la rendrait invisible, ce qui est exactement le
|
||||
défaut corrigé. `entity-resolver` était déjà conforme : il lève déjà si
|
||||
`/configuration/applications` ou le Metadata ne rendent aucune entité.
|
||||
|
||||
Mesures : `search_workflows` sur `SmartUI` → `count: 0`, `success: true`,
|
||||
cache posé (`count: 0`, `valid: true`) et hint L5.4 présent. Les trois sites
|
||||
face à une réponse `{}` → erreur levée, `{}` en cache, et le fetch suivant
|
||||
repart normalement.
|
||||
|
||||
**Mesures après.** Rafale de 6 (CustomApp) → 1 fetch + 5 joins, les 6 réponses
|
||||
à `count: 44`. Rafale de 6 (resolver) → 1 chargement, 5 GET Metadata au lieu de
|
||||
30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au
|
||||
total, plus aucun HTTP 500. Le chemin séquentiel nominal est inchangé : 1 fetch
|
||||
puis 1 `Using cached data`, 0 join.
|
||||
|
||||
**Ce que cette décision ne couvre pas.** La bascule de profil concurrente aux
|
||||
appels en vol (un `switch_wms_profile` qui redirige des requêtes déjà parties)
|
||||
reste un point ouvert de la ROADMAP, distinct.
|
||||
|
||||
+18
-45
@@ -82,43 +82,6 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Lot 5 — Clore la famille D24 (rejets client sur sorties volumineuses)
|
||||
|
||||
Mesures du 25/08/2026 (protocole, LIMAGRAIN), toutes au-dessus du seuil de
|
||||
rejet client (~70 000 caractères, D24) :
|
||||
|
||||
| Appel | Taille |
|
||||
|---|---:|
|
||||
| `query_wms_entities("Products", limit: 200)` — Reading ordinaire | **957 234** |
|
||||
| `search_wms_data("PAL")` | **847 543** |
|
||||
| `get_workflow_details(CST_SendRejectContainersToPK)` | ~101 800 |
|
||||
| `call_query_api("Products", query_type: 1, limit: 1)` — **une seule ligne** Writing | **95 288** |
|
||||
|
||||
### L5.1 — Paginer le blob `data` de `get_workflow_details`
|
||||
|
||||
La définition complète d'un workflow dépasse le seuil (~101 800 pour
|
||||
`CST_SendRejectContainersToPK`, 79 092 pour un `StackerCrane_…` EasyWMS).
|
||||
Découper le blob `data` en tranches verbatim (paramètres de fenêtre au schéma),
|
||||
signalées D24 — sans jamais résumer ni reformuler le contenu.
|
||||
|
||||
### L5.2 — Garde de taille sur les outils de requête
|
||||
|
||||
Une ligne Writing = un agrégat complet sérialisé (95 288 caractères là où la
|
||||
même ligne Reading en fait ~4 500) ; 200 lignes Reading = ~957 000 ; `search_wms_data`
|
||||
= ~848 000. Garde de taille commune sur `query_wms_entities`, `call_query_api`
|
||||
et `search_wms_data` : lignes entières écartées, `truncated`/`returned`/`omitted`/`hint`
|
||||
(D24). Ne pas réduire `MAX_QUERY_ROWS` ni les limites par défaut.
|
||||
|
||||
### L5.3 — Champ `tool` absent des erreurs construites localement
|
||||
|
||||
Les `catch` locaux d'`api-tools.js` renvoient `{ success: false, error }` sans
|
||||
le champ `tool` du contrat (convention 3) — antérieur au lot 4. Balayer tous
|
||||
les modules d'outils pour le même motif.
|
||||
|
||||
---
|
||||
|
||||
## Écarté
|
||||
|
||||
| Proposition | Raison |
|
||||
@@ -142,15 +105,25 @@ les modules d'outils pour le même motif.
|
||||
test.
|
||||
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026,
|
||||
révision du lot 2). Le serveur traite les `tools/call` **en concurrence** :
|
||||
en envoyant une rafale de requêtes dans une même session, les réponses
|
||||
reviennent dans le désordre, et un `switch_wms_profile` émis pendant que des
|
||||
requêtes sont en vol les fait partir sur le nouveau profil (observé : une
|
||||
requête destinée à EUROTRAFIC exécutée sur l'hôte `10.255.255.2` après la
|
||||
bascule suivante). Conséquence du singleton d'état global (D8). Sans gravité
|
||||
pour un usage conversationnel séquentiel, mais Claude peut émettre des appels
|
||||
d'outils **en parallèle** : à traiter si un cas réel de mélange de profils
|
||||
un `switch_wms_profile` émis pendant que des requêtes sont en vol les fait
|
||||
partir sur le nouveau profil (observé : une requête destinée à EUROTRAFIC
|
||||
exécutée sur l'hôte `10.255.255.2` après la bascule suivante). Conséquence du
|
||||
singleton d'état global (D8). À traiter si un cas réel de mélange de profils
|
||||
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
|
||||
début de chaque appel).
|
||||
début de chaque appel). La manifestation « caches » de la même concurrence
|
||||
est **traitée** (D27 : single-flight par clé, garde de génération) ; celle-ci
|
||||
ne l'est pas — D27 borne les chargements paresseux, pas le routage d'une
|
||||
requête déjà partie.
|
||||
- **L'API AD échoue sous appels concurrents nombreux** (mesuré le 25/08/2026,
|
||||
lot 6). Avant D27, une rafale de 14 `tools/call` faisait répondre **HTTP 500**
|
||||
à `POST /AD/api/Workflow/GetByApplication` pour `EasyWMS` (~4 000 workflows) —
|
||||
les 4 appels concernés en erreur, les mêmes corrects en séquentiel. C'est une
|
||||
limite du serveur AD, pas du MCP. D27 l'atténue fortement (un seul fetch par
|
||||
clé au lieu de N, et le 500 n'a pas reparu depuis), sans la supprimer : des
|
||||
clés **différentes** se chargent toujours en parallèle. À reconsidérer si le
|
||||
500 réapparaît — piste : plafonner le nombre de chargements simultanés, tous
|
||||
clés confondues. N'implémentez rien avant d'avoir une mesure : brider les
|
||||
chargements parallèles coûte de la latence sur le chemin nominal.
|
||||
- **`select_expression`** : les projections via le paramètre `Select` provoquent
|
||||
des erreurs de compilation côté serveur (D13). Irritant principal restant.
|
||||
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Passation — lot 5 (clore la famille D24)
|
||||
|
||||
Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui
|
||||
donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST.
|
||||
Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) —
|
||||
en particulier **D24**, le contrat de troncature que ce lot généralise — avant
|
||||
de toucher au code.
|
||||
|
||||
**Mission** : éliminer les derniers cas connus de réponses d'outils dépassant
|
||||
le seuil de rejet des clients MCP (~70 000 caractères, D24) — lot 5 de
|
||||
[../ROADMAP.md](../ROADMAP.md) : pagination du blob `data` de
|
||||
`get_workflow_details` (L5.1), garde de taille sur les trois outils de requête
|
||||
(L5.2), champ `tool` manquant dans les enveloppes d'erreur locales (L5.3).
|
||||
|
||||
**Hors périmètre** : tout le reste de la roadmap (L4.3, L4.4, L4.5,
|
||||
exploration Metrics). **`QueryExecuteStream` est explicitement interdit** —
|
||||
piste long terme consignée en L4.5, pas ce lot. Aucun nouvel outil : le compte
|
||||
reste à 23. Ne pousse rien (`git push` interdit), ne touche pas au `.env`,
|
||||
n'appelle jamais `execute_command` (il écrit dans le WMS).
|
||||
|
||||
---
|
||||
|
||||
## Contexte matériel
|
||||
|
||||
- Profil de travail : `LIMAGRAIN` (par défaut), host `10.255.255.2`, tenant
|
||||
`LIMAGRAI2512`.
|
||||
- **Le profil `AD` est cassé et c'est diagnostiqué — ne le réinvestigue pas**
|
||||
(tenant introuvable côté STS, point ouvert de la ROADMAP). La baseline se
|
||||
mesure avec `npm test` (profil par défaut), attendu **4/4, code de sortie 0**.
|
||||
- Baseline protocolaire à préserver : **23 outils**, **6 resources**, aucune
|
||||
écriture sur stdout hors JSON-RPC.
|
||||
|
||||
Handshake + comptages :
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
|
||||
```
|
||||
|
||||
Mesurer la taille d'une réponse d'outil (la mesure qui fait foi est la
|
||||
longueur de `content[0].text` via le protocole) :
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NOM","arguments":{}}}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('chars:',m.result.content[0].text.length);}});"
|
||||
```
|
||||
|
||||
## Contraintes non négociables
|
||||
|
||||
1. `console.error()` uniquement (D6).
|
||||
2. Contrat d'erreur : `{ success: false, error, tool }`, `isError: true` —
|
||||
c'est précisément l'objet de L5.3.
|
||||
3. Messages et hints actionnables.
|
||||
4. Aucun accès base de données (D1).
|
||||
5. **D23** : le wrapper valide les **noms** de paramètres et les requis contre
|
||||
les schémas — tout paramètre ajouté (fenêtre de L5.1…) doit être déclaré
|
||||
dans l'`inputSchema`. Le wrapper ne valide pas les **valeurs** : les gardes
|
||||
de valeur vivent dans le code de l'outil.
|
||||
6. **D24** : signal commun — `truncated: true` **uniquement** quand la réponse
|
||||
est coupée, `hint` actionnable, `returned` vs total avant coupe. `omitted`
|
||||
en plus quand des éléments entiers sont écartés. Réutilise ce vocabulaire
|
||||
exactement ; ne crée pas un second dialecte.
|
||||
7. **Pas de nouveau numéro de décision attendu** : ce lot **étend D24**
|
||||
(complète son texte : les outils de requête et `get_workflow_details`
|
||||
entrent dans son périmètre). Si une décision réellement nouvelle s'impose,
|
||||
vérifie le dernier numéro (D26 à ce jour) et réserve D27 dans le commit qui
|
||||
l'acte.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Confirmer les mesures
|
||||
|
||||
Toutes rejouées le 25/08/2026 (protocole, LIMAGRAIN) avec la commande de
|
||||
mesure ci-dessus. À **confirmer**, pas à réinvestiguer :
|
||||
|
||||
| Appel | Taille constatée |
|
||||
|---|---:|
|
||||
| `query_wms_entities` `{"entity_type":"Products","limit":200}` (Reading) | **957 234** |
|
||||
| `search_wms_data` `{"keyword":"PAL"}` | **847 543** |
|
||||
| `call_query_api` `{"entity_type":"Products","query_type":1,"limit":1}` — une seule ligne Writing | **95 288** |
|
||||
| `get_workflow_details` sur `CST_SendRejectContainersToPK` (`application: "CustomApp"`) | ~101 800 |
|
||||
| `get_workflow_details` `{"workflow_id":"2a320000-0642-47df-aa52-3b89c27c016f"}` (StackerCrane, EasyWMS) | 79 092 (dont blob `data` : 71 512) |
|
||||
|
||||
Attention aux noms de paramètres (D23 les fait respecter) : `search_workflows`
|
||||
prend `query`, `search_wms_data` et `search_logs` prennent `keyword`. Pour
|
||||
trouver l'id du workflow CST :
|
||||
`search_workflows {"query":"CST_SendRejectContainersToPK","application":"CustomApp"}`.
|
||||
|
||||
---
|
||||
|
||||
## L5.1 — Paginer le blob `data` de `get_workflow_details`
|
||||
|
||||
**Problème.** La réponse embarque la définition complète du workflow (blob
|
||||
`data`, XML/JSON EasyBuilder) : 71 512 caractères sur le StackerCrane mesuré,
|
||||
davantage sur les gros `CST_*` — la réponse dépasse le seuil client.
|
||||
|
||||
**À faire.**
|
||||
- Deux paramètres de fenêtre sur le blob `data` (au schéma, D23) : une taille
|
||||
max de tranche (défaut de l'ordre de **20 000** caractères, cohérent avec
|
||||
D24) et un offset (défaut 0). Nommage à ta main (`max_data_chars` /
|
||||
`data_offset` ou équivalent), documenté dans les descriptions.
|
||||
- La réponse porte toujours la taille **totale** du blob ; quand la fenêtre
|
||||
tronque : `truncated: true` + `hint` donnant l'offset suivant.
|
||||
- La tranche est **verbatim** : découpe de chaîne, rien d'autre.
|
||||
- Les métadonnées du workflow (`id`, `name`, `commonInfo`…) restent complètes
|
||||
dans chaque réponse ; seule `data` est fenêtrée.
|
||||
|
||||
**Pente naturelle interdite** : ne résume pas, ne reformule pas, ne « parse »
|
||||
pas le blob pour n'en renvoyer que des morceaux jugés utiles — la définition
|
||||
EasyBuilder doit rester reconstituable à l'octet près en concaténant les
|
||||
tranches.
|
||||
|
||||
**Vérification attendue** (protocole, LIMAGRAIN) :
|
||||
- `get_workflow_details` sur le StackerCrane (`2a320000-0642-47df-aa52-3b89c27c016f`)
|
||||
sans paramètre de fenêtre → réponse < ~30 000 caractères, `truncated: true`,
|
||||
taille totale annoncée = 71 512, hint avec l'offset suivant.
|
||||
- En enchaînant les tranches (offset 0, 20 000, 40 000, 60 000) : la somme des
|
||||
longueurs des tranches = 71 512, et la concaténation est identique au blob
|
||||
d'origine (compare au moins les longueurs et les 100 premiers/derniers
|
||||
caractères).
|
||||
- Un workflow à petit blob (< défaut) → réponse strictement inchangée, pas de
|
||||
`truncated`.
|
||||
|
||||
## L5.2 — Garde de taille sur les outils de requête
|
||||
|
||||
**Problème.** Aucune borne de volume sur `query_wms_entities`, `call_query_api`
|
||||
et `search_wms_data` : 957 234 caractères pour 200 lignes Reading, 847 543
|
||||
pour une recherche, et **95 288 pour une seule ligne Writing** (le modèle
|
||||
Writing sérialise l'agrégat complet — navigations, `$id`… — là où la même
|
||||
ligne Reading fait ~4 500).
|
||||
|
||||
**À faire.**
|
||||
- Garde de taille commune aux trois outils : plafond en variable
|
||||
d'environnement avec défaut (style `MAX_LOG_SEARCH_CHARS`, 25 000 — même
|
||||
ordre de grandeur, nom à ta main, documenté dans CLAUDE.md).
|
||||
- Au-delà du plafond : écarter des **lignes entières** (pour `search_wms_data` :
|
||||
des résultats entiers, par entité), et signaler D24 : `truncated: true`,
|
||||
`returned`, `omitted`, `hint` (réduire `limit`, ajouter un `filter` ; et pour
|
||||
`query_type != 0` : rappeler que les lignes Writing sont des agrégats
|
||||
complets et suggérer Reading si l'usage le permet).
|
||||
- **Cas limite à traiter explicitement** : une seule ligne dépasse le plafond
|
||||
(réel en Writing). La réponse est alors `returned: 0`, `omitted: <n>`,
|
||||
`truncated: true`, avec un hint qui explique pourquoi et quoi faire — c'est
|
||||
moins bon qu'un résultat, mais c'est mieux qu'un rejet client opaque.
|
||||
- **Ne change ni `MAX_QUERY_ROWS`, ni les limites par défaut des outils, ni le
|
||||
comportement sous le plafond** : une requête qui tient aujourd'hui doit
|
||||
renvoyer exactement la même réponse.
|
||||
- L'avertissement de volume mérite une phrase dans la description du paramètre
|
||||
`query_type` (D25).
|
||||
|
||||
**Vérification attendue** (protocole, LIMAGRAIN) :
|
||||
- `query_wms_entities` `{"entity_type":"Products","limit":200}` → réponse sous
|
||||
le plafond (+ marge d'enveloppe), `truncated: true`, `returned` < 200,
|
||||
`omitted` cohérent, hint présent.
|
||||
- `search_wms_data` `{"keyword":"PAL"}` → borné et signalé de même.
|
||||
- `call_query_api` `{"entity_type":"Products","query_type":1,"limit":1}` →
|
||||
`returned: 0`, `omitted: 1`, `truncated: true`, hint expliquant le volume
|
||||
Writing.
|
||||
- `query_wms_entities` `{"entity_type":"Container","limit":1}` → réponse
|
||||
**strictement identique** à aujourd'hui (~4 500 caractères, pas de
|
||||
`truncated`).
|
||||
- `count_wms_entities` → non concerné, inchangé.
|
||||
|
||||
## L5.3 — Champ `tool` dans les enveloppes d'erreur locales
|
||||
|
||||
**Problème.** Les `catch` locaux d'`api-tools.js` (deux blocs, vers les lignes
|
||||
145-162 et 191-208) renvoient `{ success: false, error }` **sans** le champ
|
||||
`tool` du contrat. Preuve : `call_query_api` `{"entity_type":"Products","query_type":7}`
|
||||
répond aujourd'hui `{"success": false, "error": "query_type invalide : …"}` —
|
||||
pas de `tool`.
|
||||
|
||||
**À faire.** Balayer **tous** les modules de `src/tools/` à la recherche
|
||||
d'enveloppes `success: false` construites localement : soit y ajouter `tool`,
|
||||
soit laisser l'erreur remonter au wrapper de `src/index.js` (qui l'ajoute) —
|
||||
au choix selon le cas, mais le résultat observable est uniforme. Attention à ne
|
||||
pas perdre les champs additionnels utiles des enveloppes locales (le `warning`
|
||||
de résolution d'`api-tools`, les listes de profils de `profile-tools`…).
|
||||
|
||||
**Vérification attendue** : `call_query_api` avec `query_type: 7` → l'enveloppe
|
||||
contient `"tool": "call_query_api"` ; un `grep` sur `src/tools/` ne montre plus
|
||||
d'enveloppe `success: false` sans `tool` (colle le résultat du grep dans le
|
||||
compte-rendu).
|
||||
|
||||
---
|
||||
|
||||
## Méthode
|
||||
|
||||
1. Phase 0 d'abord (cinq mesures, rejouées telles quelles).
|
||||
2. L5.1, puis L5.2, puis L5.3 — chaque bloc vérifié **en exécution via le
|
||||
protocole** avant de passer au suivant.
|
||||
3. Rappel : le serveur traite les `tools/call` en concurrence (point ouvert de
|
||||
la ROADMAP) — pour les vérifications qui comparent des réponses successives,
|
||||
envoie les requêtes séquentiellement.
|
||||
4. Les schémas changent : reboucler sur les 23 noms de `tools/list` (aucun
|
||||
`Unknown tool` ; `execute_command` vérifié statiquement, non appelé) et
|
||||
vérifier qu'un paramètre inconnu est toujours rejeté (D23).
|
||||
5. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0).
|
||||
6. « Non résolu » est une réponse acceptable pour une investigation
|
||||
time-boxée ; une hypothèse présentée comme solution ne l'est pas.
|
||||
|
||||
## Livraison
|
||||
|
||||
- Un commit par bloc (L5.1, L5.2, L5.3), messages expliquant le pourquoi,
|
||||
**mesures avant/après dans le corps du message** (tailles en caractères).
|
||||
- Documentation dans les mêmes commits : **compléter D24** (périmètre étendu
|
||||
aux outils de requête et à `get_workflow_details`) ; CLAUDE.md — la nouvelle
|
||||
variable d'environnement dans les réglages partagés, la fenêtre de
|
||||
`get_workflow_details` si elle change l'usage documenté ; ROADMAP.md —
|
||||
retirer le lot 5.
|
||||
- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en
|
||||
route : dans ROADMAP.md, pas dans le code.
|
||||
- Compte-rendu final : pour chaque bloc, la vérification attendue rejouée et
|
||||
son résultat **mesuré** (colle les tailles et les sorties), plus la baseline
|
||||
finale. Laisse `handoff-lot5.md` en place pour la révision.
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Enveloppe des API AD — un vide anormal n'est pas un vide (D27)
|
||||
*
|
||||
* Les API AD renvoient `{ entities: [...] }` (D4). Les services lisaient
|
||||
* `response?.entities || []` : toute réponse d'une **autre forme** (pas de
|
||||
* champ `entities`, corps vide, objet d'erreur) devenait un tableau vide,
|
||||
* indistinguable d'une page finale légitime — donc mise en cache avec un
|
||||
* timestamp valide. Un cache vide empoisonné pour tout le TTL, sans le
|
||||
* moindre message.
|
||||
*
|
||||
* Deux cas, deux traitements :
|
||||
*
|
||||
* | Réponse | Traitement |
|
||||
* |---|---|
|
||||
* | `{ entities: [...] }`, y compris `[]` réel | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow, D26) |
|
||||
* | tout le reste | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche |
|
||||
*
|
||||
* Volontairement sans retry ni résilience : le but est de rendre l'anomalie
|
||||
* **visible et non persistante**, pas de la rattraper.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Décrit la forme reçue, pour un message d'erreur exploitable (convention 4).
|
||||
*/
|
||||
function describeShape(response) {
|
||||
if (response === null) return 'null';
|
||||
if (response === undefined) return 'undefined';
|
||||
if (Array.isArray(response)) return `un tableau nu de ${response.length} élément(s)`;
|
||||
if (typeof response !== 'object') return `un ${typeof response}`;
|
||||
const keys = Object.keys(response);
|
||||
if (keys.length === 0) return 'un objet vide';
|
||||
return `un objet sans champ "entities" (champs reçus : ${keys.slice(0, 10).join(', ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le tableau `entities` d'une réponse d'API AD, ou lève.
|
||||
*
|
||||
* @param {any} response - la réponse brute de `apiService.post(..., true)`
|
||||
* @param {string} context - l'appel concerné, pour le message d'erreur
|
||||
* (ex. `Workflow/GetByApplication (application "EasyWMS", offset 0)`)
|
||||
* @returns {Array} le tableau `entities`, éventuellement vide
|
||||
* @throws {Error} si la réponse n'a pas la forme `{ entities: [...] }`
|
||||
*/
|
||||
function requireEntities(response, context) {
|
||||
const entities = response ? response.entities : undefined;
|
||||
|
||||
if (!Array.isArray(entities)) {
|
||||
throw new Error(
|
||||
`Réponse inattendue de l'API AD sur ${context} : ${describeShape(response)}, ` +
|
||||
`au lieu de l'enveloppe attendue { entities: [...] }. ` +
|
||||
`Rien n'a été mis en cache — relancez l'appel. ` +
|
||||
`Si l'erreur persiste, l'API AD est en défaut (elle échoue notamment sous appels concurrents nombreux).`
|
||||
);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
module.exports = { requireEntities };
|
||||
@@ -6,12 +6,17 @@
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
const { requireEntities } = require('./ad-envelope');
|
||||
|
||||
// Cache state - one cache per (application, element type) (D26)
|
||||
const cache = {};
|
||||
const cacheTimestamps = {};
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour
|
||||
|
||||
// Déduplication des chargements concurrents, une clé par (application, type) (D27).
|
||||
const singleFlight = createSingleFlight('AD');
|
||||
|
||||
/**
|
||||
* Application effective : celle demandée, sinon celle du profil actif.
|
||||
*/
|
||||
@@ -96,6 +101,25 @@ async function getElements(elementType, application) {
|
||||
return cache[key];
|
||||
}
|
||||
|
||||
// Un seul chargement par (application, type), même sous rafale, et
|
||||
// publication refusée si le cache a été invalidé pendant le fetch (D27).
|
||||
return singleFlight.run(
|
||||
key,
|
||||
() => loadElements(app, elementType, key),
|
||||
(elements) => {
|
||||
cache[key] = elements;
|
||||
cacheTimestamps[key] = Date.now();
|
||||
console.error(`[AD] Successfully cached ${elements.length} ${key}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel d'un (application, type) (pagination complète).
|
||||
* Appelé au plus une fois par clé tant qu'il est en vol (D27).
|
||||
* N'écrit RIEN en cache : la publication est le `commit` de singleFlight.run.
|
||||
*/
|
||||
async function loadElements(app, elementType, key) {
|
||||
console.error(`[AD] Cache expired or empty, fetching ${key}...`);
|
||||
|
||||
try {
|
||||
@@ -112,11 +136,15 @@ async function getElements(elementType, application) {
|
||||
// Use AD API (useAdApi=true)
|
||||
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
|
||||
|
||||
// Extract entities array from response
|
||||
const elements = response?.entities || [];
|
||||
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
|
||||
// faire passer pour une page vide (D27).
|
||||
const elements = requireEntities(
|
||||
response,
|
||||
`${elementType}/GetByApplication (application "${app}", offset ${offset})`
|
||||
);
|
||||
|
||||
// Check if response is valid
|
||||
if (!elements || elements.length === 0) {
|
||||
// Vide réel : fin de pagination (3 types sont valides mais vides, D17).
|
||||
if (elements.length === 0) {
|
||||
console.error(`[AD] No more ${elementType} to fetch`);
|
||||
break;
|
||||
}
|
||||
@@ -133,11 +161,6 @@ async function getElements(elementType, application) {
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cache[key] = allElements;
|
||||
cacheTimestamps[key] = Date.now();
|
||||
|
||||
console.error(`[AD] Successfully cached ${allElements.length} ${key}`);
|
||||
return allElements;
|
||||
} catch (error) {
|
||||
console.error(`[AD] Error fetching ${key}:`, error.message);
|
||||
@@ -238,12 +261,14 @@ function invalidateCache(elementType = null) {
|
||||
delete cache[k];
|
||||
delete cacheTimestamps[k];
|
||||
});
|
||||
singleFlight.invalidate();
|
||||
console.error(`[AD] Cache invalidated: ${elementType}`);
|
||||
} else {
|
||||
Object.keys(cache).forEach(k => {
|
||||
delete cache[k];
|
||||
delete cacheTimestamps[k];
|
||||
});
|
||||
singleFlight.invalidate();
|
||||
console.error('[AD] All caches invalidated');
|
||||
}
|
||||
}
|
||||
@@ -275,6 +300,7 @@ function getAvailableTypes() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveApplication,
|
||||
getElements,
|
||||
searchElements,
|
||||
getElementDetails,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
|
||||
// Cache state — même TTL que les autres caches (D10)
|
||||
let resolutionMap = null; // Map lower(Name | TableName) -> TableName
|
||||
@@ -18,6 +19,10 @@ let tableNames = null; // TableName[] triés (suggestions + comptage)
|
||||
let cacheTimestamp = null;
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000;
|
||||
|
||||
// Table unique : une seule clé de single-flight (D27).
|
||||
const singleFlight = createSingleFlight('EntityResolver');
|
||||
const METADATA_KEY = 'metadata';
|
||||
|
||||
// La table de résolution est par tenant — invalidée à chaque bascule (D8).
|
||||
profileManager.onSwitch(() => invalidateCache());
|
||||
|
||||
@@ -37,6 +42,23 @@ function isCacheValid() {
|
||||
async function loadResolutionMap() {
|
||||
if (isCacheValid()) return;
|
||||
|
||||
// Un seul chargement Metadata, même sous rafale concurrente (D27) : sans
|
||||
// lui, 6 appels concurrents déclenchaient 6 chargements complets. La
|
||||
// publication est refusée si le cache a été invalidé pendant le fetch.
|
||||
await singleFlight.run(METADATA_KEY, fetchResolutionMap, (loaded) => {
|
||||
resolutionMap = loaded.map;
|
||||
tableNames = loaded.names;
|
||||
cacheTimestamp = Date.now();
|
||||
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${loaded.applicationCount} application(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel de la table de résolution (D27). N'écrit rien en cache :
|
||||
* la publication est le `commit` de singleFlight.run.
|
||||
* @returns {Promise<{map: Map, names: string[], applicationCount: number}>}
|
||||
*/
|
||||
async function fetchResolutionMap() {
|
||||
console.error('[EntityResolver] Cache expired or empty, fetching Metadata...');
|
||||
|
||||
const apps = await apiService.get('/configuration/applications');
|
||||
@@ -67,10 +89,11 @@ async function loadResolutionMap() {
|
||||
throw new Error('Metadata API returned no entity for any application');
|
||||
}
|
||||
|
||||
resolutionMap = map;
|
||||
tableNames = Array.from(names).sort();
|
||||
cacheTimestamp = Date.now();
|
||||
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${appNames.length} application(s)`);
|
||||
return {
|
||||
map,
|
||||
names: Array.from(names).sort(),
|
||||
applicationCount: appNames.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,6 +202,8 @@ function invalidateCache() {
|
||||
resolutionMap = null;
|
||||
tableNames = null;
|
||||
cacheTimestamp = null;
|
||||
// Les fetchs déjà partis ne repeupleront pas la table (D27).
|
||||
singleFlight.invalidate();
|
||||
console.error('[EntityResolver] Cache cleared');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Response Limit
|
||||
* Garde de taille commune aux trois outils de requête (D24, lot 5).
|
||||
*
|
||||
* Mesures du 25/08/2026 sur `LIMAGRAI2512`, toutes au-dessus du seuil de rejet
|
||||
* du client MCP (~70 000 caractères) : 957 234 caractères pour 200 lignes
|
||||
* Reading, 847 543 pour `search_wms_data("PAL")`, et 95 288 pour **une seule**
|
||||
* ligne Writing — le modèle Writing sérialise l'agrégat complet (navigations,
|
||||
* `$id`…) là où la même ligne Reading fait ~4 500.
|
||||
*
|
||||
* Contrairement aux mécanismes de `get_system_parameters` et `search_logs`
|
||||
* (locaux car différents, D24), les trois outils de requête partagent le même
|
||||
* mécanisme — d'où ce module : on écarte des **lignes entières**, jamais
|
||||
* coupées au milieu.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_CHARS = 25000;
|
||||
|
||||
/**
|
||||
* Plafond en caractères d'une réponse d'outil de requête.
|
||||
* Même ordre de grandeur que `MAX_LOG_SEARCH_CHARS` (D24).
|
||||
*/
|
||||
function getMaxResponseChars() {
|
||||
return parseInt(process.env.MAX_QUERY_RESPONSE_CHARS) || DEFAULT_MAX_RESPONSE_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avertissement de volume propre aux contextes non-Reading (D25). Repris tel
|
||||
* quel dans les hints et dans la description du paramètre `query_type`.
|
||||
*/
|
||||
const WRITING_VOLUME_NOTE =
|
||||
'En query_type != 0, une ligne est un agrégat complet sérialisé (navigations, $id…) : ' +
|
||||
'95 288 caractères mesurés pour UNE seule ligne Products en Writing, contre ~4 500 en Reading. ' +
|
||||
'Repassez en query_type: 0 si le modèle Reading suffit.';
|
||||
|
||||
/**
|
||||
* Trouve le plus grand nombre d'éléments dont la réponse tient sous le plafond.
|
||||
*
|
||||
* @param {number} total - nombre d'éléments disponibles
|
||||
* @param {(kept: number) => string} buildText - construit la réponse sérialisée
|
||||
* pour `kept` éléments. Doit être croissante en `kept` et porter
|
||||
* elle-même les champs de troncature quand `kept < total`.
|
||||
* @returns {{ text: string, kept: number, truncated: boolean, cap: number }}
|
||||
*/
|
||||
function fitToCap(total, buildText) {
|
||||
const cap = getMaxResponseChars();
|
||||
|
||||
const full = buildText(total);
|
||||
if (full.length <= cap) {
|
||||
return { text: full, kept: total, truncated: false, cap };
|
||||
}
|
||||
|
||||
// Recherche dichotomique : ~8 constructions pour 200 lignes, là où un retrait
|
||||
// ligne à ligne en ferait 200 sur des charges utiles de ~1 Mo.
|
||||
let lo = 0;
|
||||
let hi = total - 1;
|
||||
let best = -1;
|
||||
let bestText = null;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const text = buildText(mid);
|
||||
if (text.length <= cap) {
|
||||
best = mid;
|
||||
bestText = text;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite réel en Writing : une seule ligne dépasse déjà le plafond. On
|
||||
// renvoie l'enveloppe vide et signalée — moins bon qu'un résultat, mais mieux
|
||||
// qu'un rejet client opaque.
|
||||
if (best < 0) {
|
||||
best = 0;
|
||||
bestText = buildText(0);
|
||||
}
|
||||
|
||||
return { text: bestText, kept: best, truncated: true, cap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs de troncature communs aux outils de requête — vocabulaire D24 exact
|
||||
* (`truncated`, `returned`, `omitted`, `hint`). Le total avant la coupe est
|
||||
* ajouté par l'appelant : `query_wms_entities` et `search_wms_data` le portent
|
||||
* déjà (`count`, `totalFound`), `call_query_api` non.
|
||||
*
|
||||
* @param {number} returned - éléments effectivement renvoyés
|
||||
* @param {number} total - éléments disponibles avant la coupe
|
||||
* @param {number} queryType - QueryContextType de l'appel (D25)
|
||||
* @param {string} unit - nom de l'unité écartée, au singulier ('ligne', 'résultat')
|
||||
* @param {boolean} [feminine] - accord du hint sur `unit` ('ligne' est féminin)
|
||||
* @param {string} [extraHint] - phrase supplémentaire propre à l'outil
|
||||
*/
|
||||
function truncationSignal({ returned, total, queryType = 0, unit, feminine = false, extraHint }) {
|
||||
const cap = getMaxResponseChars();
|
||||
const omitted = total - returned;
|
||||
const plafond = `Plafond de taille de réponse atteint (${cap} caractères, MAX_QUERY_RESPONSE_CHARS)`;
|
||||
const e = feminine ? 'e' : '';
|
||||
const aucun = feminine ? 'Aucune' : 'Aucun';
|
||||
const unSeul = feminine ? 'une seule' : 'un seul';
|
||||
const entiers = feminine ? 'entières' : 'entiers';
|
||||
|
||||
// Cas limite réel en Writing : même une seule ligne dépasse le plafond.
|
||||
let hint = returned === 0
|
||||
? `${plafond} : ${aucun} ${unit} ne tient dans la réponse — ${unSeul} ${unit} dépasse déjà le plafond ` +
|
||||
`à ${feminine ? 'elle' : 'lui'} seul${e}. Restreignez la requête (filter plus étroit, autre entité) : ` +
|
||||
`le contenu n'est pas coupé au milieu, il est écarté en entier.`
|
||||
: `${plafond} : ${returned} ${unit}(s) renvoyé${e}(s) sur ${total}, ${omitted} écarté${e}(s) — des ` +
|
||||
`${unit}s ${entiers}, jamais coupé${e}s au milieu. Réduisez limit ou ajoutez un filter pour cibler.`;
|
||||
|
||||
if (extraHint) hint += ` ${extraHint}`;
|
||||
if (queryType) hint += ` ${WRITING_VOLUME_NOTE}`;
|
||||
|
||||
return { truncated: true, returned, omitted, hint };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMaxResponseChars,
|
||||
fitToCap,
|
||||
truncationSignal,
|
||||
WRITING_VOLUME_NOTE,
|
||||
DEFAULT_MAX_RESPONSE_CHARS,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Single-flight + génération de cache — chargements paresseux sous
|
||||
* concurrence (D27)
|
||||
*
|
||||
* Les services à cache (workflow, AD, resolver) chargent paresseusement : le
|
||||
* premier appelant qui trouve le cache invalide déclenche le fetch. Le serveur
|
||||
* traitant les `tools/call` en concurrence, deux défauts en découlaient, et ce
|
||||
* module porte les deux :
|
||||
*
|
||||
* 1. **Duplication** — N appelants arrivés pendant un fetch trouvaient tous le
|
||||
* cache invalide et lançaient N chaînes complètes (mesuré : 6 chargements
|
||||
* Metadata en parallèle pour une seule table). Une Map
|
||||
* `clé de cache -> promesse en vol` les fait rejoindre le fetch en cours.
|
||||
* 2. **Écriture post-invalidation** — un fetch parti avant une bascule de
|
||||
* profil (D8) terminait après elle et repeuplait le cache avec les données
|
||||
* de l'ancien tenant, timestamp neuf. Un compteur de génération, incrémenté
|
||||
* à chaque invalidation, fait **jeter** un résultat d'une génération
|
||||
* périmée au lieu de l'écrire.
|
||||
*
|
||||
* Le single-flight est **par clé** — deux applications différentes se chargent
|
||||
* toujours en parallèle (D26 : rien n'est préchargé, rien n'est sérialisé
|
||||
* au-delà de la clé demandée). Pas de dépendance externe : une Map.
|
||||
*
|
||||
* @param {string} label - préfixe de log du service appelant (D6, MONITORING §2)
|
||||
*/
|
||||
function createSingleFlight(label) {
|
||||
const inFlight = new Map(); // clé de cache -> promesse du chargement en cours
|
||||
let generation = 0; // incrémenté à chaque invalidation
|
||||
|
||||
/**
|
||||
* Exécute `fetcher` pour cette clé, ou rejoint le chargement déjà en vol,
|
||||
* puis publie le résultat via `commit` **si la génération n'a pas changé**.
|
||||
*
|
||||
* La promesse est retirée de la Map au règlement, succès **ou** échec : un
|
||||
* fetch en erreur ne reste pas coincé, l'appel suivant refetche.
|
||||
*
|
||||
* L'appelant reçoit toujours le résultat de son fetch, même périmé — c'est
|
||||
* sa **mise en cache** qui est refusée, pas sa réponse : il a demandé ces
|
||||
* données avant l'invalidation, il les obtient.
|
||||
*
|
||||
* @param {string} key - clé de cache (une par entrée de cache indépendante)
|
||||
* @param {() => Promise<any>} fetcher - le chargement réel, appelé au plus
|
||||
* une fois tant qu'il est en vol ; il ne doit **rien** écrire en cache
|
||||
* @param {(value: any) => void} [commit] - publication en cache, appelée
|
||||
* seulement si aucune invalidation n'est survenue pendant le fetch
|
||||
* @returns {Promise<any>} le résultat du chargement (partagé par les joignants)
|
||||
*/
|
||||
function run(key, fetcher, commit) {
|
||||
const pending = inFlight.get(key);
|
||||
if (pending) {
|
||||
console.error(`[${label}] Fetch already in flight for "${key}", joining it`);
|
||||
return pending;
|
||||
}
|
||||
|
||||
const startGeneration = generation;
|
||||
const promise = (async () => {
|
||||
const value = await fetcher();
|
||||
if (generation !== startGeneration) {
|
||||
console.error(
|
||||
`[${label}] Result for "${key}" discarded, not cached: ` +
|
||||
`cache invalidated during fetch (generation ${startGeneration} -> ${generation})`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
if (commit) commit(value);
|
||||
return value;
|
||||
})();
|
||||
|
||||
inFlight.set(key, promise);
|
||||
|
||||
// Libération au règlement. Le test d'identité évite qu'une promesse
|
||||
// périmée (Map vidée par une invalidation, puis nouveau fetch démarré)
|
||||
// supprime l'entrée de son successeur.
|
||||
const release = () => {
|
||||
if (inFlight.get(key) === promise) inFlight.delete(key);
|
||||
};
|
||||
promise.then(release, release);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque toutes les données en vol comme périmées : la génération avance et
|
||||
* la Map est vidée. À appeler depuis l'invalidation du service (l'abonnement
|
||||
* `onSwitch()` reste le seul déclencheur, D8).
|
||||
*
|
||||
* Vider la Map ne coupe personne : les appelants déjà en attente gardent
|
||||
* leur référence à la promesse et reçoivent son résultat — simplement, ce
|
||||
* résultat ne sera pas mis en cache.
|
||||
*/
|
||||
function invalidate() {
|
||||
generation++;
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de chargements en vol — diagnostic seulement.
|
||||
*/
|
||||
function pendingCount() {
|
||||
return inFlight.size;
|
||||
}
|
||||
|
||||
return { run, invalidate, pendingCount };
|
||||
}
|
||||
|
||||
module.exports = { createSingleFlight };
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
const { requireEntities } = require('./ad-envelope');
|
||||
|
||||
// Cache state — un cache de workflows par application (D26)
|
||||
let workflowCaches = {}; // application -> workflows[]
|
||||
@@ -17,6 +19,10 @@ let applicationsCache = null; // liste allégée de POST /Application/GetAll
|
||||
let applicationsTimestamp = null;
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds
|
||||
|
||||
// Déduplication des chargements concurrents, par clé de cache (D27). Deux
|
||||
// clés distinctes ici : une par application, plus la liste d'applications.
|
||||
const singleFlight = createSingleFlight('Workflow');
|
||||
|
||||
// Clear cache when profile changes — workflows are per-tenant, so the previous
|
||||
// profile's cache is meaningless after a switch.
|
||||
profileManager.onSwitch(() => clearCache());
|
||||
@@ -56,6 +62,27 @@ async function fetchAllWorkflows(application) {
|
||||
return workflowCaches[app];
|
||||
}
|
||||
|
||||
// Un seul chargement par application, même sous rafale concurrente, et
|
||||
// publication en cache seulement si aucune invalidation n'est survenue
|
||||
// pendant le fetch (D27).
|
||||
return singleFlight.run(
|
||||
`workflows::${app}`,
|
||||
() => loadWorkflows(app),
|
||||
(workflows) => {
|
||||
workflowCaches[app] = workflows;
|
||||
cacheTimestamps[app] = Date.now();
|
||||
console.error(`[Workflow] Successfully cached ${workflows.length} workflows for "${app}"`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel des workflows d'une application (pagination complète).
|
||||
* Appelé au plus une fois par application tant qu'il est en vol (D27).
|
||||
* N'écrit RIEN en cache : la publication est le `commit` de singleFlight.run,
|
||||
* qui la refuse si le cache a été invalidé entre-temps.
|
||||
*/
|
||||
async function loadWorkflows(app) {
|
||||
console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`);
|
||||
|
||||
try {
|
||||
@@ -72,11 +99,17 @@ async function fetchAllWorkflows(application) {
|
||||
// Use AD API (useAdApi=true)
|
||||
const response = await apiService.post('/Workflow/GetByApplication', body, true);
|
||||
|
||||
// Extract entities array from response
|
||||
const workflows = response?.entities || [];
|
||||
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
|
||||
// faire passer pour une page vide (D27) : un cache vide empoisonné
|
||||
// durerait tout le TTL.
|
||||
const workflows = requireEntities(
|
||||
response,
|
||||
`Workflow/GetByApplication (application "${app}", offset ${offset})`
|
||||
);
|
||||
|
||||
// Check if response is valid
|
||||
if (!workflows || workflows.length === 0) {
|
||||
// Vide réel : fin de pagination (une application peut n'avoir aucun
|
||||
// workflow — SmartUI, D26).
|
||||
if (workflows.length === 0) {
|
||||
console.error('[Workflow] No more workflows to fetch');
|
||||
break;
|
||||
}
|
||||
@@ -93,11 +126,6 @@ async function fetchAllWorkflows(application) {
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
workflowCaches[app] = allWorkflows;
|
||||
cacheTimestamps[app] = Date.now();
|
||||
|
||||
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows for "${app}"`);
|
||||
return allWorkflows;
|
||||
} catch (error) {
|
||||
console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message);
|
||||
@@ -113,25 +141,74 @@ async function fetchAllWorkflows(application) {
|
||||
* @returns {Promise<Array<{name: string, id: string, version: number}>>}
|
||||
*/
|
||||
async function fetchApplications() {
|
||||
if (applicationsCache && applicationsTimestamp &&
|
||||
Date.now() - applicationsTimestamp < CACHE_TTL) {
|
||||
return applicationsCache;
|
||||
const cached = getCachedApplications();
|
||||
if (cached) return cached;
|
||||
|
||||
// Même déduplication et même garde de génération, sur sa propre clé (D27).
|
||||
return singleFlight.run('applications', loadApplications, (applications) => {
|
||||
applicationsCache = applications;
|
||||
applicationsTimestamp = Date.now();
|
||||
console.error(`[Workflow] Cached ${applications.length} application(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel de la liste d'applications (D27). N'écrit rien en cache.
|
||||
*/
|
||||
async function loadApplications() {
|
||||
console.error('[Workflow] Fetching application list (Application/GetAll)...');
|
||||
const response = await apiService.post('/Application/GetAll', null, true);
|
||||
const entities = response?.entities || [];
|
||||
const entities = requireEntities(response, 'Application/GetAll');
|
||||
|
||||
applicationsCache = entities.map(a => ({
|
||||
return entities.map(a => ({
|
||||
name: a.name || a.Name,
|
||||
id: a.id || a.Id,
|
||||
version: a.version ?? a.Version,
|
||||
})).filter(a => a.name);
|
||||
applicationsTimestamp = Date.now();
|
||||
}
|
||||
|
||||
console.error(`[Workflow] Cached ${applicationsCache.length} application(s)`);
|
||||
/**
|
||||
* Liste des applications déjà en cache, ou null si le cache est vide/expiré.
|
||||
* Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse
|
||||
* de recherche sans jamais précharger une application non demandée (D26).
|
||||
* @returns {Array<{name: string, id: string, version: number}>|null}
|
||||
*/
|
||||
function getCachedApplications() {
|
||||
if (applicationsCache && applicationsTimestamp &&
|
||||
Date.now() - applicationsTimestamp < CACHE_TTL) {
|
||||
return applicationsCache;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application
|
||||
* sur les neuf déclarées, et rien dans la réponse ne le disait : une session
|
||||
* cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à
|
||||
* tort qu'il n'y en avait aucun (25/08/2026).
|
||||
*
|
||||
* Les autres applications sont nommées depuis la liste allégée **déjà en
|
||||
* cache** ; sans elle, le hint reste générique et renvoie vers
|
||||
* `list_workflow_categories` — jamais de fetch pour construire un hint.
|
||||
*
|
||||
* @param {string} application - application effectivement interrogée
|
||||
* @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…)
|
||||
*/
|
||||
function buildOtherApplicationsHint(application, sujet) {
|
||||
const cached = getCachedApplications();
|
||||
const others = (cached || []).map(a => a.name).filter(n => n !== application);
|
||||
|
||||
const liste = others.length
|
||||
? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.`
|
||||
: `Appelez list_workflow_categories pour lister les autres applications déclarées.`;
|
||||
|
||||
const custom = application.toLowerCase() === 'customapp'
|
||||
? ''
|
||||
: ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`;
|
||||
|
||||
return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` +
|
||||
`les autres ne le sont jamais implicitement.${custom} ${liste}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search workflows by query string.
|
||||
@@ -227,6 +304,8 @@ function clearCache() {
|
||||
cacheTimestamps = {};
|
||||
applicationsCache = null;
|
||||
applicationsTimestamp = null;
|
||||
// Les fetchs déjà partis ne repeupleront pas ce cache (D27).
|
||||
singleFlight.invalidate();
|
||||
console.error('[Workflow] Cache cleared');
|
||||
}
|
||||
|
||||
@@ -251,6 +330,9 @@ function getCacheStatus() {
|
||||
module.exports = {
|
||||
fetchAllWorkflows,
|
||||
fetchApplications,
|
||||
getCachedApplications,
|
||||
buildOtherApplicationsHint,
|
||||
resolveApplication,
|
||||
searchWorkflows,
|
||||
getWorkflowDetails,
|
||||
getWorkflowStats,
|
||||
|
||||
+11
-1
@@ -247,15 +247,25 @@ async function searchADElementsTool(args) {
|
||||
code: e.code || e.Code
|
||||
}));
|
||||
|
||||
// L5.4 : même correctif que search_workflows — l'application interrogée est
|
||||
// toujours rappelée, et un résultat vide signale que les huit autres n'ont
|
||||
// pas été regardées. Le hint se construit depuis la liste d'applications
|
||||
// DÉJÀ en cache : aucun appel réseau, aucun préchargement (D26).
|
||||
const effectiveApplication = adService.resolveApplication(application);
|
||||
const hint = mappedResults.length === 0
|
||||
? workflowService.buildOtherApplicationsHint(effectiveApplication, `élément ${element_type}`)
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
...(application ? { application } : {}),
|
||||
application: effectiveApplication,
|
||||
query,
|
||||
count: mappedResults.length,
|
||||
...(hint ? { hint } : {}),
|
||||
elements: mappedResults
|
||||
}, null, 2)
|
||||
}]
|
||||
|
||||
+34
-14
@@ -1,6 +1,7 @@
|
||||
const apiService = require('../services/api-service').getInstance();
|
||||
const entityResolver = require('../services/entity-resolver');
|
||||
const { assertValidQueryType } = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* Tools MCP pour interagir avec les APIs WMS
|
||||
@@ -39,7 +40,7 @@ function listTools() {
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning.',
|
||||
description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning. ATTENTION VOLUME : une ligne Writing est un agrégat complet sérialisé — 95 288 caractères mesurés pour UNE ligne Products, contre ~4 500 en Reading. La réponse est plafonnée (MAX_QUERY_RESPONSE_CHARS) et les lignes en trop sont écartées avec un signal truncated.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
@@ -123,25 +124,42 @@ async function callQueryAPI(args) {
|
||||
queryType,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
const head = {
|
||||
success: true,
|
||||
entityType: entity_type,
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
...(queryType !== 0 ? { queryType } : {}),
|
||||
result,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Garde de taille (D24) : une SEULE ligne Writing faisait 95 288 caractères
|
||||
// — le modèle Writing sérialise l'agrégat complet. On écarte des lignes
|
||||
// entières ; sous le plafond, la réponse est strictement celle d'avant.
|
||||
if (!Array.isArray(result)) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ...head, result }, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
const buildText = (kept) => {
|
||||
const payload = { ...head };
|
||||
if (kept < result.length) {
|
||||
// Cet outil ne porte pas de champ de total : on l'ajoute (D24).
|
||||
payload.totalRows = result.length;
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: result.length,
|
||||
queryType,
|
||||
unit: 'ligne',
|
||||
feminine: true,
|
||||
}));
|
||||
}
|
||||
payload.result = result.slice(0, kept);
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(result.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
@@ -151,6 +169,7 @@ async function callQueryAPI(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'call_query_api',
|
||||
...(resolution?.warning ? { warning: resolution.warning } : {}),
|
||||
},
|
||||
null,
|
||||
@@ -197,6 +216,7 @@ async function executeCommand(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'execute_command',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -113,7 +113,7 @@ async function listLogFiles() {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'list_log_files' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -156,6 +156,7 @@ async function readRecentLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'read_recent_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -225,6 +226,7 @@ async function searchLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'search_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -109,6 +109,7 @@ async function getEntityMetadata(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `No entity matching "${entity_name}" found`,
|
||||
tool: 'get_entity_metadata',
|
||||
availableCount: Array.isArray(entities) ? entities.length : '?',
|
||||
hint: 'Call get_entity_metadata without entity_name to see all entities',
|
||||
}, null, 2),
|
||||
@@ -150,7 +151,7 @@ async function getEntityMetadata(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'get_entity_metadata' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -193,7 +194,7 @@ async function genericSearch(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'generic_search' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,7 @@ function getCurrentProfileTool() {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'get_current_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -127,6 +128,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing "profile" argument',
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -156,6 +158,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const wmsQueryService = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* List available WMS query tools
|
||||
@@ -46,7 +47,7 @@ Never guess enum string values — they differ between Reading and Writing model
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.',
|
||||
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0. VOLUME WARNING: a Writing row is a full serialised aggregate (navigations, $id…) — 95 288 characters measured for ONE Products row, against ~4 500 in Reading. The response is capped (MAX_QUERY_RESPONSE_CHARS) and excess rows are dropped whole, with a truncated signal.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
@@ -177,6 +178,10 @@ async function executeTool(name, args) {
|
||||
|
||||
/**
|
||||
* Tool: query_wms_entities
|
||||
*
|
||||
* Garde de taille (D24) : 200 lignes Reading faisaient 957 234 caractères, au
|
||||
* delà du seuil de rejet du client MCP. On écarte des lignes ENTIÈRES depuis la
|
||||
* fin ; sous le plafond, la réponse est strictement celle d'avant.
|
||||
*/
|
||||
async function queryWmsEntities(args) {
|
||||
const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
|
||||
@@ -191,17 +196,36 @@ async function queryWmsEntities(args) {
|
||||
query_type
|
||||
);
|
||||
|
||||
const { data, ...head } = result;
|
||||
|
||||
// Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes.
|
||||
if (!Array.isArray(data)) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...result
|
||||
}, null, 2)
|
||||
}]
|
||||
content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }]
|
||||
};
|
||||
}
|
||||
|
||||
// `count` (dans head) porte déjà le total avant la coupe — c'est le total
|
||||
// exigé par D24, inutile d'en ajouter un second.
|
||||
const buildText = (kept) => {
|
||||
const payload = { success: true, ...head };
|
||||
if (kept < data.length) {
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: data.length,
|
||||
queryType: query_type,
|
||||
unit: 'ligne',
|
||||
feminine: true,
|
||||
}));
|
||||
}
|
||||
payload.data = data.slice(0, kept);
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(data.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: count_wms_entities
|
||||
*/
|
||||
@@ -272,17 +296,52 @@ async function searchWmsData(args) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
keyword,
|
||||
totalFound,
|
||||
results
|
||||
}, null, 2)
|
||||
}]
|
||||
// Garde de taille (D24) : search_wms_data("PAL") faisait 847 543 caractères.
|
||||
// L'unité écartée est un RÉSULTAT entier ; les résultats gardés sont répartis
|
||||
// en tourniquet entre les entités, pour qu'une entité volumineuse placée en
|
||||
// tête n'efface pas silencieusement les suivantes — c'est exactement le
|
||||
// faux négatif que L5.4 corrige par ailleurs.
|
||||
const entityKeys = Object.keys(results).filter(k => Array.isArray(results[k].data));
|
||||
const slots = [];
|
||||
const maxRows = entityKeys.reduce((m, k) => Math.max(m, results[k].data.length), 0);
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
for (const k of entityKeys) {
|
||||
if (i < results[k].data.length) slots.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
const buildText = (kept) => {
|
||||
const keepCount = {};
|
||||
entityKeys.forEach(k => { keepCount[k] = 0; });
|
||||
for (let i = 0; i < kept; i++) keepCount[slots[i]]++;
|
||||
|
||||
const payload = { success: true, keyword, totalFound };
|
||||
if (kept < slots.length) {
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: slots.length,
|
||||
unit: 'résultat',
|
||||
extraHint: 'Les résultats gardés sont répartis entre les entités : voir returned/omitted par entité. ' +
|
||||
'Relancez query_wms_entities entité par entité avec un filter plus précis pour voir le reste.',
|
||||
}));
|
||||
}
|
||||
|
||||
payload.results = {};
|
||||
for (const [k, v] of Object.entries(results)) {
|
||||
if (!Array.isArray(v.data)) {
|
||||
payload.results[k] = v; // entité en erreur : { error, count }, déjà minuscule
|
||||
continue;
|
||||
}
|
||||
const keptRows = v.data.slice(0, keepCount[k]);
|
||||
payload.results[k] = keptRows.length < v.data.length
|
||||
? { count: v.count, returned: keptRows.length, omitted: v.data.length - keptRows.length, data: keptRows }
|
||||
: { count: v.count, data: keptRows };
|
||||
}
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(slots.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
|
||||
const workflowService = require('../services/workflow-service');
|
||||
|
||||
// Taille par défaut d'une tranche du blob `data` de get_workflow_details.
|
||||
// Ordre de grandeur cible de D24 (~20-25 000 caractères par réponse) : avec
|
||||
// l'échappement JSON et les métadonnées, 20 000 caractères de blob tiennent
|
||||
// sous ~23 000 caractères de réponse.
|
||||
const DEFAULT_MAX_DATA_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* List available workflow tools
|
||||
*/
|
||||
@@ -39,7 +45,8 @@ function listTools() {
|
||||
},
|
||||
{
|
||||
name: 'get_workflow_details',
|
||||
description: 'Get full details of a specific workflow by ID or name',
|
||||
description: `Get full details of a specific workflow by ID or name.
|
||||
The EasyBuilder definition (the \`data\` blob) is large — 71 512 characters for a StackerCrane workflow, 92 362 for CST_SendRejectContainersToPK — so it is returned as a VERBATIM WINDOW (max_data_chars / data_offset). Workflow metadata is always complete; only \`data\` is windowed. dataTotalChars always carries the full blob size, and concatenating the slices in offset order reproduces the definition byte for byte.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -52,6 +59,16 @@ function listTools() {
|
||||
type: 'string',
|
||||
description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".',
|
||||
},
|
||||
max_data_chars: {
|
||||
type: 'number',
|
||||
description: `Maximum number of characters of the \`data\` blob returned by this call (default: ${DEFAULT_MAX_DATA_CHARS}). The slice is verbatim — never summarised, reformatted or parsed. Pass 0 for metadata only.`,
|
||||
default: DEFAULT_MAX_DATA_CHARS,
|
||||
},
|
||||
data_offset: {
|
||||
type: 'number',
|
||||
description: 'Character offset in the `data` blob where the returned slice starts (default: 0). When the response carries truncated: true, its hint gives the next offset to pass here.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
required: ['workflow_id'],
|
||||
},
|
||||
@@ -117,13 +134,23 @@ async function searchWorkflows(args) {
|
||||
|
||||
const results = await workflowService.searchWorkflows(query, category, limit, application);
|
||||
|
||||
// L5.4 : l'application interrogée est TOUJOURS rappelée (pas seulement quand
|
||||
// elle a été passée), et un résultat vide dit qu'une seule application sur
|
||||
// neuf a été regardée — c'est ce silence qui avait fait conclure à tort à
|
||||
// l'absence de workflows CST_.
|
||||
const effectiveApplication = workflowService.resolveApplication(application);
|
||||
const hint = results.length === 0
|
||||
? workflowService.buildOtherApplicationsHint(effectiveApplication, 'workflow')
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...(application ? { application } : {}),
|
||||
application: effectiveApplication,
|
||||
count: results.length,
|
||||
...(hint ? { hint } : {}),
|
||||
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
|
||||
// applicationName, commonInfo. Pas de code/category/description.
|
||||
workflows: results.map(w => {
|
||||
@@ -143,23 +170,74 @@ async function searchWorkflows(args) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de
|
||||
* paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau.
|
||||
*/
|
||||
function assertWindowValue(value, fallback, paramName) {
|
||||
if (value == null) return fallback;
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
const attendu = paramName === 'max_data_chars'
|
||||
? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules`
|
||||
: 'offset de départ dans le blob data, défaut 0';
|
||||
throw new Error(
|
||||
`${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_workflow_details
|
||||
*
|
||||
* La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères
|
||||
* sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse
|
||||
* complète dépassait le seuil de rejet du client MCP (D24). On renvoie une
|
||||
* TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées
|
||||
* restent complètes, et concaténer les tranches dans l'ordre des offsets
|
||||
* reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce
|
||||
* blob pour n'en renvoyer que des morceaux jugés utiles.
|
||||
*/
|
||||
async function getWorkflowDetails(args) {
|
||||
const { workflow_id, application } = args;
|
||||
const { workflow_id, application, max_data_chars, data_offset } = args;
|
||||
|
||||
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'})`);
|
||||
const maxDataChars = assertWindowValue(max_data_chars, DEFAULT_MAX_DATA_CHARS, 'max_data_chars');
|
||||
const dataOffset = assertWindowValue(data_offset, 0, 'data_offset');
|
||||
|
||||
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'}, max_data_chars=${maxDataChars}, data_offset=${dataOffset})`);
|
||||
|
||||
const workflow = await workflowService.getWorkflowDetails(workflow_id, application);
|
||||
|
||||
const payload = { success: true, workflow };
|
||||
|
||||
// Seul un blob `data` textuel se fenêtre ; un workflow sans définition (ou
|
||||
// d'une forme inattendue) sort inchangé.
|
||||
if (typeof workflow?.data === 'string') {
|
||||
const total = workflow.data.length;
|
||||
const slice = workflow.data.slice(dataOffset, dataOffset + maxDataChars);
|
||||
const nextOffset = dataOffset + slice.length;
|
||||
|
||||
payload.workflow = { ...workflow, data: slice };
|
||||
// La taille totale est portée par TOUTE réponse : truncated se vérifie
|
||||
// depuis la réponse elle-même (D24).
|
||||
payload.dataTotalChars = total;
|
||||
payload.dataOffset = dataOffset;
|
||||
payload.returned = slice.length;
|
||||
|
||||
if (nextOffset < total) {
|
||||
payload.truncated = true;
|
||||
payload.hint =
|
||||
`Blob \`data\` tronqué : ${slice.length} caractère(s) sur ${total} renvoyé(s) depuis l'offset ${dataOffset}. ` +
|
||||
`Rappelez get_workflow_details avec les mêmes workflow_id/application et data_offset: ${nextOffset} pour la tranche ` +
|
||||
`suivante (max_data_chars change la taille des tranches). Les tranches sont verbatim : les concaténer dans l'ordre ` +
|
||||
`des offsets reconstitue la définition EasyBuilder à l'octet près.`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
workflow
|
||||
}, null, 2)
|
||||
text: JSON.stringify(payload, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user