Compare commits

...

12 Commits

Author SHA1 Message Date
Arthur Ria 5eadc1f6a6 Révision lot 6 : validé — supprime handoff-lot6.md
Les trois blocs rejoués passent : rafales de 6 en 3/3 (un seul fetch
workflow, un seul chargement resolver, six réponses correctes, zéro
HTTP 500), garde de génération en 3/3 (fetch pré-bascule jeté, seul le
fetch post-bascule est mis en cache), enveloppe AD conforme (formes
anormales levées, entities [] réel cachable, SmartUI sans erreur avec
le hint L5.4). Régression décisive : la rafale mixte de 14 appels qui
produisait les résultats faux au lot 5 est désormais entièrement
correcte en pleine concurrence. Baseline 23/6, npm test 4/4 exit 0,
zéro console.log, D27 écrite, fragilité AD consignée avec garde-fou.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 16:25:48 +02:00
Arthur Ria b758a0e09d Doc : consigner la fragilite de l'API AD sous concurrence (hors perimetre)
Anomalie mesuree pendant le lot 6, hors de son perimetre parce qu'elle est
cote serveur AD : sous rafale de 14 tools/call, POST /AD/api/Workflow/
GetByApplication repond HTTP 500 pour EasyWMS (~4000 workflows), les memes
appels passant en sequentiel.

D27 l'attenue fortement — un seul fetch par cle au lieu de N, et le 500 n'a
plus reparu sur aucun des rejeux post-correctif — sans la supprimer : des cles
differentes se chargent toujours en parallele, par conception (D26). Consigne
en point ouvert avec la mise en garde qui va avec : brider les chargements
paralleles couterait de la latence sur le chemin nominal, donc pas de code
avant une mesure qui le justifie.

Documentation seule, aucun changement de code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:55:36 +02:00
Arthur Ria 242b0c0f1c L6.3 : une reponse hors enveloppe leve, au lieu de se faire passer pour vide
Les services lisaient `response?.entities || []` sur les reponses de l'API AD
(enveloppe { entities: [...] }, D4). Toute reponse d'une AUTRE forme — corps
vide, objet d'erreur, champ absent — devenait donc un tableau vide,
indistinguable d'une page finale legitime, et etait mise en cache avec un
timestamp valide : un cache vide empoisonne pour tout le TTL, sans le moindre
message. C'est la cause probable du `count: 0` mesure sous rafale, et le mode
d'echec le plus couteux du lot, parce qu'il se lit comme une reponse.

Le contrat est porte par src/services/ad-envelope.js pour les trois sites
(Workflow/GetByApplication, Application/GetAll, <Type>/GetByApplication) :
`{ entities: [...] }`, `[]` reel compris, est rendu tel quel ; toute autre
forme leve. entity-resolver etait deja conforme — il leve deja si
/configuration/applications ou le Metadata ne rendent aucune entite.

Volontairement sans retry ni logique de resilience : le but est de rendre
l'anomalie visible et non persistante. La rattraper la rendrait invisible,
c'est-a-dire exactement le defaut corrige.

--- Verifications (LIMAGRAIN) ---

Vide LEGITIME — search_workflows sur SmartUI (0 workflow, D26) :

  search_workflows(SmartUI) : success=true application=SmartUI count=0
                              isError=false
    error   : (aucune)
    hint    : Aucun workflow trouve dans l'application "SmartUI" — c'est la
              SEULE interrogee, les autres ne le sont jamais implicitement.
              Le specifique client (prefixe CST_) vit dans "CustomApp" : [...]
    cache pose ? workflowCachesByApplication =
      {"SmartUI":{"cached":true,"count":0,"timestamp":1787665868988,
                  "age":0,"valid":true}}
  stderr : No more workflows to fetch / Successfully cached 0 workflows

Forme SANS `entities` — non declenchable a la demande contre le vrai WMS,
couverte par un test direct (apiService.post substitue, renvoie {}) :

  --- workflow-service  fetchAllWorkflows("EasyWMS") avec une reponse {} ---
    erreur levee : Failed to fetch workflows for application "EasyWMS":
      Reponse inattendue de l'API AD sur Workflow/GetByApplication
      (application "EasyWMS", offset 0) : un objet vide, au lieu de
      l'enveloppe attendue { entities: [...] }. Rien n'a ete mis en cache —
      relancez l'appel. Si l'erreur persiste, l'API AD est en defaut [...]
    cache : {}  (attendu {})
  --- workflow-service  fetchApplications() avec une reponse {} ---
    erreur levee : Reponse inattendue de l'API AD sur Application/GetAll : [...]
    cache : {}  (attendu {})
  --- ad-service        getElements("Command") avec une reponse {} ---
    erreur levee : Failed to fetch Command for application "EasyWMS": [...]
    cache : {}  (attendu {})
  --- puis une reponse normale : le refetch repart (rien de coince) ---
    1 workflow(s), cache : {"EasyWMS":{"cached":true,"count":1,...}}

Cas nominaux du helper (unitaire) : { entities: [] } et { entities: [1,2] }
passent ; {}, null, undefined, [], { error }, "texte" levent tous.

--- Non-regression, rafales rejouees 3 fois ---

  L6.1 rafale workflow  RUN 1/2/3 : 6/6 a count=44 | fetch=1 joins=5
  L6.1 rafale resolver  RUN 1/2/3 : 6/6 succes | chargements=1 joins=5 GET=5
  L6.1 rafale mixte     RUN 1/2/3 : CustomApp@44=3 EasyWMS@50=3 |
                                    fetch=2 joins=4
  L6.2 bascule          RUN 1/2/3 : caches peuples : 0 (attendu 0)
  sequentiel nominal    count=50 puis count=50 | fetch=1 cached=1 joins=0

Baseline finale : tools/list 23, resources/list 6 ; npm test 4/4, exit 0.

ROADMAP : lot 6 retire. Le point ouvert « bascule de profil concurrente aux
appels en vol » reste — D27 borne les chargements paresseux, pas le routage
d'une requete deja partie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:54:54 +02:00
Arthur Ria 097b76c7ef L6.2 : garde de generation contre les ecritures post-invalidation
Un fetch parti AVANT une invalidation (clearCache/invalidateCache, declenchees
par onSwitch a la bascule de profil, D8) terminait APRES elle et ecrivait quand
meme son resultat : le cache repartait peuple avec les donnees de l'ancien
tenant, timestamp neuf, valid: true. Defaut latent avant L6.1 ; deterministe
apres, puisque la promesse en vol survit desormais a l'invalidation.

Compteur de generation par service, incremente a chaque invalidation. Le fetch
capture la generation au depart et, au moment de publier, jette son resultat si
elle a bouge. Surtout : les fonctions de chargement n'ecrivent plus rien en
cache — la publication est un `commit` passe a singleFlight.run, appele
seulement si la generation n'a pas change. La garde devient structurelle, pas
conventionnelle : un chargement ne peut plus publier par inadvertance.

L'invalidation vide aussi la Map des promesses en vol. L'appelant deja en
attente recoit quand meme son resultat — il l'a demande avant la bascule ;
c'est sa mise en cache qui est refusee.

--- Verifications (LIMAGRAIN) ---

Rafale [search_workflows(EasyWMS), switch_wms_profile(EUROTRAFIC)] envoyee d'un
bloc, puis get_application_summary SEQUENTIEL apres les deux reponses.

AVANT la garde (HEAD = L6.1), 3 rejeux — le cache survit a la bascule :

  ===== RUN 1 =====
    search_workflows : success=true count=50
    switch_wms_profile : success=true
    get_application_summary -> workflowCachesByApplication =
      {"EasyWMS":{"cached":true,"count":3944,"timestamp":1787665677594,
                  "age":0,"valid":true}}
                              adElementsByApplication = {}
    => caches peuples : 1  (attendu 0)
  ===== RUN 2 =====  idem, count 3944, timestamp 1787665687744, valid true
  ===== RUN 3 =====  idem, count 3944, timestamp 1787665703945, valid true

APRES la garde, 3 rejeux — aucun cache peuple :

  ===== RUN 1 =====
    search_workflows : success=true count=50
    switch_wms_profile : success=true
    get_application_summary -> workflowCachesByApplication = {}
                              adElementsByApplication      = {}
    => caches peuples : 0  (attendu 0)
    -- stderr : 1 resultat(s) jete(s) | 2 'Cache cleared'
  ===== RUN 2 =====  identique : caches peuples 0, 1 resultat jete
  ===== RUN 3 =====  identique : caches peuples 0, 1 resultat jete

Test direct et deterministe (invalidation declenchee pendant un fetch tenu
ouvert par une porte) :

  [Workflow] Cache expired or empty for "TestApp", fetching from API...
  --- invalidation PENDANT le fetch (clearCache) ---
  [Workflow] Cache cleared
  [Workflow] Fetched 1 workflows (total: 1)
  [Workflow] Result for "workflows::TestApp" discarded, not cached:
             cache invalidated during fetch (generation 0 -> 1)
    l'appelant recoit bien son resultat : 1 workflow(s)
    cache apres coup : {}  (attendu {})

Non-regression L6.1, 3 rejeux de la rafale de 6 (CustomApp) :

  == RUN 1 : 6 reponses a 44 | fetch=1 joins=5 caches=1
  == RUN 2 : 6 reponses a 44 | fetch=1 joins=5 caches=1
  == RUN 3 : 6 reponses a 44 | fetch=1 joins=5 caches=1

Chemin sequentiel nominal, inchange :

  [search_workflows] success=true application=EasyWMS count=50 len=14220
  [search_workflows] success=true application=EasyWMS count=50 len=14220
    fetch=1 cached=1 joins=0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:49:39 +02:00
Arthur Ria b7151b3bc7 L6.1 : dedupliquer les chargements paresseux en vol (single-flight)
Le serveur traite les tools/call en concurrence. Les trois services a cache
chargent paresseusement sans se coordonner : le premier appelant qui trouve le
cache invalide lance le fetch, et tous ceux qui arrivent pendant ce fetch le
trouvent *encore* invalide et lancent le leur. Une rafale de 6 appels
identiques declenchait donc 6 chargements complets pour une seule cle.

Ce n'est pas qu'un gaspillage : la duplication surcharge l'API AD au point de
la faire echouer. Rafale mixte de 14 appels, avant correction — les 4 appels
EasyWMS (~4000 workflows) reviennent en erreur, les memes passent en
sequentiel :

  [Workflow] Error fetching workflows for "EasyWMS": POST
  https://10.255.255.2/AD/api/Workflow/GetByApplication failed (HTTP 500)
  fetching from API: 8 | EntityResolver Cache expired or empty: 3

Motif commun extrait dans src/services/single-flight.js — une Map de
promesses, pas de dependance externe. Une cle par entree de cache
(workflows::<app>, applications, <app>::<type>, metadata) : deux cles
distinctes se chargent toujours en parallele, aucun prechargement (D26
intact). La promesse est retiree au reglement, succes *ou* echec, pour qu'un
fetch en erreur ne reste pas coince.

Le log de fetch reste l'observable (un par chargement reel) ; les appelants
joints emettent une ligne distincte "Fetch already in flight ... joining it".

--- Verifications (LIMAGRAIN), rafales rejouees 3 fois ---

Phase 0, reproduction avant correction :
  6 x search_workflows CustomApp  -> count 44 x6, 'fetching from API' : 6
  6 x query_wms_entities Container -> 6 succes, 'EntityResolver] Cache
                                      expired or empty' : 6

Rafale de 6 search_workflows {"query":"CST_","application":"CustomApp"} :

  ===== RUN 1 =====            ===== RUN 2 =====            ===== RUN 3 =====
  id 10 success=true application=CustomApp count=44   (idem RUN 2 et RUN 3,
  id 11 success=true application=CustomApp count=44    les 6 reponses a 44)
  id 12 success=true application=CustomApp count=44
  id 13 success=true application=CustomApp count=44
  id 14 success=true application=CustomApp count=44
  id 15 success=true application=CustomApp count=44
  -- 'fetching from API' : 1  | 'joining it' : 5   [RUN 1]
  -- 'fetching from API' : 1  | 'joining it' : 5   [RUN 2]
  -- 'fetching from API' : 1  | 'joining it' : 5   [RUN 3]

Rafale de 6 query_wms_entities {"entity_type":"Container","limit":1} :

  RUN 1/2/3 : id 10..15 success=true count=1 (6/6)
  -- 'EntityResolver] Cache expired or empty' : 1 | joins : 5 | GET
     Metadata/Entities : 5   [identique RUN 1, RUN 2, RUN 3]
  (avant : 6 chargements, soit 30 GET Metadata)

Rafale mixte EasyWMS + CustomApp (3 + 3) — un fetch par application :

  RUN 1/2/3 : CustomApp count=44 x3, EasyWMS count=50 x3
  [Workflow] Cache expired or empty for "CustomApp", fetching from API...
  [Workflow] Cache expired or empty for "EasyWMS", fetching from API...
     total fetch=2 joins=4   [identique RUN 1, RUN 2, RUN 3]
  Plus aucun HTTP 500 : un seul fetch EasyWMS concurrent au lieu de 4.

Chemin sequentiel nominal, strictement inchange (driver sequentiel) :

  [search_workflows] success=true application=EasyWMS count=50 len=14220
  [search_workflows] success=true application=EasyWMS count=50 len=14220
  --- fetch=1 cached=1 joins=0

Liberation de la Map sur echec (test direct, apiService.post substitue :
echoue au 1er appel, reussit ensuite) :

  [Workflow] Cache expired or empty for "TestApp", fetching from API...
  [Workflow] Fetch already in flight for "workflows::TestApp", joining it  (x2)
  --- rafale de 3 sur un fetch en echec :
    appelant 0/1/2: rejected - Failed to fetch workflows ... panne reseau simulee
    appels reseau reels: 1 (attendu 1 : les 3 partagent le meme fetch)
    cache pose ? {} (attendu {} : rien en cache sur echec)
  --- appel suivant (la Map doit avoir ete liberee) :
    resultat: 1 workflow(s), appels reseau cumules: 2

Baseline : tools/list 23, resources/list 6 ; npm test 4/4 exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:43:56 +02:00
Arthur Ria 8a631f5ea4 Passation lot 6 : chargements paresseux sous concurrence (D27 réservée)
Le point ouvert concurrence est scindé : la manifestation caches devient
le lot 6 (L6.1 single-flight par clé, L6.2 garde de génération contre
les écritures post-invalidation, L6.3 refus d'encaisser un vide anormal
— response?.entities || [] transforme une réponse transitoirement
anormale en cache vide empoisonné pour tout le TTL, cause probable du
count 0 mesuré au lot 5). La manifestation bascule de profil reste en
point ouvert, hors périmètre du lot.

Preuves dans la passation : 6 chargements Metadata parallèles pour une
rafale de 6 appels (lot 2), count 0 contre 44 en séquentiel (lot 5),
avec la commande de rafale reproductible et l'exigence de rejouer
chaque vérification de concurrence trois fois.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:34:01 +02:00
Arthur Ria 1851b38c62 Révision lot 5 : validé ; course de chargement paresseux consignée
Les vérifications des quatre blocs rejouées via le protocole passent
toutes : fenêtre verbatim (23 117 chars par défaut, tranches
20000+20000+20000+11512 = 71 512 reconstituant le blob à l'octet près),
plafond de volume (24 432 / 22 992 / 738 sur les trois outils, réponses
sous plafond identiques), champ tool présent sur les 15 enveloppes
locales (grep 15/15), découvrabilité (hint nommant CustomApp sur
résultat vide, aucun hint parasite). Baseline 23/6, npm test 4/4 exit 0.

Découverte de révision : sans bascule de profil, une rafale concurrente
pendant le chargement paresseux du cache workflow renvoie des résultats
faux en silence (count 0 sur CustomApp contre 44 en séquentiel) — les
fetchs en vol ne sont pas dédupliqués. Ajouté au point ouvert
concurrence avec la piste de correction.

handoff-lot5.md supprimé (livré et révisé).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:27:35 +02:00
Arthur Ria 660c62c32c L5.4 : rendre les autres applications visibles depuis les recherches
Cas reel du 25/08/2026 : une session Cowork cherchant des workflows CST_ sans
passer application="CustomApp" a conclu que l'AD n'en contenait aucun -- alors
que CustomApp en porte 153. Le parametre application existait bien (D26) et
etait documente ; ce qui manquait, c'est que RIEN dans la reponse ne disait
qu'on n'avait regarde qu'une application sur neuf. Un defaut silencieux se lit
comme une exhaustivite.

search_workflows et search_ad_elements rappellent desormais TOUJOURS
l'application effectivement interrogee (plus seulement quand le parametre a ete
passe), et ajoutent un hint quand la recherche revient vide.

Seuil a 0 resultat, pas "peu" : toute valeur non nulle produirait un hint
parasite sur une recherche legitimement etroite, et le mode d'echec observe est
bien le zero pris pour une absence.

Le hint nomme les autres applications depuis la liste allegee DEJA en cache ;
sans elle il reste generique et renvoie vers list_workflow_categories. Aucun
appel reseau n'est fait pour construire un hint -- ce serait exactement le
prechargement que D26 interdit.

Verifie en execution (protocole, LIMAGRAIN) :

  search_workflows {"query":"CST_"}                 404 chars
    application: "EasyWMS", count: 0, hint nommant CustomApp et renvoyant
    vers list_workflow_categories
  search_workflows {"query":"CST_"} apres
    list_workflow_categories                        461 chars
    meme hint, enrichi de la liste en cache : Common, Notifications, SmartUI,
    WarehouseWebDesigner, GalileoFaults, CustomApp, User, AGV
  search_workflows {"query":"CST_","application":"CustomApp"}
    count: 44, application: "CustomApp" -- dont CST_SendRejectContainersToPK
  search_workflows {"query":"stacker"}   14 192 -> 14 220 chars
    count: 50 inchange, application: "EasyWMS" presente, aucun hint parasite
  search_ad_elements Command "CST_"                 457 chars
    application: "EasyWMS", count: 0, meme hint

stderr : aucun fetch d'une application non demandee. Seules EasyWMS et
CustomApp sont chargees, chacune sur demande explicite.

Rebouclage complet apres modification des schemas : 23 outils, 6 resources,
les 23 noms de tools/list atteignent leur module (aucun "Unknown tool" ;
execute_command verifie statiquement, non appele car il ecrit dans le WMS),
D23 rejette toujours un parametre inconnu sur les quatre schemas modifies,
npm test 4/4 en code 0.

Lot 5 retire de la ROADMAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:19:59 +02:00
Arthur Ria a1acb781b0 L5.3 : champ tool dans toutes les enveloppes d'erreur locales
Convention 3 impose { success: false, error, tool }, mais le champ tool n'etait
ajoute que par le wrapper de src/index.js. Les enveloppes construites
localement dans src/tools/ ne le portaient pas : call_query_api avec
query_type: 7 repondait { success: false, error: "query_type invalide : ..." },
sans tool. Le contrat etait donc respecte ou non selon le chemin d'erreur --
alors qu'il est lu par Claude, pas par un humain.

Balayage des 8 modules : 15 enveloppes success:false au total, 11 y ont gagne
le champ tool (ad-tools, config-tools, wms-query-tools et workflow-tools
l'avaient deja via le catch de leur executeTool). Les champs additionnels sont
conserves et passent apres tool : warning de resolution d'api-tools,
availableCount + hint de get_entity_metadata, listes de profils de
profile-tools.

Grep de controle -- 15 enveloppes, 0 sans tool :

  src/tools/ad-tools.js:140            tool: name
  src/tools/api-tools.js:170           tool: 'call_query_api'
  src/tools/api-tools.js:217           tool: 'execute_command'
  src/tools/config-tools.js:179        tool: 'get_system_parameters'
  src/tools/log-tools.js:116           tool: 'list_log_files'
  src/tools/log-tools.js:157           tool: 'read_recent_logs'
  src/tools/log-tools.js:227           tool: 'search_logs'
  src/tools/metadata-tools.js:110      tool: 'get_entity_metadata'
  src/tools/metadata-tools.js:154      tool: 'get_entity_metadata'
  src/tools/metadata-tools.js:197      tool: 'generic_search'
  src/tools/profile-tools.js:111       tool: 'get_current_wms_profile'
  src/tools/profile-tools.js:129       tool: 'switch_wms_profile'
  src/tools/profile-tools.js:159       tool: 'switch_wms_profile'
  src/tools/wms-query-tools.js:169     tool: name
  src/tools/workflow-tools.js:117      tool: name

Verifie en execution (protocole, LIMAGRAIN sauf mention) -- 11 enveloppes
declenchees, toutes avec tool :

  call_query_api query_type: 7          -> tool: call_query_api
  get_entity_metadata entite inconnue   -> tool + availableCount + hint
  query_wms_entities entite inconnue    -> tool: query_wms_entities
  get_workflow_details id inconnu       -> tool: get_workflow_details
  get_ad_elements type inconnu          -> tool: get_ad_elements
  switch_wms_profile profil inconnu     -> tool + profiles
  read_recent_logs fichier inexistant   -> tool: read_recent_logs
  list_log_files / search_logs /
    read_recent_logs sur EUROTRAFIC     -> tool, garde SaaS D9 (sans reseau)

Trois catch restent couverts statiquement, faute de declencheur : celui
d'execute_command (interdit d'appel, il ecrit dans le WMS), celui de
generic_search (l'API tolere categorie inexistante comme limit negative :
elle repond success), et celui de get_current_wms_profile (inatteignable tant
qu'un profil par defaut se charge au demarrage).

Baseline preservee : 23 outils, 6 resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:16:28 +02:00
Arthur Ria 54a6849563 L5.2 : plafonne la taille de reponse des trois outils de requete
Aucune borne de VOLUME n'existait sur query_wms_entities, call_query_api et
search_wms_data -- seulement une borne de LIGNES (MAX_QUERY_ROWS). Le modele
Writing serialise l'agregat complet (navigations, $id) : une seule ligne
Products y pese 95 288 caracteres, la ou la meme ligne Reading en fait ~4 500.
Toutes ces reponses depassaient le seuil de rejet du client MCP (D24), qui
renvoie un echec opaque plutot qu'un resultat partiel.

Plafond commun MAX_QUERY_RESPONSE_CHARS (defaut 25 000, meme ordre de grandeur
que MAX_LOG_SEARCH_CHARS), applique par src/services/response-limit.js : on
ecarte des LIGNES ENTIERES, jamais coupees au milieu, et on signale avec le
vocabulaire D24 (truncated / returned / omitted / hint). Le helper est partage
parce que les trois outils partagent le meme mecanisme -- ce que les mecanismes
de get_system_parameters et search_logs, eux, ne font pas. La recherche
dichotomique evite 200 reconstructions d'une charge utile de ~1 Mo.

Mesures avant/apres (protocole, LIMAGRAIN, longueur de content[0].text) :

  query_wms_entities Products limit 200   957 234 -> 24 432
    count 200, returned 5, omitted 195, truncated
  search_wms_data "PAL"                   847 543 -> 22 992
    totalFound 150, returned 4, omitted 146 ; reparti en tourniquet :
    Products 2, Containers 1, Tasks 1 -- sans quoi Products, en tete,
    consommerait tout le budget et les deux autres reviendraient a zero
    resultat sans que rien ne le dise
  call_query_api Products query_type 1      95 288 -> 738
    cas limite : returned 0, omitted 1, truncated, hint expliquant le
    volume Writing et renvoyant vers Reading

Sous le plafond, rien ne change -- verifie identique OCTET POUR OCTET contre
la version precedente :

  query_wms_entities Container limit 1     4 476 -> 4 476
  call_query_api Products limit 2          9 671 -> 9 671
  get_entity_schema Container             11 172 -> 11 172
  search_wms_data sous plafond            11 767 -> 11 767
  count_wms_entities Products                120 -> 120 (non concerne)

MAX_QUERY_ROWS et les limites par defaut des outils sont inchanges : le
correctif est le bornage signale, pas une reduction silencieuse. L'avertissement
de volume rejoint la description du parametre query_type (D25) de
query_wms_entities et call_query_api, pas celle de count_wms_entities dont la
reponse est un scalaire.

Baseline preservee : 23 outils, 6 resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:13:51 +02:00
Arthur Ria 6f54d765c4 L5.1 : fenetre verbatim sur le blob data de get_workflow_details
La reponse embarquait la definition EasyBuilder complete, au-dela du seuil de
rejet du client MCP (~70 000 caracteres, D24). Deux parametres de fenetre au
schema (D23) : max_data_chars (defaut 20 000) et data_offset (defaut 0). Les
metadonnees du workflow restent completes dans chaque tranche ; seul `data`
est fenetre, et dataTotalChars est porte par toute reponse.

La tranche est verbatim -- decoupe de chaine, rien d'autre. Ne jamais resumer
ni parser ce blob : la concatenation des tranches doit reconstituer la
definition a l'octet pres. Verifie : 20 000 + 20 000 + 20 000 + 11 512 =
71 512, concatenation identique au blob d'origine (premiers et derniers
caracteres compris).

Mesures avant/apres (protocole, LIMAGRAIN, longueur de content[0].text) :

  StackerCrane_LocationIsAccessibleByExtractor_PR   79 092 -> 23 117
    (blob data : 71 512, desormais annonce par dataTotalChars)
  CST_SendRejectContainersToPK (CustomApp)         101 816 -> 23 023
    (blob data : 92 362)
  StackerCrane_LoadMovementForOutboundTask_PR       10 587 -> 10 652
    (blob de 9 013 : sous le defaut, objet workflow identique a l'octet pres,
     ni truncated ni hint -- les +65 caracteres sont les trois champs de
     fenetre, contrat "total toujours porte" de D24)

Gardes de valeur dans le code de l'outil, pas dans le wrapper (D23 ne valide
que les noms) : data_offset: -5 et max_data_chars: 1.5 echouent avant tout
appel reseau avec un message nommant l'attendu.

Baseline preservee : 23 outils, 6 resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:08:13 +02:00
Arthur Ria 5ec2990347 L5.4 : découvrabilité des applications dans les réponses de recherche
Cas réel du 25/08/2026 : une session Cowork cherchant des workflows CST_
sans passer application=CustomApp a conclu à tort à leur absence de l'AD.
Vérifié en révision : CST_PickingTasksSequencing_PR et
CST_ChooseDestinationFromPS existent bien dans CustomApp sur LIMAGRAI2512.
Le correctif (rappel de l'application interrogée + hint sur résultat
maigre, depuis la liste Application/GetAll déjà en cache) rejoint le
lot 5, pas encore lancé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:50:17 +02:00
17 changed files with 933 additions and 344 deletions
+62 -3
View File
@@ -64,7 +64,10 @@ src/
│ ├── workflow-service.js Workflows, lazy loading + cache │ ├── workflow-service.js Workflows, lazy loading + cache
│ ├── ad-service.js Application Dictionary, 20 types, cache par type │ ├── ad-service.js Application Dictionary, 20 types, cache par type
│ ├── wms-query-service.js Construction d'expressions LINQ │ ├── 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 └── tools/ 23 outils MCP
├── wms-query-tools.js query_wms_entities, count_wms_entities, ├── wms-query-tools.js query_wms_entities, count_wms_entities,
│ get_entity_schema, search_wms_data │ get_entity_schema, search_wms_data
@@ -105,7 +108,11 @@ serveur au démarrage.
[MONITORING.md](MONITORING.md) §2. [MONITORING.md](MONITORING.md) §2.
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse 3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
structurée `{ success: false, error, tool }` avec `isError: true` — le 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 4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
disponibles : … »). disponibles : … »).
@@ -150,6 +157,11 @@ actif à chaque appel.
de `search_logs` — au-delà, des résultats entiers sont écartés et signalés de `search_logs` — au-delà, des résultats entiers sont écartés et signalés
(`truncated`, D24). (`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 **Au runtime.** `profile-manager` est un singleton d'état global. Les services
s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant : 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 | | `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 **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 — Les tailles de page par type viennent de l'observation des timeouts serveur —
ne les augmentez pas à l'aveugle. 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. `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 ## Écrire une requête WMS
```js ```js
+177 -3
View File
@@ -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` | | `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`…) | | `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 | | `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 : Les mécanismes restent **volontairement locaux**, car ils diffèrent :
`get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu, `get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu,
on continue avec l'offset suivant) ; `search_logs` plafonne le volume 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 (`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 **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 en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
forcerait une abstraction commune à deux mécanismes qui n'en ont pas. 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 : 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 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 bornée quel que soit le nombre d'applications interrogées (D24). Il expose
aussi les caches de workflows par application. 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
View File
@@ -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é ## Écarté
| Proposition | Raison | | Proposition | Raison |
@@ -142,15 +105,25 @@ les modules d'outils pour le même motif.
test. test.
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026, - **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** : 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 un `switch_wms_profile` émis pendant que des requêtes sont en vol les fait
reviennent dans le désordre, et un `switch_wms_profile` émis pendant que des partir sur le nouveau profil (observé : une requête destinée à EUROTRAFIC
requêtes sont en vol les fait partir sur le nouveau profil (observé : une exécutée sur l'hôte `10.255.255.2` après la bascule suivante). Conséquence du
requête destinée à EUROTRAFIC exécutée sur l'hôte `10.255.255.2` après la singleton d'état global (D8). À traiter si un cas réel de mélange de profils
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
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au 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 - **`select_expression`** : les projections via le paramètre `Select` provoquent
des erreurs de compilation côté serveur (D13). Irritant principal restant. 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 - **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
-212
View File
@@ -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.
+59
View File
@@ -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 };
+35 -9
View File
@@ -6,12 +6,17 @@
const apiService = require('./api-service').getInstance(); const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager'); 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) // Cache state - one cache per (application, element type) (D26)
const cache = {}; const cache = {};
const cacheTimestamps = {}; const cacheTimestamps = {};
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour 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. * Application effective : celle demandée, sinon celle du profil actif.
*/ */
@@ -96,6 +101,25 @@ async function getElements(elementType, application) {
return cache[key]; 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}...`); console.error(`[AD] Cache expired or empty, fetching ${key}...`);
try { try {
@@ -112,11 +136,15 @@ async function getElements(elementType, application) {
// Use AD API (useAdApi=true) // Use AD API (useAdApi=true)
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true); const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
// Extract entities array from response // Une réponse hors enveloppe { entities: [...] } lève au lieu de se
const elements = response?.entities || []; // faire passer pour une page vide (D27).
const elements = requireEntities(
response,
`${elementType}/GetByApplication (application "${app}", offset ${offset})`
);
// Check if response is valid // Vide réel : fin de pagination (3 types sont valides mais vides, D17).
if (!elements || elements.length === 0) { if (elements.length === 0) {
console.error(`[AD] No more ${elementType} to fetch`); console.error(`[AD] No more ${elementType} to fetch`);
break; break;
} }
@@ -133,11 +161,6 @@ async function getElements(elementType, application) {
offset += pageSize; offset += pageSize;
} }
// Update cache
cache[key] = allElements;
cacheTimestamps[key] = Date.now();
console.error(`[AD] Successfully cached ${allElements.length} ${key}`);
return allElements; return allElements;
} catch (error) { } catch (error) {
console.error(`[AD] Error fetching ${key}:`, error.message); console.error(`[AD] Error fetching ${key}:`, error.message);
@@ -238,12 +261,14 @@ function invalidateCache(elementType = null) {
delete cache[k]; delete cache[k];
delete cacheTimestamps[k]; delete cacheTimestamps[k];
}); });
singleFlight.invalidate();
console.error(`[AD] Cache invalidated: ${elementType}`); console.error(`[AD] Cache invalidated: ${elementType}`);
} else { } else {
Object.keys(cache).forEach(k => { Object.keys(cache).forEach(k => {
delete cache[k]; delete cache[k];
delete cacheTimestamps[k]; delete cacheTimestamps[k];
}); });
singleFlight.invalidate();
console.error('[AD] All caches invalidated'); console.error('[AD] All caches invalidated');
} }
} }
@@ -275,6 +300,7 @@ function getAvailableTypes() {
} }
module.exports = { module.exports = {
resolveApplication,
getElements, getElements,
searchElements, searchElements,
getElementDetails, getElementDetails,
+29 -4
View File
@@ -11,6 +11,7 @@
const apiService = require('./api-service').getInstance(); const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager'); const profileManager = require('../config/profile-manager');
const { createSingleFlight } = require('./single-flight');
// Cache state — même TTL que les autres caches (D10) // Cache state — même TTL que les autres caches (D10)
let resolutionMap = null; // Map lower(Name | TableName) -> TableName let resolutionMap = null; // Map lower(Name | TableName) -> TableName
@@ -18,6 +19,10 @@ let tableNames = null; // TableName[] triés (suggestions + comptage)
let cacheTimestamp = null; let cacheTimestamp = null;
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; 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). // La table de résolution est par tenant — invalidée à chaque bascule (D8).
profileManager.onSwitch(() => invalidateCache()); profileManager.onSwitch(() => invalidateCache());
@@ -37,6 +42,23 @@ function isCacheValid() {
async function loadResolutionMap() { async function loadResolutionMap() {
if (isCacheValid()) return; 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...'); console.error('[EntityResolver] Cache expired or empty, fetching Metadata...');
const apps = await apiService.get('/configuration/applications'); 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'); throw new Error('Metadata API returned no entity for any application');
} }
resolutionMap = map; return {
tableNames = Array.from(names).sort(); map,
cacheTimestamp = Date.now(); names: Array.from(names).sort(),
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${appNames.length} application(s)`); applicationCount: appNames.length,
};
} }
/** /**
@@ -179,6 +202,8 @@ function invalidateCache() {
resolutionMap = null; resolutionMap = null;
tableNames = null; tableNames = null;
cacheTimestamp = null; cacheTimestamp = null;
// Les fetchs déjà partis ne repeupleront pas la table (D27).
singleFlight.invalidate();
console.error('[EntityResolver] Cache cleared'); console.error('[EntityResolver] Cache cleared');
} }
+124
View File
@@ -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`) 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' 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,
};
+106
View File
@@ -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 };
+100 -18
View File
@@ -9,6 +9,8 @@
const apiService = require('./api-service').getInstance(); const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager'); 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) // Cache state — un cache de workflows par application (D26)
let workflowCaches = {}; // application -> workflows[] let workflowCaches = {}; // application -> workflows[]
@@ -17,6 +19,10 @@ let applicationsCache = null; // liste allégée de POST /Application/GetAll
let applicationsTimestamp = null; let applicationsTimestamp = null;
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds 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 // Clear cache when profile changes — workflows are per-tenant, so the previous
// profile's cache is meaningless after a switch. // profile's cache is meaningless after a switch.
profileManager.onSwitch(() => clearCache()); profileManager.onSwitch(() => clearCache());
@@ -56,6 +62,27 @@ async function fetchAllWorkflows(application) {
return workflowCaches[app]; 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...`); console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`);
try { try {
@@ -72,11 +99,17 @@ async function fetchAllWorkflows(application) {
// Use AD API (useAdApi=true) // Use AD API (useAdApi=true)
const response = await apiService.post('/Workflow/GetByApplication', body, true); const response = await apiService.post('/Workflow/GetByApplication', body, true);
// Extract entities array from response // Une réponse hors enveloppe { entities: [...] } lève au lieu de se
const workflows = response?.entities || []; // 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 // Vide réel : fin de pagination (une application peut n'avoir aucun
if (!workflows || workflows.length === 0) { // workflow — SmartUI, D26).
if (workflows.length === 0) {
console.error('[Workflow] No more workflows to fetch'); console.error('[Workflow] No more workflows to fetch');
break; break;
} }
@@ -93,11 +126,6 @@ async function fetchAllWorkflows(application) {
offset += pageSize; offset += pageSize;
} }
// Update cache
workflowCaches[app] = allWorkflows;
cacheTimestamps[app] = Date.now();
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows for "${app}"`);
return allWorkflows; return allWorkflows;
} catch (error) { } catch (error) {
console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message); console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message);
@@ -113,24 +141,73 @@ async function fetchAllWorkflows(application) {
* @returns {Promise<Array<{name: string, id: string, version: number}>>} * @returns {Promise<Array<{name: string, id: string, version: number}>>}
*/ */
async function fetchApplications() { async function fetchApplications() {
if (applicationsCache && applicationsTimestamp && const cached = getCachedApplications();
Date.now() - applicationsTimestamp < CACHE_TTL) { if (cached) return cached;
return applicationsCache;
}
// 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)...'); console.error('[Workflow] Fetching application list (Application/GetAll)...');
const response = await apiService.post('/Application/GetAll', null, true); 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, name: a.name || a.Name,
id: a.id || a.Id, id: a.id || a.Id,
version: a.version ?? a.Version, version: a.version ?? a.Version,
})).filter(a => a.name); })).filter(a => a.name);
applicationsTimestamp = Date.now(); }
console.error(`[Workflow] Cached ${applicationsCache.length} application(s)`); /**
return applicationsCache; * 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}`;
} }
/** /**
@@ -227,6 +304,8 @@ function clearCache() {
cacheTimestamps = {}; cacheTimestamps = {};
applicationsCache = null; applicationsCache = null;
applicationsTimestamp = null; applicationsTimestamp = null;
// Les fetchs déjà partis ne repeupleront pas ce cache (D27).
singleFlight.invalidate();
console.error('[Workflow] Cache cleared'); console.error('[Workflow] Cache cleared');
} }
@@ -251,6 +330,9 @@ function getCacheStatus() {
module.exports = { module.exports = {
fetchAllWorkflows, fetchAllWorkflows,
fetchApplications, fetchApplications,
getCachedApplications,
buildOtherApplicationsHint,
resolveApplication,
searchWorkflows, searchWorkflows,
getWorkflowDetails, getWorkflowDetails,
getWorkflowStats, getWorkflowStats,
+11 -1
View File
@@ -247,15 +247,25 @@ async function searchADElementsTool(args) {
code: e.code || e.Code 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 { return {
content: [{ content: [{
type: 'text', type: 'text',
text: JSON.stringify({ text: JSON.stringify({
success: true, success: true,
elementType: element_type, elementType: element_type,
...(application ? { application } : {}), application: effectiveApplication,
query, query,
count: mappedResults.length, count: mappedResults.length,
...(hint ? { hint } : {}),
elements: mappedResults elements: mappedResults
}, null, 2) }, null, 2)
}] }]
+39 -19
View File
@@ -1,6 +1,7 @@
const apiService = require('../services/api-service').getInstance(); const apiService = require('../services/api-service').getInstance();
const entityResolver = require('../services/entity-resolver'); const entityResolver = require('../services/entity-resolver');
const { assertValidQueryType } = require('../services/wms-query-service'); const { assertValidQueryType } = require('../services/wms-query-service');
const { fitToCap, truncationSignal } = require('../services/response-limit');
/** /**
* Tools MCP pour interagir avec les APIs WMS * Tools MCP pour interagir avec les APIs WMS
@@ -39,7 +40,7 @@ function listTools() {
}, },
query_type: { query_type: {
type: 'number', 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, default: 0,
}, },
}, },
@@ -123,25 +124,42 @@ async function callQueryAPI(args) {
queryType, queryType,
}); });
return { const head = {
content: [ success: true,
{ entityType: entity_type,
type: 'text', resolvedTableName: tableName,
text: JSON.stringify( ...(warning ? { warning } : {}),
{ ...(queryType !== 0 ? { queryType } : {}),
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) { } catch (err) {
return { return {
content: [ content: [
@@ -151,6 +169,7 @@ async function callQueryAPI(args) {
{ {
success: false, success: false,
error: err.message, error: err.message,
tool: 'call_query_api',
...(resolution?.warning ? { warning: resolution.warning } : {}), ...(resolution?.warning ? { warning: resolution.warning } : {}),
}, },
null, null,
@@ -197,6 +216,7 @@ async function executeCommand(args) {
{ {
success: false, success: false,
error: err.message, error: err.message,
tool: 'execute_command',
}, },
null, null,
2 2
+3 -1
View File
@@ -113,7 +113,7 @@ async function listLogFiles() {
return { return {
content: [{ content: [{
type: 'text', 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, isError: true,
}; };
@@ -156,6 +156,7 @@ async function readRecentLogs(args) {
{ {
success: false, success: false,
error: err.message, error: err.message,
tool: 'read_recent_logs',
}, },
null, null,
2 2
@@ -225,6 +226,7 @@ async function searchLogs(args) {
{ {
success: false, success: false,
error: err.message, error: err.message,
tool: 'search_logs',
}, },
null, null,
2 2
+3 -2
View File
@@ -109,6 +109,7 @@ async function getEntityMetadata(args) {
text: JSON.stringify({ text: JSON.stringify({
success: false, success: false,
error: `No entity matching "${entity_name}" found`, error: `No entity matching "${entity_name}" found`,
tool: 'get_entity_metadata',
availableCount: Array.isArray(entities) ? entities.length : '?', availableCount: Array.isArray(entities) ? entities.length : '?',
hint: 'Call get_entity_metadata without entity_name to see all entities', hint: 'Call get_entity_metadata without entity_name to see all entities',
}, null, 2), }, null, 2),
@@ -150,7 +151,7 @@ async function getEntityMetadata(args) {
return { return {
content: [{ content: [{
type: 'text', 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, isError: true,
}; };
@@ -193,7 +194,7 @@ async function genericSearch(args) {
return { return {
content: [{ content: [{
type: 'text', 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, isError: true,
}; };
+3
View File
@@ -110,6 +110,7 @@ function getCurrentProfileTool() {
text: JSON.stringify({ text: JSON.stringify({
success: false, success: false,
error: err.message, error: err.message,
tool: 'get_current_wms_profile',
profiles: profileManager.listProfiles(), profiles: profileManager.listProfiles(),
}, null, 2), }, null, 2),
}], }],
@@ -127,6 +128,7 @@ function switchProfileTool(args) {
text: JSON.stringify({ text: JSON.stringify({
success: false, success: false,
error: 'Missing "profile" argument', error: 'Missing "profile" argument',
tool: 'switch_wms_profile',
profiles: profileManager.listProfiles(), profiles: profileManager.listProfiles(),
}, null, 2), }, null, 2),
}], }],
@@ -156,6 +158,7 @@ function switchProfileTool(args) {
text: JSON.stringify({ text: JSON.stringify({
success: false, success: false,
error: err.message, error: err.message,
tool: 'switch_wms_profile',
profiles: profileManager.listProfiles(), profiles: profileManager.listProfiles(),
}, null, 2), }, null, 2),
}], }],
+78 -19
View File
@@ -4,6 +4,7 @@
*/ */
const wmsQueryService = require('../services/wms-query-service'); const wmsQueryService = require('../services/wms-query-service');
const { fitToCap, truncationSignal } = require('../services/response-limit');
/** /**
* List available WMS query tools * List available WMS query tools
@@ -46,7 +47,7 @@ Never guess enum string values — they differ between Reading and Writing model
}, },
query_type: { query_type: {
type: 'number', 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, default: 0,
}, },
}, },
@@ -177,6 +178,10 @@ async function executeTool(name, args) {
/** /**
* Tool: query_wms_entities * 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) { async function queryWmsEntities(args) {
const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args; const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
@@ -191,15 +196,34 @@ async function queryWmsEntities(args) {
query_type query_type
); );
return { const { data, ...head } = result;
content: [{
type: 'text', // Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes.
text: JSON.stringify({ if (!Array.isArray(data)) {
success: true, return {
...result content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }]
}, 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 }] };
} }
/** /**
@@ -272,17 +296,52 @@ async function searchWmsData(args) {
} }
} }
return { // Garde de taille (D24) : search_wms_data("PAL") faisait 847 543 caractères.
content: [{ // L'unité écartée est un RÉSULTAT entier ; les résultats gardés sont répartis
type: 'text', // en tourniquet entre les entités, pour qu'une entité volumineuse placée en
text: JSON.stringify({ // tête n'efface pas silencieusement les suivantes — c'est exactement le
success: true, // faux négatif que L5.4 corrige par ailleurs.
keyword, const entityKeys = Object.keys(results).filter(k => Array.isArray(results[k].data));
totalFound, const slots = [];
results const maxRows = entityKeys.reduce((m, k) => Math.max(m, results[k].data.length), 0);
}, null, 2) 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 = { module.exports = {
+86 -8
View File
@@ -5,6 +5,12 @@
const workflowService = require('../services/workflow-service'); 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 * List available workflow tools
*/ */
@@ -39,7 +45,8 @@ function listTools() {
}, },
{ {
name: 'get_workflow_details', 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: { inputSchema: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -52,6 +59,16 @@ function listTools() {
type: 'string', type: 'string',
description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".', 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'], required: ['workflow_id'],
}, },
@@ -117,13 +134,23 @@ async function searchWorkflows(args) {
const results = await workflowService.searchWorkflows(query, category, limit, application); 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 { return {
content: [{ content: [{
type: 'text', type: 'text',
text: JSON.stringify({ text: JSON.stringify({
success: true, success: true,
...(application ? { application } : {}), application: effectiveApplication,
count: results.length, count: results.length,
...(hint ? { hint } : {}),
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version, // Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
// applicationName, commonInfo. Pas de code/category/description. // applicationName, commonInfo. Pas de code/category/description.
workflows: results.map(w => { 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 * 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) { 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 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 { return {
content: [{ content: [{
type: 'text', type: 'text',
text: JSON.stringify({ text: JSON.stringify(payload, null, 2)
success: true,
workflow
}, null, 2)
}] }]
}; };
} }