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