Compare commits
19 Commits
702c2ecd2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eadc1f6a6 | |||
| b758a0e09d | |||
| 242b0c0f1c | |||
| 097b76c7ef | |||
| b7151b3bc7 | |||
| 8a631f5ea4 | |||
| 1851b38c62 | |||
| 660c62c32c | |||
| a1acb781b0 | |||
| 54a6849563 | |||
| 6f54d765c4 | |||
| 5ec2990347 | |||
| 90b2c89ff1 | |||
| b941f367ae | |||
| 92de85cf53 | |||
| 706e628715 | |||
| 88dab289cc | |||
| cc34894ae4 | |||
| 35b53dbef5 |
@@ -64,7 +64,10 @@ src/
|
||||
│ ├── workflow-service.js Workflows, lazy loading + cache
|
||||
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
||||
│ ├── wms-query-service.js Construction d'expressions LINQ
|
||||
│ └── log-service.js Lecture et recherche dans les fichiers de logs
|
||||
│ ├── single-flight.js Déduplication des chargements + génération (D27)
|
||||
│ ├── ad-envelope.js Enveloppe { entities } des API AD : vide anormal = erreur (D27)
|
||||
│ ├── log-service.js Lecture et recherche dans les fichiers de logs
|
||||
│ └── response-limit.js Plafond de taille commun aux outils de requête (D24)
|
||||
└── tools/ 23 outils MCP
|
||||
├── wms-query-tools.js query_wms_entities, count_wms_entities,
|
||||
│ get_entity_schema, search_wms_data
|
||||
@@ -105,7 +108,11 @@ serveur au démarrage.
|
||||
[MONITORING.md](MONITORING.md) §2.
|
||||
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
|
||||
structurée `{ success: false, error, tool }` avec `isError: true` — le
|
||||
wrapper est dans le handler `tools/call` de `src/index.js`.
|
||||
wrapper est dans le handler `tools/call` de `src/index.js`. Les enveloppes
|
||||
construites **localement** dans `src/tools/` portent le champ `tool` elles
|
||||
aussi : le wrapper ne les voit pas, et une erreur sans `tool` sort du
|
||||
contrat. Les champs supplémentaires utiles (`warning` de résolution,
|
||||
`profiles`, `hint`…) viennent après.
|
||||
4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
|
||||
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
|
||||
disponibles : … »).
|
||||
@@ -150,6 +157,11 @@ actif à chaque appel.
|
||||
de `search_logs` — au-delà, des résultats entiers sont écartés et signalés
|
||||
(`truncated`, D24).
|
||||
|
||||
**`MAX_QUERY_RESPONSE_CHARS`** (défaut 25 000) : même plafond pour
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` — au-delà, des
|
||||
lignes entières sont écartées et signalées (D24). `count_wms_entities` n'est
|
||||
pas concerné.
|
||||
|
||||
**Au runtime.** `profile-manager` est un singleton d'état global. Les services
|
||||
s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant :
|
||||
|
||||
@@ -170,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
|
||||
@@ -205,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
|
||||
@@ -233,11 +299,13 @@ pluriel), `Tasks`, `Stocks`, `ProductLocations`, `InboundOrders`, `Receptions`,
|
||||
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
|
||||
|
||||
+265
-3
@@ -458,15 +458,69 @@ sa réponse et **signale** la coupe. Le signal est commun :
|
||||
| `truncated: true` | présent **uniquement** quand la réponse a été coupée — jamais `truncated: false` |
|
||||
| `hint` | présent ssi `truncated` ; actionnable : dit comment continuer (`offset` suivant) ou réduire (filtres, `context_lines`…) |
|
||||
| `returned` | nombre d'éléments effectivement renvoyés |
|
||||
| total (`totalParameters`, `totalResults`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
|
||||
| total (`totalParameters`, `totalResults`, `dataTotalChars`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même |
|
||||
|
||||
Les mécanismes restent **volontairement locaux**, car ils diffèrent :
|
||||
`get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu,
|
||||
on continue avec l'offset suivant) ; `search_logs` plafonne le volume
|
||||
(`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats
|
||||
**entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce
|
||||
en plus `omitted`, le compte écarté. Pas de helper partagé : le factoriser
|
||||
forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
|
||||
factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
Les **trois outils de requête**, eux, partagent le même mécanisme — ils
|
||||
partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère
|
||||
est le mécanisme, pas le nombre d'appelants.
|
||||
|
||||
**Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient
|
||||
encore le seuil, toutes mesurées sur `LIMAGRAI2512` :
|
||||
|
||||
`get_workflow_details` **fenêtre le blob `data`** (`max_data_chars`, défaut
|
||||
20 000 ; `data_offset`, défaut 0) — 79 092 caractères pour un StackerCrane
|
||||
(dont 71 512 de blob), 101 816 pour `CST_SendRejectContainersToPK` (92 362 de
|
||||
blob), ramenés à ~23 000. La tranche est **verbatim** : découpe de chaîne, rien
|
||||
d'autre. Ne jamais résumer, reformuler ni « parser » cette définition
|
||||
EasyBuilder — la concaténation des tranches dans l'ordre des offsets doit la
|
||||
reconstituer à l'octet près (vérifié : 20 000 + 20 000 + 20 000 + 11 512 =
|
||||
71 512, concaténation identique au blob d'origine). Les métadonnées du workflow
|
||||
restent complètes dans chaque tranche ; seul `data` est fenêtré, et
|
||||
`dataTotalChars` est porté par **toute** réponse — y compris non tronquée, où
|
||||
la seule différence avec l'ancienne réponse est ces trois champs de fenêtre
|
||||
(+65 caractères mesurés).
|
||||
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur
|
||||
volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que
|
||||
`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper
|
||||
commun `src/services/response-limit.js` (recherche dichotomique : ~8
|
||||
constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de
|
||||
~1 Mo) :
|
||||
|
||||
| Appel | Avant | Après |
|
||||
|---|---:|---:|
|
||||
| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) |
|
||||
| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) |
|
||||
| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 |
|
||||
|
||||
Trois points de cadrage, tous vérifiés en exécution :
|
||||
|
||||
- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique
|
||||
**octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`,
|
||||
`call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond).
|
||||
`MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés :
|
||||
le correctif est le bornage signalé, pas une réduction silencieuse.
|
||||
- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing —
|
||||
`call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`,
|
||||
`omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing
|
||||
et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux
|
||||
qu'un rejet client opaque.
|
||||
- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les
|
||||
entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans
|
||||
cela, une entité volumineuse placée en tête consommerait tout le budget et
|
||||
les suivantes reviendraient à zéro résultat sans que rien ne le dise —
|
||||
exactement le faux négatif que corrige L5.4.
|
||||
|
||||
`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un
|
||||
scalaire), et son `query_type` ne porte donc pas l'avertissement de volume
|
||||
ajouté aux deux autres.
|
||||
|
||||
Deux garde-fous de cadrage :
|
||||
|
||||
@@ -480,3 +534,211 @@ Deux garde-fous de cadrage :
|
||||
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.
|
||||
|
||||
+24
-80
@@ -17,77 +17,13 @@ contient que ce qui reste à faire.
|
||||
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 | 0–8 | 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 — traitée : la table de résolution (D21) **agrège le Metadata de
|
||||
toutes les applications** déployées, et non le seul `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
|
||||
|
||||
@@ -146,8 +82,6 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Écarté
|
||||
|
||||
| Proposition | Raison |
|
||||
@@ -171,15 +105,25 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
||||
test.
|
||||
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026,
|
||||
révision du lot 2). Le serveur traite les `tools/call` **en concurrence** :
|
||||
en envoyant une rafale de requêtes dans une même session, les réponses
|
||||
reviennent dans le désordre, et un `switch_wms_profile` émis pendant que des
|
||||
requêtes sont en vol les fait partir sur le nouveau profil (observé : une
|
||||
requête destinée à EUROTRAFIC exécutée sur l'hôte `10.255.255.2` après la
|
||||
bascule suivante). Conséquence du singleton d'état global (D8). Sans gravité
|
||||
pour un usage conversationnel séquentiel, mais Claude peut émettre des appels
|
||||
d'outils **en parallèle** : à traiter si un cas réel de mélange de profils
|
||||
un `switch_wms_profile` émis pendant que des requêtes sont en vol les fait
|
||||
partir sur le nouveau profil (observé : une requête destinée à EUROTRAFIC
|
||||
exécutée sur l'hôte `10.255.255.2` après la bascule suivante). Conséquence du
|
||||
singleton d'état global (D8). À traiter si un cas réel de mélange de profils
|
||||
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
|
||||
début de chaque appel).
|
||||
début de chaque appel). La manifestation « caches » de la même concurrence
|
||||
est **traitée** (D27 : single-flight par clé, garde de génération) ; celle-ci
|
||||
ne l'est pas — D27 borne les chargements paresseux, pas le routage d'une
|
||||
requête déjà partie.
|
||||
- **L'API AD échoue sous appels concurrents nombreux** (mesuré le 25/08/2026,
|
||||
lot 6). Avant D27, une rafale de 14 `tools/call` faisait répondre **HTTP 500**
|
||||
à `POST /AD/api/Workflow/GetByApplication` pour `EasyWMS` (~4 000 workflows) —
|
||||
les 4 appels concernés en erreur, les mêmes corrects en séquentiel. C'est une
|
||||
limite du serveur AD, pas du MCP. D27 l'atténue fortement (un seul fetch par
|
||||
clé au lieu de N, et le 500 n'a pas reparu depuis), sans la supprimer : des
|
||||
clés **différentes** se chargent toujours en parallèle. À reconsidérer si le
|
||||
500 réapparaît — piste : plafonner le nombre de chargements simultanés, tous
|
||||
clés confondues. N'implémentez rien avant d'avoir une mesure : brider les
|
||||
chargements parallèles coûte de la latence sur le chemin nominal.
|
||||
- **`select_expression`** : les projections via le paramètre `Select` provoquent
|
||||
des erreurs de compilation côté serveur (D13). Irritant principal restant.
|
||||
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
|
||||
|
||||
+63
-98
@@ -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.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -323,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,
|
||||
};
|
||||
|
||||
@@ -363,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const apiService = require('./api-service').getInstance();
|
||||
const profileManager = require('../config/profile-manager');
|
||||
const { createSingleFlight } = require('./single-flight');
|
||||
|
||||
// Cache state — même TTL que les autres caches (D10)
|
||||
let resolutionMap = null; // Map lower(Name | TableName) -> TableName
|
||||
@@ -18,6 +19,10 @@ let tableNames = null; // TableName[] triés (suggestions + comptage)
|
||||
let cacheTimestamp = null;
|
||||
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000;
|
||||
|
||||
// Table unique : une seule clé de single-flight (D27).
|
||||
const singleFlight = createSingleFlight('EntityResolver');
|
||||
const METADATA_KEY = 'metadata';
|
||||
|
||||
// La table de résolution est par tenant — invalidée à chaque bascule (D8).
|
||||
profileManager.onSwitch(() => invalidateCache());
|
||||
|
||||
@@ -37,6 +42,23 @@ function isCacheValid() {
|
||||
async function loadResolutionMap() {
|
||||
if (isCacheValid()) return;
|
||||
|
||||
// Un seul chargement Metadata, même sous rafale concurrente (D27) : sans
|
||||
// lui, 6 appels concurrents déclenchaient 6 chargements complets. La
|
||||
// publication est refusée si le cache a été invalidé pendant le fetch.
|
||||
await singleFlight.run(METADATA_KEY, fetchResolutionMap, (loaded) => {
|
||||
resolutionMap = loaded.map;
|
||||
tableNames = loaded.names;
|
||||
cacheTimestamp = Date.now();
|
||||
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${loaded.applicationCount} application(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement réel de la table de résolution (D27). N'écrit rien en cache :
|
||||
* la publication est le `commit` de singleFlight.run.
|
||||
* @returns {Promise<{map: Map, names: string[], applicationCount: number}>}
|
||||
*/
|
||||
async function fetchResolutionMap() {
|
||||
console.error('[EntityResolver] Cache expired or empty, fetching Metadata...');
|
||||
|
||||
const apps = await apiService.get('/configuration/applications');
|
||||
@@ -67,10 +89,11 @@ async function loadResolutionMap() {
|
||||
throw new Error('Metadata API returned no entity for any application');
|
||||
}
|
||||
|
||||
resolutionMap = map;
|
||||
tableNames = Array.from(names).sort();
|
||||
cacheTimestamp = Date.now();
|
||||
console.error(`[EntityResolver] Cached ${tableNames.length} entities from ${appNames.length} application(s)`);
|
||||
return {
|
||||
map,
|
||||
names: Array.from(names).sort(),
|
||||
applicationCount: appNames.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,15 +142,22 @@ function suggestClosest(input, limit = 5) {
|
||||
* 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 — AVANT tout appel réseau de
|
||||
* requête, avec suggestions proches et renvoi vers get_entity_metadata
|
||||
* @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) {
|
||||
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.');
|
||||
}
|
||||
@@ -150,6 +180,15 @@ async function resolveEntityType(entityType) {
|
||||
|
||||
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.`
|
||||
@@ -163,6 +202,8 @@ function invalidateCache() {
|
||||
resolutionMap = null;
|
||||
tableNames = null;
|
||||
cacheTimestamp = null;
|
||||
// Les fetchs déjà partis ne repeupleront pas la table (D27).
|
||||
singleFlight.invalidate();
|
||||
console.error('[EntityResolver] Cache cleared');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Response Limit
|
||||
* Garde de taille commune aux trois outils de requête (D24, lot 5).
|
||||
*
|
||||
* Mesures du 25/08/2026 sur `LIMAGRAI2512`, toutes au-dessus du seuil de rejet
|
||||
* du client MCP (~70 000 caractères) : 957 234 caractères pour 200 lignes
|
||||
* Reading, 847 543 pour `search_wms_data("PAL")`, et 95 288 pour **une seule**
|
||||
* ligne Writing — le modèle Writing sérialise l'agrégat complet (navigations,
|
||||
* `$id`…) là où la même ligne Reading fait ~4 500.
|
||||
*
|
||||
* Contrairement aux mécanismes de `get_system_parameters` et `search_logs`
|
||||
* (locaux car différents, D24), les trois outils de requête partagent le même
|
||||
* mécanisme — d'où ce module : on écarte des **lignes entières**, jamais
|
||||
* coupées au milieu.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_CHARS = 25000;
|
||||
|
||||
/**
|
||||
* Plafond en caractères d'une réponse d'outil de requête.
|
||||
* Même ordre de grandeur que `MAX_LOG_SEARCH_CHARS` (D24).
|
||||
*/
|
||||
function getMaxResponseChars() {
|
||||
return parseInt(process.env.MAX_QUERY_RESPONSE_CHARS) || DEFAULT_MAX_RESPONSE_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avertissement de volume propre aux contextes non-Reading (D25). Repris tel
|
||||
* quel dans les hints et dans la description du paramètre `query_type`.
|
||||
*/
|
||||
const WRITING_VOLUME_NOTE =
|
||||
'En query_type != 0, une ligne est un agrégat complet sérialisé (navigations, $id…) : ' +
|
||||
'95 288 caractères mesurés pour UNE seule ligne Products en Writing, contre ~4 500 en Reading. ' +
|
||||
'Repassez en query_type: 0 si le modèle Reading suffit.';
|
||||
|
||||
/**
|
||||
* Trouve le plus grand nombre d'éléments dont la réponse tient sous le plafond.
|
||||
*
|
||||
* @param {number} total - nombre d'éléments disponibles
|
||||
* @param {(kept: number) => string} buildText - construit la réponse sérialisée
|
||||
* pour `kept` éléments. Doit être croissante en `kept` et porter
|
||||
* elle-même les champs de troncature quand `kept < total`.
|
||||
* @returns {{ text: string, kept: number, truncated: boolean, cap: number }}
|
||||
*/
|
||||
function fitToCap(total, buildText) {
|
||||
const cap = getMaxResponseChars();
|
||||
|
||||
const full = buildText(total);
|
||||
if (full.length <= cap) {
|
||||
return { text: full, kept: total, truncated: false, cap };
|
||||
}
|
||||
|
||||
// Recherche dichotomique : ~8 constructions pour 200 lignes, là où un retrait
|
||||
// ligne à ligne en ferait 200 sur des charges utiles de ~1 Mo.
|
||||
let lo = 0;
|
||||
let hi = total - 1;
|
||||
let best = -1;
|
||||
let bestText = null;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const text = buildText(mid);
|
||||
if (text.length <= cap) {
|
||||
best = mid;
|
||||
bestText = text;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite réel en Writing : une seule ligne dépasse déjà le plafond. On
|
||||
// renvoie l'enveloppe vide et signalée — moins bon qu'un résultat, mais mieux
|
||||
// qu'un rejet client opaque.
|
||||
if (best < 0) {
|
||||
best = 0;
|
||||
bestText = buildText(0);
|
||||
}
|
||||
|
||||
return { text: bestText, kept: best, truncated: true, cap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs de troncature communs aux outils de requête — vocabulaire D24 exact
|
||||
* (`truncated`, `returned`, `omitted`, `hint`). Le total avant la coupe est
|
||||
* ajouté par l'appelant : `query_wms_entities` et `search_wms_data` le portent
|
||||
* déjà (`count`, `totalFound`), `call_query_api` non.
|
||||
*
|
||||
* @param {number} returned - éléments effectivement renvoyés
|
||||
* @param {number} total - éléments disponibles avant la coupe
|
||||
* @param {number} queryType - QueryContextType de l'appel (D25)
|
||||
* @param {string} unit - nom de l'unité écartée, au singulier ('ligne', 'résultat')
|
||||
* @param {boolean} [feminine] - accord du hint sur `unit` ('ligne' est féminin)
|
||||
* @param {string} [extraHint] - phrase supplémentaire propre à l'outil
|
||||
*/
|
||||
function truncationSignal({ returned, total, queryType = 0, unit, feminine = false, extraHint }) {
|
||||
const cap = getMaxResponseChars();
|
||||
const omitted = total - returned;
|
||||
const plafond = `Plafond de taille de réponse atteint (${cap} caractères, MAX_QUERY_RESPONSE_CHARS)`;
|
||||
const e = feminine ? 'e' : '';
|
||||
const aucun = feminine ? 'Aucune' : 'Aucun';
|
||||
const unSeul = feminine ? 'une seule' : 'un seul';
|
||||
const entiers = feminine ? 'entières' : 'entiers';
|
||||
|
||||
// Cas limite réel en Writing : même une seule ligne dépasse le plafond.
|
||||
let hint = returned === 0
|
||||
? `${plafond} : ${aucun} ${unit} ne tient dans la réponse — ${unSeul} ${unit} dépasse déjà le plafond ` +
|
||||
`à ${feminine ? 'elle' : 'lui'} seul${e}. Restreignez la requête (filter plus étroit, autre entité) : ` +
|
||||
`le contenu n'est pas coupé au milieu, il est écarté en entier.`
|
||||
: `${plafond} : ${returned} ${unit}(s) renvoyé${e}(s) sur ${total}, ${omitted} écarté${e}(s) — des ` +
|
||||
`${unit}s ${entiers}, jamais coupé${e}s au milieu. Réduisez limit ou ajoutez un filter pour cibler.`;
|
||||
|
||||
if (extraHint) hint += ` ${extraHint}`;
|
||||
if (queryType) hint += ` ${WRITING_VOLUME_NOTE}`;
|
||||
|
||||
return { truncated: true, returned, omitted, hint };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMaxResponseChars,
|
||||
fitToCap,
|
||||
truncationSignal,
|
||||
WRITING_VOLUME_NOTE,
|
||||
DEFAULT_MAX_RESPONSE_CHARS,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Single-flight + génération de cache — chargements paresseux sous
|
||||
* concurrence (D27)
|
||||
*
|
||||
* Les services à cache (workflow, AD, resolver) chargent paresseusement : le
|
||||
* premier appelant qui trouve le cache invalide déclenche le fetch. Le serveur
|
||||
* traitant les `tools/call` en concurrence, deux défauts en découlaient, et ce
|
||||
* module porte les deux :
|
||||
*
|
||||
* 1. **Duplication** — N appelants arrivés pendant un fetch trouvaient tous le
|
||||
* cache invalide et lançaient N chaînes complètes (mesuré : 6 chargements
|
||||
* Metadata en parallèle pour une seule table). Une Map
|
||||
* `clé de cache -> promesse en vol` les fait rejoindre le fetch en cours.
|
||||
* 2. **Écriture post-invalidation** — un fetch parti avant une bascule de
|
||||
* profil (D8) terminait après elle et repeuplait le cache avec les données
|
||||
* de l'ancien tenant, timestamp neuf. Un compteur de génération, incrémenté
|
||||
* à chaque invalidation, fait **jeter** un résultat d'une génération
|
||||
* périmée au lieu de l'écrire.
|
||||
*
|
||||
* Le single-flight est **par clé** — deux applications différentes se chargent
|
||||
* toujours en parallèle (D26 : rien n'est préchargé, rien n'est sérialisé
|
||||
* au-delà de la clé demandée). Pas de dépendance externe : une Map.
|
||||
*
|
||||
* @param {string} label - préfixe de log du service appelant (D6, MONITORING §2)
|
||||
*/
|
||||
function createSingleFlight(label) {
|
||||
const inFlight = new Map(); // clé de cache -> promesse du chargement en cours
|
||||
let generation = 0; // incrémenté à chaque invalidation
|
||||
|
||||
/**
|
||||
* Exécute `fetcher` pour cette clé, ou rejoint le chargement déjà en vol,
|
||||
* puis publie le résultat via `commit` **si la génération n'a pas changé**.
|
||||
*
|
||||
* La promesse est retirée de la Map au règlement, succès **ou** échec : un
|
||||
* fetch en erreur ne reste pas coincé, l'appel suivant refetche.
|
||||
*
|
||||
* L'appelant reçoit toujours le résultat de son fetch, même périmé — c'est
|
||||
* sa **mise en cache** qui est refusée, pas sa réponse : il a demandé ces
|
||||
* données avant l'invalidation, il les obtient.
|
||||
*
|
||||
* @param {string} key - clé de cache (une par entrée de cache indépendante)
|
||||
* @param {() => Promise<any>} fetcher - le chargement réel, appelé au plus
|
||||
* une fois tant qu'il est en vol ; il ne doit **rien** écrire en cache
|
||||
* @param {(value: any) => void} [commit] - publication en cache, appelée
|
||||
* seulement si aucune invalidation n'est survenue pendant le fetch
|
||||
* @returns {Promise<any>} le résultat du chargement (partagé par les joignants)
|
||||
*/
|
||||
function run(key, fetcher, commit) {
|
||||
const pending = inFlight.get(key);
|
||||
if (pending) {
|
||||
console.error(`[${label}] Fetch already in flight for "${key}", joining it`);
|
||||
return pending;
|
||||
}
|
||||
|
||||
const startGeneration = generation;
|
||||
const promise = (async () => {
|
||||
const value = await fetcher();
|
||||
if (generation !== startGeneration) {
|
||||
console.error(
|
||||
`[${label}] Result for "${key}" discarded, not cached: ` +
|
||||
`cache invalidated during fetch (generation ${startGeneration} -> ${generation})`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
if (commit) commit(value);
|
||||
return value;
|
||||
})();
|
||||
|
||||
inFlight.set(key, promise);
|
||||
|
||||
// Libération au règlement. Le test d'identité évite qu'une promesse
|
||||
// périmée (Map vidée par une invalidation, puis nouveau fetch démarré)
|
||||
// supprime l'entrée de son successeur.
|
||||
const release = () => {
|
||||
if (inFlight.get(key) === promise) inFlight.delete(key);
|
||||
};
|
||||
promise.then(release, release);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque toutes les données en vol comme périmées : la génération avance et
|
||||
* la Map est vidée. À appeler depuis l'invalidation du service (l'abonnement
|
||||
* `onSwitch()` reste le seul déclencheur, D8).
|
||||
*
|
||||
* Vider la Map ne coupe personne : les appelants déjà en attente gardent
|
||||
* leur référence à la promesse et reçoivent son résultat — simplement, ce
|
||||
* résultat ne sera pas mis en cache.
|
||||
*/
|
||||
function invalidate() {
|
||||
generation++;
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de chargements en vol — diagnostic seulement.
|
||||
*/
|
||||
function pendingCount() {
|
||||
return inFlight.size;
|
||||
}
|
||||
|
||||
return { run, invalidate, pendingCount };
|
||||
}
|
||||
|
||||
module.exports = { createSingleFlight };
|
||||
@@ -6,6 +6,27 @@
|
||||
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
|
||||
* @param {string} entityType - Entity type (Products, Containers, etc.)
|
||||
@@ -19,11 +40,19 @@ const entityResolver = require('./entity-resolver');
|
||||
* @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.
|
||||
const { tableName, warning } = await entityResolver.resolveEntityType(entityType);
|
||||
// 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
|
||||
@@ -39,17 +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,
|
||||
@@ -57,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}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,11 +182,17 @@ 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.
|
||||
const { tableName, warning } = await entityResolver.resolveEntityType(entityType);
|
||||
// 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()
|
||||
@@ -165,20 +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}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,4 +228,5 @@ module.exports = {
|
||||
searchEntities,
|
||||
getEntitySchema,
|
||||
countEntities,
|
||||
assertValidQueryType,
|
||||
};
|
||||
|
||||
@@ -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,18 +126,90 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
@@ -99,9 +218,10 @@ async function fetchAllWorkflows() {
|
||||
* @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) {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
async function searchWorkflows(query, category = null, limit = 50, application) {
|
||||
const workflows = await fetchAllWorkflows(application);
|
||||
|
||||
let results = workflows;
|
||||
|
||||
@@ -130,15 +250,17 @@ 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) {
|
||||
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.');
|
||||
}
|
||||
|
||||
const workflows = await fetchAllWorkflows();
|
||||
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
|
||||
@@ -149,82 +271,70 @@ async function getWorkflowDetails(workflowId) {
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow not found: ${workflowId}. Utilisez search_workflows pour trouver l'id ou le nom exact.`);
|
||||
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 distinct applicationName values.
|
||||
* Workflows have no category field — applicationName is the only grouping the
|
||||
* AD API provides, and every workflow of the active application carries the
|
||||
* same value (e.g. "EasyWMS").
|
||||
* Get workflow statistics for one application
|
||||
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
||||
*/
|
||||
async function listWorkflowCategories() {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
|
||||
const categories = new Set();
|
||||
workflows.forEach(w => {
|
||||
const applicationName = w.applicationName || w.ApplicationName;
|
||||
if (applicationName) {
|
||||
categories.add(applicationName);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort alphabetically
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow statistics
|
||||
*/
|
||||
async function getWorkflowStats() {
|
||||
const workflows = await fetchAllWorkflows();
|
||||
const categories = await listWorkflowCategories();
|
||||
|
||||
// Count workflows per applicationName (the only grouping in the data)
|
||||
const categoryCounts = {};
|
||||
workflows.forEach(w => {
|
||||
const cat = w.applicationName || w.ApplicationName || '(unknown)';
|
||||
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
|
||||
|
||||
+56
-31
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const adService = require('../services/ad-service');
|
||||
const workflowService = require('../services/workflow-service');
|
||||
|
||||
/**
|
||||
* List available AD tools
|
||||
@@ -12,7 +13,7 @@ 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,
|
||||
@@ -21,7 +22,7 @@ function listTools() {
|
||||
},
|
||||
{
|
||||
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,
|
||||
@@ -35,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'],
|
||||
},
|
||||
@@ -59,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'],
|
||||
},
|
||||
@@ -78,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'],
|
||||
},
|
||||
@@ -140,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)
|
||||
}]
|
||||
};
|
||||
@@ -181,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));
|
||||
@@ -208,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
|
||||
@@ -220,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 => ({
|
||||
@@ -234,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)
|
||||
}]
|
||||
@@ -252,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: [{
|
||||
@@ -264,6 +288,7 @@ async function getADElementDetailsTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
...(application ? { application } : {}),
|
||||
element
|
||||
}, null, 2)
|
||||
}]
|
||||
|
||||
+60
-20
@@ -1,5 +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
|
||||
@@ -36,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'],
|
||||
},
|
||||
@@ -85,12 +92,23 @@ 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.
|
||||
const { tableName, warning } = await entityResolver.resolveEntityType(entity_type);
|
||||
// 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.${tableName}`;
|
||||
@@ -103,26 +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,
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
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: [
|
||||
@@ -132,6 +169,8 @@ async function callQueryAPI(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'call_query_api',
|
||||
...(resolution?.warning ? { warning: resolution.warning } : {}),
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -177,6 +216,7 @@ async function executeCommand(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'execute_command',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -113,7 +113,7 @@ async function listLogFiles() {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'list_log_files' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -156,6 +156,7 @@ async function readRecentLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'read_recent_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -225,6 +226,7 @@ async function searchLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'search_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -109,6 +109,7 @@ async function getEntityMetadata(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `No entity matching "${entity_name}" found`,
|
||||
tool: 'get_entity_metadata',
|
||||
availableCount: Array.isArray(entities) ? entities.length : '?',
|
||||
hint: 'Call get_entity_metadata without entity_name to see all entities',
|
||||
}, null, 2),
|
||||
@@ -150,7 +151,7 @@ async function getEntityMetadata(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'get_entity_metadata' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -193,7 +194,7 @@ async function genericSearch(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'generic_search' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,7 @@ function getCurrentProfileTool() {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'get_current_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -127,6 +128,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing "profile" argument',
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -156,6 +158,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const wmsQueryService = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* List available WMS query tools
|
||||
@@ -13,7 +14,7 @@ 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).
|
||||
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.
|
||||
|
||||
@@ -44,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'],
|
||||
},
|
||||
@@ -96,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'],
|
||||
},
|
||||
@@ -167,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: [{
|
||||
@@ -261,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 = {
|
||||
|
||||
+131
-24
@@ -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,7 +18,7 @@ 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,
|
||||
@@ -23,26 +29,45 @@ function listTools() {
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Filter by applicationName — the only grouping the AD API provides (workflows have no category field). All workflows of the active application share the same value (e.g. "EasyWMS").',
|
||||
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'],
|
||||
@@ -50,11 +75,16 @@ function listTools() {
|
||||
},
|
||||
{
|
||||
name: 'list_workflow_categories',
|
||||
description: 'List workflow groupings by applicationName. Workflows have no category field in the AD API — applicationName is the only grouping available, and all workflows of the active application share the same value.',
|
||||
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',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -98,18 +128,29 @@ 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,
|
||||
...(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 => {
|
||||
@@ -129,23 +170,74 @@ async function searchWorkflows(args) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de
|
||||
* paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau.
|
||||
*/
|
||||
function assertWindowValue(value, fallback, paramName) {
|
||||
if (value == null) return fallback;
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
const attendu = paramName === 'max_data_chars'
|
||||
? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules`
|
||||
: 'offset de départ dans le blob data, défaut 0';
|
||||
throw new Error(
|
||||
`${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: get_workflow_details
|
||||
*
|
||||
* La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères
|
||||
* sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse
|
||||
* complète dépassait le seuil de rejet du client MCP (D24). On renvoie une
|
||||
* TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées
|
||||
* restent complètes, et concaténer les tranches dans l'ordre des offsets
|
||||
* reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce
|
||||
* blob pour n'en renvoyer que des morceaux jugés utiles.
|
||||
*/
|
||||
async function getWorkflowDetails(args) {
|
||||
const { workflow_id } = 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)
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -154,22 +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,
|
||||
note: 'Workflows have no category field in the AD API — these are the distinct applicationName values, the only grouping available. All workflows of the active application share the same value.',
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user