Compare commits

...

38 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
Arthur Ria 90b2c89ff1 Passation lot 5 : clore la famille D24 (fenêtre workflow, garde requêtes, champ tool)
Deux mesures nouvelles du 25/08/2026 aggravent le dossier : une requête
Reading ordinaire query_wms_entities(Products, limit 200) pèse 957 234
caractères et search_wms_data(PAL) 847 543 — le dépassement du seuil de
rejet client (~70 000, D24) ne se limite donc pas au Writing ni aux
workflows. Les points ouverts correspondants deviennent un lot 5 planifié
dans la ROADMAP (L5.1 fenêtre verbatim sur le blob data, L5.2 garde de
taille commune aux trois outils de requête avec le cas limite d'une
ligne seule au-dessus du plafond, L5.3 balayage du champ tool).

Pas de nouveau numéro de décision : le lot étend D24. D27 réservable si
besoin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:57:39 +02:00
Arthur Ria b941f367ae Révision lot 4 : validé ; ligne Writing énorme et champ tool absent consignés
Les vérifications attendues des trois blocs rejouées via le protocole
passent : api://catalog assaini (plus de QueryType 1, d'Aliases ni de
suffixe d'assembly), query_type opérationnel (Writing 1 ligne, Metrics en
erreur parlante, 7 rejeté localement sans aucun trafic réseau, warning de
résolution conservé jusque dans l'échec WMS), caches par application
étanches (4012/153, retour en cache hit, invalidation complète à la
bascule). Baseline 23/6, npm test 4/4 exit 0, diffs propres.

Deux découvertes consignées en points ouverts : une ligne Products en
Writing pèse 95 288 caractères (famille D24), et les catch locaux
d'api-tools omettent le champ tool du contrat (antérieur au lot).

Leçon de révision : mon appel search_workflows(search_term=...) des lots
précédents était silencieusement ignoré pré-D23 — le paramètre s'appelle
query. La validation D23 a transformé mon erreur en signal.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:46:50 +02:00
Arthur Ria 92de85cf53 L4.2 : paramètre application sur les outils AD et workflow (D26)
L'application venait de WMS_APPLICATION (partagée par tous les profils) : le
MCP n'interrogeait que EasyWMS, alors que CustomApp porte le spécifique
client (153 workflows CST_ sur ce tenant) et que 9 applications sont
déclarées par Application/GetAll.

Paramètre application (défaut : l'application du profil, comportement
inchangé sans lui) sur get_ad_elements, search_ad_elements,
get_ad_element_details, search_workflows, get_workflow_details,
list_workflow_categories. Clés de cache : ad-service passe par
(application, type), workflow-service par application — sans quoi un appel
CustomApp polluerait le cache EasyWMS. L'invalidation reste l'abonnement
onSwitch (D8), le chargement reste paresseux (aucun préchargement des 9
applications, D10). list_workflow_categories s'adosse à Application/GetAll
(liste allégée en cache : le blob data de chaque application pèse ~100 Ko) ;
get_application_summary regroupe par application et ne détaille que les
entrées en cache (D24), workflows compris. Acté en D26 ; CLAUDE.md mis à
jour (Caches, AD), L4.2 retiré de la ROADMAP.

Vérifications rejouées via le protocole (LIMAGRAIN / LIMAGRAI2512),
requêtes séquentielles :
- search_workflows(CST_, application:CustomApp) -> 5 objets peuplés dont
  CST_SendRejectContainersToPK ([Workflow] Successfully cached 153 workflows
  for "CustomApp").
- get_ad_elements(Workflow, application:CustomApp) -> count 153, éléments
  CST_* ([AD] Successfully cached 153 CustomApp::Workflow).
- Séquence EasyWMS -> CustomApp -> EasyWMS sur search_workflows : 4012 vs
  153, retour en cache hit ([Workflow] Using cached data for "EasyWMS"),
  aucune pollution ; get_application_summary montre les deux caches
  (workflowCachesByApplication EasyWMS 4012 / CustomApp 153).
- Sans paramètre application -> comportement inchangé (W1 = W3).
- switch_wms_profile EUROTRAFIC puis retour -> [Workflow] Cache cleared,
  [AD] All caches invalidated, [EntityResolver] Cache cleared ; summary vide.
- list_workflow_categories -> 9 applications, comptes réels des applications
  chargées.
Baseline : tools/list 23 (les 23 noms répondent), resources 6, rejet D23
d'un paramètre inconnu OK (search_workflows/applikation), npm test 4/4
exit 0.

Anomalie hors périmètre consignée dans ROADMAP.md : get_workflow_details
peut dépasser le seuil de rejet client (~101 800 caractères mesurés sur
CST_SendRejectContainersToPK), comportement antérieur au lot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:24:12 +02:00
Arthur Ria 706e628715 L4.1 : expose query_type sur les outils de requête (D25)
QueryType était figé à 0 en dur dans executeQuery/executeScalarQuery : le
modèle Writing, opérationnel et mesuré, était inatteignable. Paramètre
query_type (défaut 0) sur call_query_api, query_wms_entities,
count_wms_entities — get_entity_schema et search_wms_data restent des
raccourcis Reading.

D3 reste la règle par défaut : la bascule est un opt-in, avertie dans les
descriptions d'outils (statuts en énumérations en Writing). Garde de valeur
assertValidQueryType dans le code (le wrapper D23 ne valide pas les valeurs),
avant tout réseau. En query_type != 0, un nom inconnu du Metadata Reading
passe tel quel avec warning (allowUnknown du resolver) — conservé aussi dans
la réponse d'erreur si le WMS échoue ensuite. Acté en D25 ; CLAUDE.md nuancé,
L4.1 retiré de la ROADMAP (reliquat : exploration Metrics, rapport à part).

Vérifications rejouées via le protocole (LIMAGRAIN / LIMAGRAI2512) :
- call_query_api(Products, query_type:1, limit:1) -> success, 1 ligne,
  queryType:1 dans la réponse.
- call_query_api(Products, query_type:3) -> erreur structurée contenant
  'ApplicationMetricDataContext' ne contient pas de définition pour 'Products'.
- call_query_api(Products, query_type:7) -> erreur locale nommant les 4
  contextes, aucun [API] POST/GET dans stderr.
- query_wms_entities(Container, limit:1) sans query_type -> comportement
  inchangé (resolvedTableName Containers, Reading, pas de champ queryType).
- call_query_api(FooBar123, query_type:1) -> transmis tel quel (payload
  QueryType:1 Expression Context.FooBar123...), erreur WMS
  ApplicationWritingRepository + warning de résolution dans la réponse.
- count_wms_entities(Product, query_type:1) -> count 51145 (~51160).
Baseline : tools/list 23, resources 6, rejet D23 d'un paramètre inconnu OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:17:10 +02:00
Arthur Ria 88dab289cc L4.0 (suite) : retire L4.0 de la ROADMAP, oublié au commit précédent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:16:17 +02:00
Arthur Ria cc34894ae4 L4.0 : corrige api://catalog qui enseignait le piège D3
La resource api://catalog, lue par les sessions Claude, contredisait les
décisions actées :
- exemple QueryExecute en QueryType 1 (D3 interdit de le recopier) ;
- Select/Take dans l'expression, contraire à la répartition
  expression/options et à D13 (projections instables) ;
- entité fantôme Aliases (corrigée partout ailleurs au lot 2) ;
- exemple Command avec suffixe d'assembly, cause de FileLoadException (D14) ;
- réponse Workflow décrite avec des champs inexistants (Category, Status,
  Definition) au lieu des clés réelles minuscules id/name/version/
  applicationName/commonInfo (D4, D5) ;
- section Configuration décrivant l'ancien .env mono-profil (D8).

L'exemple passe en QueryType 0 avec les règles de répartition, la liste
d'entités renvoie vers get_entity_metadata comme source de vérité, la
réponse AD documente l'enveloppe { entities: [...] }.

Vérifié via le protocole (resources/read api://catalog) :
  contient "QueryType": 1 : false
  contient Aliases : false
  renvoie vers get_entity_metadata : true
tools/list : 23 outils, resources : inchangées.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:12:14 +02:00
Arthur Ria 35b53dbef5 Passation lot 4 : query_type, application AD/workflow, catalog D3 (L4.0-L4.2)
Mesures rafraîchies du 25/08/2026 : Writing (QueryType 1) répond 1 ligne
sur Context.Products, Metrics (3) existe sans Products, CustomApp
renvoie ses workflows CST_ sous la clé entities de GetByApplication.
Découverte consignée en L4.0 : la resource api://catalog documente un
exemple QueryType: 1 (le piège D3), un Select dans l'expression et
l'entité fantôme Aliases.

D25 (rapport D3/query_type) et D26 (clés de cache par application)
réservées. L4.3-L4.5 et l'exploration Metrics explicitement hors
périmètre. Le point délicat resolver/Writing est cadré : nom inconnu du
Reading transmis tel quel avec warning quand query_type != 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:58:50 +02:00
Arthur Ria 702c2ecd2a Supprime handoff-lot3.md : passation livrée et révisée
Révision du lot 3 : les sept vérifications attendues rejouées via le
protocole passent (21 404 chars / 50 sur 168 paginés avec hint, plafond
25 000 respecté sur search_logs avec truncated + omitted, D23 intact,
limit 200 = opt-in explicite au volume). Baseline 23/6, npm test 4/4
exit 0, diffs propres, D24 conforme aux mesures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:49:59 +02:00
Arthur Ria b37c2ac251 L3.1c : acte le contrat de troncature en D24, retire le lot 3 de la ROADMAP
Les deux bornages (L3.1a pagination, L3.1b plafond de volume) partagent
le même vocabulaire de signal — truncated présent uniquement quand la
réponse est coupée, hint actionnable, returned vs total avant coupe —
mais gardent des implémentations locales : paginer et plafonner un
volume sont deux mécanismes distincts, un helper commun forcerait une
abstraction qu'ils n'ont pas. D24 consigne ce contrat, les garde-fous
de cadrage (défauts inchangés, mesure protocolaire qui fait foi) et le
changement de sens de totalParameters.

ROADMAP : L3.1 livré, le lot 3 devenait vide — section retirée ; la
référence à L3.1 dans L4.5 (QueryExecuteStream) renvoie désormais à
D24. read_recent_logs est laissé tel quel : sans helper partagé, rien
de gratuit à lui apporter (L3.1c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:36:37 +02:00
Arthur Ria d0a6cc1a0b L3.1b : plafonne la taille de réponse de search_logs (MAX_LOG_SEARCH_CHARS)
max_results (50) borne le nombre de résultats mais pas le volume : les
context_lines multiplient la taille, et la réponse aux seuls défauts
atteignait 52-56 000 caractères — rejetée par le client MCP. Le tool
écarte désormais des résultats ENTIERS (jamais coupés au milieu de leur
contexte) jusqu'à passer sous le plafond, et le signale : truncated,
returned/omitted, hint actionnable (affiner keyword, réduire
context_lines, baisser max_results).

Plafond : MAX_LOG_SEARCH_CHARS, variable d'environnement avec défaut
25 000 — même style de lecture que MAX_QUERY_ROWS/QUERY_TIMEOUT
(parseInt(process.env.X) || défaut), documentée dans CLAUDE.md
(réglages partagés). 25 000 correspond à l'ordre de grandeur cible du
lot (~20-25 000 chars) et se mesure sur content[0].text, la taille qui
fait foi côté protocole. Les défauts max_results=50 et context_lines=2
sont inchangés : le correctif est le bornage signalé, pas un changement
de comportement par défaut.

Mesures via le protocole (LIMAGRAIN, content[0].text) :
- {"keyword":"Error"} : avant 52 162 chars / 50 résultats ; après
  24 164 chars, returned 16, omitted 34, truncated true, hint présent.
- {"keyword":"Error","max_results":3} : 4 855 chars, 3/3, pas de
  troncature signalée.
- mot-clé sans occurrence : 112 chars, 0 résultat, pas de truncated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:35:49 +02:00
Arthur Ria c4d6b5650e L3.1a : pagine get_system_parameters (limit/offset, signal truncated)
La sortie sans filtre (168 paramètres fusionnés) atteignait 70 141
caractères et se faisait rejeter par le client MCP. Ajout de limit
(défaut 50) et offset (défaut 0), déclarés au schéma (D23), appliqués
après filtres et tri. Quand la pagination coupe, la réponse porte
truncated: true et un hint donnant l'offset suivant et les filtres
pour réduire.

totalParameters change de sens : c'était le nombre brut d'entités
Parameter chargées (toujours 168), c'est désormais le total
correspondant aux filtres AVANT pagination — le signal truncated se
vérifie ainsi depuis la réponse (offset + returned < totalParameters).
Sans filtre les deux définitions coïncident.

Mesures via le protocole (LIMAGRAIN, content[0].text) :
- {} : avant 70 141 chars / 168 renvoyés ; après 21 404 chars,
  returned 50, totalParameters 168, truncated true, hint présent.
- {"limit":200} : 70 156 chars, les 168, pas de truncated (opt-in
  explicite au volume complet).
- {"offset":160} : 3 503 chars, 8 renvoyés, pas de truncated.
- {"search":"PICK"} : 10 041 chars, 23/23, pas de truncated —
  comportement inchangé sur petit résultat.
- {"lines":5} : toujours rejeté par le wrapper (D23 intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:34:23 +02:00
Arthur Ria 7b25e79e98 Passation lot 3 : bornage des sorties volumineuses (L3.1), D24 réservée
Mesures rafraîchies du 25/08/2026 via le protocole (LIMAGRAIN) :
get_system_parameters {} -> 70 141 caractères pour 168 paramètres,
search_logs Error avec les défauts -> 55 954 caractères pour 50
résultats. Trois blocs : pagination limit/offset, garde-fou de taille
cumulée, cohérence du signal truncated (D24 si contrat commun).
QueryExecuteStream explicitement hors périmètre (piste L4.5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:14:37 +02:00
Arthur Ria fdebca500f Supprime handoff-lot2.md : passation livrée et révisée
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:35:46 +02:00
Arthur Ria 37c68a4d9a Révision lot 2 : validé ; log du resolver corrigé, concurrence des appels consignée
Les vérifications attendues des quatre correctifs ont été rejouées via le
protocole et passent toutes : résolution Container/Alias/Item (échec avant
tout POST, 6 GET seulement), rejet des paramètres inconnus et requis
manquants, garde-fous get_workflow_details/search_logs, corps STS dans
les erreurs d'auth sans fuite de credential. Contrôle latéral séquentiel :
la bascule EUROTRAFIC reconstruit bien la table par tenant (280 entités,
7 applications, contre 288/5 sur LIMAGRAIN).

Trivialité corrigée : tableNames.size (undefined sur un tableau) ->
.length dans le log de chargement du resolver.

Découverte de révision consignée en point ouvert : le serveur traite les
tools/call en concurrence, une bascule de profil pendant des appels en
vol les fait partir sur le nouveau profil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:35:37 +02:00
Arthur Ria 808586e615 L3.2 : fait remonter statut et corps de la réponse STS dans les erreurs d'auth
authenticate() ré-enveloppait l'erreur axios en "Authentication failed:
Request failed with status code 400" et jetait le corps de la réponse du
STS, qui contenait le diagnostic exact ({"error":"invalid_request",
"error_description":"Tenant not found"} sur le profil AD).

Réutilise _enrichHttpError (L1.1) dans authenticate() et
refreshOAuthToken(), avec payload volontairement omis : il contient les
credentials (password grant) ou le refresh_token ; l'helper n'inclut
jamais les headers. Le chemin "token trop vieux -> password grant" de
refreshOAuthToken() sort du try : un échec d'authenticate() y était
rattrapé pour... rappeler authenticate() à l'identique ; il propage
désormais son erreur enrichie directement.

Mesure (via le protocole) : switch_wms_profile("AD") puis
count_wms_entities("Products") ->
  Count failed for Products: Authentication failed: POST
  https://10.255.255.2/EasySTS/OAuth/Token failed (HTTP 400): Request
  failed with status code 400
  Response body: {"error":"invalid_request","error_description":"Tenant
  not found"}
Aucun credential ni header dans la réponse (vérifié : pas de
password/Basic/Bearer/payload).

ROADMAP : L3.2 retirée, point ouvert "profil AD" mis à jour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:36:26 +02:00
Arthur Ria 97ab56f928 L2.3 : garde-fous get_workflow_details et search_logs
Deux anomalies préexistantes au lot 1, mesurées le 24/08/2026 :

- get_workflow_details({}) renvoyait success: true avec le premier
  workflow du cache : getWorkflowDetails() comparait w.Id/w.Code/w.Name,
  clés qui n'existent pas sur les objets AD réels (minuscules, D5) —
  undefined === undefined matchait. Clés mortes supprimées (id et name
  seuls existent, les parseInt sur des GUID étaient morts aussi), garde
  d'entrée rejetant workflow_id absent avec renvoi vers search_workflows.
- search_logs({}) plantait en "Cannot read properties of undefined
  (reading 'toLowerCase')" : garde d'entrée nommant "keyword" avec un
  exemple d'appel.

Le wrapper D23 rejette déjà ces appels via required — les gardes côté
code restent, la validation SDK n'étant pas garantie pour les appelants
directs des services.

Mesures :
- via le protocole, les deux appels {} -> "Paramètre(s) requis
  manquant(s) pour ... " (wrapper D23)
- gardes appelées en direct (sans wrapper) :
  getWorkflowDetails(undefined) jette "workflow_id est requis (id ou nom
  exact du workflow)..." ; search_logs({}) répond success: false avec le
  message nommant keyword
- get_workflow_details avec un id réel (2e workflow du cache, pas le
  premier) -> objet brut complet ($id, validFrom, ..., data), id/name
  conformes à la recherche

ROADMAP : L2.3 retirée, lot 2 soldé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:34:38 +02:00
Arthur Ria 5386f54922 L2.2 : rejette les paramètres inconnus et les requis manquants (D23)
Mesure V1 (24/08/2026) : le SDK MCP ignore additionalProperties: false —
read_recent_logs({lines: 5}) avec la clause sur le schéma répondait
success: true, returnedLines: 100 (retombée silencieuse sur le défaut).
La validation vit donc dans le wrapper tools/call de src/index.js,
pilotée par les schémas de la table de routage (D22) : paramètre inconnu
ou requis manquant -> erreur structurée nommant le fautif et les
paramètres valides, avant tout dispatch.

Les 23 schémas portent additionalProperties: false — inerte côté SDK,
mais c'est le contrat que lisent les clients. Pas de renommage de
paramètres (écarté, cf. ROADMAP).

Mesures (via le protocole) :
- read_recent_logs({"lines": 5}) -> "Paramètre(s) inconnu(s) pour
  read_recent_logs : lines. Paramètres valides : count, log_file."
- read_recent_logs({"count": 5}) -> succès, returnedLines: 5
- boucle sur 22 outils avec {} (execute_command vérifié statiquement) :
  22/22 répondent, aucun Unknown tool, les 11 outils à paramètres requis
  échouent avec le message actionnable
- handshake : 23 outils, 6 resources

Docs : D23 dans DECISIONS.md, L2.2 retirée de la ROADMAP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:32:15 +02:00
Arthur Ria cb625a7918 L2.1 : résout entity_type via l'API Metadata (D21)
Context.{entity_type} attend le TableName du Metadata, pas le nom
d'entité de l'AD (Container -> Containers, mais Alias -> Alias) : un nom
faux partait en HTTP 500 de compilation LINQ. Nouveau service
entity-resolver.js : table Name|TableName (insensible à la casse) ->
TableName, agrégée sur les applications déployées (via
GET /configuration/applications — les applications sans contexte
requêtable n'y figurent pas et n'apportent 0 entité Metadata), cache TTL
partagé, invalidation par onSwitch (D8). Branché dans wms-query-service
(query/count/schema/search) et call_query_api.

Nom inconnu -> échec avant tout appel réseau de requête, suggestions
proches + renvoi vers get_entity_metadata. Metadata injoignable -> le
nom passe tel quel avec un warning dans la réponse.

Mesures (LIMAGRAIN, via le protocole) :
- query_wms_entities("Container", limit 1) -> succès, 1 ligne, résolu
  Containers
- query_wms_entities("Alias") -> succès, invariant (pas de pluriel)
- query_wms_entities("Item") -> "Item" n'existe pas dans le modèle
  Reading. Proches : RFMenuItems, Sites. 288 entités disponibles —
  aucune ligne [API] POST dans stderr
- count_wms_entities("Product") -> 51160
- get_entity_schema("Container") et call_query_api("Container") : mêmes
  résolutions
- 288 TableName distincts sur 5 applications, aucun conflit
  Name -> TableName (mesuré le 24/08/2026)

Docs : D21 dans DECISIONS.md ; CLAUDE.md (piège retiré des points
ouverts, liste d'entités corrigée Aliases -> Alias, entity-resolver dans
la structure) ; exemple singulier/pluriel dans wms://query-examples ;
ROADMAP allégée (cause racine + L2.1 + L3.3 livrés).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:28:27 +02:00
Arthur Ria 8c5792da52 Révision lot 1 : validé ; passation lot 2 (L2.1-L2.3 + L3.2), L2.3 consigné
Lot 1 révisé selon la grille : vérifications L1.1/L1.2/L1.3 rejouées en
protocole (22 outils routés + execute_command en statique), diffs lus,
baseline 23/6 et npm test 4/4 confirmés. Deux anomalies préexistantes
découvertes en bouclant sur les outils avec arguments vides :
get_workflow_details({}) renvoie le premier workflow du cache (clés
mortes Id/Code/Name dans la comparaison), search_logs({}) échoue en
TypeError non actionnable — consignées en L2.3.

handoff-lot1.md supprimé (livré), handoff-lot2.md rédigé : D21 et D23
réservés, profil AD signalé cassé (ne pas réinvestiguer), vérifications
attendues par correctif.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:13:19 +02:00
Arthur Ria 52b5f90521 Roadmap : profil AD en échec (tenant introuvable), erreurs d'auth muettes (L3.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:04:46 +02:00
Arthur Ria 03f561fdf7 Supervision : rôle durable de vérification et de passation
Ajoute docs/supervision.md, référencé depuis CLAUDE.md et docs/README.md.

Décrit le rôle de superviseur du projet, distinct des sessions qui codent :
vérifier l'état réel du MCP contre le WMS, réviser leurs livraisons sans les
croire sur parole, et rédiger la passation suivante.

Contient la baseline chiffrée à préserver (23 outils, 6 resources, npm test
4/4) et les mesures de référence du tenant, la boîte à outils de vérification
(handshake MCP, appel d'outil via le protocole, sonde directe de l'API), une
grille de revue en sept points, les six règles de rédaction d'une passation, et
les garde-fous (lecture seule, pas de push, pas de réécriture d'historique).

Consigne les quatre modes d'échec déjà observés sur ce dépôt : taxonomie
inventée, casse de champ supposée, collision de préfixe de routage, hypothèse
présentée comme solution. Ils sont récurrents et se repèrent vite quand on
sait quoi chercher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:51:08 +02:00
Arthur Ria e5614f3b60 Roadmap : retire le lot 1 livré, invalide la piste ClientModule (L4.3)
Le lot 1 (erreurs HTTP détaillées, routage par table, projections des
workflows) est livré et vérifié contre le WMS réel — il sort de la
roadmap. D22 étant écrite, L3.2 est ajusté en conséquence.

L4.3 est corrigé d'après mesure : le champ ClientModule de QueryExecute
est accepté mais sans effet observable dans les logs du WMS — une
requête en échec envoyée avec ClientModule: "MCP-WMS" reste tracée
« Execute error. Client: GNA », et la chaîne n'apparaît dans aucun log.
Le champ n'a donc pas été renseigné.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:47:14 +02:00
Arthur Ria 3a89c317e8 L1.3 : aligne les projections des workflows sur les clés réelles de l'AD
Les clés réelles d'un workflow AD (relevées en direct, minuscules,
cf. D5) sont : id, name, version, applicationName, commonInfo, data…
search_workflows projetait w.Id, w.Code, w.Name, w.Category,
w.Description, w.Created, w.Modified — toutes undefined, supprimées par
JSON.stringify : 50 objets vides pour un count pourtant correct.

La projection porte désormais id, name, applicationName, version, et
les équivalents réels de created/modified trouvés dans commonInfo
(createdBy, createDate, updateDate). Code et Description n'existent
dans aucune casse : non projetés.

La notion de catégorie n'a aucun support dans les données : elle est
mappée explicitement sur applicationName, seul regroupement fourni par
l'API AD — assumé dans les descriptions d'outils et par une note dans
la réponse de list_workflow_categories, qui renvoyait 0 catégorie et
classait les 4012 workflows en « Uncategorized ». Le paramètre category
de search_workflows filtre sur applicationName. Le filtre de recherche
ne teste plus description/code, clés inexistantes.

Vérifié contre le WMS réel : search_workflows("stacker") renvoie des
objets peuplés (StackerCrane_…), list_workflow_categories renvoie
EasyWMS avec 4012 workflows, get_workflow_details renvoie toujours
l'objet brut complet (data 71 Ko).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:45:24 +02:00
Arthur Ria e0bdc1707d L1.2 : route les outils par table explicite nom -> module (D22)
Le routage par préfixe de nom laissait deux outils listés dans
tools/list mais injoignables : get_entity_metadata (capté par
startsWith('get_entity_') avant sa propre branche) et list_log_files
(aucune branche : le nom contient _log_files, pas _logs).

Une table nom d'outil -> module est construite au démarrage depuis les
listTools() des 8 modules de src/tools/. tools/list est servi depuis
cette même table et le dispatch devient un lookup : un outil listé est
un outil routé, par construction. Deux modules déclarant le même nom
font échouer le serveur au démarrage avec un message nommant les deux
modules. Le wrapper d'erreur du handler tools/call est inchangé, les
23 outils gardent leurs noms.

Vérifié contre le WMS réel : get_entity_metadata renvoie 232 entités,
list_log_files renvoie 19 fichiers, tools/list expose toujours 23
outils et chaque nom listé est traité par le executeTool() de son
module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:43:40 +02:00
Arthur Ria 3dad5c6088 L1.1 : remonte le détail des erreurs HTTP dans les réponses d'outils
Toute erreur d'API se résumait à « Request failed with status code 500 »
alors que le WMS renvoie le diagnostic complet (erreurs de compilation
LINQ, entité inconnue…) dans le corps de la réponse, jusqu'ici jeté par
les catch de post() et get().

L'erreur propagée porte désormais : verbe, URL complète, statut HTTP,
payload envoyé (dont Application et QueryType), et corps de réponse
tronqué à 2000 caractères. Pour les corps structurés, Message et
InnerException.Message sont extraits plutôt qu'un JSON.stringify
intégral qui noierait le diagnostic dans le bruit WatsonBuckets.
Le rejeu après refresh de token 401 est conservé, et une erreur pendant
le rejeu est enrichie de la même façon. Aucun credential ni token dans
le message (les headers ne sont jamais inclus).

Vérifié contre le WMS réel : query_wms_entities("Container") fait
apparaître « 'ApplicationReadingContext' ne contient pas de définition
pour 'Container' » dans la réponse de l'outil. npm test : 4/4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:41:18 +02:00
Arthur Ria 7c722dae91 Passation : prompt autoportant pour le lot 1
Ajoute docs/handoff-lot1.md, le prompt à donner à une nouvelle session Claude
Code pour implémenter le lot 1 de la roadmap. Il vivait jusqu'ici dans un
dossier temporaire de session, donc perdable.

Contient la phase 0 de vérifications préalables (les quatre contextes de
requête, le périmètre applications, le champ ClientModule), les trois
correctifs avec leurs preuves et leurs vérifications attendues, et les
consignes de livraison.

Document à usage unique : à supprimer une fois le lot 1 livré, la référence
durable restant ROADMAP.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:33:11 +02:00
24 changed files with 2459 additions and 739 deletions
+104 -36
View File
@@ -13,6 +13,7 @@ volontaires ; les références `D1`, `D2`… de ce fichier y renvoient.
| Pourquoi le code est ainsi, pièges terrain | [DECISIONS.md](DECISIONS.md) |
| Le serveur ne répond pas, lire ses logs | [MONITORING.md](MONITORING.md) |
| Ce qui reste à faire | [ROADMAP.md](ROADMAP.md) |
| Superviser le projet, réviser une livraison | [docs/supervision.md](docs/supervision.md) |
| Accéder aux logs du WMS | [docs/logs.md](docs/logs.md) |
| Références EasyWMS (API, entités) | [docs/](docs/) |
@@ -59,10 +60,14 @@ src/
│ └── logs.js logs://guide — patterns d'erreur et scénarios de debug
├── services/ Logique métier
│ ├── api-service.js OAuth + client HTTP + helpers de requête (singleton)
│ ├── entity-resolver.js Résolution Name|TableName -> TableName (D21)
│ ├── workflow-service.js Workflows, lazy loading + cache
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
│ ├── wms-query-service.js Construction d'expressions LINQ
── log-service.js Lecture et recherche dans les fichiers de logs
── single-flight.js Déduplication des chargements + génération (D27)
│ ├── ad-envelope.js Enveloppe { entities } des API AD : vide anormal = erreur (D27)
│ ├── log-service.js Lecture et recherche dans les fichiers de logs
│ └── response-limit.js Plafond de taille commun aux outils de requête (D24)
└── tools/ 23 outils MCP
├── wms-query-tools.js query_wms_entities, count_wms_entities,
│ get_entity_schema, search_wms_data
@@ -86,10 +91,11 @@ docs/
⚠️ utilise QueryType 1 : ne pas recopier (D3)
```
**Routage.** `src/index.js` route les appels d'outils **par préfixe de nom**
(`name.startsWith('query_wms_')`, `name.includes('_logs')`, …). En ajoutant un
outil, vérifiez que son nom tombe dans la bonne branche — sinon il apparaîtra
dans `tools/list` mais renverra `Unknown tool`.
**Routage.** `src/index.js` construit au démarrage une **table nom d'outil →
module** depuis les `listTools()` des 8 modules de `src/tools/` ; `tools/list`
et le dispatch sont servis par cette même table, donc un outil listé est routé
par construction (D22). Deux modules déclarant le même nom font échouer le
serveur au démarrage.
---
@@ -102,7 +108,11 @@ dans `tools/list` mais renverra `Unknown tool`.
[MONITORING.md](MONITORING.md) §2.
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
structurée `{ success: false, error, tool }` avec `isError: true` — le
wrapper est dans le handler `tools/call` de `src/index.js`.
wrapper est dans le handler `tools/call` de `src/index.js`. Les enveloppes
construites **localement** dans `src/tools/` portent le champ `tool` elles
aussi : le wrapper ne les voit pas, et une erreur sans `tool` sort du
contrat. Les champs supplémentaires utiles (`warning` de résolution,
`profiles`, `hint`…) viennent après.
4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
disponibles : … »).
@@ -143,6 +153,15 @@ explicite (D9). Par défaut `false`.
**`LOGS_PATH`** accepte le placeholder `{host}`, substitué par le host du profil
actif à chaque appel.
**`MAX_LOG_SEARCH_CHARS`** (défaut 25 000) : plafond en caractères de la réponse
de `search_logs` — au-delà, des résultats entiers sont écartés et signalés
(`truncated`, D24).
**`MAX_QUERY_RESPONSE_CHARS`** (défaut 25 000) : même plafond pour
`query_wms_entities`, `call_query_api` et `search_wms_data` — au-delà, des
lignes entières sont écartées et signalées (D24). `count_wms_entities` n'est
pas concerné.
**Au runtime.** `profile-manager` est un singleton d'état global. Les services
s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant :
@@ -163,21 +182,73 @@ disponibles : c'est ainsi que Claude sait appeler `switch_wms_profile`.
## Caches
Deux caches, TTL commun `WORKFLOW_CACHE_TTL` (3 600 000 ms), chargement
paresseux, vidés à chaque bascule de profil (D10).
TTL commun `WORKFLOW_CACHE_TTL` (3 600 000 ms), chargement paresseux, vidés à
chaque bascule de profil (D10). Les outils AD et workflow acceptent un
paramètre **`application`** (défaut : l'application du profil) — les clés de
cache incluent l'application pour éviter toute pollution croisée (D26).
| Cache | Granularité | Pagination |
|---|---|---|
| `workflow-service` | global (~3 700 workflows) | `WORKFLOW_PAGE_SIZE`, 5000 |
| `ad-service` | **un par type** (20 types) | `AD_ELEMENT_TYPES` : `View` 200, `Workflow` 5000, `Resource` 15000, autres 100000 |
| `workflow-service` | **un par application** (~4 000 EasyWMS, 153 CustomApp) + liste allégée d'`Application/GetAll` | `WORKFLOW_PAGE_SIZE`, 5000 |
| `ad-service` | **un par (application, type)** (20 types) | `AD_ELEMENT_TYPES` : `View` 200, `Workflow` 5000, `Resource` 15000, autres 100000 |
**Ne préchargez jamais les 9 applications** : seule l'application demandée est
chargée (D26). `search_workflows` et `search_ad_elements` rappellent toujours
l'application interrogée et, sur résultat **vide**, ajoutent un `hint` nommant
les autres — construit depuis la liste d'applications **déjà en cache**, jamais
par un appel réseau (D26).
Les tailles de page par type viennent de l'observation des timeouts serveur —
ne les augmentez pas à l'aveugle.
Les chargements sont **dédupliqués par clé de cache** : sous appels concurrents,
une seule chaîne de fetch part par clé et les autres appelants la rejoignent
(D27) — deux applications différentes se chargent toujours en parallèle. Un
fetch parti avant une invalidation ne repeuple plus le cache après elle : la
publication passe par un `commit` gardé par un compteur de génération. **Ne
remettez jamais d'écriture de cache dans une fonction de chargement.**
Une réponse d'API AD **hors enveloppe** `{ entities: [...] }` lève au lieu de
passer pour un tableau vide : sinon un cache vide s'installe pour tout le TTL
(D27). Un `entities: []` **réel** reste cachable — des applications sont
légitimement vides.
`get_application_summary` expose l'état des caches sans redémarrage.
---
## Sorties bornées (D24)
Une réponse d'outil de plus de ~70 000 caractères est **rejetée par le client
MCP**. Les outils qui peuvent dépasser ce seuil bornent et **signalent** :
`truncated: true` (jamais `false`), `hint` actionnable, `returned`, et le total
avant la coupe. Réutilisez ce vocabulaire, n'en inventez pas un second.
**`get_workflow_details` fenêtre le blob `data`** (la définition EasyBuilder :
71 512 caractères sur un StackerCrane, 92 362 sur un gros `CST_*`) :
`max_data_chars` (défaut 20 000) et `data_offset` (défaut 0). Les métadonnées
restent complètes, `dataTotalChars` est porté par toute réponse, et la tranche
est **verbatim** — concaténer les tranches dans l'ordre des offsets reconstitue
la définition à l'octet près. Ne la résumez pas, ne la « parsez » pas.
**Les trois outils de requête plafonnent leur volume** via
`src/services/response-limit.js` (`MAX_QUERY_RESPONSE_CHARS`) : au-delà, des
lignes entières sont écartées, jamais coupées au milieu. Sous le plafond, la
réponse est inchangée **octet pour octet** — c'est la contrainte à préserver si
vous y touchez.
| Outil | Unité écartée | Total porté |
|---|---|---|
| `query_wms_entities` | une ligne | `count` (déjà présent) |
| `call_query_api` | une ligne | `totalRows` (ajouté à la coupe) |
| `search_wms_data` | un résultat, réparti en tourniquet entre les entités | `totalFound` (déjà présent) |
Cas limite réel : **une seule ligne Writing dépasse le plafond** (95 288
caractères mesurés) — la réponse est alors `returned: 0`, `omitted: 1`,
`truncated: true`, avec un hint qui renvoie vers Reading.
---
## Écrire une requête WMS
```js
@@ -198,8 +269,10 @@ la plus fréquente :
Autres règles :
- **`QueryType: 0` (Reading)**, jamais 1 : les statuts sont alors des chaînes
(D3).
- **`QueryType: 0` (Reading) par défaut** : les statuts sont alors des chaînes
(D3). La bascule vers Writing/Metrics passe par le paramètre `query_type`
des outils de requête — un opt-in documenté (D25), jamais un défaut : ne
recopiez aucun exemple en `QueryType: 1`.
- **Pas de date relative.** `DateTime.Now`, `DateTime.Today`, `AddDays()` ne
sont pas traduisibles : écrire `new DateTime(2026, 8, 1)` (D12).
- **`select_expression` est instable** : les projections via le paramètre
@@ -217,17 +290,22 @@ Autres règles :
## Entités et éléments AD
**Entités interrogeables** (Query API) : `Products`, `Containers`, `Accounts`,
`Suppliers`, `Kits`, `Aliases`, `Tasks`, `Stocks`, `ProductLocations`,
`InboundOrders`, `Receptions`, `OutboundOrders`. La liste faisant foi s'obtient
par `get_entity_metadata` (API Metadata) — le catalogue de la resource
`wms://entities` est un raccourci de confort, pas la référence.
**Entités interrogeables** (Query API) : `entity_type` accepte le nom d'entité
AD (`Container`) ou le `TableName` (`Containers`), insensible à la casse — la
résolution passe par `entity-resolver.js` (D21). Courantes : `Products`,
`Containers`, `Accounts`, `Suppliers`, `Kits`, `Alias` (invariant, pas de
pluriel), `Tasks`, `Stocks`, `ProductLocations`, `InboundOrders`, `Receptions`,
`OutboundOrders`. La liste faisant foi (288 entités, toutes applications
confondues) s'obtient par `get_entity_metadata` (API Metadata) — le catalogue
de la resource `wms://entities` est un raccourci de confort, pas la référence.
**Application Dictionary** : 20 types, ~38 800 éléments. `Resource` (29 374) est
de loin le plus lourd ; 3 types sont valides mais vides (`Dashboard`,
`TimelineTemplate`, `Toggle`). `WorkflowAction` et `WritingModel` ont été
retirés — 404 (D17). Détail :
[docs/ad-api-validation.md](docs/ad-api-validation.md).
**Application Dictionary** : 20 types, ~38 800 éléments (sur `EasyWMS`).
`Resource` (29 374) est de loin le plus lourd ; 3 types sont valides mais vides
(`Dashboard`, `TimelineTemplate`, `Toggle`). `WorkflowAction` et `WritingModel`
ont été retirés — 404 (D17). Détail :
[docs/ad-api-validation.md](docs/ad-api-validation.md). 9 applications AD sont
déclarées ; **`CustomApp` porte le spécifique client** (workflows `CST_*`) et
s'interroge via le paramètre `application` des outils AD et workflow (D26).
**Paramètres système** : pas d'entité `CommandParameterData`. La configuration
se lit dans `Parameter` (+ `DefaultValue`) et `ParamValue` (surcharges par
@@ -261,8 +339,9 @@ powershell -ExecutionPolicy Bypass -File scripts/test-ad-api.ps1 -WmsHost 10.255
1. Déclarer le schéma dans `listTools()` du module `src/tools/` concerné.
2. Traiter le cas dans son `executeTool()`.
3. **Vérifier le routage par préfixe** dans `src/index.js` — ou ajouter une
branche.
3. Rien à faire dans `src/index.js` pour un module existant : la table de
routage est construite depuis `listTools()` (D22). Un **nouveau module**
doit être ajouté à `TOOL_MODULES`.
4. Logger avec le préfixe du module.
5. Renvoyer les erreurs, ne pas les lever hors du wrapper.
6. Tester le handshake complet :
@@ -275,16 +354,5 @@ printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"
## Points ouverts
Voir [ROADMAP.md](ROADMAP.md) : lots de correction planifiés, cause racine
commune (résolution `Name` -> `TableName` des entités), et propositions
Voir [ROADMAP.md](ROADMAP.md) : lots de correction planifiés et propositions
explicitement écartées.
⚠️ Deux pièges connus et non encore corrigés, à garder en tête en attendant le
lot 1 :
- `entity_type` est interpolé sans validation dans `Context.{entity_type}`. Le
nom attendu est le `TableName` de l'API Metadata, pas le nom d'entité de l'AD
(`Container` -> `Containers`, mais `Alias` -> `Alias`). Un mauvais nom donne un
HTTP 500 dont le détail est aujourd'hui perdu.
- `get_entity_metadata` et `list_log_files` sont listés dans `tools/list` mais
non routés dans `src/index.js` : ils renvoient `Unknown tool`.
+392
View File
@@ -350,3 +350,395 @@ Le fichier est retiré du répertoire de travail, **mais il reste dans
l'historique git** (commit `b59cbb3`). Considérez ces mots de passe comme
compromis et changez-les ; à défaut, réécrivez l'historique avant toute
publication du dépôt.
---
## D21 — `Context.{...}` attend le `TableName` du Metadata, résolu par service
**Piège.** Les expressions LINQ de `QueryExecute` référencent les entités par
le `TableName` de l'API Metadata, **pas** par le nom d'entité de l'Application
Dictionary. Ce n'est pas une pluralisation : `Container` -> `Containers`, mais
`Alias` -> `Alias` (invariant), et `Item` n'existe pas. Un nom faux part en
HTTP 500 (erreur de compilation `'ApplicationReadingContext' ne contient pas
de définition pour '...'`). Seul `TableName` fait foi — **ne réinventez pas de
règle grammaticale**.
**Décision.** `src/services/entity-resolver.js` construit une table
`Name | TableName (insensible à la casse) -> TableName` et tous les points
d'interpolation (`wms-query-service`, `call_query_api`) passent par elle.
Mesures du 24/08/2026 (`LIMAGRAI2512`) :
- Le contexte de lecture est **commun au tenant** : la table agrège le
Metadata de toutes les applications. La liste vient de
`GET /configuration/applications` (5 applications déployées avec version) —
les applications EasyBuilder sans contexte requêtable (`CustomApp`…) n'y
figurent pas et ne fournissent de toute façon **0 entité** Metadata.
- 288 `TableName` distincts, aucun conflit `Name -> TableName` entre
applications.
Comportements :
- **Nom inconnu** : échec avant tout appel réseau de requête, message avec
suggestions proches et renvoi vers `get_entity_metadata`.
- **Metadata injoignable** : le nom passe tel quel (comportement historique)
et la réponse porte un `warning` — on ne bloque pas tout le serveur pour un
cache irrécupérable.
- Cache : TTL partagé (`WORKFLOW_CACHE_TTL`), chargement paresseux,
invalidation par abonnement `onSwitch()` (D8, D10).
---
## D22 — Routage des outils par table explicite, plus par préfixe de nom
**Piège.** Le handler `tools/call` de `src/index.js` routait par préfixe de nom
(`startsWith`, `includes`) dans une cascade de `else if`. Deux outils listés
dans `tools/list` n'atteignaient jamais leur module — reproduits le
24/08/2026 :
| Outil | Cause | Erreur renvoyée |
|---|---|---|
| `get_entity_metadata` | capté par `startsWith('get_entity_')` (branche `wms-query-tools`, placée avant la sienne) | `Unknown WMS query tool: get_entity_metadata` |
| `list_log_files` | la branche logs testait `includes('_logs')`, or le nom contient `_log_files` | `Unknown tool: list_log_files` |
Le routage par préfixe fait dépendre la joignabilité d'un outil de l'**ordre
des branches** et de conventions de nommage implicites : chaque ajout d'outil
pouvait en casser un autre silencieusement.
**Décision.** Une table `nom d'outil → module` est construite au démarrage en
parcourant les `listTools()` des 8 modules de `src/tools/`. `tools/list` est
servi depuis cette même table et le dispatch est un lookup : un outil listé
est un outil routé, **par construction**. Deux modules déclarant le même nom
font échouer le serveur au démarrage (message nommant les deux modules) —
c'est un bug de développement, pas un cas d'exécution.
La table ne présume rien de la signature des outils : `(name, args)` est
transmis tel quel au `executeTool()` du module. Ajouter un paramètre à un
outil ne la concerne pas.
---
## D23 — Le SDK ne valide pas les arguments : validation dans le wrapper
**Piège mesuré (24/08/2026).** Le SDK MCP (`@modelcontextprotocol/sdk` 1.x)
ne valide **pas** les arguments d'appel contre l'`inputSchema` déclaré :
`additionalProperties: false` est ignoré, et un paramètre inconnu
(`read_recent_logs(lines: 60)`) retombe silencieusement sur les défauts
(`count = 100`) sans le moindre signal.
**Décision.** Le wrapper `tools/call` de `src/index.js` valide chaque appel
contre le schéma de la table de routage (D22) avant le dispatch — schéma
déclaré = contrat appliqué, pour les 23 outils d'un coup :
- **paramètre inconnu** → erreur structurée nommant le paramètre fautif **et**
les paramètres valides de l'outil ;
- **paramètre `required` manquant** → même forme d'erreur.
Les 23 schémas portent aussi `additionalProperties: false` : inerte côté SDK,
mais c'est le contrat que lisent les clients. La validation reste volontairement
superficielle (noms et présence, pas les types) : le but est de supprimer le
silence, pas de réimplémenter JSON Schema.
Le renommage des paramètres (`entity_type`/`query` uniformisés) a été **écarté**
au profit de cette validation — voir ROADMAP « Écarté ».
---
## D24 — Contrat de troncature : borné + signalé, jamais un rejet silencieux
**Piège mesuré (24-25/08/2026).** Une réponse d'outil de ~70 000 caractères
(`get_system_parameters` sans filtre ; `search_logs` atteignait 52-56 000 avec
les seuls défauts) est **rejetée par le client MCP** — l'utilisateur voit un
échec opaque au lieu d'un résultat partiel.
**Décision.** Tout outil susceptible de produire une sortie volumineuse borne
sa réponse et **signale** la coupe. Le signal est commun :
| Champ | Sémantique |
|---|---|
| `truncated: true` | présent **uniquement** quand la réponse a été coupée — jamais `truncated: false` |
| `hint` | présent ssi `truncated` ; actionnable : dit comment continuer (`offset` suivant) ou réduire (filtres, `context_lines`…) |
| `returned` | nombre d'éléments effectivement renvoyés |
| total (`totalParameters`, `totalResults`, `dataTotalChars`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
Les mécanismes restent **volontairement locaux**, car ils diffèrent :
`get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu,
on continue avec l'offset suivant) ; `search_logs` plafonne le volume
(`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats
**entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce
en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
Les **trois outils de requête**, eux, partagent le même mécanisme — ils
partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère
est le mécanisme, pas le nombre d'appelants.
**Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient
encore le seuil, toutes mesurées sur `LIMAGRAI2512` :
`get_workflow_details` **fenêtre le blob `data`** (`max_data_chars`, défaut
20 000 ; `data_offset`, défaut 0) — 79 092 caractères pour un StackerCrane
(dont 71 512 de blob), 101 816 pour `CST_SendRejectContainersToPK` (92 362 de
blob), ramenés à ~23 000. La tranche est **verbatim** : découpe de chaîne, rien
d'autre. Ne jamais résumer, reformuler ni « parser » cette définition
EasyBuilder — la concaténation des tranches dans l'ordre des offsets doit la
reconstituer à l'octet près (vérifié : 20 000 + 20 000 + 20 000 + 11 512 =
71 512, concaténation identique au blob d'origine). Les métadonnées du workflow
restent complètes dans chaque tranche ; seul `data` est fenêtré, et
`dataTotalChars` est porté par **toute** réponse — y compris non tronquée, où
la seule différence avec l'ancienne réponse est ces trois champs de fenêtre
(+65 caractères mesurés).
`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur
volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que
`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper
commun `src/services/response-limit.js` (recherche dichotomique : ~8
constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de
~1 Mo) :
| Appel | Avant | Après |
|---|---:|---:|
| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) |
| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) |
| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 |
Trois points de cadrage, tous vérifiés en exécution :
- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique
**octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`,
`call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond).
`MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés :
le correctif est le bornage signalé, pas une réduction silencieuse.
- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing —
`call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`,
`omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing
et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux
qu'un rejet client opaque.
- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les
entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans
cela, une entité volumineuse placée en tête consommerait tout le budget et
les suivantes reviendraient à zéro résultat sans que rien ne le dise —
exactement le faux négatif que corrige L5.4.
`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un
scalaire), et son `query_type` ne porte donc pas l'avertissement de volume
ajouté aux deux autres.
Deux garde-fous de cadrage :
- **Ne pas réduire les défauts existants** (`max_results` 50, `context_lines` 2)
pour passer sous le plafond : le correctif est le bornage signalé, pas un
changement silencieux de comportement.
- La taille qui fait foi est celle de `content[0].text` **mesurée via le
protocole**, pas une estimation. Ordre de grandeur cible : ~20-25 000
caractères par réponse.
Au passage, `totalParameters` a changé de sens : c'était le nombre brut
d'entités `Parameter` chargées, c'est désormais le total correspondant aux
filtres avant pagination (identique sans filtre).
---
## D25 — `query_type` : opt-in explicite, D3 reste la règle par défaut
**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** `QueryType` était
figé à `0` en dur dans `executeQuery()` et `executeScalarQuery()`, rendant le
modèle Writing — opérationnel et mesuré (`Context.Products` en `QueryType: 1`
répond) — inatteignable.
**Décision.** Un paramètre `query_type` (entier, défaut `0`) est exposé sur
**trois outils** : `call_query_api`, `query_wms_entities`,
`count_wms_entities`. `get_entity_schema` et `search_wms_data` restent des
raccourcis Reading, sans paramètre.
**Rapport à D3.** D3 n'est pas révisée : le Reading reste la règle par défaut,
car en Writing les statuts sont des **énumérations** — les comparaisons de
chaînes (`== "Release"`), cas le plus courant en debug, y échouent. La bascule
est un opt-in explicite et les descriptions d'outils portent l'avertissement.
Modalités :
- **Garde de valeur dans le code de l'outil**, pas dans le wrapper : D23 valide
les noms de paramètres, pas les valeurs. Hors `0..3` (ou non entier) →
erreur locale via `assertValidQueryType()` (`wms-query-service.js`), **avant
tout appel réseau**, nommant les quatre contextes.
- **`2` et `3` sont transmis tels quels** : le WMS répond et son diagnostic
remonte entier (L1.1). Sur `LIMAGRAI2512` : `2` = DataWarehouse non configuré
(`Could not resolve serviceType 'IDataWarehouse…'`), `3` = Metrics, contexte
présent mais modèle distinct (`'ApplicationMetricDataContext' ne contient pas
de définition pour 'Products'`).
- **Interaction avec le resolver (D21)** : la table de résolution est
construite sur le Metadata **Reading**. Quand `query_type != 0`, un nom qui
se résout se résout normalement (`Products` marche en Writing, mesuré) ; un
nom **inconnu** du Reading n'est **pas** bloqué — il passe tel quel avec un
`warning` dans la réponse (`allowUnknown` du resolver, même mécanique que le
repli « Metadata injoignable »), car le modèle Writing/Metrics peut contenir
des entités hors Reading. Le `warning` est conservé aussi dans la réponse
d'erreur si le WMS échoue ensuite.
---
## D26 — Paramètre `application` : caches par application, chargement toujours paresseux
**Contexte (mesures des 24-25/08/2026, `LIMAGRAI2512`).** L'application
interrogée venait de `WMS_APPLICATION` (partagée par tous les profils) : le MCP
ne voyait que `EasyWMS`. Or `POST /AD/api/Application/GetAll` déclare **9
applications**, et **CustomApp porte le spécifique client** (153 workflows
`CST_*` sur ce tenant) — précisément ce qu'on cherche en debug. Les 11 entités
`CustomApp` ne sont requêtables dans aucun contexte : l'API AD est le seul
accès au spécifique client.
**Décision.** Un paramètre `application` (défaut : l'application du profil,
donc comportement strictement inchangé sans lui) sur six outils :
`get_ad_elements`, `search_ad_elements`, `get_ad_element_details`,
`search_workflows`, `get_workflow_details`, `list_workflow_categories`.
**Contrat de cache.**
| Service | Clé avant | Clé après |
|---|---|---|
| `ad-service` | un cache par type | un cache par **(application, type)** (`app::type`) |
| `workflow-service` | un cache global | un cache par **application** |
Sans ces clés, un appel CustomApp polluerait le cache EasyWMS du même type.
Règles associées :
- **L'invalidation reste l'abonnement `onSwitch()`** (D8) : la bascule de
profil vide **tous** les caches, toutes applications confondues. Aucune
invalidation manuelle inter-module.
- **Pas de préchargement des 9 applications** (D10) : seule l'application
effectivement demandée est chargée — le type `Resource` pèse 29 374 éléments
sur la seule EasyWMS.
- `workflow-service` cache aussi la liste de `Application/GetAll`, **allégée**
(`name`, `id`, `version`) : chaque élément de la réponse brute embarque un
blob `data` de ~100 Ko (la définition EasyBuilder complète) qu'on ne
conserve pas.
- `list_workflow_categories` est adossé à `Application/GetAll` (les 9
applications) et non plus aux `applicationName` du seul cache actif. La note
de L1.3 reste vraie — pas de champ catégorie ; les comptes de workflows ne
sont affichés que pour les applications déjà chargées (paresseux). Le
paramètre `category` de `search_workflows` (filtre sur `applicationName`)
subsiste : `application` choisit le jeu chargé, `category` filtre dedans —
leur articulation est documentée dans les descriptions.
- `get_application_summary` regroupe l'état par application puis par type et
ne détaille que les entrées **effectivement en cache** : la sortie reste
bornée quel que soit le nombre d'applications interrogées (D24). Il expose
aussi les caches de workflows par application.
**Le paramètre ne suffisait pas : il faut que la réponse le dise** (lot 5,
25/08/2026). Cas réel : une session Cowork cherchant des workflows `CST_*` sans
passer `application: "CustomApp"` a conclu que l'AD n'en contenait aucun — alors
que `CST_PickingTasksSequencing_PR` et `CST_ChooseDestinationFromPS` existent.
Le paramètre était disponible et documenté ; ce qui manquait, c'est que **rien
dans la réponse ne disait qu'on n'avait regardé qu'une application sur neuf**.
Un défaut silencieux se lit comme une exhaustivité.
`search_workflows` et `search_ad_elements` rappellent donc **toujours**
l'application effectivement interrogée (plus seulement quand le paramètre a été
passé), et ajoutent un `hint` quand la recherche revient **vide** :
- Seuil à **0 résultat**, pas « peu ». Toute valeur non nulle produirait un hint
parasite sur une recherche légitimement étroite, et le mode d'échec observé
est bien le zéro pris pour une absence.
- Le hint nomme les autres applications depuis la liste allégée **déjà en
cache** ; sans elle, il reste générique et renvoie vers
`list_workflow_categories`. **Jamais de fetch pour construire un hint**
ce serait précisément le préchargement que cette décision interdit.
- Il nomme `CustomApp` en clair, sauf quand c'est déjà l'application
interrogée : c'est une connaissance statique, déjà portée par les
descriptions d'outils, pas une donnée à aller chercher.
---
## D27 — Chargements paresseux sous concurrence : single-flight + génération
**Contexte (mesuré le 25/08/2026, `LIMAGRAI2512`).** Le serveur traite les
`tools/call` **en concurrence** : une rafale d'appels dans une même session
s'exécute en parallèle. Les trois services à cache chargeaient paresseusement
sans se coordonner — le premier appelant qui trouve le cache invalide lance le
fetch, et tous ceux qui arrivent pendant ce fetch trouvent le cache **encore**
invalide et lancent le leur. Mesures avant correction :
| Rafale | Résultat |
|---|---|
| 6 × `search_workflows` (CustomApp) | 6 × `fetching from API` pour une seule clé |
| 6 × `query_wms_entities` (Container) | 6 × `[EntityResolver] Cache expired or empty` — soit 30 GET Metadata |
| 14 appels mixtes | l'API AD répond **HTTP 500** sur `Workflow/GetByApplication` (EasyWMS, ~4 000 workflows) — les 4 appels EasyWMS échouent, les mêmes passent en séquentiel |
La dernière ligne est le vrai coût : la duplication ne gaspille pas seulement
des appels, elle **surcharge l'API AD au point de la faire échouer**.
**Décision.** Un motif unique, `src/services/single-flight.js`, partagé par
`workflow-service`, `ad-service` et `entity-resolver` — une `Map` de promesses,
pas une dépendance externe :
- **Une clé de single-flight par entrée de cache** : `workflows::<app>` et
`applications` pour les workflows, `<app>::<type>` pour l'AD, une clé unique
pour le resolver. Deux clés distinctes se chargent toujours **en parallèle**
le single-flight ne sérialise rien au-delà de la clé demandée, et
n'introduit aucun préchargement (D26 intact).
- **La promesse est retirée au règlement, succès *ou* échec.** Un fetch en
erreur ne reste pas coincé dans la Map : l'appel suivant refetche. Les
appelants joints reçoivent la même erreur, et rien n'est mis en cache.
- **Le log de fetch reste l'observable** (D6) : une ligne `fetching from API`
/ `Cache expired or empty` par chargement **réel**. Les appelants joints
émettent une ligne distincte (`Fetch already in flight … joining it`) — ne
fusionnez pas les deux, c'est ce qui rend la déduplication vérifiable depuis
stderr.
**Garde de génération.** Un fetch parti *avant* une invalidation terminait
*après* elle et écrivait quand même son résultat : le cache repartait peuplé
avec les données de l'ancien tenant, timestamp neuf, `valid: true`. Défaut
latent avant le single-flight, **déterministe après** (la promesse en vol
survit à l'invalidation). D'où :
- Un **compteur de génération par service**, incrémenté à chaque invalidation
(`clearCache()` / `invalidateCache()`, toujours déclenchées par
`onSwitch()` — D8 inchangé). Le fetch capture la génération au départ.
- **Les fonctions de chargement n'écrivent plus rien en cache** : la
publication est un `commit` passé à `singleFlight.run`, appelé *seulement*
si la génération n'a pas bougé. C'est structurel, pas conventionnel — un
fetch ne peut plus publier par inadvertance.
- L'invalidation vide aussi la Map des promesses en vol. **L'appelant reçoit
quand même son résultat** — il l'a demandé avant la bascule ; c'est sa mise
en cache qui est refusée, tracée par
`Result for "…" discarded, not cached`.
Mesuré sur `[search_workflows(EasyWMS), switch_wms_profile(EUROTRAFIC)]` envoyé
d'un bloc, puis `get_application_summary` en séquentiel : **3/3 avant**, le
cache EasyWMS de LIMAGRAIN (3 944 workflows) survit à la bascule avec un
timestamp neuf ; **3/3 après**, aucun cache peuplé.
**Vide anormal ≠ vide réel.** Les services lisaient `response?.entities || []`
sur les réponses de l'API AD (enveloppe `{ entities: [...] }`, D4). Toute
réponse d'une **autre forme** devenait donc un tableau vide, indistinguable
d'une page finale légitime — et mise en cache avec un timestamp valide : un
cache vide empoisonné pour tout le TTL, sans message. C'est la cause probable
du `count: 0` mesuré sous rafale, et le mode d'échec le plus coûteux du lot :
il se lit comme une réponse.
`src/services/ad-envelope.js` porte le contrat pour les trois sites
(`Workflow/GetByApplication`, `Application/GetAll`, `<Type>/GetByApplication`) :
| Réponse | Traitement |
|---|---|
| `{ entities: [...] }`, `[]` réel compris | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow ; 3 types AD valides mais vides, D17) |
| toute autre forme | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche |
**Pas de retry, pas de résilience.** L'anomalie doit être **visible et non
persistante** ; la rattraper la rendrait invisible, ce qui est exactement le
défaut corrigé. `entity-resolver` était déjà conforme : il lève déjà si
`/configuration/applications` ou le Metadata ne rendent aucune entité.
Mesures : `search_workflows` sur `SmartUI``count: 0`, `success: true`,
cache posé (`count: 0`, `valid: true`) et hint L5.4 présent. Les trois sites
face à une réponse `{}` → erreur levée, `{}` en cache, et le fetch suivant
repart normalement.
**Mesures après.** Rafale de 6 (CustomApp) → 1 fetch + 5 joins, les 6 réponses
à `count: 44`. Rafale de 6 (resolver) → 1 chargement, 5 GET Metadata au lieu de
30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au
total, plus aucun HTTP 500. Le chemin séquentiel nominal est inchangé : 1 fetch
puis 1 `Using cached data`, 0 join.
**Ce que cette décision ne couvre pas.** La bascule de profil concurrente aux
appels en vol (un `switch_wms_profile` qui redirige des requêtes déjà parties)
reste un point ouvert de la ROADMAP, distinct.
+49 -218
View File
@@ -12,232 +12,35 @@ contient que ce qui reste à faire.
---
## Cause racine commune
Le MCP interpole `entity_type` dans `Context.{entity_type}` **sans aucune
validation** (vérifié : aucune liste blanche dans le code). Or le nom attendu
par le contexte de lecture n'est pas le nom d'entité de l'Application
Dictionary.
L'API Metadata (`GET /Metadata/Entities`, **232 entités**) donne la
correspondance exacte :
| `Name` (renvoyé par `search_ad_elements`) | `TableName` (attendu par `Context.`) |
|---|---|
| `Container` | `Containers` |
| `Product` | `Products` |
| `ContainerType` | `ContainerTypes` |
| `Alias` | `Alias`**invariant, pas de pluriel** |
| `Item` | *n'existe pas dans le modèle Reading* |
Ce n'est donc pas une règle de pluralisation : c'est un mapping, et seul
`TableName` fait foi. `TableName` est unique sur les 232 entités.
Conséquences déjà constatées :
- une session utilisant les noms de l'AD (singuliers) déclenche un **HTTP 500**
sur chaque requête ;
- la liste d'entités documentée était fausse (`Aliases` n'existe pas, c'est
`Alias`) ;
- le MCP n'expose que 12 entités figées là où l'API en connaît 232.
---
## Lot 1 — Déblocage
Objectif : rendre le MCP auto-diagnosticable et réparer ce qui est cassé. Ce lot
seul aurait suffi à ce qu'une session se débrouille sans intervention.
### L1.1 — Remonter le détail des erreurs HTTP
Aujourd'hui toute erreur d'API se résume à `Request failed with status code 500`.
Or le WMS renvoie déjà le diagnostic complet dans le corps de la réponse :
```json
{"ClassName":"System.AggregateException","Message":"Compile Error: ...
'ApplicationReadingContext' ne contient pas de définition pour 'Container' ..."}
```
Enrichir l'erreur au point de passage unique (`api-service.post` / `.get`) avec :
statut, URL, verbe, payload envoyé, corps de réponse tronqué à ~2000 caractères.
**Fichier :** `src/services/api-service.js` (catch de `post` et `get`).
### L1.2 — Fiabiliser le routage des outils
Deux outils sont listés dans `tools/list` mais ne sont routés vers aucun module,
à cause du routage par préfixe :
| Outil | Cause | Erreur observée |
|---|---|---|
| `get_entity_metadata` | capté par `startsWith('get_entity_')` avant sa propre branche | `Unknown WMS query tool` |
| `list_log_files` | ne contient pas `_logs` mais `_log_files` | `Unknown tool` |
Remplacer le routage par préfixe par une **table explicite nom → module**,
construite depuis les `listTools()` de chaque module. Un outil listé mais non
routé devient alors impossible par construction, au lieu d'être rattrapé au cas
par cas.
**Fichier :** `src/index.js` (handler `tools/call`).
### L1.3 — Corriger les projections de champs des workflows
L'API AD renvoie les champs en minuscules (`id`, `name`, `version`,
`applicationName`). Deux endroits supposent une autre forme :
- `search_workflows` projette `w.Id`, `w.Code`, `w.Name`, `w.Category` → tous
`undefined`, supprimés par `JSON.stringify`**50 objets vides** pour un
`count` pourtant correct ;
- `workflow-service` lit `w.category || w.Category`, deux clés inexistantes →
`list_workflow_categories` renvoie **0 catégorie** et classe les 4012
workflows en `Uncategorized`.
Le champ le plus proche d'une catégorie est `applicationName`, mais il vaut
`EasyWMS` pour tous les workflows : la notion de catégorie n'a **aucun support**
dans les données. Décider en connaissance de cause plutôt que d'inventer une
taxonomie.
**Fichiers :** `src/tools/workflow-tools.js`, `src/services/workflow-service.js`.
---
## Lot 2 — Correctif de fond
### L2.1 — Résolution des entités via l'API Metadata
Accepter `entity_type` au nom d'entité (`Container`) ou au nom de jeu
(`Containers`), insensible à la casse, et émettre `Context.{TableName}`. Cache
identique aux autres (TTL partagé, invalidation au changement de profil).
Sur nom inconnu, échouer **avant tout appel réseau**, avec un message
actionnable :
> « Item » n'existe pas dans le modèle Reading. Proches : ItemGroup, StockItem.
> 232 entités disponibles — utilisez `get_entity_metadata` pour la liste.
Supprime la cause des 500 et débloque 232 entités au lieu de 12.
### L2.2 — Rejeter les paramètres inconnus
Le SDK MCP ignore silencieusement les paramètres non déclarés : un appel
`read_recent_logs(lines: 60)` retombe sur le défaut `count = 100` sans le
moindre signal, et l'appelant conclut à un paramètre ignoré.
Ajouter `additionalProperties: false` aux 23 schémas d'outils.
C'est le correctif retenu **à la place** d'une uniformisation des noms de
paramètres : renommer casse les usages existants pour un gain cosmétique, alors
que la cause réelle est l'absence de signal.
---
## Lot 3 — Ergonomie et documentation
### L3.1 — Bornage des sorties volumineuses
- `get_system_parameters` : ajouter `limit` / `offset`, aujourd'hui absents
(sortie constatée : 70 000 caractères, rejetée par le client).
- `search_logs` : garde-fou de taille. `max_results` existe déjà, mais les
`context_lines` multiplient le volume (88 000 caractères pour 50 résultats).
- Renvoyer `truncated: true` explicitement plutôt que de laisser le client se
faire rejeter.
### L3.2 — Documentation
- DECISIONS.md : **D21** la règle `TableName`, **D22** le routage par table
explicite.
- CLAUDE.md : corriger la liste d'entités (`Aliases``Alias`) et renvoyer vers
`get_entity_metadata` comme source de vérité.
- `wms://query-examples` : un exemple singulier/pluriel commenté.
---
## Lot 4 — Modèle de données et applications
Deux angles morts constatés le 24/08/2026, plus larges que les lots 1 à 3. Les
Deux angles morts constatés le 24/08/2026, plus larges que les lots 2 et 3. Les
chiffres ci-dessous sont mesurés sur le tenant `LIMAGRAI2512`.
### L4.1 — Le modèle Writing est inatteignable
### L4.1 (reliquat) — Explorer le contexte Metrics
`QueryType` est figé à `0` (Reading) en dur dans `api-service.js`
(`executeQuery` et `executeScalarQuery`). Or `QueryContextType` a **quatre**
valeurs. Testées une à une :
| Valeur | Contexte | Résultat sur `LIMAGRAI2512` |
|---|---|---|
| `0` | Reading | opérationnel (seul utilisé aujourd'hui) |
| `1` | Writing | **opérationnel**`Context.Products` répond |
| `2` | DataWarehouse | **non configuré** : `Could not resolve serviceType 'IDataWarehouse…'` |
| `3` | Metrics | contexte présent (`ApplicationMetricDataContext`), modèle non exploré |
Exposer `query_type` sur les outils de requête, défaut `0`. Attention : D3 reste
vrai — en Writing les champs de statut sont des **énumérations**, donc
`== "Release"` échoue. La bascule doit être un choix explicite et documenté.
Le contexte `Metrics` mérite une exploration à part : c'est probablement là que
vivent les données agrégées produites par les jobs `MetricGatherer`.
### L4.2 — Une seule application sur neuf est visible
`Application` vient de `WMS_APPLICATION` dans `.env`, **partagé par tous les
profils**, sans surcharge par appel ni paramètre d'outil. Le MCP n'interroge donc
jamais que `EasyWMS`.
`POST /AD/api/Application/GetAll` en déclare **9** :
| Application | Workflows | Queries | Entities |
|---|---:|---:|---:|
| EasyWMS | 4012 | 2239 | 338 |
| **CustomApp** | **153** | **54** | **11** |
| AGV | 71 | 14 | 5 |
| Notifications | 26 | 35 | 24 |
| GalileoFaults | 9 | 20 | 24 |
| Common | 1 | 7 | 25 |
| SmartUI, User, WarehouseWebDesigner | 0 | 08 | 0 |
**CustomApp porte le spécifique client** — ses workflows sont préfixés `CST_`
(`CST_SendRejectContainersToPK`, `CST_Task`, `CST_Container`…). C'est
précisément ce qu'on cherche en debug, et c'est aujourd'hui invisible. Au total
**260 workflows et ~130 queries** hors périmètre.
Deux chantiers de difficulté très différentes :
**API AD — simple.** L'application est un champ du payload
(`[application, tenant, pageSize, offset]`). Vérifié : `["CustomApp", tenant,
5, 0]` sur `/Workflow/GetByApplication` renvoie bien les workflows `CST_`. Il
suffit d'un paramètre `application` sur les outils AD et workflow, avec une clé
de cache incluant l'application (sinon un cache pollué mélange les
applications).
**QueryExecute — tranché : le champ `Application` ne partitionne rien.**
`Context.AgvTasks` (entité de l'application AGV) répond aussi bien avec
`Application: "AGV"` qu'avec `Application: "EasyWMS"`. Le contexte de lecture est
**commun au tenant** : toutes les applications y déversent leurs entités.
Conséquence pour L2.1 : la table de résolution doit **agréger le Metadata de
toutes les applications** (`GET /Metadata/Entities?applicationName=…` par
application, 232 + 20 + 6 + …), et non se limiter à `EasyWMS`. Inutile en
revanche d'ajouter un paramètre `application` à `QueryExecute` : il ne changerait
rien.
**Les entités `CustomApp` ne sont interrogeables dans aucun contexte.** Les 11
entités `CST_` ont été testées sous les quatre `QueryType`, au singulier et au
pluriel : échec partout, et `Metadata/Entities` comme `Metadata/EntitiesAll`
renvoient **0 entité** pour `CustomApp`. Aucune n'est marquée
`isDataWarehouse`. Ce sont des définitions EasyBuilder (`FromMetadata: false`)
sans projection dans un contexte requêtable.
**L'API AD reste donc le seul accès au spécifique client** — ce qui rend le
paramètre `application` sur les outils AD et workflow d'autant plus utile.
L'exposition de `query_type` est livrée (D25). Reste l'investigation : le
contexte `Metrics` (`QueryType: 3`, `ApplicationMetricDataContext`) mérite une
exploration à part — c'est probablement là que vivent les données agrégées
produites par les jobs `MetricGatherer`. Livrable : un rapport, pas du code
(même phase d'investigation que L4.4).
### L4.3 — Identifier le MCP dans les logs du WMS
`QueryExecute` accepte un champ **`ClientModule`** que le MCP n'envoie pas.
Résultat : ses requêtes apparaissent dans les logs du WMS sous
Les requêtes du MCP apparaissent dans les logs du WMS sous
`Execute error. Client: GNA` — le client OAuth partagé — donc indistinguables de
celles du vrai client GNA.
Renseigner `ClientModule` (`"MCP-WMS"` ou le nom du profil actif) rend chaque
requête du MCP traçable côté serveur. Vérifié : le champ est accepté.
**La piste `ClientModule` est invalidée** (mesuré le 24/08/2026, lot 1) : le
champ est bien accepté par `QueryExecute` (pas d'erreur), mais il est **sans
effet observable**. Une requête en échec envoyée avec
`ClientModule: "MCP-WMS"` est tracée `Execute error. Client: GNA`, et ni
`MCP-WMS` ni `ClientModule` n'apparaissent nulle part dans
`ApplicationService.log` ni `HttpResponseTime.log`. Le `Client:` des logs vient
du client OAuth, pas du payload — le champ n'a donc **pas** été renseigné.
Piste restante (non vérifiée) : un client OAuth dédié au MCP côté EasySTS
changerait le `Client:` des logs, mais suppose une configuration côté WMS.
### L4.4 — Historique d'exécution des workflows par API
@@ -268,7 +71,7 @@ La référence de l'API documente des champs que le MCP n'envoie jamais :
| `Parameters` | requêtes **paramétrées** (dictionnaire `nom -> {TypeName, Value}`) — supprimerait toute concaténation de chaîne dans les filtres, et pourrait débloquer D13 (`Select`) |
| `CommandTimeout` | timeout par requête, au lieu du timeout HTTP global de 30 s |
| `QueryId` + `POST /QueryCancel` | annulation d'une requête longue |
| `POST /QueryExecuteStream` | résultats en flux — piste sérieuse pour L3.1 (sorties volumineuses) |
| `POST /QueryExecuteStream` | résultats en flux — piste long terme pour les sorties volumineuses, au-delà du bornage signalé de D24 |
Autres endpoints jamais utilisés, à évaluer : `QueryEvents`, `QueryCommands`,
`QueryCorrelationEvents`, `QuerySnapshots` (event sourcing — utile en debug),
@@ -279,8 +82,6 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
---
---
## Écarté
| Proposition | Raison |
@@ -293,6 +94,36 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
## Points ouverts (hors lots)
- **Profil `AD` : tenant introuvable** (mesuré le 24/08/2026). `npm test -- --all`
échoue 0/4 sur ce profil ; le STS de `10.255.255.2` répond
`400 {"error":"invalid_request","error_description":"Tenant not found"}` pour
le tenant `AD`. L'hôte et le STS fonctionnent (LIMAGRAIN, même hôte, passe
4/4) : c'est la valeur `AD_TENANT` du `.env` qui ne correspond plus à un
tenant existant. Correction côté propriétaire du dépôt (mettre à jour ou
retirer le profil) — pas un bug du code. Depuis L3.2, le corps de la réponse
du STS (`Tenant not found`) remonte dans les erreurs d'outils et du smoke
test.
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026,
révision du lot 2). Le serveur traite les `tools/call` **en concurrence** :
un `switch_wms_profile` émis pendant que des requêtes sont en vol les fait
partir sur le nouveau profil (observé : une requête destinée à EUROTRAFIC
exécutée sur l'hôte `10.255.255.2` après la bascule suivante). Conséquence du
singleton d'état global (D8). À traiter si un cas réel de mélange de profils
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
début de chaque appel). La manifestation « caches » de la même concurrence
est **traitée** (D27 : single-flight par clé, garde de génération) ; celle-ci
ne l'est pas — D27 borne les chargements paresseux, pas le routage d'une
requête déjà partie.
- **L'API AD échoue sous appels concurrents nombreux** (mesuré le 25/08/2026,
lot 6). Avant D27, une rafale de 14 `tools/call` faisait répondre **HTTP 500**
à `POST /AD/api/Workflow/GetByApplication` pour `EasyWMS` (~4 000 workflows) —
les 4 appels concernés en erreur, les mêmes corrects en séquentiel. C'est une
limite du serveur AD, pas du MCP. D27 l'atténue fortement (un seul fetch par
clé au lieu de N, et le 500 n'a pas reparu depuis), sans la supprimer : des
clés **différentes** se chargent toujours en parallèle. À reconsidérer si le
500 réapparaît — piste : plafonner le nombre de chargements simultanés, tous
clés confondues. N'implémentez rien avant d'avoir une mesure : brider les
chargements parallèles coûte de la latence sur le chemin nominal.
- **`select_expression`** : les projections via le paramètre `Select` provoquent
des erreurs de compilation côté serveur (D13). Irritant principal restant.
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
+2
View File
@@ -12,6 +12,8 @@ Documents de référence sur EasyWMS et ses API, conservés dans le dépôt pour
| Fichier | Nature |
|---|---|
| [supervision.md](supervision.md) | **Rôle de supervision** — vérifier le MCP, réviser les livraisons des sessions de codage, rédiger les passations |
| [handoff-lot1.md](handoff-lot1.md) | **Passation** — prompt autoportant pour le lot 1 de la [roadmap](../ROADMAP.md). À supprimer une fois le lot livré |
| [logs.md](logs.md) | **Rédigé pour ce projet** — accès aux logs WMS, outils, limites |
| [ad-api-validation.md](ad-api-validation.md) | **Rédigé pour ce projet** — campagne de validation curl des 19 types AD testés (17 valides) |
| [api/Application Service API Reference.md](api/Application%20Service%20API%20Reference.md) | Référence de l'API ApplicationService |
+253
View File
@@ -0,0 +1,253 @@
# Supervision du projet mcp-wms-api
> **Comment s'en servir.** Ouvrir une session Claude Code dans
> `D:\GIT\_PERSO\mcp-wms-api` et lui dire : « Lis `docs/supervision.md` et
> prends ce rôle. »
>
> Document durable, contrairement aux passations `docs/handoff-*.md` qui sont à
> usage unique.
---
Tu tiens le rôle de **superviseur** du serveur MCP `mcp-wms-api` : un serveur
MCP (Node.js, CommonJS) qui donne à Claude un accès en lecture à un WMS EasyWMS
de Mecalux, via ses API REST uniquement.
Tu n'écris pas les fonctionnalités. D'autres sessions Claude Code le font, à
partir de prompts de passation que **tu** rédiges. Ton travail tient en trois
gestes qui se répètent :
1. **Vérifier** l'état réel du MCP contre le WMS réel.
2. **Réviser** ce que les sessions de codage ont livré, sans les croire sur
parole.
3. **Rédiger** la passation suivante.
Ta valeur tient entièrement à un principe : **tu mesures, tu ne supposes pas.**
Un rapport d'agent, une doc, un commentaire de code sont des indices — la seule
preuve est l'exécution contre le WMS.
---
## 1. Où vit la vérité
| Fichier | Rôle | Qui l'écrit |
|---|---|---|
| [../CLAUDE.md](../CLAUDE.md) | architecture, conventions de code | toi, quand le code change |
| [../DECISIONS.md](../DECISIONS.md) | **pourquoi** le code est ainsi, pièges vérifiés (`D1`…) | toi, ou la session de codage sur consigne |
| [../ROADMAP.md](../ROADMAP.md) | ce qui reste à faire, par lot, et ce qui est écarté | toi |
| [../MONITORING.md](../MONITORING.md) | supervision du serveur MCP en exploitation | toi |
| [logs.md](logs.md) | accès aux logs du WMS | toi |
| `handoff-*.md` | passations à usage unique | toi, supprimées une fois livrées |
Règle de répartition, pour éviter que tout finisse en vrac dans le même
fichier :
- un fait **mesuré et acté**`DECISIONS.md`, avec un numéro `D<n>` ;
- un travail **à faire**`ROADMAP.md` ;
- une **consigne à un agent** → un `handoff-*.md` ;
- une proposition **écartée** → la section « Écarté » de `ROADMAP.md`, avec sa
raison. Sans ça, elle sera reproposée dans trois mois.
**Numérotation des décisions.** `D21` est réservée au lot 2 (règle
`TableName`), `D22` au lot 1 (routage par table explicite). Vérifie le dernier
numéro utilisé avant d'en attribuer un.
---
## 2. Baseline : ce qui doit rester vrai
Toute session de codage doit laisser ces valeurs intactes. Un écart non
expliqué est une régression, pas une amélioration.
| Contrôle | Attendu |
|---|---|
| `tools/list` | **23** outils |
| `resources/list` | **6** resources |
| `npm test` | **4/4**, code de sortie 0 |
| Démarrage | aucune écriture sur stdout hors JSON-RPC |
Mesures de référence sur le tenant `LIMAGRAI2512` (24/08/2026). Elles dépendent
du tenant : les revérifier plutôt que de les citer de mémoire sur un autre
profil.
| Mesure | Valeur |
|---|---|
| Entités du Metadata `EasyWMS` | 232 |
| Applications déclarées | 9 |
| Workflows `EasyWMS` / `CustomApp` | 4012 / 153 |
| Types AD | 20, ~38 800 éléments |
| Contextes de requête utilisables | Reading (0), Writing (1), Metrics (3). DataWarehouse (2) non configuré |
---
## 3. Boîte à outils de vérification
Toutes ces commandes sont **en lecture seule** côté WMS. Elles ont été
exécutées et fonctionnent telles quelles.
### Handshake MCP complet
```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);}});"
```
### Appeler un outil réellement, via le protocole
Ajoute une ligne `tools/call` après la notification `initialized`. C'est la
**seule** façon de vérifier qu'un outil est routé — un outil peut apparaître
dans `tools/list` et renvoyer `Unknown tool` (c'est arrivé pour
`get_entity_metadata` et `list_log_files`).
```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_OUTIL","arguments":{}}}' | node src/index.js 2>/dev/null
```
**Contrôle systématique après toute modification du routage** : chaque nom
renvoyé par `tools/list` doit résoudre. Boucle sur les 23, ne teste pas
seulement ceux qu'on vient de corriger.
### Sonder le WMS directement
Court-circuite les outils pour savoir ce que l'API répond vraiment :
```bash
node -e "
require('dotenv').config();
const pm=require('./src/config/profile-manager'); pm.loadProfiles();
const api=require('./src/services/api-service').getInstance();
(async()=>{
try{ const r=await api.executeQuery('Context.Products.OrderBy(z => z.Id)',{take:1}); console.log('OK', r.length); }
catch(e){ console.log('status', e.response?.status); console.log(JSON.stringify(e.response?.data).slice(0,600)); }
})();"
```
C'est ce qui a révélé que les HTTP 500 portaient déjà le diagnostic complet
dans leur corps. **Quand un outil échoue, descends toujours à ce niveau** avant
de conclure quoi que ce soit sur la cause.
### Référence de l'API
`https://<host>/ApplicationService/Help` — page d'aide générée par le service,
**source de vérité** sur les champs et les endpoints. Elle a déjà démenti deux
de nos affirmations. La consulter avant d'affirmer qu'une capacité n'existe pas.
---
## 4. Réviser une livraison
Quand une session de codage rend son travail, applique cette grille. Ne saute
pas d'étape parce que le compte-rendu a l'air soigné : les comptes-rendus les
plus assurés sont souvent les moins vérifiés.
**a. Reproduis la vérification attendue toi-même.** Chaque passation en définit
une par correctif. Rejoue-la. Si elle passe chez toi, c'est un fait ; si elle
n'est pas rejouable, c'est une affirmation.
**b. Relance la baseline complète** (§2). Une correction qui casse le handshake
ou `npm test` n'est pas une correction.
**c. Cherche la régression latérale.** Le correctif touche un point de passage
partagé ? `api-service.post` sert tous les outils, `index.js` route tout,
`workflow-service` alimente trois outils. Teste au-delà du périmètre annoncé.
**d. Lis le diff, pas seulement le compte-rendu.** `git show --stat` puis le
diff complet. Tu cherches en particulier :
- un `console.log()` ajouté — casse la session Claude Desktop (D6) ;
- un secret introduit dans un fichier suivi ;
- une invalidation de cache faite à la main plutôt que par `onSwitch()` (D8) ;
- un `git add -A` qui a emporté des fichiers hors périmètre ;
- une valeur inventée là où l'agent aurait dû mesurer.
**e. Traque les quatre modes d'échec déjà observés sur ce dépôt.** Ils
reviennent :
| Mode | Signature |
|---|---|
| Taxonomie inventée | l'agent dérive une catégorie d'un préfixe de nom faute de champ réel |
| Casse supposée | `w.Name` alors que l'API renvoie `w.name` — objets vides, comptage correct (D5) |
| Collision de préfixe | un outil listé et non routé, à cause d'un `startsWith` |
| Hypothèse présentée en solution | « il suffit de… » sans exécution derrière |
**f. Vérifie la trace écrite.** Une décision prise pendant l'implémentation
doit atterrir dans `DECISIONS.md` avec son numéro ; le lot livré doit sortir de
`ROADMAP.md` ; une anomalie découverte hors périmètre doit y entrer.
**g. Rends un verdict net.** Ce qui est **mesuré**, ce qui est **déclaré mais
non vérifiable**, ce qui est **à reprendre**. Pas de « globalement bon ».
---
## 5. Rédiger la passation suivante
Un `docs/handoff-<lot>.md`, autoportant : la session qui le lit n'a pas ton
contexte et ne l'aura jamais.
Structure qui a fonctionné :
1. **Cadre** — le dépôt, la mission en une phrase, ce qui est explicitement
**hors** périmètre.
2. **Contexte matériel** — le profil qui marche, la baseline, les commandes de
vérification copiables.
3. **Contraintes non négociables**`console.error` seulement, le contrat
d'erreur des outils, pas d'accès base, ne pas toucher au `.env`.
4. **Phase 0 s'il y a lieu** — vérifications avant de coder, avec les mesures
déjà faites à confirmer.
5. **Un bloc par correctif** — problème, **preuve mesurée**, ce qu'il faut
faire, points d'attention, **vérification attendue**.
6. **Méthode** — ordre des travaux, obligation de vérifier en exécution.
7. **Livraison** — granularité des commits, mises à jour de doc, ne pas pousser.
Les six règles qui font la différence entre un prompt suivi et un prompt
réinterprété :
- **Donne les preuves, pas les symptômes.** Colle la sortie brute, les clés
réelles d'un objet, les numéros de ligne. Sinon l'agent refait le diagnostic
et peut aboutir ailleurs.
- **Marque ce qui est déjà tranché** — « ne le réinvestigue pas ». Économise des
heures et évite les conclusions contradictoires.
- **Time-boxe les investigations ouvertes** et autorise explicitement « non
résolu » comme réponse. Sans ça, l'agent invente plutôt que d'admettre.
- **Nomme la pente naturelle et interdis-la.** Exemple réel : « n'invente pas
une taxonomie en dérivant des catégories d'un préfixe de nom ».
- **Une vérification attendue par correctif**, formulée en résultat observable.
- **Réserve les numéros** de décisions pour éviter les collisions entre lots
menés en parallèle.
---
## 6. Surveillance courante
Entre deux livraisons, ce qui mérite un passage régulier :
- **`npm test` sur tous les profils** — `npm test -- --all`. Détecte une
expiration de credentials ou un WMS injoignable avant que ça ne devienne un
faux diagnostic.
- **Cohérence doc / code.** Le nombre d'outils annoncé, les listes d'entités,
les chemins de fichiers cités. Cette doc a déjà annoncé 7 resources pour 6, un
`README.md` inexistant et une entité `Aliases` qui n'existe pas.
- **Retours d'usage.** Une session Cowork ou Desktop qui bute est la meilleure
source de bugs réels — mais **ses conclusions sont à revérifier**. Sur les
8 anomalies du rapport du 24/08, 3 étaient réelles, 3 partiellement fausses,
2 non fondées, et la cause racine n'y figurait pas.
- **Les logs du WMS**, quand une erreur reste opaque : `search_logs`, ou les
partages décrits dans [logs.md](logs.md). Ce sont eux qui ont livré la cause
racine des HTTP 500.
---
## 7. Garde-fous
- **Lecture seule côté WMS.** `QueryExecute`, `QueryScalarExecute`, Metadata et
l'API AD ne modifient rien. `execute_command` **écrit** : ne l'appelle pas
pour tester.
- **Ne pousse pas.** `main` a un remote (`git.arthur-ria.fr`). Le push est une
décision du propriétaire du dépôt.
- **Ne réécris pas l'historique.** Le dépôt est publié ; un `filter-repo`
imposerait un force-push sur une branche partagée.
- **Le `.env` contient des credentials réels** et est ignoré par git. Ne le
modifie pas, ne le recopie pas ailleurs, n'en cite pas le contenu.
- **`console.error()` uniquement.** stdout appartient au protocole MCP (D6).
- **Ne corrige pas toi-même** ce que tu découvres en révisant, sauf trivialité
évidente : consigne-le dans `ROADMAP.md` et mets-le dans la passation
suivante. Sinon tu deviens l'implémenteur et plus personne ne te révise.
+72 -42
View File
@@ -62,6 +62,68 @@ const metadataTools = require('./tools/metadata-tools.js');
const configTools = require('./tools/config-tools.js');
const profileTools = require('./tools/profile-tools.js');
const TOOL_MODULES = [
{ moduleName: 'workflow-tools', module: workflowTools },
{ moduleName: 'wms-query-tools', module: wmsQueryTools },
{ moduleName: 'api-tools', module: apiTools },
{ moduleName: 'log-tools', module: logTools },
{ moduleName: 'ad-tools', module: adTools },
{ moduleName: 'metadata-tools', module: metadataTools },
{ moduleName: 'config-tools', module: configTools },
{ moduleName: 'profile-tools', module: profileTools },
];
// Table explicite nom d'outil -> module, construite depuis les listTools() de
// chaque module : un outil listé est un outil routé, par construction. Le
// routage par préfixe de nom laissait des outils listés mais injoignables
// (get_entity_metadata capté par la mauvaise branche, list_log_files capté
// par aucune).
// Un nom déclaré par deux modules est un bug de développement : on échoue au
// démarrage, pas à l'exécution.
const toolRegistry = new Map();
for (const { moduleName, module } of TOOL_MODULES) {
for (const definition of module.listTools()) {
const existing = toolRegistry.get(definition.name);
if (existing) {
throw new Error(
`[Server] Duplicate tool name "${definition.name}" declared by both ` +
`${existing.moduleName} and ${moduleName} — rename one of them`
);
}
toolRegistry.set(definition.name, { moduleName, module, definition });
}
}
/**
* Valide les arguments d'un appel d'outil contre son inputSchema (D23).
* Le SDK MCP ne valide pas les schémas d'entrée — mesuré le 24/08/2026 :
* `additionalProperties: false` est ignoré et un paramètre inconnu retombe
* silencieusement sur les défauts. La validation vit donc ici, pilotée par la
* même table que tools/list : schéma déclaré = contrat appliqué.
*/
function validateToolArgs(definition, args) {
const schema = definition.inputSchema || {};
const properties = schema.properties || {};
const validNames = Object.keys(properties);
const validList = validNames.length > 0 ? validNames.join(', ') : '(aucun)';
const unknown = Object.keys(args || {}).filter(key => !(key in properties));
if (unknown.length > 0) {
throw new Error(
`Paramètre(s) inconnu(s) pour ${definition.name} : ${unknown.join(', ')}. ` +
`Paramètres valides : ${validList}.`
);
}
const missing = (schema.required || []).filter(key => args?.[key] === undefined);
if (missing.length > 0) {
throw new Error(
`Paramètre(s) requis manquant(s) pour ${definition.name} : ${missing.join(', ')}. ` +
`Paramètres valides : ${validList}.`
);
}
}
// Create MCP Server
const server = new Server(
{
@@ -138,19 +200,10 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
* List all available tools
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
const allTools = [
...workflowTools.listTools(),
...wmsQueryTools.listTools(),
...apiTools.listTools(),
...logTools.listTools(),
...adTools.listTools(),
...metadataTools.listTools(),
...configTools.listTools(),
...profileTools.listTools(),
];
// Servi depuis la table de routage : la liste exposée et le dispatch ne
// peuvent pas diverger.
return {
tools: allTools,
tools: Array.from(toolRegistry.values(), entry => entry.definition),
};
});
@@ -164,37 +217,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
console.error(`[Server] Executing tool: ${name}`);
// Route to the appropriate handler based on tool name
if (name.startsWith('search_workflows') ||
name.startsWith('get_workflow_') ||
name.startsWith('list_workflow_')) {
return await workflowTools.executeTool(name, args);
} else if (name.startsWith('query_wms_') ||
name.startsWith('count_wms_') ||
name.startsWith('get_entity_') ||
name.startsWith('search_wms_')) {
return await wmsQueryTools.executeTool(name, args);
} else if (name.startsWith('call_query_api') ||
name.startsWith('execute_command')) {
return await apiTools.executeTool(name, args);
} else if (name.includes('_logs')) {
return await logTools.executeTool(name, args);
} else if (name.startsWith('get_application_') ||
name.startsWith('get_ad_') ||
name.startsWith('search_ad_') ||
name.startsWith('list_ad_')) {
return await adTools.executeTool(name, args);
} else if (name === 'get_entity_metadata' || name === 'generic_search') {
return await metadataTools.executeTool(name, args);
} else if (name === 'get_system_parameters') {
return await configTools.executeTool(name, args);
} else if (name === 'list_wms_profiles' ||
name === 'get_current_wms_profile' ||
name === 'switch_wms_profile') {
return await profileTools.executeTool(name, args);
} else {
throw new Error(`Unknown tool: ${name}`);
const entry = toolRegistry.get(name);
if (!entry) {
throw new Error(
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
);
}
validateToolArgs(entry.definition, args);
return await entry.module.executeTool(name, args);
} catch (error) {
console.error(`[Server] Error executing tool ${name}:`, error.message);
return {
+63 -98
View File
@@ -42,9 +42,13 @@ function getAPICatalog() {
The WMS provides several REST APIs for querying and modifying data.
**Base URL:** \`${process.env.WMS_API_BASE_URL || 'https://10.255.255.2/ApplicationService/api'}\`
**Base URL:** \`https://<host>/ApplicationService/api\` — built from the active
profile's host (see \`get_current_wms_profile\`).
**Authentication:** OAuth 2.0 Bearer Token (automatic)
The generated help page at \`https://<host>/ApplicationService/Help\` is the
authoritative reference for endpoints and fields.
---
## Query API
@@ -60,44 +64,55 @@ Execute LINQ queries against WMS entities.
\`\`\`json
{
"Application": "EasyWMS",
"QueryType": 1,
"Expression": "Context.{EntityType}.Select(z => z)"
"QueryType": 0,
"Expression": "Context.Products.Where(z => z.Code == \\"X\\").OrderBy(z => z.Id)",
"Take": 100
}
\`\`\`
### Supported Entity Types
Rules (see the query tools for details):
| Entity Type | Description |
|-------------|-------------|
| Products | Product references and SKUs |
| Containers | Pallets, boxes, and container types |
| Stocks | Available inventory by location |
| ProductLocations | Product placement in warehouse |
| Tasks | WMS tasks (picks, puts, moves, etc.) |
| Accounts | Customer accounts |
| Suppliers | Supplier information |
| Kits | Product kits and bundles |
| Aliases | Product aliases and alternative codes |
| InboundOrders | Inbound/receiving orders |
| Receptions | Actual receptions |
| OutboundOrders | Outbound/shipping orders |
- **\`QueryType: 0\` (Reading) is the default** — status fields are strings
(\`"Release"\`). \`QueryType: 1\` (Writing) exists but status fields become
enums there: string comparisons fail. Old examples using \`1\` must not be
copied. The query tools expose this as the opt-in \`query_type\` parameter.
- **\`Where\` and \`OrderBy\` go in the Expression; \`Take\`/\`Skip\` are API
parameters.** \`OrderBy\` is mandatory as soon as \`Take\` is used.
- **No \`Select\` projections** — the \`Select\` parameter causes server-side
compile errors. Query full rows.
- **No relative dates** (\`DateTime.Now\`, \`AddDays()\`) — write literal dates:
\`new DateTime(2026, 8, 1)\`.
### Example Queries
### Entity Types
Common entities: Products, Containers, Stocks, ProductLocations, Tasks,
Accounts, Suppliers, Kits, Alias (invariant — no plural form), InboundOrders,
Receptions, OutboundOrders.
**The authoritative list (288 entities) comes from \`get_entity_metadata\`**
(Metadata API) — entity names are resolved case-insensitively from the AD name
(Container) or the TableName (Containers).
### Example Expressions
\`\`\`
# Get all products (limited)
Context.Products.Take(100).Select(z => z)
# Filter + mandatory OrderBy (Take passed as API parameter, not in the expression)
Context.Products.Where(z => z.Code.Contains("ABC")).OrderBy(z => z.Id)
# Get specific fields
Context.Products.Select(z => new { z.Id, z.Code, z.Name })
# Filter and select
Context.Tasks.Where(z => z.Status == "Pending").Take(50).Select(z => z)
# Status comparison — strings in Reading (QueryType 0)
Context.OutboundOrders.Where(z => z.OutboundOrderStatus == "Release").OrderBy(z => z.Id)
\`\`\`
### MCP Tool
### Counting
Use \`call_query_api\` tool to execute queries.
**Endpoint:** \`/api/QueryScalarExecute\` — same body, expression ends with
\`.Count()\` / \`.Sum(...)\`. Prefer the \`count_wms_entities\` tool for any
"how many" question.
### MCP Tools
\`query_wms_entities\`, \`count_wms_entities\`, \`call_query_api\`,
\`get_entity_schema\`, \`search_wms_data\`.
---
@@ -114,31 +129,7 @@ Execute commands to modify WMS data.
\`\`\`json
[
{
"Name": "CommandName, Mecalux.ITSW.EasyWMS.Modules.Contracts",
"Properties": {
"PropertyName": "value"
}
}
]
\`\`\`
### Common Commands
| Command | Description |
|---------|-------------|
| ProductRemoveCommand | Remove a product |
| ProductUpdateCommand | Update product information |
| ContainerCreateCommand | Create a new container |
| TaskCancelCommand | Cancel a task |
| InboundOrderCancelCommandV2 | Cancel an inbound order |
| OutboundOrderCancelCommand | Cancel an outbound order |
### Example Command
\`\`\`json
[
{
"Name": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand, Mecalux.ITSW.EasyWMS.Modules.Contracts",
"Name": "Mecalux.ITSW.EasyWMS.Modules.MasterData.Contracts.Commands.ProductRemoveCommand",
"Properties": {
"Id": "product-guid-here"
}
@@ -146,6 +137,11 @@ Execute commands to modify WMS data.
]
\`\`\`
**\`Name\` is the \`InternalCommandName\` from the Application Dictionary, used
as-is.** Never append an assembly suffix (\`, Mecalux.ITSW...Contracts\`) — it
causes a \`FileLoadException\`. Retrieve the exact name via
\`get_ad_element_details\` before executing.
### MCP Tool
Use \`execute_command\` tool to execute commands.
@@ -154,7 +150,7 @@ Use \`execute_command\` tool to execute commands.
---
## Workflow API
## Workflow API (Application Dictionary)
Retrieve workflow definitions by application.
@@ -165,30 +161,23 @@ Retrieve workflow definitions by application.
### Request Format
\`\`\`json
["EasyWMS", "AD", 5000, 0]
["EasyWMS", "<tenant>", 5000, 0]
\`\`\`
Parameters:
1. Application name (e.g., "EasyWMS")
2. Tenant code (e.g., "AD")
3. Page size (e.g., 5000)
4. Offset (e.g., 0 for first page)
Parameters (positional): application name, tenant code, page size, offset.
### Response
Array of workflow objects with:
- Id, Code, Name
- Category, Description
- Version, Status
- Created, Modified
- Definition (JSON)
An envelope object \`{ "entities": [...] }\` — **not** a bare array. Each
workflow object carries lowercase keys: \`id\`, \`name\`, \`version\`,
\`applicationName\`, \`commonInfo\` (createdBy, createDate, updateDate). There
is no category, code or description field.
### MCP Tools
Use workflow tools to interact with workflows:
- \`search_workflows\` - Search by name, code, description
- \`search_workflows\` - Search by name
- \`get_workflow_details\` - Get full workflow definition
- \`list_workflow_categories\` - List all categories
- \`list_workflow_categories\` - List applications (workflows have no category field)
---
@@ -197,13 +186,13 @@ Use workflow tools to interact with workflows:
All APIs use OAuth 2.0 authentication.
**Token Endpoint:** \`/EasySTS/OAuth/Token\`
**Grant Types:** password, refresh_token
**Grant Types:** password, refresh_token (\`tenant_code\` is mandatory)
### Token Management
- Tokens expire after ~1200 seconds
- Automatic refresh when < 1000 seconds remaining
- Credentials configured in .env file
- Credentials come from the active profile (multi-profile \`.env\`)
The MCP server handles authentication automatically.
@@ -217,16 +206,10 @@ The MCP server handles authentication automatically.
- \`400\` - Bad request (invalid query/command)
- \`401\` - Unauthorized (token expired or invalid)
- \`403\` - Forbidden (insufficient permissions)
- \`500\` - Internal server error
- \`500\` - Internal server error (incl. LINQ compile errors)
### Error Response Format
\`\`\`json
{
"error": "Error message",
"details": "Detailed error information"
}
\`\`\`
The response body of a 500 carries the real diagnostic (e.g. the compile
error naming the context) — MCP tools surface it in their error messages.
---
@@ -238,24 +221,6 @@ The MCP server handles authentication automatically.
---
## Configuration
API settings are configured via environment variables:
\`\`\`env
WMS_API_BASE_URL=https://10.255.255.2/ApplicationService/api
WMS_API_TOKEN_URL=https://10.255.255.2/EasySTS/OAuth/Token
WMS_API_TENANT=AD
WMS_API_USERNAME=your-username
WMS_API_PASSWORD=your-password
WORKFLOW_API_BASE=https://10.255.255.2/AD/api
WORKFLOW_PAGE_SIZE=5000
MAX_QUERY_ROWS=1000
QUERY_TIMEOUT=30000
\`\`\`
---
**Note:** Use MCP tools to interact with these APIs. Direct API calls require proper authentication handling.
`;
}
+14
View File
@@ -42,6 +42,20 @@ function getQueryExamples() {
> (QueryType=Reading). Status/enum fields are **strings** (enum names), never integers.
> Always verify enum values via \`docs://entities/\` or \`get_entity_metadata\` before filtering.
## Entity names — singular AD name or TableName, both accepted
\`entity_type\` is resolved case-insensitively against the Metadata API: the AD
entity name (singular) and the TableName both work. The mapping is **not** a
pluralisation rule — only the Metadata \`TableName\` is authoritative:
\`\`\`
query_wms_entities(entity_type="Container") # AD name -> resolved to Containers
query_wms_entities(entity_type="Containers") # TableName -> used as-is
query_wms_entities(entity_type="Alias") # invariant: TableName IS "Alias" (no plural)
query_wms_entities(entity_type="Item") # fails fast: not in the Reading model,
# error lists close matches + get_entity_metadata
\`\`\`
---
## Diagnostic Recipes
+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 };
+114 -61
View File
@@ -6,12 +6,32 @@
const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager');
const { createSingleFlight } = require('./single-flight');
const { requireEntities } = require('./ad-envelope');
// Cache state - one cache per element type
// Cache state - one cache per (application, element type) (D26)
const cache = {};
const cacheTimestamps = {};
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour
// Déduplication des chargements concurrents, une clé par (application, type) (D27).
const singleFlight = createSingleFlight('AD');
/**
* Application effective : celle demandée, sinon celle du profil actif.
*/
function resolveApplication(application) {
return (application && application.trim()) || profileManager.getCurrent().application;
}
/**
* Clé de cache composite (D26) — sans elle, un appel CustomApp polluerait le
* cache EasyWMS du même type.
*/
function cacheKey(application, elementType) {
return `${application}::${elementType}`;
}
// Invalidate all caches when profile changes — AD elements are per-tenant.
profileManager.onSwitch(() => invalidateCache());
@@ -44,60 +64,87 @@ const AD_ELEMENT_TYPES = {
};
/**
* Check if cache is valid for a given element type
* Check if cache is valid for a given (application, element type)
*/
function isCacheValid(elementType) {
if (!cache[elementType] || !cacheTimestamps[elementType]) {
function isCacheValid(application, elementType) {
const key = cacheKey(application, elementType);
if (!cache[key] || !cacheTimestamps[key]) {
return false;
}
const now = Date.now();
const age = now - cacheTimestamps[elementType];
const age = now - cacheTimestamps[key];
return age < CACHE_TTL;
}
/**
* Get all elements of a specific type from AD API
* Implements lazy loading with caching and pagination
* Implements lazy loading with caching and pagination.
* Lazy par application (D26) : seule l'application demandée est chargée.
*
* @param {string} elementType - Type of element (Command, Query, Dialog, etc.)
* @param {string} [application] - Application AD (défaut : profil actif)
* @returns {Promise<Array>} Array of elements
*/
async function getElements(elementType) {
async function getElements(elementType, application) {
// Validate element type
if (!AD_ELEMENT_TYPES[elementType]) {
throw new Error(`Unknown element type: ${elementType}. Valid types: ${Object.keys(AD_ELEMENT_TYPES).join(', ')}`);
}
const app = resolveApplication(application);
const key = cacheKey(app, elementType);
// Check cache
if (isCacheValid(elementType)) {
console.error(`[AD] Cache hit: ${elementType} (${cache[elementType].length} elements)`);
return cache[elementType];
if (isCacheValid(app, elementType)) {
console.error(`[AD] Cache hit: ${key} (${cache[key].length} elements)`);
return cache[key];
}
console.error(`[AD] Cache expired or empty, fetching ${elementType}...`);
// Un seul chargement par (application, type), même sous rafale, et
// publication refusée si le cache a été invalidé pendant le fetch (D27).
return singleFlight.run(
key,
() => loadElements(app, elementType, key),
(elements) => {
cache[key] = elements;
cacheTimestamps[key] = Date.now();
console.error(`[AD] Successfully cached ${elements.length} ${key}`);
}
);
}
/**
* Chargement réel d'un (application, type) (pagination complète).
* Appelé au plus une fois par clé tant qu'il est en vol (D27).
* N'écrit RIEN en cache : la publication est le `commit` de singleFlight.run.
*/
async function loadElements(app, elementType, key) {
console.error(`[AD] Cache expired or empty, fetching ${key}...`);
try {
let allElements = [];
let offset = 0;
const pageSize = AD_ELEMENT_TYPES[elementType];
const profile = profileManager.getCurrent();
const application = profile.application;
const tenant = profile.tenant;
const tenant = profileManager.getCurrent().tenant;
while (true) {
const body = [application, tenant, pageSize, offset];
const body = [app, tenant, pageSize, offset];
console.error(`[AD] Fetching ${elementType}: offset=${offset}, pageSize=${pageSize}`);
console.error(`[AD] Fetching ${key}: offset=${offset}, pageSize=${pageSize}`);
// Use AD API (useAdApi=true)
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
// Extract entities array from response
const elements = response?.entities || [];
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
// faire passer pour une page vide (D27).
const elements = requireEntities(
response,
`${elementType}/GetByApplication (application "${app}", offset ${offset})`
);
// Check if response is valid
if (!elements || elements.length === 0) {
// Vide réel : fin de pagination (3 types sont valides mais vides, D17).
if (elements.length === 0) {
console.error(`[AD] No more ${elementType} to fetch`);
break;
}
@@ -114,15 +161,10 @@ async function getElements(elementType) {
offset += pageSize;
}
// Update cache
cache[elementType] = allElements;
cacheTimestamps[elementType] = Date.now();
console.error(`[AD] Successfully cached ${allElements.length} ${elementType}`);
return allElements;
} catch (error) {
console.error(`[AD] Error fetching ${elementType}:`, error.message);
throw new Error(`Failed to fetch ${elementType}: ${error.message}`);
console.error(`[AD] Error fetching ${key}:`, error.message);
throw new Error(`Failed to fetch ${elementType} for application "${app}": ${error.message}`);
}
}
@@ -131,9 +173,10 @@ async function getElements(elementType) {
* @param {string} elementType - Type of element
* @param {string} query - Search query (matches name, description, etc.)
* @param {number} limit - Maximum results to return
* @param {string} [application] - Application AD (défaut : profil actif)
*/
async function searchElements(elementType, query, limit = 50) {
const elements = await getElements(elementType);
async function searchElements(elementType, query, limit = 50, application) {
const elements = await getElements(elementType, application);
if (!query) {
return elements.slice(0, limit);
@@ -157,9 +200,10 @@ async function searchElements(elementType, query, limit = 50) {
* Get element details by ID or name
* @param {string} elementType - Type of element
* @param {string|number} elementId - Element ID or name
* @param {string} [application] - Application AD (défaut : profil actif)
*/
async function getElementDetails(elementType, elementId) {
const elements = await getElements(elementType);
async function getElementDetails(elementType, elementId, application) {
const elements = await getElements(elementType, application);
// Try to find by Id, id, Code, code, Name, or name
const element = elements.find(e =>
@@ -174,67 +218,75 @@ async function getElementDetails(elementType, elementId) {
);
if (!element) {
throw new Error(`${elementType} not found: ${elementId}`);
const app = resolveApplication(application);
throw new Error(
`${elementType} not found: ${elementId} (application "${app}"). ` +
`Utilisez search_ad_elements — pensez au paramètre application ` +
`(ex: "CustomApp" pour le spécifique client).`
);
}
return element;
}
/**
* Get application summary (count of each element type)
* Only loads types that are already cached to avoid long wait times
* Get application summary — état des caches par (application, type) (D26).
* Seules les entrées effectivement en cache sont détaillées, pour rester
* borné quel que soit le nombre d'applications interrogées (D24).
* @returns {Object} application -> type -> { count, cacheAge }
*/
function getApplicationSummary() {
const summary = {};
const byApplication = {};
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
if (cache[type]) {
summary[type] = {
count: cache[type].length,
cached: true,
cacheAge: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null
};
} else {
summary[type] = {
count: 0,
cached: false,
cacheAge: null
};
}
Object.keys(cache).forEach(key => {
const [app, type] = key.split('::');
if (!byApplication[app]) byApplication[app] = {};
byApplication[app][type] = {
count: cache[key].length,
cacheAge: cacheTimestamps[key] ? Math.floor((Date.now() - cacheTimestamps[key]) / 1000) : null
};
});
return summary;
return byApplication;
}
/**
* Invalidate cache for a specific type or all types
* Invalidate cache for a specific type (across all applications) or all types
*/
function invalidateCache(elementType = null) {
if (elementType) {
delete cache[elementType];
delete cacheTimestamps[elementType];
Object.keys(cache)
.filter(k => k.endsWith(`::${elementType}`))
.forEach(k => {
delete cache[k];
delete cacheTimestamps[k];
});
singleFlight.invalidate();
console.error(`[AD] Cache invalidated: ${elementType}`);
} else {
Object.keys(cache).forEach(k => {
delete cache[k];
delete cacheTimestamps[k];
});
singleFlight.invalidate();
console.error('[AD] All caches invalidated');
}
}
/**
* Get cache status
* Get cache status, par application puis type (D26)
*/
function getCacheStatus() {
const status = {};
Object.keys(AD_ELEMENT_TYPES).forEach(type => {
status[type] = {
cached: !!cache[type],
count: cache[type] ? cache[type].length : 0,
timestamp: cacheTimestamps[type],
age: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null,
valid: isCacheValid(type)
Object.keys(cache).forEach(key => {
const [app, type] = key.split('::');
if (!status[app]) status[app] = {};
status[app][type] = {
cached: true,
count: cache[key].length,
timestamp: cacheTimestamps[key],
age: cacheTimestamps[key] ? Math.floor((Date.now() - cacheTimestamps[key]) / 1000) : null,
valid: isCacheValid(app, type)
};
});
return status;
@@ -248,6 +300,7 @@ function getAvailableTypes() {
}
module.exports = {
resolveApplication,
getElements,
searchElements,
getElementDetails,
+126 -37
View File
@@ -77,8 +77,12 @@ class APIService {
console.error(`[API] Authentication successful. Token expires in ~${this.tokenMaxAge}s`);
return this.token;
} catch (error) {
console.error('[API] Authentication failed:', error.message);
throw new Error(`Authentication failed: ${error.message}`);
// Statut + corps de la réponse STS dans le message : c'est là que vit le
// diagnostic ("Tenant not found", ...). Payload volontairement omis — il
// contient les credentials ; _enrichHttpError n'inclut jamais les headers.
const enriched = this._enrichHttpError(error, 'POST', profile.tokenUrl, undefined);
console.error('[API] Authentication failed:', enriched.message);
throw new Error(`Authentication failed: ${enriched.message}`);
}
}
@@ -88,17 +92,17 @@ class APIService {
async refreshOAuthToken() {
const tokenAge = this.getTokenAge();
try {
// If token is too old (>= maxAge), use password grant
if (tokenAge >= this.tokenMaxAge) {
console.error('[API] Token too old, re-authenticating with password...');
return await this.authenticate();
}
// If token is too old (>= maxAge), use password grant
if (tokenAge >= this.tokenMaxAge) {
console.error('[API] Token too old, re-authenticating with password...');
return await this.authenticate();
}
const profile = profileManager.getCurrent();
try {
// Otherwise use refresh_token grant
console.error('[API] Refreshing token with refresh_token grant...');
const profile = profileManager.getCurrent();
const response = await this.httpClient.post(
profile.tokenUrl,
new URLSearchParams({
@@ -120,7 +124,10 @@ class APIService {
console.error('[API] Token refreshed successfully');
return this.token;
} catch (error) {
console.error('[API] Token refresh failed, re-authenticating:', error.message);
// Même enrichissement que authenticate() : statut + corps STS, sans le
// payload (refresh_token) ni les headers.
const enriched = this._enrichHttpError(error, 'POST', profile.tokenUrl, undefined);
console.error('[API] Token refresh failed, re-authenticating:', enriched.message);
return await this.authenticate();
}
}
@@ -136,6 +143,71 @@ class APIService {
}
}
/**
* Extract the useful part of an HTTP error response body.
* Structured WMS errors carry the diagnostic in Message / InnerException.Message —
* a full JSON.stringify would drown it in WatsonBuckets / HResult noise.
* @param {*} data - Response body (object, string, or anything axios parsed)
* @returns {string|null} Truncated human-readable body, or null if empty
*/
_describeResponseBody(data) {
const MAX_BODY_LENGTH = 2000;
if (data == null || data === '') return null;
if (typeof data === 'string') return data.slice(0, MAX_BODY_LENGTH);
if (typeof data === 'object') {
const parts = [];
if (data.ClassName) parts.push(data.ClassName);
if (data.Message) parts.push(data.Message);
let inner = data.InnerException;
while (inner && inner.Message) {
// AggregateException répète souvent le même message dans InnerException
if (inner.Message !== data.Message) parts.push(`Inner: ${inner.Message}`);
inner = inner.InnerException;
}
const text = parts.length > 0 ? parts.join(' — ') : JSON.stringify(data);
return text.slice(0, MAX_BODY_LENGTH);
}
return String(data).slice(0, MAX_BODY_LENGTH);
}
/**
* Build an enriched Error from a failed HTTP call: status, verb, full URL,
* request payload and response body. The WMS puts the real diagnostic
* (compile errors, unknown entity, ...) in the response body — without this,
* every failure reads "Request failed with status code 500".
* Never includes headers (Bearer token) — payloads passed through post/get
* carry no credentials.
* @param {Error} error - Original axios error
* @param {string} method - HTTP verb ('POST' | 'GET')
* @param {string} url - Full request URL
* @param {*} payload - Request body (POST) or query params (GET)
* @returns {Error} Enriched error (original kept in .cause, status in .status)
*/
_enrichHttpError(error, method, url, payload) {
const status = error.response?.status;
const parts = [`${method} ${url} failed${status != null ? ` (HTTP ${status})` : ''}: ${error.message}`];
if (payload !== undefined && payload !== null) {
let serialized;
try {
serialized = JSON.stringify(payload);
} catch {
serialized = String(payload);
}
if (serialized !== '{}') {
parts.push(`Request payload: ${serialized.slice(0, 1000)}`);
}
}
const body = this._describeResponseBody(error.response?.data);
if (body) parts.push(`Response body: ${body}`);
const enriched = new Error(parts.join('\n'));
enriched.status = status;
enriched.cause = error;
return enriched;
}
/**
* Make a POST request to WMS API
* @param {string} endpoint - API endpoint (e.g., '/QueryExecute' or '/AD/api/Workflow/GetByApplication')
@@ -162,25 +234,31 @@ class APIService {
return response.data;
} catch (error) {
console.error(`[API] Request failed: ${error.message}`);
// If unauthorized, try refreshing token and retry once
if (error.response?.status === 401) {
console.error('[API] Unauthorized, refreshing token and retrying...');
await this.refreshOAuthToken();
const retryResponse = await this.httpClient.post(url, data, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
try {
const retryResponse = await this.httpClient.post(url, data, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
return retryResponse.data;
return retryResponse.data;
} catch (retryError) {
const enrichedRetry = this._enrichHttpError(retryError, 'POST', url, data);
console.error(`[API] Retry after token refresh failed: ${enrichedRetry.message}`);
throw enrichedRetry;
}
}
throw error;
const enriched = this._enrichHttpError(error, 'POST', url, data);
console.error(`[API] Request failed: ${enriched.message}`);
throw enriched;
}
}
@@ -210,25 +288,31 @@ class APIService {
return response.data;
} catch (error) {
console.error(`[API] Request failed: ${error.message}`);
// If unauthorized, try refreshing token and retry once
if (error.response?.status === 401) {
console.error('[API] Unauthorized, refreshing token and retrying...');
await this.refreshOAuthToken();
const retryResponse = await this.httpClient.get(url, {
params,
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json'
}
});
try {
const retryResponse = await this.httpClient.get(url, {
params,
headers: {
'Authorization': `Bearer ${this.token}`,
'Accept': 'application/json'
}
});
return retryResponse.data;
return retryResponse.data;
} catch (retryError) {
const enrichedRetry = this._enrichHttpError(retryError, 'GET', url, params);
console.error(`[API] Retry after token refresh failed: ${enrichedRetry.message}`);
throw enrichedRetry;
}
}
throw error;
const enriched = this._enrichHttpError(error, 'GET', url, params);
console.error(`[API] Request failed: ${enriched.message}`);
throw enriched;
}
}
@@ -239,14 +323,17 @@ class APIService {
* e.g. "Context.OutboundOrders.Where(z => z.OutboundOrderStatus == \"Release\").OrderBy(z => z.Id)"
*
* @param {string} expression - LINQ expression (Context.Entity or Context.Entity.Where(...))
* @param {object} options - { take, skip, select, orderBy, inlineCount }
* @param {object} options - { take, skip, select, orderBy, inlineCount, queryType }
*/
async executeQuery(expression, options = {}) {
const { take, skip, select, orderBy, inlineCount } = options;
const { take, skip, select, orderBy, inlineCount, queryType } = options;
const body = {
Application: profileManager.getCurrent().application,
QueryType: 0, // Reading = 0 (status fields are strings), Writing = 1 (enums)
// Reading = 0 par défaut (statuts en chaînes) ; Writing/Metrics en
// opt-in explicite via query_type (D25) — la garde de valeur vit dans
// wms-query-service.assertValidQueryType, pas ici.
QueryType: queryType ?? 0,
Expression: expression,
};
@@ -279,11 +366,13 @@ class APIService {
* Execute a scalar LINQ query (Count, Sum, etc.) via QueryScalarExecute.
* Returns the scalar value directly.
* @param {string} fullExpression - e.g. "Context.OutboundOrders.Where(...).Count()"
* @param {object} options - { queryType }
*/
async executeScalarQuery(fullExpression) {
async executeScalarQuery(fullExpression, options = {}) {
const body = {
Application: profileManager.getCurrent().application,
QueryType: 0, // Reading = 0 — string enum names in filters (Writing=1 fails with enum comparisons)
// Reading = 0 par défaut — voir executeQuery / D25.
QueryType: options.queryType ?? 0,
Expression: fullExpression,
};
+227
View File
@@ -0,0 +1,227 @@
/**
* Entity Resolver Service
* Résout un nom d'entité (Name de l'AD ou TableName, insensible à la casse)
* vers le TableName attendu par Context.{...} dans les requêtes LINQ (D21).
*
* Le mapping n'est PAS une pluralisation (Container -> Containers, mais
* Alias -> Alias) : seul le TableName de l'API Metadata fait foi. Le contexte
* de lecture étant commun au tenant, la table agrège le Metadata de toutes
* les applications installées.
*/
const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager');
const { createSingleFlight } = require('./single-flight');
// Cache state — même TTL que les autres caches (D10)
let resolutionMap = null; // Map lower(Name | TableName) -> TableName
let tableNames = null; // TableName[] triés (suggestions + comptage)
let cacheTimestamp = null;
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000;
// Table unique : une seule clé de single-flight (D27).
const singleFlight = createSingleFlight('EntityResolver');
const METADATA_KEY = 'metadata';
// La table de résolution est par tenant — invalidée à chaque bascule (D8).
profileManager.onSwitch(() => invalidateCache());
function isCacheValid() {
if (!resolutionMap || !cacheTimestamp) return false;
return Date.now() - cacheTimestamp < CACHE_TTL;
}
/**
* Charge la table de résolution depuis l'API Metadata, agrégée sur toutes
* les applications installées.
* GET /configuration/applications ne liste que les applications déployées
* avec une version — les applications EasyBuilder sans contexte requêtable
* (CustomApp...) n'y figurent pas et ne fournissent de toute façon aucune
* entité Metadata.
*/
async function loadResolutionMap() {
if (isCacheValid()) return;
// Un seul chargement Metadata, même sous rafale concurrente (D27) : sans
// lui, 6 appels concurrents déclenchaient 6 chargements complets. La
// publication est refusée si le cache a été invalidé pendant le fetch.
await singleFlight.run(METADATA_KEY, fetchResolutionMap, (loaded) => {
resolutionMap = loaded.map;
tableNames = loaded.names;
cacheTimestamp = Date.now();
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${loaded.applicationCount} application(s)`);
});
}
/**
* Chargement réel de la table de résolution (D27). N'écrit rien en cache :
* la publication est le `commit` de singleFlight.run.
* @returns {Promise<{map: Map, names: string[], applicationCount: number}>}
*/
async function fetchResolutionMap() {
console.error('[EntityResolver] Cache expired or empty, fetching Metadata...');
const apps = await apiService.get('/configuration/applications');
const appNames = (Array.isArray(apps) ? apps : [])
.map(a => a.Name || a.name)
.filter(Boolean);
if (appNames.length === 0) {
throw new Error('GET /configuration/applications returned no application');
}
const map = new Map();
const names = new Set();
for (const app of appNames) {
const entities = await apiService.getMetadataEntities(app);
for (const e of (Array.isArray(entities) ? entities : [])) {
const tableName = e.TableName || e.tableName;
const name = e.Name || e.name;
if (!tableName) continue;
names.add(tableName);
map.set(tableName.toLowerCase(), tableName);
if (name) map.set(name.toLowerCase(), tableName);
}
}
if (names.size === 0) {
throw new Error('Metadata API returned no entity for any application');
}
return {
map,
names: Array.from(names).sort(),
applicationCount: appNames.length,
};
}
/**
* Distance de Levenshtein — uniquement pour suggérer des noms proches.
*/
function levenshtein(a, b) {
const m = a.length;
const n = b.length;
let prev = Array.from({ length: n + 1 }, (_, j) => j);
for (let i = 1; i <= m; i++) {
const curr = [i];
for (let j = 1; j <= n; j++) {
curr[j] = Math.min(
prev[j] + 1,
curr[j - 1] + 1,
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
);
}
prev = curr;
}
return prev[n];
}
/**
* Suggère les TableName les plus proches d'un nom inconnu :
* correspondances par sous-chaîne d'abord, puis distance d'édition.
*/
function suggestClosest(input, limit = 5) {
const lower = input.toLowerCase();
const scored = tableNames.map(tn => {
const l = tn.toLowerCase();
const score = (l.includes(lower) || lower.includes(l))
? Math.abs(l.length - lower.length) // sous-chaîne : quasi-match
: 100 + levenshtein(lower, l); // sinon : distance d'édition
return { tn, score };
});
scored.sort((a, b) => a.score - b.score || a.tn.localeCompare(b.tn));
const maxEditDistance = Math.max(3, Math.floor(lower.length / 2));
return scored
.filter(s => s.score < 100 + maxEditDistance)
.slice(0, limit)
.map(s => s.tn);
}
/**
* Résout un nom d'entité vers son TableName.
*
* @param {string} entityType - Name AD ou TableName, insensible à la casse
* @param {object} [options]
* @param {boolean} [options.allowUnknown=false] - Un nom inconnu du Reading
* passe tel quel avec un warning au lieu d'échouer. Utilisé quand
* query_type != 0 (D25) : la table est construite sur le Metadata Reading,
* or le modèle Writing/Metrics peut contenir des entités hors Reading.
* @returns {Promise<{tableName: string, warning?: string}>}
* - nom connu : { tableName } (le TableName exact)
* - Metadata injoignable : { tableName: entityType, warning } — on laisse
* passer le nom tel quel (comportement historique) plutôt que de tout
* bloquer, et on le dit dans la réponse
* @throws {Error} nom inconnu du modèle Reading (sauf allowUnknown) — AVANT
* tout appel réseau de requête, avec suggestions proches et renvoi vers
* get_entity_metadata
*/
async function resolveEntityType(entityType, options = {}) {
const { allowUnknown = false } = options;
if (!entityType || typeof entityType !== 'string' || entityType.trim() === '') {
throw new Error('entity_type est requis. Utilisez get_entity_metadata pour la liste des entités interrogeables.');
}
const trimmed = entityType.trim();
try {
await loadResolutionMap();
} catch (err) {
console.error(`[EntityResolver] Metadata unreachable, passing "${trimmed}" through as-is: ${err.message}`);
return {
tableName: trimmed,
warning: `Le nom d'entité "${trimmed}" n'a pas pu être validé (API Metadata injoignable : ${err.message}). Il est transmis tel quel au WMS.`,
};
}
const tableName = resolutionMap.get(trimmed.toLowerCase());
if (tableName) {
return { tableName };
}
const suggestions = suggestClosest(trimmed);
const closest = suggestions.length > 0 ? ` Proches : ${suggestions.join(', ')}.` : '';
if (allowUnknown) {
console.error(`[EntityResolver] "${trimmed}" unknown to Reading metadata, passing through (allowUnknown)`);
return {
tableName: trimmed,
warning: `"${trimmed}" est inconnu du modèle Reading (Metadata) ; il est transmis tel quel car query_type != 0 — le contexte demandé peut contenir des entités hors Reading.${closest}`,
};
}
throw new Error(
`"${trimmed}" n'existe pas dans le modèle Reading.${closest} ` +
`${tableNames.length} entités disponibles — utilisez get_entity_metadata pour la liste.`
);
}
/**
* Invalide la table de résolution (bascule de profil).
*/
function invalidateCache() {
resolutionMap = null;
tableNames = null;
cacheTimestamp = null;
// Les fetchs déjà partis ne repeupleront pas la table (D27).
singleFlight.invalidate();
console.error('[EntityResolver] Cache cleared');
}
/**
* État du cache (exposé par get_application_summary si besoin).
*/
function getCacheStatus() {
return {
cached: resolutionMap !== null,
count: tableNames ? tableNames.length : 0,
timestamp: cacheTimestamp,
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
valid: isCacheValid(),
};
}
module.exports = {
resolveEntityType,
invalidateCache,
getCacheStatus,
};
+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`…) là où la même ligne Reading fait ~4 500.
*
* Contrairement aux mécanismes de `get_system_parameters` et `search_logs`
* (locaux car différents, D24), les trois outils de requête partagent le même
* mécanisme — d'où ce module : on écarte des **lignes entières**, jamais
* coupées au milieu.
*/
const DEFAULT_MAX_RESPONSE_CHARS = 25000;
/**
* Plafond en caractères d'une réponse d'outil de requête.
* Même ordre de grandeur que `MAX_LOG_SEARCH_CHARS` (D24).
*/
function getMaxResponseChars() {
return parseInt(process.env.MAX_QUERY_RESPONSE_CHARS) || DEFAULT_MAX_RESPONSE_CHARS;
}
/**
* Avertissement de volume propre aux contextes non-Reading (D25). Repris tel
* quel dans les hints et dans la description du paramètre `query_type`.
*/
const WRITING_VOLUME_NOTE =
'En query_type != 0, une ligne est un agrégat complet sérialisé (navigations, $id…) : ' +
'95 288 caractères mesurés pour UNE seule ligne Products en Writing, contre ~4 500 en Reading. ' +
'Repassez en query_type: 0 si le modèle Reading suffit.';
/**
* Trouve le plus grand nombre d'éléments dont la réponse tient sous le plafond.
*
* @param {number} total - nombre d'éléments disponibles
* @param {(kept: number) => string} buildText - construit la réponse sérialisée
* pour `kept` éléments. Doit être croissante en `kept` et porter
* elle-même les champs de troncature quand `kept < total`.
* @returns {{ text: string, kept: number, truncated: boolean, cap: number }}
*/
function fitToCap(total, buildText) {
const cap = getMaxResponseChars();
const full = buildText(total);
if (full.length <= cap) {
return { text: full, kept: total, truncated: false, cap };
}
// Recherche dichotomique : ~8 constructions pour 200 lignes, là où un retrait
// ligne à ligne en ferait 200 sur des charges utiles de ~1 Mo.
let lo = 0;
let hi = total - 1;
let best = -1;
let bestText = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const text = buildText(mid);
if (text.length <= cap) {
best = mid;
bestText = text;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// Cas limite réel en Writing : une seule ligne dépasse déjà le plafond. On
// renvoie l'enveloppe vide et signalée — moins bon qu'un résultat, mais mieux
// qu'un rejet client opaque.
if (best < 0) {
best = 0;
bestText = buildText(0);
}
return { text: bestText, kept: best, truncated: true, cap };
}
/**
* Champs de troncature communs aux outils de requête — vocabulaire D24 exact
* (`truncated`, `returned`, `omitted`, `hint`). Le total avant la coupe est
* ajouté par l'appelant : `query_wms_entities` et `search_wms_data` le portent
* déjà (`count`, `totalFound`), `call_query_api` non.
*
* @param {number} returned - éléments effectivement renvoyés
* @param {number} total - éléments disponibles avant la coupe
* @param {number} queryType - QueryContextType de l'appel (D25)
* @param {string} unit - nom de l'unité écartée, au singulier ('ligne', 'résultat')
* @param {boolean} [feminine] - accord du hint sur `unit` ('ligne' est féminin)
* @param {string} [extraHint] - phrase supplémentaire propre à l'outil
*/
function truncationSignal({ returned, total, queryType = 0, unit, feminine = false, extraHint }) {
const cap = getMaxResponseChars();
const omitted = total - returned;
const plafond = `Plafond de taille de réponse atteint (${cap} caractères, MAX_QUERY_RESPONSE_CHARS)`;
const e = feminine ? 'e' : '';
const aucun = feminine ? 'Aucune' : 'Aucun';
const unSeul = feminine ? 'une seule' : 'un seul';
const entiers = feminine ? 'entières' : 'entiers';
// Cas limite réel en Writing : même une seule ligne dépasse le plafond.
let hint = returned === 0
? `${plafond} : ${aucun} ${unit} ne tient dans la réponse — ${unSeul} ${unit} dépasse déjà le plafond ` +
`à ${feminine ? 'elle' : 'lui'} seul${e}. Restreignez la requête (filter plus étroit, autre entité) : ` +
`le contenu n'est pas coupé au milieu, il est écarté en entier.`
: `${plafond} : ${returned} ${unit}(s) renvoyé${e}(s) sur ${total}, ${omitted} écarté${e}(s) — des ` +
`${unit}s ${entiers}, jamais coupé${e}s au milieu. Réduisez limit ou ajoutez un filter pour cibler.`;
if (extraHint) hint += ` ${extraHint}`;
if (queryType) hint += ` ${WRITING_VOLUME_NOTE}`;
return { truncated: true, returned, omitted, hint };
}
module.exports = {
getMaxResponseChars,
fitToCap,
truncationSignal,
WRITING_VOLUME_NOTE,
DEFAULT_MAX_RESPONSE_CHARS,
};
+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 };
+63 -9
View File
@@ -4,6 +4,28 @@
*/
const apiService = require('./api-service').getInstance();
const entityResolver = require('./entity-resolver');
/**
* Garde de valeur de query_type (D25). Le wrapper D23 valide les noms de
* paramètres, pas les valeurs — cette garde s'exécute AVANT tout appel réseau
* (y compris la résolution d'entité) et nomme les quatre contextes.
* @param {*} queryType - valeur reçue de l'outil (défaut 0 si absent)
* @returns {number} la valeur validée
*/
function assertValidQueryType(queryType) {
if (queryType == null) return 0;
if (!Number.isInteger(queryType) || queryType < 0 || queryType > 3) {
throw new Error(
`query_type invalide : ${JSON.stringify(queryType)}. Valeurs acceptées : ` +
`0 = Reading (défaut — statuts en chaînes, ex. "Release"), ` +
`1 = Writing (statuts en énumérations : les comparaisons de chaînes échouent), ` +
`2 = DataWarehouse (souvent non configuré), ` +
`3 = Metrics (modèle de données distinct).`
);
}
return queryType;
}
/**
* Build a LINQ select expression
@@ -18,15 +40,27 @@ const apiService = require('./api-service').getInstance();
* @param {string} selectExpression - LINQ select expression
* @param {string|null} filter - Optional filter
* @param {number} limit - Result limit
* @param {number} queryType - QueryContextType (0 = Reading par défaut, D25)
*/
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100) {
async function queryEntities(entityType, selectExpression = 'z => z', filter = null, limit = 100, queryType = 0) {
// Garde de valeur avant tout réseau (D25).
queryType = assertValidQueryType(queryType);
// Résolution Name/TableName -> TableName (D21). Un nom inconnu échoue ici,
// avant tout appel réseau de requête — l'erreur porte les suggestions.
// En query_type != 0, un nom hors Reading passe tel quel avec warning : le
// modèle Writing/Metrics peut contenir des entités hors Reading (D25).
const { tableName, warning } = await entityResolver.resolveEntityType(entityType, {
allowUnknown: queryType !== 0,
});
try {
// Enforce max limit
const maxLimit = parseInt(process.env.MAX_QUERY_ROWS) || 1000;
const actualLimit = Math.min(limit, maxLimit);
// Build expression: Context + optional Where + OrderBy (required by EF when Take is used)
let expression = `Context.${entityType}`;
let expression = `Context.${tableName}`;
if (filter) {
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
expression += `.Where(${whereExpr})`;
@@ -34,15 +68,19 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n
// OrderBy must be embedded in the expression (not as a separate API param)
expression += `.OrderBy(z => z.Id)`;
console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression}`);
console.error(`[WMSQuery] Querying ${entityType}: ${expression} | take=${actualLimit} select=${selectExpression} queryType=${queryType}`);
const result = await apiService.executeQuery(expression, {
take: actualLimit,
select: selectExpression !== 'z => z' ? selectExpression : undefined,
queryType,
});
return {
entityType,
resolvedTableName: tableName,
...(warning ? { warning } : {}),
...(queryType !== 0 ? { queryType } : {}),
expression,
limit: actualLimit,
count: Array.isArray(result) ? result.length : 0,
@@ -50,7 +88,9 @@ async function queryEntities(entityType, selectExpression = 'z => z', filter = n
};
} catch (error) {
console.error(`[WMSQuery] Query failed:`, error.message);
throw new Error(`Query failed for ${entityType}: ${error.message}`);
// Le warning de résolution (nom hors Reading en query_type != 0) reste
// visible même quand le WMS échoue ensuite.
throw new Error(`Query failed for ${entityType}: ${error.message}${warning ? `\nWarning: ${warning}` : ''}`);
}
}
@@ -142,11 +182,21 @@ async function getEntitySchema(entityType) {
* Count entities with optional filter
* @param {string} entityType - Entity type
* @param {string|null} filter - Optional filter
* @param {number} queryType - QueryContextType (0 = Reading par défaut, D25)
*/
async function countEntities(entityType, filter = null) {
async function countEntities(entityType, filter = null, queryType = 0) {
// Garde de valeur avant tout réseau (D25).
queryType = assertValidQueryType(queryType);
// Résolution Name/TableName -> TableName (D21) — échec avant appel réseau
// sur nom inconnu, sauf en query_type != 0 (passage tel quel + warning, D25).
const { tableName, warning } = await entityResolver.resolveEntityType(entityType, {
allowUnknown: queryType !== 0,
});
try {
// Build: Context.Entity.Where(...).Count()
const parts = [`Context.${entityType}`];
const parts = [`Context.${tableName}`];
if (filter) {
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
parts.push(`Where(${whereExpr})`);
@@ -154,18 +204,21 @@ async function countEntities(entityType, filter = null) {
parts.push('Count()');
const fullExpression = parts.join('.');
console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression}`);
console.error(`[WMSQuery] Counting ${entityType}: ${fullExpression} | queryType=${queryType}`);
const count = await apiService.executeScalarQuery(fullExpression);
const count = await apiService.executeScalarQuery(fullExpression, { queryType });
return {
entityType,
resolvedTableName: tableName,
...(warning ? { warning } : {}),
...(queryType !== 0 ? { queryType } : {}),
filter,
count
};
} catch (error) {
console.error(`[WMSQuery] Count failed:`, error.message);
throw new Error(`Count failed for ${entityType}: ${error.message}`);
throw new Error(`Count failed for ${entityType}: ${error.message}${warning ? `\nWarning: ${warning}` : ''}`);
}
}
@@ -175,4 +228,5 @@ module.exports = {
searchEntities,
getEntitySchema,
countEntities,
assertValidQueryType,
};
+213 -101
View File
@@ -2,67 +2,114 @@
* Workflow Service
* Handles workflow fetching with lazy loading and caching
* Workflows are only loaded when first requested (not at startup)
*
* Un cache par application (D26) : le paramètre `application` des outils
* sélectionne l'application AD interrogée (défaut : celle du profil actif).
*/
const apiService = require('./api-service').getInstance();
const profileManager = require('../config/profile-manager');
const { createSingleFlight } = require('./single-flight');
const { requireEntities } = require('./ad-envelope');
// Cache state
let workflowCache = null;
let cacheTimestamp = null;
// Cache state — un cache de workflows par application (D26)
let workflowCaches = {}; // application -> workflows[]
let cacheTimestamps = {}; // application -> timestamp
let applicationsCache = null; // liste allégée de POST /Application/GetAll
let applicationsTimestamp = null;
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds
// Déduplication des chargements concurrents, par clé de cache (D27). Deux
// clés distinctes ici : une par application, plus la liste d'applications.
const singleFlight = createSingleFlight('Workflow');
// Clear cache when profile changes — workflows are per-tenant, so the previous
// profile's cache is meaningless after a switch.
profileManager.onSwitch(() => clearCache());
/**
* Check if cache is still valid
* Application effective : celle demandée, sinon celle du profil actif.
*/
function isCacheValid() {
if (!workflowCache || !cacheTimestamp) {
function resolveApplication(application) {
return (application && application.trim()) || profileManager.getCurrent().application;
}
/**
* Check if cache is still valid for an application
*/
function isCacheValid(application) {
if (!workflowCaches[application] || !cacheTimestamps[application]) {
return false;
}
const now = Date.now();
const age = now - cacheTimestamp;
const age = Date.now() - cacheTimestamps[application];
return age < CACHE_TTL;
}
/**
* Fetch all workflows from API with pagination
* Uses high page size (5000) to minimize API calls
* Fetch all workflows of an application from API with pagination.
* Uses high page size (5000) to minimize API calls.
* Lazy : seule l'application effectivement demandée est chargée (D26) — ne
* jamais précharger les 9 applications.
* @param {string} [application] - Application AD (défaut : profil actif)
*/
async function fetchAllWorkflows() {
async function fetchAllWorkflows(application) {
const app = resolveApplication(application);
// Check cache validity
if (isCacheValid()) {
console.error('[Workflow] Using cached data');
return workflowCache;
if (isCacheValid(app)) {
console.error(`[Workflow] Using cached data for "${app}"`);
return workflowCaches[app];
}
console.error('[Workflow] Cache expired or empty, fetching from API...');
// Un seul chargement par application, même sous rafale concurrente, et
// publication en cache seulement si aucune invalidation n'est survenue
// pendant le fetch (D27).
return singleFlight.run(
`workflows::${app}`,
() => loadWorkflows(app),
(workflows) => {
workflowCaches[app] = workflows;
cacheTimestamps[app] = Date.now();
console.error(`[Workflow] Successfully cached ${workflows.length} workflows for "${app}"`);
}
);
}
/**
* Chargement réel des workflows d'une application (pagination complète).
* Appelé au plus une fois par application tant qu'il est en vol (D27).
* N'écrit RIEN en cache : la publication est le `commit` de singleFlight.run,
* qui la refuse si le cache a été invalidé entre-temps.
*/
async function loadWorkflows(app) {
console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`);
try {
let allWorkflows = [];
let offset = 0;
const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000;
const profile = profileManager.getCurrent();
const application = profile.application;
const tenant = profile.tenant;
const tenant = profileManager.getCurrent().tenant;
while (true) {
const body = [application, tenant, pageSize, offset];
const body = [app, tenant, pageSize, offset];
console.error(`[Workflow] Fetching page: offset=${offset}, pageSize=${pageSize}`);
console.error(`[Workflow] Fetching page: application=${app}, offset=${offset}, pageSize=${pageSize}`);
// Use AD API (useAdApi=true)
const response = await apiService.post('/Workflow/GetByApplication', body, true);
// Extract entities array from response
const workflows = response?.entities || [];
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
// faire passer pour une page vide (D27) : un cache vide empoisonné
// durerait tout le TTL.
const workflows = requireEntities(
response,
`Workflow/GetByApplication (application "${app}", offset ${offset})`
);
// Check if response is valid
if (!workflows || workflows.length === 0) {
// Vide réel : fin de pagination (une application peut n'avoir aucun
// workflow — SmartUI, D26).
if (workflows.length === 0) {
console.error('[Workflow] No more workflows to fetch');
break;
}
@@ -79,26 +126,102 @@ async function fetchAllWorkflows() {
offset += pageSize;
}
// Update cache
workflowCache = allWorkflows;
cacheTimestamp = Date.now();
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows`);
return allWorkflows;
} catch (error) {
console.error('[Workflow] Error fetching workflows:', error.message);
throw new Error(`Failed to fetch workflows: ${error.message}`);
console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message);
throw new Error(`Failed to fetch workflows for application "${app}": ${error.message}`);
}
}
/**
* Search workflows by query string
* @param {string} query - Search query (matches name, description, etc.)
* @param {string|null} category - Optional category filter
* @param {number} limit - Maximum results to return
* Liste les applications déclarées (POST /Application/GetAll, payload null).
* La réponse est une enveloppe { entities: [...] } (D4) dont chaque élément
* porte un blob `data` volumineux — on ne conserve que les champs légers.
* Cache TTL commun, vidé au switch de profil.
* @returns {Promise<Array<{name: string, id: string, version: number}>>}
*/
async function searchWorkflows(query, category = null, limit = 50) {
const workflows = await fetchAllWorkflows();
async function fetchApplications() {
const cached = getCachedApplications();
if (cached) return cached;
// Même déduplication et même garde de génération, sur sa propre clé (D27).
return singleFlight.run('applications', loadApplications, (applications) => {
applicationsCache = applications;
applicationsTimestamp = Date.now();
console.error(`[Workflow] Cached ${applications.length} application(s)`);
});
}
/**
* Chargement réel de la liste d'applications (D27). N'écrit rien en cache.
*/
async function loadApplications() {
console.error('[Workflow] Fetching application list (Application/GetAll)...');
const response = await apiService.post('/Application/GetAll', null, true);
const entities = requireEntities(response, 'Application/GetAll');
return entities.map(a => ({
name: a.name || a.Name,
id: a.id || a.Id,
version: a.version ?? a.Version,
})).filter(a => a.name);
}
/**
* Liste des applications déjà en cache, ou null si le cache est vide/expiré.
* Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse
* de recherche sans jamais précharger une application non demandée (D26).
* @returns {Array<{name: string, id: string, version: number}>|null}
*/
function getCachedApplications() {
if (applicationsCache && applicationsTimestamp &&
Date.now() - applicationsTimestamp < CACHE_TTL) {
return applicationsCache;
}
return null;
}
/**
* Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application
* sur les neuf déclarées, et rien dans la réponse ne le disait : une session
* cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à
* tort qu'il n'y en avait aucun (25/08/2026).
*
* Les autres applications sont nommées depuis la liste allégée **déjà en
* cache** ; sans elle, le hint reste générique et renvoie vers
* `list_workflow_categories` — jamais de fetch pour construire un hint.
*
* @param {string} application - application effectivement interrogée
* @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…)
*/
function buildOtherApplicationsHint(application, sujet) {
const cached = getCachedApplications();
const others = (cached || []).map(a => a.name).filter(n => n !== application);
const liste = others.length
? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.`
: `Appelez list_workflow_categories pour lister les autres applications déclarées.`;
const custom = application.toLowerCase() === 'customapp'
? ''
: ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`;
return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` +
`les autres ne le sont jamais implicitement.${custom} ${liste}`;
}
/**
* Search workflows by query string.
* Real AD keys (lowercase, cf. D5): id, name, version, applicationName,
* commonInfo — no description/code/category field exists.
* @param {string} query - Search query (matches workflow name)
* @param {string|null} category - Optional applicationName filter (the only
* grouping the AD API provides)
* @param {number} limit - Maximum results to return
* @param {string} [application] - Application AD interrogée (défaut : profil)
*/
async function searchWorkflows(query, category = null, limit = 50, application) {
const workflows = await fetchAllWorkflows(application);
let results = workflows;
@@ -107,21 +230,16 @@ async function searchWorkflows(query, category = null, limit = 50) {
const lowerQuery = query.toLowerCase();
results = results.filter(w => {
const name = (w.name || w.Name || '').toLowerCase();
const description = (w.description || w.Description || '').toLowerCase();
const code = (w.code || w.Code || '').toLowerCase();
return name.includes(lowerQuery) ||
description.includes(lowerQuery) ||
code.includes(lowerQuery);
return name.includes(lowerQuery);
});
}
// Filter by category if provided
// Filter by applicationName if provided
if (category) {
const lowerCategory = category.toLowerCase();
results = results.filter(w => {
const wfCategory = (w.category || w.Category || '').toLowerCase();
return wfCategory.includes(lowerCategory);
const applicationName = (w.applicationName || w.ApplicationName || '').toLowerCase();
return applicationName.includes(lowerCategory);
});
}
@@ -132,97 +250,91 @@ async function searchWorkflows(query, category = null, limit = 50) {
/**
* Get workflow details by ID
* @param {string|number} workflowId - Workflow ID
* @param {string} [application] - Application AD interrogée (défaut : profil)
*/
async function getWorkflowDetails(workflowId) {
const workflows = await fetchAllWorkflows();
async function getWorkflowDetails(workflowId, application) {
// Garde d'entrée : sans elle, un workflow_id absent matchait le premier
// workflow du cache (undefined === undefined sur les clés mortes ci-dessous).
if (workflowId == null || workflowId === '') {
throw new Error('workflow_id est requis (id ou nom exact du workflow). Utilisez search_workflows pour le trouver.');
}
// Try to find by Id, id, Code, code, Name, or name
const app = resolveApplication(application);
const workflows = await fetchAllWorkflows(app);
// Clés réelles de l'API AD (minuscules, D5) : id, name. Les variantes
// Id/Code/Name n'existent pas sur ces objets — les comparer faisait matcher
// undefined === undefined dès que workflow_id manquait.
const workflow = workflows.find(w =>
w.id === workflowId ||
w.Id === workflowId ||
w.id === parseInt(workflowId) ||
w.Id === parseInt(workflowId) ||
w.Code === workflowId ||
w.code === workflowId ||
w.Name === workflowId ||
w.name === workflowId
);
if (!workflow) {
throw new Error(`Workflow not found: ${workflowId}`);
throw new Error(
`Workflow not found: ${workflowId} (application "${app}"). ` +
`Utilisez search_workflows pour trouver l'id ou le nom exact — ` +
`pensez au paramètre application (ex: "CustomApp" pour le spécifique client).`
);
}
return workflow;
}
/**
* List all workflow categories
* Get workflow statistics for one application
* @param {string} [application] - Application AD interrogée (défaut : profil)
*/
async function listWorkflowCategories() {
const workflows = await fetchAllWorkflows();
// Extract unique categories (try both lowercase and uppercase)
const categories = new Set();
workflows.forEach(w => {
const category = w.category || w.Category;
if (category) {
categories.add(category);
}
});
// Sort alphabetically
return Array.from(categories).sort();
}
/**
* Get workflow statistics
*/
async function getWorkflowStats() {
const workflows = await fetchAllWorkflows();
const categories = await listWorkflowCategories();
// Count workflows per category
const categoryCounts = {};
workflows.forEach(w => {
const cat = w.category || w.Category || 'Uncategorized';
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
});
async function getWorkflowStats(application) {
const app = resolveApplication(application);
const workflows = await fetchAllWorkflows(app);
return {
application: app,
total: workflows.length,
categories: categories.length,
categoryCounts,
cacheAge: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null
cacheAge: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null
};
}
/**
* Clear workflow cache (force refresh on next request)
* Clear workflow caches (force refresh on next request) — toutes applications.
*/
function clearCache() {
workflowCache = null;
cacheTimestamp = null;
workflowCaches = {};
cacheTimestamps = {};
applicationsCache = null;
applicationsTimestamp = null;
// Les fetchs déjà partis ne repeupleront pas ce cache (D27).
singleFlight.invalidate();
console.error('[Workflow] Cache cleared');
}
/**
* Get cache status
* Get cache status, per application (D26)
* @returns {Object} application -> { cached, count, timestamp, age, valid }
*/
function getCacheStatus() {
return {
cached: workflowCache !== null,
count: workflowCache ? workflowCache.length : 0,
timestamp: cacheTimestamp,
age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null,
valid: isCacheValid()
};
const status = {};
Object.keys(workflowCaches).forEach(app => {
status[app] = {
cached: true,
count: workflowCaches[app].length,
timestamp: cacheTimestamps[app],
age: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null,
valid: isCacheValid(app)
};
});
return status;
}
module.exports = {
fetchAllWorkflows,
fetchApplications,
getCachedApplications,
buildOtherApplicationsHint,
resolveApplication,
searchWorkflows,
getWorkflowDetails,
listWorkflowCategories,
getWorkflowStats,
clearCache,
getCacheStatus
+61 -31
View File
@@ -4,6 +4,7 @@
*/
const adService = require('../services/ad-service');
const workflowService = require('../services/workflow-service');
/**
* List available AD tools
@@ -12,17 +13,19 @@ function listTools() {
return [
{
name: 'get_application_summary',
description: 'Get summary of Application Dictionary elements. Shows count of cached elements per type (Commands, Queries, Dialogs, Views, etc.). Only counts already-loaded types to avoid long waits.',
description: 'Get summary of Application Dictionary caches, grouped by application then element type (D26), plus the per-application workflow caches. Only already-loaded entries are detailed to avoid long waits.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
{
name: 'get_ad_elements',
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour.',
description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour, per (application, type).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
element_type: {
type: 'string',
@@ -33,6 +36,10 @@ function listTools() {
description: 'Maximum number of elements to return (default: 100, max: 1000)',
default: 100,
},
application: {
type: 'string',
description: 'AD application to query (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix). Full list via list_workflow_categories.',
},
},
required: ['element_type'],
},
@@ -42,6 +49,7 @@ function listTools() {
description: 'Search Application Dictionary elements by name, description, or code. Searches within a specific element type.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
element_type: {
type: 'string',
@@ -56,6 +64,10 @@ function listTools() {
description: 'Maximum results (default: 50)',
default: 50,
},
application: {
type: 'string',
description: 'AD application to search in (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix).',
},
},
required: ['element_type', 'query'],
},
@@ -65,6 +77,7 @@ function listTools() {
description: 'Get detailed information about a specific AD element by ID or name',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
element_type: {
type: 'string',
@@ -74,6 +87,10 @@ function listTools() {
type: 'string',
description: 'Element ID or name',
},
application: {
type: 'string',
description: 'AD application the element belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix).',
},
},
required: ['element_type', 'element_id'],
},
@@ -83,6 +100,7 @@ function listTools() {
description: 'List all available Application Dictionary element types',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
@@ -135,38 +153,37 @@ async function executeTool(name, args) {
async function getApplicationSummaryTool(args) {
console.error('[ADTools] Getting application summary');
const summary = adService.getApplicationSummary();
// État par (application, type) — seules les entrées en cache sont
// détaillées, la sortie reste bornée quel que soit le nombre
// d'applications interrogées (D24, D26).
const adByApplication = adService.getApplicationSummary();
const workflowsByApplication = workflowService.getCacheStatus();
// Calculate totals
let totalCached = 0;
let cachedEntries = 0;
let totalElements = 0;
const cachedTypes = [];
const uncachedTypes = [];
Object.entries(summary).forEach(([type, info]) => {
if (info.cached) {
totalCached++;
Object.values(adByApplication).forEach(types => {
Object.values(types).forEach(info => {
cachedEntries++;
totalElements += info.count;
cachedTypes.push(type);
} else {
uncachedTypes.push(type);
}
});
});
const availableTypes = adService.getAvailableTypes();
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
summary: {
totalTypes: Object.keys(summary).length,
cachedTypes: totalCached,
uncachedTypes: uncachedTypes.length,
totalElements: totalElements
availableTypes: availableTypes.length,
cachedEntries,
totalElements,
applications: Object.keys(adByApplication)
},
elementCounts: summary,
cached: cachedTypes,
notCached: uncachedTypes
adElementsByApplication: adByApplication,
workflowCachesByApplication: workflowsByApplication,
note: 'Caches AD par (application, type) et caches workflows par application — chargés paresseusement à la première demande. Types valides via list_ad_types.'
}, null, 2)
}]
};
@@ -176,11 +193,11 @@ async function getApplicationSummaryTool(args) {
* Tool: get_ad_elements
*/
async function getADElementsTool(args) {
const { element_type, limit = 100 } = args;
const { element_type, limit = 100, application } = args;
console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit})`);
console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit}, application: ${application || '(profil)'})`);
const elements = await adService.getElements(element_type);
const elements = await adService.getElements(element_type, application);
// Limit results
const limitedElements = elements.slice(0, Math.min(limit, 1000));
@@ -203,6 +220,7 @@ async function getADElementsTool(args) {
text: JSON.stringify({
success: true,
elementType: element_type,
...(application ? { application } : {}),
count: elements.length,
returned: mappedElements.length,
elements: mappedElements
@@ -215,11 +233,11 @@ async function getADElementsTool(args) {
* Tool: search_ad_elements
*/
async function searchADElementsTool(args) {
const { element_type, query, limit = 50 } = args;
const { element_type, query, limit = 50, application } = args;
console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}`);
console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}, application=${application || '(profil)'}`);
const results = await adService.searchElements(element_type, query, limit);
const results = await adService.searchElements(element_type, query, limit, application);
// Map to simplified format
const mappedResults = results.map(e => ({
@@ -229,14 +247,25 @@ async function searchADElementsTool(args) {
code: e.code || e.Code
}));
// L5.4 : même correctif que search_workflows — l'application interrogée est
// toujours rappelée, et un résultat vide signale que les huit autres n'ont
// pas été regardées. Le hint se construit depuis la liste d'applications
// DÉJÀ en cache : aucun appel réseau, aucun préchargement (D26).
const effectiveApplication = adService.resolveApplication(application);
const hint = mappedResults.length === 0
? workflowService.buildOtherApplicationsHint(effectiveApplication, `élément ${element_type}`)
: null;
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
elementType: element_type,
application: effectiveApplication,
query,
count: mappedResults.length,
...(hint ? { hint } : {}),
elements: mappedResults
}, null, 2)
}]
@@ -247,11 +276,11 @@ async function searchADElementsTool(args) {
* Tool: get_ad_element_details
*/
async function getADElementDetailsTool(args) {
const { element_type, element_id } = args;
const { element_type, element_id, application } = args;
console.error(`[ADTools] Getting ${element_type} details: ${element_id}`);
console.error(`[ADTools] Getting ${element_type} details: ${element_id} (application: ${application || '(profil)'})`);
const element = await adService.getElementDetails(element_type, element_id);
const element = await adService.getElementDetails(element_type, element_id, application);
return {
content: [{
@@ -259,6 +288,7 @@ async function getADElementDetailsTool(args) {
text: JSON.stringify({
success: true,
elementType: element_type,
...(application ? { application } : {}),
element
}, null, 2)
}]
+67 -18
View File
@@ -1,4 +1,7 @@
const apiService = require('../services/api-service').getInstance();
const entityResolver = require('../services/entity-resolver');
const { assertValidQueryType } = require('../services/wms-query-service');
const { fitToCap, truncationSignal } = require('../services/response-limit');
/**
* Tools MCP pour interagir avec les APIs WMS
@@ -15,10 +18,11 @@ function listTools() {
description: 'Appelle l\'API Query du WMS pour interroger des entités (Containers, Stocks, Tasks, Products, etc.)',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entity_type: {
type: 'string',
description: 'Type d\'entité (Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Aliases, InboundOrders, Receptions, OutboundOrders)',
description: 'Type d\'entité — nom AD (Container) ou TableName (Containers), insensible à la casse, résolu via l\'API Metadata. Ex: Containers, Stocks, ProductLocations, Tasks, Products, Accounts, Suppliers, Kits, Alias, InboundOrders, Receptions, OutboundOrders. Liste complète via get_entity_metadata.',
},
expression: {
type: 'string',
@@ -34,6 +38,11 @@ function listTools() {
description: 'Limite de résultats (défaut: 100)',
default: 100,
},
query_type: {
type: 'number',
description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning. 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,
},
},
required: ['entity_type'],
},
@@ -43,6 +52,7 @@ function listTools() {
description: 'Exécute une commande WMS (ATTENTION: peut modifier des données). Toujours récupérer la commande via get_ad_elements/get_ad_element_details avant d\'exécuter.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
command_name: {
type: 'string',
@@ -82,11 +92,26 @@ async function executeTool(name, args) {
* Tool: call_query_api
*/
async function callQueryAPI(args) {
const { entity_type, expression = 'z => z', filter, limit = 100 } = args;
const { entity_type, expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
// Conservé hors du try : si la requête échoue ensuite côté WMS, le warning
// de résolution (nom hors Reading) reste dans la réponse d'erreur.
let resolution = null;
try {
// Garde de valeur avant tout réseau (D25) — le wrapper D23 ne valide pas
// les valeurs.
const queryType = assertValidQueryType(query_type);
// Résolution Name/TableName -> TableName (D21) — échec avant appel réseau
// sur nom inconnu, sauf en query_type != 0 (passage tel quel + warning, D25).
resolution = await entityResolver.resolveEntityType(entity_type, {
allowUnknown: queryType !== 0,
});
const { tableName, warning } = resolution;
// Expression = Context.Entity + optional Where + OrderBy (required by EF when Take is used)
let linqExpression = `Context.${entity_type}`;
let linqExpression = `Context.${tableName}`;
if (filter) {
const whereExpr = /^\s*\w+\s*=>/.test(filter) ? filter : `z => ${filter}`;
linqExpression += `.Where(${whereExpr})`;
@@ -96,24 +121,45 @@ async function callQueryAPI(args) {
const result = await apiService.executeQuery(linqExpression, {
take: limit || undefined,
select: expression !== 'z => z' ? expression : undefined,
queryType,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
entityType: entity_type,
result,
},
null,
2
),
},
],
const head = {
success: true,
entityType: entity_type,
resolvedTableName: tableName,
...(warning ? { warning } : {}),
...(queryType !== 0 ? { queryType } : {}),
};
// Garde de taille (D24) : une SEULE ligne Writing faisait 95 288 caractères
// — le modèle Writing sérialise l'agrégat complet. On écarte des lignes
// entières ; sous le plafond, la réponse est strictement celle d'avant.
if (!Array.isArray(result)) {
return {
content: [{ type: 'text', text: JSON.stringify({ ...head, result }, null, 2) }],
};
}
const buildText = (kept) => {
const payload = { ...head };
if (kept < result.length) {
// Cet outil ne porte pas de champ de total : on l'ajoute (D24).
payload.totalRows = result.length;
Object.assign(payload, truncationSignal({
returned: kept,
total: result.length,
queryType,
unit: 'ligne',
feminine: true,
}));
}
payload.result = result.slice(0, kept);
return JSON.stringify(payload, null, 2);
};
const { text } = fitToCap(result.length, buildText);
return { content: [{ type: 'text', text }] };
} catch (err) {
return {
content: [
@@ -123,6 +169,8 @@ async function callQueryAPI(args) {
{
success: false,
error: err.message,
tool: 'call_query_api',
...(resolution?.warning ? { warning: resolution.warning } : {}),
},
null,
2
@@ -168,6 +216,7 @@ async function executeCommand(args) {
{
success: false,
error: err.message,
tool: 'execute_command',
},
null,
2
+44 -9
View File
@@ -30,6 +30,7 @@ Examples:
- get_system_parameters(search="CROSSDOCK") — parameters whose code/description matches`,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
warehouse: {
type: 'string',
@@ -48,6 +49,16 @@ Examples:
description: 'If true, return only parameters that have at least one warehouse override (default: false).',
default: false,
},
limit: {
type: 'number',
description: 'Maximum number of parameters returned per call (default: 50). The full unfiltered list is ~70,000 characters — raise this only if you really need everything at once.',
default: 50,
},
offset: {
type: 'number',
description: 'Number of matching parameters to skip, for pagination (default: 0). Combine with limit to walk the full list.',
default: 0,
},
},
},
},
@@ -63,9 +74,18 @@ async function executeTool(name, args) {
}
}
const DEFAULT_PARAMS_LIMIT = 50;
async function getSystemParameters(args) {
const { warehouse, param_class, search, only_overridden = false } = args || {};
// Bornes de pagination — valeurs invalides ramenées aux défauts, la
// validation du wrapper (D23) ne contrôle que les noms de paramètres.
const rawLimit = Number(args && args.limit);
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.floor(rawLimit) : DEFAULT_PARAMS_LIMIT;
const rawOffset = Number(args && args.offset);
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0;
try {
// Both entities are small (a few hundred rows max) — fetch fully and merge
// client-side to avoid LINQ string-injection and null-field pitfalls.
@@ -123,18 +143,33 @@ async function getSystemParameters(args) {
rows.sort((a, b) => String(a.code).localeCompare(String(b.code)));
// Pagination (L3.1) : sans elle la sortie sans filtre atteint ~70 000
// caractères et se fait rejeter par les clients MCP. totalParameters est
// le total correspondant aux filtres, AVANT pagination — le signal
// truncated se vérifie donc depuis la réponse : offset + returned < total.
const matched = rows.length;
const page = rows.slice(offset, offset + limit);
const truncated = offset + page.length < matched;
const payload = {
success: true,
warehouse: warehouse || '(none — effective value = default)',
filters: { param_class: param_class || null, search: search || null, only_overridden },
totalParameters: matched,
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
returned: page.length,
offset,
};
if (truncated) {
payload.truncated = true;
payload.hint = `Showing parameters ${offset + 1}-${offset + page.length} of ${matched}. Call again with offset=${offset + page.length} for the next page, or narrow the result with param_class / search.`;
}
payload.parameters = page;
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
warehouse: warehouse || '(none — effective value = default)',
filters: { param_class: param_class || null, search: search || null, only_overridden },
totalParameters: Array.isArray(parameters) ? parameters.length : 0,
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
returned: rows.length,
parameters: rows,
}, null, 2),
text: JSON.stringify(payload, null, 2),
}],
};
} catch (err) {
+44 -16
View File
@@ -15,6 +15,7 @@ function listTools() {
description: 'Lit les dernières lignes des fichiers de logs',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
count: {
type: 'number',
@@ -33,6 +34,7 @@ function listTools() {
description: 'Liste tous les fichiers de logs disponibles sous LOGS_PATH avec leur taille et date de modification',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
@@ -41,6 +43,7 @@ function listTools() {
description: 'Recherche un mot-clé dans les fichiers de logs avec contexte',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
keyword: {
type: 'string',
@@ -110,7 +113,7 @@ async function listLogFiles() {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message }, null, 2),
text: JSON.stringify({ success: false, error: err.message, tool: 'list_log_files' }, null, 2),
}],
isError: true,
};
@@ -153,6 +156,7 @@ async function readRecentLogs(args) {
{
success: false,
error: err.message,
tool: 'read_recent_logs',
},
null,
2
@@ -171,24 +175,47 @@ async function searchLogs(args) {
const { keyword, max_results = 50, context_lines = 2 } = args;
try {
// Garde d'entrée : sans elle, un keyword absent plantait en
// "Cannot read properties of undefined (reading 'toLowerCase')".
if (typeof keyword !== 'string' || keyword.trim() === '') {
throw new Error('Le paramètre "keyword" (mot-clé à rechercher) est requis. Exemple : search_logs({"keyword": "Execute error"}).');
}
const result = await logService.searchLogs(keyword, max_results, context_lines);
// Garde-fou de taille (L3.1) : max_results borne le nombre de résultats,
// pas le volume — les context_lines multiplient la taille (55 954 chars
// mesurés avec les seuls défauts, rejetés par le client MCP). Au-delà du
// plafond on écarte des résultats ENTIERS (jamais coupés au milieu de
// leur contexte) et on le signale : truncated + omitted + hint.
const cap = parseInt(process.env.MAX_LOG_SEARCH_CHARS) || 25000;
const buildText = (kept) => {
const omitted = result.results.length - kept.length;
const payload = {
success: true,
keyword: result.keyword,
totalResults: result.totalResults,
returned: kept.length,
};
if (omitted > 0) {
payload.truncated = true;
payload.omitted = omitted;
payload.hint = `Plafond de taille de réponse atteint (${cap} caractères) : ${kept.length} résultat(s) renvoyé(s) sur ${result.totalResults}, ${omitted} écarté(s). Affinez le keyword, réduisez context_lines ou baissez max_results.`;
}
payload.results = kept;
return JSON.stringify(payload, null, 2);
};
let kept = result.results.slice();
let text = buildText(kept);
while (text.length > cap && kept.length > 0) {
kept.pop();
text = buildText(kept);
}
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
keyword: result.keyword,
totalResults: result.totalResults,
results: result.results,
},
null,
2
),
},
],
content: [{ type: 'text', text }],
};
} catch (err) {
return {
@@ -199,6 +226,7 @@ async function searchLogs(args) {
{
success: false,
error: err.message,
tool: 'search_logs',
},
null,
2
+5 -2
View File
@@ -16,6 +16,7 @@ Use this to discover the exact field names and types for any entity before build
- With entity_name (partial match ok, case-insensitive): returns field names + types for that entity`,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entity_name: {
type: 'string',
@@ -34,6 +35,7 @@ Examples:
- generic_search() — list available search categories`,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
query: {
type: 'string',
@@ -107,6 +109,7 @@ async function getEntityMetadata(args) {
text: JSON.stringify({
success: false,
error: `No entity matching "${entity_name}" found`,
tool: 'get_entity_metadata',
availableCount: Array.isArray(entities) ? entities.length : '?',
hint: 'Call get_entity_metadata without entity_name to see all entities',
}, null, 2),
@@ -148,7 +151,7 @@ async function getEntityMetadata(args) {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message }, null, 2),
text: JSON.stringify({ success: false, error: err.message, tool: 'get_entity_metadata' }, null, 2),
}],
isError: true,
};
@@ -191,7 +194,7 @@ async function genericSearch(args) {
return {
content: [{
type: 'text',
text: JSON.stringify({ success: false, error: err.message }, null, 2),
text: JSON.stringify({ success: false, error: err.message, tool: 'generic_search' }, null, 2),
}],
isError: true,
};
+6
View File
@@ -16,6 +16,7 @@ function listTools() {
description: 'List all WMS profiles configured in .env (AD, LIMAGRAIN, ...) with their host and tenant. Use this to see which WMS backends are available.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
@@ -24,6 +25,7 @@ function listTools() {
description: 'Return the currently active WMS profile (name, host, tenant, application). If no profile is active, returns an error explaining that switch_wms_profile must be called first.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
@@ -32,6 +34,7 @@ function listTools() {
description: 'Switch the active WMS profile. Resets the OAuth token and clears workflow/AD caches so the next API call targets the new backend. Use list_wms_profiles to see valid names.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
profile: {
type: 'string',
@@ -107,6 +110,7 @@ function getCurrentProfileTool() {
text: JSON.stringify({
success: false,
error: err.message,
tool: 'get_current_wms_profile',
profiles: profileManager.listProfiles(),
}, null, 2),
}],
@@ -124,6 +128,7 @@ function switchProfileTool(args) {
text: JSON.stringify({
success: false,
error: 'Missing "profile" argument',
tool: 'switch_wms_profile',
profiles: profileManager.listProfiles(),
}, null, 2),
}],
@@ -153,6 +158,7 @@ function switchProfileTool(args) {
text: JSON.stringify({
success: false,
error: err.message,
tool: 'switch_wms_profile',
profiles: profileManager.listProfiles(),
}, null, 2),
}],
+102 -27
View File
@@ -4,6 +4,7 @@
*/
const wmsQueryService = require('../services/wms-query-service');
const { fitToCap, truncationSignal } = require('../services/response-limit');
/**
* List available WMS query tools
@@ -13,8 +14,9 @@ function listTools() {
{
name: 'query_wms_entities',
description: `Query WMS entities using LINQ expressions. Returns rows (up to 1000).
Uses QueryExecute with QueryType=Reading — status fields are STRINGS (enum names, not integers).
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Location, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Aliases.
Uses QueryExecute with QueryType=Reading by default — status fields are STRINGS (enum names, not integers). Other contexts via query_type (opt-in, see the parameter warning).
Common entities: Products, Containers, Tasks, Stocks, ProductLocations, Locations, InboundOrders, OutboundOrders, Receptions, Accounts, Suppliers, Kits, Alias. Full list via get_entity_metadata.
entity_type accepts the AD entity name (Container) or the TableName (Containers), case-insensitive — resolved via the Metadata API.
IMPORTANT — before building a filter with a status/enum field:
1. Check docs first: read resource docs://entities/ (e.g. easywms_reading_entites_outboundorder_OutboundOrderStatus for OutboundOrders)
@@ -23,10 +25,11 @@ IMPORTANT — before building a filter with a status/enum field:
Never guess enum string values — they differ between Reading and Writing models.`,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entity_type: {
type: 'string',
description: 'Entity type (Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Aliases, Receptions)',
description: 'Entity type — AD name (Container) or TableName (Containers), case-insensitive, resolved via the Metadata API. E.g. Products, Containers, Tasks, Stocks, ProductLocations, InboundOrders, OutboundOrders, Accounts, Suppliers, Kits, Alias, Receptions.',
},
select_expression: {
type: 'string',
@@ -42,6 +45,11 @@ Never guess enum string values — they differ between Reading and Writing model
description: 'Maximum results to return (default: 100, max: 1000)',
default: 100,
},
query_type: {
type: 'number',
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0. 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,
},
},
required: ['entity_type'],
},
@@ -51,6 +59,7 @@ Never guess enum string values — they differ between Reading and Writing model
description: 'Get the schema/structure of a WMS entity by querying one sample record',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entity_type: {
type: 'string',
@@ -83,6 +92,7 @@ Verified values (curl-tested):
(sur un emplacement: ajouter && z.LocationCode == "X")`,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entity_type: {
type: 'string',
@@ -92,6 +102,11 @@ Verified values (curl-tested):
type: 'string',
description: 'Optional LINQ filter condition. Status fields are strings (enum names from Reading model). Always verify enum values via docs://entities/ before use.',
},
query_type: {
type: 'number',
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.',
default: 0,
},
},
required: ['entity_type'],
},
@@ -101,6 +116,7 @@ Verified values (curl-tested):
description: 'Search for a keyword across multiple WMS entities',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
keyword: {
type: 'string',
@@ -162,39 +178,63 @@ async function executeTool(name, args) {
/**
* Tool: query_wms_entities
*
* Garde de taille (D24) : 200 lignes Reading faisaient 957 234 caractères, au
* delà du seuil de rejet du client MCP. On écarte des lignes ENTIÈRES depuis la
* fin ; sous le plafond, la réponse est strictement celle d'avant.
*/
async function queryWmsEntities(args) {
const { entity_type, select_expression = 'z => z', filter, limit = 100 } = args;
const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit}`);
console.error(`[WMSQueryTools] Querying ${entity_type}: limit=${limit} query_type=${query_type}`);
const result = await wmsQueryService.queryEntities(
entity_type,
select_expression,
filter,
limit
limit,
query_type
);
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
...result
}, null, 2)
}]
const { data, ...head } = result;
// Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes.
if (!Array.isArray(data)) {
return {
content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }]
};
}
// `count` (dans head) porte déjà le total avant la coupe — c'est le total
// exigé par D24, inutile d'en ajouter un second.
const buildText = (kept) => {
const payload = { success: true, ...head };
if (kept < data.length) {
Object.assign(payload, truncationSignal({
returned: kept,
total: data.length,
queryType: query_type,
unit: 'ligne',
feminine: true,
}));
}
payload.data = data.slice(0, kept);
return JSON.stringify(payload, null, 2);
};
const { text } = fitToCap(data.length, buildText);
return { content: [{ type: 'text', text }] };
}
/**
* Tool: count_wms_entities
*/
async function countWmsEntities(args) {
const { entity_type, filter } = args;
const { entity_type, filter, query_type = 0 } = args;
console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''}`);
console.error(`[WMSQueryTools] Counting ${entity_type}${filter ? ` where ${filter}` : ''} query_type=${query_type}`);
const result = await wmsQueryService.countEntities(entity_type, filter || null);
const result = await wmsQueryService.countEntities(entity_type, filter || null, query_type);
return {
content: [{
@@ -256,17 +296,52 @@ async function searchWmsData(args) {
}
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
keyword,
totalFound,
results
}, null, 2)
}]
// Garde de taille (D24) : search_wms_data("PAL") faisait 847 543 caractères.
// L'unité écartée est un RÉSULTAT entier ; les résultats gardés sont répartis
// en tourniquet entre les entités, pour qu'une entité volumineuse placée en
// tête n'efface pas silencieusement les suivantes — c'est exactement le
// faux négatif que L5.4 corrige par ailleurs.
const entityKeys = Object.keys(results).filter(k => Array.isArray(results[k].data));
const slots = [];
const maxRows = entityKeys.reduce((m, k) => Math.max(m, results[k].data.length), 0);
for (let i = 0; i < maxRows; i++) {
for (const k of entityKeys) {
if (i < results[k].data.length) slots.push(k);
}
}
const buildText = (kept) => {
const keepCount = {};
entityKeys.forEach(k => { keepCount[k] = 0; });
for (let i = 0; i < kept; i++) keepCount[slots[i]]++;
const payload = { success: true, keyword, totalFound };
if (kept < slots.length) {
Object.assign(payload, truncationSignal({
returned: kept,
total: slots.length,
unit: 'résultat',
extraHint: 'Les résultats gardés sont répartis entre les entités : voir returned/omitted par entité. ' +
'Relancez query_wms_entities entité par entité avec un filter plus précis pour voir le reste.',
}));
}
payload.results = {};
for (const [k, v] of Object.entries(results)) {
if (!Array.isArray(v.data)) {
payload.results[k] = v; // entité en erreur : { error, count }, déjà minuscule
continue;
}
const keptRows = v.data.slice(0, keepCount[k]);
payload.results[k] = keptRows.length < v.data.length
? { count: v.count, returned: keptRows.length, omitted: v.data.length - keptRows.length, data: keptRows }
: { count: v.count, data: keptRows };
}
return JSON.stringify(payload, null, 2);
};
const { text } = fitToCap(slots.length, buildText);
return { content: [{ type: 'text', text }] };
}
module.exports = {
+149 -34
View File
@@ -5,6 +5,12 @@
const workflowService = require('../services/workflow-service');
// Taille par défaut d'une tranche du blob `data` de get_workflow_details.
// Ordre de grandeur cible de D24 (~20-25 000 caractères par réponse) : avec
// l'échappement JSON et les métadonnées, 20 000 caractères de blob tiennent
// sous ~23 000 caractères de réponse.
const DEFAULT_MAX_DATA_CHARS = 20000;
/**
* List available workflow tools
*/
@@ -12,35 +18,56 @@ function listTools() {
return [
{
name: 'search_workflows',
description: 'Search workflows by name, description, or code. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour.',
description: 'Search workflows by name. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour, per application. Client-specific workflows (CST_* prefix) live in the "CustomApp" application — pass application: "CustomApp" to search them.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
query: {
type: 'string',
description: 'Search query (searches in name, description, code)',
description: 'Search query (searches in workflow name)',
},
category: {
type: 'string',
description: 'Filter by workflow category/application',
description: 'Filter by the applicationName field of the returned workflows (workflows have no category field). Since `application` selects which application is fetched, all its workflows share the same applicationName — prefer `application` to change scope; `category` only narrows within the fetched set.',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 50)',
default: 50,
},
application: {
type: 'string',
description: 'AD application whose workflows are searched (default: the active profile\'s application, usually EasyWMS). Client-specific workflows live in "CustomApp". Full list via list_workflow_categories.',
},
},
},
},
{
name: 'get_workflow_details',
description: 'Get full details of a specific workflow by ID or code',
description: `Get full details of a specific workflow by ID or name.
The EasyBuilder definition (the \`data\` blob) is large — 71 512 characters for a StackerCrane workflow, 92 362 for CST_SendRejectContainersToPK — so it is returned as a VERBATIM WINDOW (max_data_chars / data_offset). Workflow metadata is always complete; only \`data\` is windowed. dataTotalChars always carries the full blob size, and concatenating the slices in offset order reproduces the definition byte for byte.`,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
workflow_id: {
type: 'string',
description: 'Workflow ID or code',
description: 'Workflow ID or exact name',
},
application: {
type: 'string',
description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".',
},
max_data_chars: {
type: 'number',
description: `Maximum number of characters of the \`data\` blob returned by this call (default: ${DEFAULT_MAX_DATA_CHARS}). The slice is verbatim — never summarised, reformatted or parsed. Pass 0 for metadata only.`,
default: DEFAULT_MAX_DATA_CHARS,
},
data_offset: {
type: 'number',
description: 'Character offset in the `data` blob where the returned slice starts (default: 0). When the response carries truncated: true, its hint gives the next offset to pass here.',
default: 0,
},
},
required: ['workflow_id'],
@@ -48,10 +75,16 @@ function listTools() {
},
{
name: 'list_workflow_categories',
description: 'List all available workflow categories',
description: 'List the AD applications declared on the tenant (Application/GetAll) with their workflow counts where already loaded. Workflows have no category field — the application is the only grouping. Use the `application` parameter of the workflow/AD tools to query a specific one (e.g. "CustomApp" for client-specific CST_* workflows).',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
properties: {
application: {
type: 'string',
description: 'Load and count the workflows of this application (default: the active profile\'s application). Other applications are listed without loading them.',
},
},
},
},
];
@@ -95,50 +128,116 @@ async function executeTool(name, args) {
* Tool: search_workflows
*/
async function searchWorkflows(args) {
const { query, category, limit = 50 } = args;
const { query, category, limit = 50, application } = args;
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}`);
console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}, application=${application || '(profil)'}`);
const results = await workflowService.searchWorkflows(query, category, limit);
const results = await workflowService.searchWorkflows(query, category, limit, application);
// L5.4 : l'application interrogée est TOUJOURS rappelée (pas seulement quand
// elle a été passée), et un résultat vide dit qu'une seule application sur
// neuf a été regardée — c'est ce silence qui avait fait conclure à tort à
// l'absence de workflows CST_.
const effectiveApplication = workflowService.resolveApplication(application);
const hint = results.length === 0
? workflowService.buildOtherApplicationsHint(effectiveApplication, 'workflow')
: null;
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
application: effectiveApplication,
count: results.length,
workflows: results.map(w => ({
id: w.Id,
code: w.Code,
name: w.Name,
category: w.Category,
description: w.Description,
version: w.Version,
created: w.Created,
modified: w.Modified
}))
...(hint ? { hint } : {}),
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
// applicationName, commonInfo. Pas de code/category/description.
workflows: results.map(w => {
const commonInfo = w.commonInfo || w.CommonInfo || {};
return {
id: w.id || w.Id,
name: w.name || w.Name,
applicationName: w.applicationName || w.ApplicationName,
version: w.version || w.Version,
createdBy: commonInfo.createdBy,
createDate: commonInfo.createDate,
updateDate: commonInfo.updateDate
};
})
}, null, 2)
}]
};
}
/**
* Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de
* paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau.
*/
function assertWindowValue(value, fallback, paramName) {
if (value == null) return fallback;
if (!Number.isInteger(value) || value < 0) {
const attendu = paramName === 'max_data_chars'
? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules`
: 'offset de départ dans le blob data, défaut 0';
throw new Error(
`${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).`
);
}
return value;
}
/**
* Tool: get_workflow_details
*
* La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères
* sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse
* complète dépassait le seuil de rejet du client MCP (D24). On renvoie une
* TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées
* restent complètes, et concaténer les tranches dans l'ordre des offsets
* reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce
* blob pour n'en renvoyer que des morceaux jugés utiles.
*/
async function getWorkflowDetails(args) {
const { workflow_id } = args;
const { workflow_id, application, max_data_chars, data_offset } = args;
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id}`);
const maxDataChars = assertWindowValue(max_data_chars, DEFAULT_MAX_DATA_CHARS, 'max_data_chars');
const dataOffset = assertWindowValue(data_offset, 0, 'data_offset');
const workflow = await workflowService.getWorkflowDetails(workflow_id);
console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'}, max_data_chars=${maxDataChars}, data_offset=${dataOffset})`);
const workflow = await workflowService.getWorkflowDetails(workflow_id, application);
const payload = { success: true, workflow };
// Seul un blob `data` textuel se fenêtre ; un workflow sans définition (ou
// d'une forme inattendue) sort inchangé.
if (typeof workflow?.data === 'string') {
const total = workflow.data.length;
const slice = workflow.data.slice(dataOffset, dataOffset + maxDataChars);
const nextOffset = dataOffset + slice.length;
payload.workflow = { ...workflow, data: slice };
// La taille totale est portée par TOUTE réponse : truncated se vérifie
// depuis la réponse elle-même (D24).
payload.dataTotalChars = total;
payload.dataOffset = dataOffset;
payload.returned = slice.length;
if (nextOffset < total) {
payload.truncated = true;
payload.hint =
`Blob \`data\` tronqué : ${slice.length} caractère(s) sur ${total} renvoyé(s) depuis l'offset ${dataOffset}. ` +
`Rappelez get_workflow_details avec les mêmes workflow_id/application et data_offset: ${nextOffset} pour la tranche ` +
`suivante (max_data_chars change la taille des tranches). Les tranches sont verbatim : les concaténer dans l'ordre ` +
`des offsets reconstitue la définition EasyBuilder à l'octet près.`;
}
}
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
workflow
}, null, 2)
text: JSON.stringify(payload, null, 2)
}]
};
}
@@ -147,21 +246,37 @@ async function getWorkflowDetails(args) {
* Tool: list_workflow_categories
*/
async function listWorkflowCategories(args) {
console.error('[WorkflowTools] Listing workflow categories');
const { application } = args || {};
const categories = await workflowService.listWorkflowCategories();
const stats = await workflowService.getWorkflowStats();
console.error(`[WorkflowTools] Listing applications (workflow groupings), application=${application || '(profil)'}`);
// La liste vient d'Application/GetAll (9 applications sur le tenant mesuré),
// pas des applicationName du seul cache actif (D26). Seule l'application
// demandée (ou celle du profil) est chargée — pas de préchargement des
// autres (D10) : leurs comptes n'apparaissent que si déjà en cache.
const applications = await workflowService.fetchApplications();
const stats = await workflowService.getWorkflowStats(application);
const cacheStatus = workflowService.getCacheStatus();
const enriched = applications.map(a => ({
name: a.name,
version: a.version,
...(cacheStatus[a.name]
? { workflowCount: cacheStatus[a.name].count, cacheAge: cacheStatus[a.name].age }
: { workflowCount: null }),
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
totalCategories: categories.length,
categories,
stats: {
note: 'Workflows have no category field in the AD API — the application is the only grouping. workflowCount is only known for applications already loaded (lazy loading); pass application to search_workflows/get_ad_elements to load one.',
totalApplications: applications.length,
applications: enriched,
loaded: {
application: stats.application,
totalWorkflows: stats.total,
categoryCounts: stats.categoryCounts,
cacheAge: stats.cacheAge
}
}, null, 2)