Compare commits
30 Commits
8c5792da52
...
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 |
@@ -60,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
|
||||||
@@ -104,7 +108,11 @@ serveur au démarrage.
|
|||||||
[MONITORING.md](MONITORING.md) §2.
|
[MONITORING.md](MONITORING.md) §2.
|
||||||
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
|
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
|
||||||
structurée `{ success: false, error, tool }` avec `isError: true` — le
|
structurée `{ success: false, error, tool }` avec `isError: true` — le
|
||||||
wrapper est dans le handler `tools/call` de `src/index.js`.
|
wrapper est dans le handler `tools/call` de `src/index.js`. Les enveloppes
|
||||||
|
construites **localement** dans `src/tools/` portent le champ `tool` elles
|
||||||
|
aussi : le wrapper ne les voit pas, et une erreur sans `tool` sort du
|
||||||
|
contrat. Les champs supplémentaires utiles (`warning` de résolution,
|
||||||
|
`profiles`, `hint`…) viennent après.
|
||||||
4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
|
4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
|
||||||
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
|
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
|
||||||
disponibles : … »).
|
disponibles : … »).
|
||||||
@@ -145,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 :
|
||||||
|
|
||||||
@@ -165,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
|
||||||
@@ -200,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
|
||||||
@@ -219,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
|
||||||
@@ -278,14 +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.
|
||||||
|
|
||||||
⚠️ Piège connu et non encore corrigé, à garder en tête en attendant le lot 2 :
|
|
||||||
|
|
||||||
- `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 (erreur de compilation LINQ) remonte désormais dans
|
|
||||||
la réponse de l'outil (L1.1).
|
|
||||||
|
|||||||
+363
@@ -353,6 +353,41 @@ 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
|
## 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
|
**Piège.** Le handler `tools/call` de `src/index.js` routait par préfixe de nom
|
||||||
@@ -379,3 +414,331 @@ 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
|
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
|
transmis tel quel au `executeTool()` du module. Ajouter un paramètre à un
|
||||||
outil ne la concerne pas.
|
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.
|
||||||
|
|||||||
+31
-188
@@ -12,195 +12,18 @@ 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 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.
|
|
||||||
|
|
||||||
### L2.3 — Arguments manquants : deux garde-fous
|
|
||||||
|
|
||||||
Découverts lors de la révision du lot 1 (24/08/2026), en bouclant sur les 23
|
|
||||||
outils avec des arguments vides. Préexistants au lot 1 (vérifié sur le diff) :
|
|
||||||
|
|
||||||
- `get_workflow_details` sans `workflow_id` renvoie `success: true` avec **le
|
|
||||||
premier workflow du cache**. Cause : `getWorkflowDetails()`
|
|
||||||
(`workflow-service.js`) compare `w.Code === workflowId` — or `Code`, `Id`,
|
|
||||||
`Name` n'existent pas sur les objets réels (clés minuscules, D5), donc
|
|
||||||
`undefined === undefined` matche. Supprimer les clés mortes de la
|
|
||||||
comparaison et rejeter un `workflow_id` absent avec un message actionnable.
|
|
||||||
- `search_logs` sans terme de recherche renvoie
|
|
||||||
`Search failed: Cannot read properties of undefined (reading 'toLowerCase')`
|
|
||||||
— le contrat d'erreur tient, mais le message viole la convention 4
|
|
||||||
(actionnable). Garde d'entrée avec le nom du paramètre attendu.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 — Erreurs d'authentification muettes
|
|
||||||
|
|
||||||
`authenticate()` (`api-service.js`) ré-enveloppe l'erreur axios en
|
|
||||||
`new Error('Authentication failed: ' + error.message)` : le **corps de la
|
|
||||||
réponse du STS est perdu**, alors qu'il contient le diagnostic complet.
|
|
||||||
|
|
||||||
Preuve (24/08/2026, profil `AD`) : le smoke test affiche seulement
|
|
||||||
`Authentication failed: Request failed with status code 400` ; en rejouant la
|
|
||||||
même requête à la main, le corps était
|
|
||||||
`{"error":"invalid_request","error_description":"Tenant not found"}` — le
|
|
||||||
diagnostic exact, invisible depuis les outils comme depuis le smoke test.
|
|
||||||
|
|
||||||
Faire remonter `error.response.data` dans le message, comme L1.1 l'a fait pour
|
|
||||||
les outils. Même contrainte : ne jamais logguer les credentials.
|
|
||||||
|
|
||||||
### L3.3 — Documentation
|
|
||||||
|
|
||||||
- DECISIONS.md : **D21** la règle `TableName` (D22, le routage par table
|
|
||||||
explicite, a été livrée avec le lot 1).
|
|
||||||
- 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 2 et 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
|
||||||
|
|
||||||
@@ -248,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),
|
||||||
@@ -259,8 +82,6 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Écarté
|
## Écarté
|
||||||
|
|
||||||
| Proposition | Raison |
|
| Proposition | Raison |
|
||||||
@@ -279,8 +100,30 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
|||||||
le tenant `AD`. L'hôte et le STS fonctionnent (LIMAGRAIN, même hôte, passe
|
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
|
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
|
tenant existant. Correction côté propriétaire du dépôt (mettre à jour ou
|
||||||
retirer le profil) — pas un bug du code. Le message opaque du smoke test est
|
retirer le profil) — pas un bug du code. Depuis L3.2, le corps de la réponse
|
||||||
traité à part (L3.2).
|
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
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
# Passation — lot 2
|
|
||||||
|
|
||||||
Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui
|
|
||||||
donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST.
|
|
||||||
Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) avant de
|
|
||||||
toucher au code.
|
|
||||||
|
|
||||||
**Mission** : livrer le lot 2 de [../ROADMAP.md](../ROADMAP.md) — résolution
|
|
||||||
des noms d'entités via l'API Metadata (L2.1), rejet des paramètres inconnus
|
|
||||||
(L2.2), deux garde-fous d'arguments manquants (L2.3) — plus un petit correctif
|
|
||||||
du lot 3 (L3.2, erreurs d'authentification muettes).
|
|
||||||
|
|
||||||
**Hors périmètre** : tout le reste de la roadmap (L3.1, lot 4), le build pkg,
|
|
||||||
le déploiement. Ne pousse rien (`git push` interdit), ne touche pas au `.env`,
|
|
||||||
n'appelle jamais `execute_command` (il écrit dans le WMS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contexte matériel
|
|
||||||
|
|
||||||
- Profil de travail : `LIMAGRAIN` (profil par défaut), host `10.255.255.2`,
|
|
||||||
tenant `LIMAGRAI2512`. `EUROTRAFIC` fonctionne aussi (SaaS).
|
|
||||||
- **Le profil `AD` est cassé et c'est déjà diagnostiqué — ne le réinvestigue
|
|
||||||
pas.** Son tenant n'existe plus côté STS (`400 Tenant not found`). Conséquence
|
|
||||||
pratique : `npm test -- --all` échouera toujours sur AD ; la baseline se
|
|
||||||
mesure avec `npm test` (profil par défaut), attendu **4/4, code de sortie 0**.
|
|
||||||
- Baseline protocolaire à préserver : **23 outils**, **6 resources**, aucune
|
|
||||||
écriture sur stdout hors JSON-RPC.
|
|
||||||
|
|
||||||
Handshake + comptages :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
|
|
||||||
```
|
|
||||||
|
|
||||||
Appeler un outil via le protocole (la seule preuve qu'un outil marche) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NOM","arguments":{}}}' | node src/index.js 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contraintes non négociables
|
|
||||||
|
|
||||||
1. `console.error()` uniquement — une écriture sur stdout casse Claude Desktop
|
|
||||||
(D6).
|
|
||||||
2. Un outil ne plante jamais le serveur : erreurs en réponse structurée
|
|
||||||
`{ success: false, error, tool }`, `isError: true` (wrapper de
|
|
||||||
`src/index.js`).
|
|
||||||
3. Messages d'erreur actionnables : dire quoi faire ensuite (convention 4 de
|
|
||||||
CLAUDE.md).
|
|
||||||
4. Aucun accès base de données (D1).
|
|
||||||
5. Tout nouvel état lié au tenant s'invalide par abonnement
|
|
||||||
`profileManager.onSwitch()`, jamais à la main depuis un autre module (D8).
|
|
||||||
6. `QueryType: 0` (D3), pas de date relative (D12), 1000 lignes max.
|
|
||||||
|
|
||||||
**Numéros de décision réservés** : **D21** = règle `TableName` (L2.1),
|
|
||||||
**D23** = validation des paramètres (L2.2). N'en attribue pas d'autres sans les
|
|
||||||
réserver dans DECISIONS.md.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Vérifications avant de coder
|
|
||||||
|
|
||||||
Mesures déjà faites (24/08/2026, `LIMAGRAI2512`) à **confirmer**, pas à
|
|
||||||
réinvestiguer :
|
|
||||||
|
|
||||||
- `GET /Metadata/Entities` renvoie **232 entités** pour `EasyWMS`, chacune avec
|
|
||||||
`Name` et `TableName` ; `TableName` est **unique** sur les 232.
|
|
||||||
- Le mapping n'est **pas** une pluralisation : `Container` -> `Containers`,
|
|
||||||
mais `Alias` -> `Alias` (invariant), et `Item` n'existe pas.
|
|
||||||
- Le contexte de lecture est **commun au tenant** : la table de résolution doit
|
|
||||||
agréger le Metadata de **toutes** les applications
|
|
||||||
(`GET /Metadata/Entities?applicationName=…` — 9 applications, listées par
|
|
||||||
`POST /AD/api/Application/GetAll`, payload `null`). `CustomApp` renvoie
|
|
||||||
**0 entité** : c'est mesuré et normal, ne cherche pas pourquoi.
|
|
||||||
|
|
||||||
**V1 — le SDK MCP valide-t-il les schémas ?** Mesure indispensable avant L2.2 :
|
|
||||||
ajoute `additionalProperties: false` à **un** schéma, appelle l'outil via le
|
|
||||||
protocole avec un paramètre inconnu, et observe. Si le SDK rejette : L2.2 =
|
|
||||||
ajout sur les 23 schémas. Si le SDK laisse passer (probable) : la validation
|
|
||||||
doit se faire dans le wrapper `tools/call` de `src/index.js` (à la main ou via
|
|
||||||
une lib déjà présente dans l'arbre des dépendances — n'ajoute pas de dépendance
|
|
||||||
lourde sans nécessité). Time-box : 30 min ; si le comportement du SDK est
|
|
||||||
ambigu, note-le dans le commit et implémente la validation côté wrapper.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L2.1 — Résolution des entités via l'API Metadata
|
|
||||||
|
|
||||||
**Problème.** `entity_type` est interpolé sans validation dans
|
|
||||||
`Context.{entity_type}` à deux endroits : `src/tools/api-tools.js:89` et
|
|
||||||
`src/services/wms-query-service.js:29` (et `:149`). Le nom attendu est le
|
|
||||||
`TableName` du Metadata, pas le nom d'entité de l'AD. Un mauvais nom part en
|
|
||||||
HTTP 500 côté WMS.
|
|
||||||
|
|
||||||
**Preuve.** `query_wms_entities("Container")` répond aujourd'hui :
|
|
||||||
|
|
||||||
```
|
|
||||||
Query failed for Container: POST …/QueryExecute failed (HTTP 500): …
|
|
||||||
Response body: … error CS1061: 'ApplicationReadingContext' ne contient pas de
|
|
||||||
définition pour 'Container' …
|
|
||||||
```
|
|
||||||
|
|
||||||
**À faire.**
|
|
||||||
- Une table de résolution `Name|TableName (insensible à la casse) -> TableName`,
|
|
||||||
construite depuis le Metadata **agrégé sur les 9 applications**, dans un
|
|
||||||
service (pas dupliquée dans les deux points d'interpolation).
|
|
||||||
- Cache : TTL partagé (`WORKFLOW_CACHE_TTL`), chargement paresseux,
|
|
||||||
**abonnement `onSwitch()`** pour l'invalidation (D8).
|
|
||||||
- Nom inconnu → échec **avant tout appel réseau**, message actionnable avec
|
|
||||||
suggestions proches et renvoi vers `get_entity_metadata`, par exemple :
|
|
||||||
« "Item" n'existe pas dans le modèle Reading. Proches : ItemGroup, StockItem.
|
|
||||||
232 entités disponibles — utilisez `get_entity_metadata` pour la liste. »
|
|
||||||
- Si le Metadata est injoignable au moment de résoudre : laisse passer le nom
|
|
||||||
tel quel (comportement actuel) plutôt que de bloquer tout — et dis-le dans la
|
|
||||||
réponse.
|
|
||||||
|
|
||||||
**Pente naturelle interdite** : ne réinvente pas une règle de pluralisation
|
|
||||||
(« ajouter un s sauf si… »). Seul `TableName` fait foi ; c'est un mapping mesuré,
|
|
||||||
pas une grammaire.
|
|
||||||
|
|
||||||
**Vérification attendue** (via le protocole, profil LIMAGRAIN) :
|
|
||||||
- `query_wms_entities("Container", limit 1)` → **succès**, 1 ligne (résolu en
|
|
||||||
`Containers`).
|
|
||||||
- `query_wms_entities("Alias")` → succès (invariant, pas de pluriel inventé).
|
|
||||||
- `query_wms_entities("Item")` → échec **sans appel réseau** (aucune ligne
|
|
||||||
`[API] POST` dans les logs stderr), message avec suggestions.
|
|
||||||
- `count_wms_entities("Product")` → succès, ~51 160.
|
|
||||||
- `get_entity_schema` et `call_query_api` bénéficient de la même résolution.
|
|
||||||
|
|
||||||
## L2.2 — Rejeter les paramètres inconnus
|
|
||||||
|
|
||||||
**Problème.** Le SDK ignore silencieusement les paramètres non déclarés :
|
|
||||||
`read_recent_logs(lines: 60)` retombe sur `count = 100` sans signal.
|
|
||||||
|
|
||||||
**À faire.** Selon le résultat de V1 : `additionalProperties: false` sur les
|
|
||||||
23 schémas, et/ou validation dans le wrapper `tools/call`. Le message de rejet
|
|
||||||
nomme le paramètre inconnu **et** les paramètres valides de l'outil.
|
|
||||||
|
|
||||||
**Décision déjà tranchée, ne pas rouvrir** : on ne renomme aucun paramètre
|
|
||||||
(cf. ROADMAP « Écarté »).
|
|
||||||
|
|
||||||
**Vérification attendue** : `read_recent_logs` avec `{"lines": 5}` → erreur
|
|
||||||
structurée nommant `lines` comme inconnu et `count` comme valide. Un appel
|
|
||||||
valide (`{"count": 5}`) → succès, 5 lignes.
|
|
||||||
|
|
||||||
## L2.3 — Garde-fous d'arguments manquants
|
|
||||||
|
|
||||||
**Preuves** (mesurées le 24/08/2026, préexistantes au lot 1) :
|
|
||||||
- `get_workflow_details` avec `{}` → `success: true` avec **le premier workflow
|
|
||||||
du cache**. Cause dans `workflow-service.js` `getWorkflowDetails()` : la
|
|
||||||
recherche compare `w.Id`, `w.Code`, `w.Name` — clés qui n'existent pas sur
|
|
||||||
les objets réels (minuscules, D5) — donc `undefined === undefined` matche.
|
|
||||||
- `search_logs` avec `{}` →
|
|
||||||
`Search failed: Cannot read properties of undefined (reading 'toLowerCase')`.
|
|
||||||
|
|
||||||
**À faire.** Supprimer les clés mortes (`Id`, `Code`, `Name` majuscules) de la
|
|
||||||
comparaison de `getWorkflowDetails()` ; rejeter `workflow_id` absent avec un
|
|
||||||
message actionnable. Garde d'entrée sur `search_logs` nommant le paramètre
|
|
||||||
attendu. (Si L2.2 rend certains cas impossibles via `required`, garde quand
|
|
||||||
même la garde côté code : la validation SDK n'est pas garantie.)
|
|
||||||
|
|
||||||
**Vérification attendue** : les deux appels avec `{}` renvoient une erreur
|
|
||||||
structurée actionnable ; `get_workflow_details` avec un id réel renvoie
|
|
||||||
toujours l'objet brut complet.
|
|
||||||
|
|
||||||
## L3.2 — Erreurs d'authentification muettes
|
|
||||||
|
|
||||||
**Preuve.** `authenticate()` dans `src/services/api-service.js` (le `catch`
|
|
||||||
vers la ligne 79) ré-enveloppe l'erreur axios en
|
|
||||||
`Authentication failed: Request failed with status code 400` et **jette le
|
|
||||||
corps de la réponse du STS**, qui contenait le diagnostic exact :
|
|
||||||
`{"error":"invalid_request","error_description":"Tenant not found"}` (mesuré
|
|
||||||
sur le profil `AD`).
|
|
||||||
|
|
||||||
**À faire.** Faire remonter statut + corps de réponse du STS dans le message,
|
|
||||||
sur le modèle de ce que L1.1 a fait pour `post()`/`get()` (réutilise le même
|
|
||||||
helper d'enrichissement si possible). **Jamais** les credentials ni les headers
|
|
||||||
dans le message. Même traitement pour `refreshOAuthToken()`.
|
|
||||||
|
|
||||||
**Vérification attendue** : `switch_wms_profile("AD")` puis un
|
|
||||||
`count_wms_entities("Products")` → la réponse d'outil contient
|
|
||||||
`Tenant not found` (et aucun mot de passe). Ne « répare » pas le profil AD :
|
|
||||||
son échec est précisément ce qui rend le correctif vérifiable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Méthode
|
|
||||||
|
|
||||||
1. Phase 0 (V1) d'abord — L2.2 en dépend.
|
|
||||||
2. Ordre : L2.1, puis L2.2, puis L2.3, puis L3.2. Chaque correctif vérifié **en
|
|
||||||
exécution via le protocole** avant de passer au suivant.
|
|
||||||
3. Après tout changement de `src/index.js` ou des schémas : reboucler sur les
|
|
||||||
23 noms de `tools/list` et vérifier qu'aucun ne répond `Unknown tool`
|
|
||||||
(n'appelle pas `execute_command` — vérifie sa branche statiquement).
|
|
||||||
4. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0).
|
|
||||||
5. « Non résolu » est une réponse acceptable pour une investigation time-boxée ;
|
|
||||||
une hypothèse présentée comme solution ne l'est pas.
|
|
||||||
|
|
||||||
## Livraison
|
|
||||||
|
|
||||||
- Un commit par correctif (L2.1, L2.2, L2.3, L3.2), messages expliquant le
|
|
||||||
pourquoi, mesures dans le corps du message.
|
|
||||||
- Documentation dans les mêmes commits : **D21** et **D23** dans DECISIONS.md ;
|
|
||||||
CLAUDE.md — retirer le piège `entity_type` des « Points ouverts » et corriger
|
|
||||||
la liste d'entités (`Aliases` -> `Alias`, renvoi vers `get_entity_metadata`) ;
|
|
||||||
ROADMAP.md — retirer L2.1/L2.2/L2.3/L3.2 livrés (L3.3 documentation est
|
|
||||||
partiellement couverte par ces mises à jour : ajuste-la, ne la supprime que si
|
|
||||||
tout est fait, l'exemple `wms://query-examples` compris).
|
|
||||||
- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en
|
|
||||||
route : dans ROADMAP.md, pas dans le code.
|
|
||||||
- Compte-rendu final : pour chaque correctif, la vérification attendue rejouée
|
|
||||||
et son résultat **mesuré** (colle la sortie), plus la baseline finale.
|
|
||||||
@@ -94,6 +94,36 @@ for (const { moduleName, module } of TOOL_MODULES) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
{
|
{
|
||||||
@@ -193,6 +223,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
|
`Unknown tool: ${name}. Available tools: ${Array.from(toolRegistry.keys()).join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
validateToolArgs(entry.definition, args);
|
||||||
return await entry.module.executeTool(name, 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);
|
||||||
|
|||||||
+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 };
|
||||||
+114
-61
@@ -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,
|
||||||
|
|||||||
+27
-15
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,18 +126,90 @@ 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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les applications déclarées (POST /Application/GetAll, payload null).
|
||||||
|
* La réponse est une enveloppe { entities: [...] } (D4) dont chaque élément
|
||||||
|
* porte un blob `data` volumineux — on ne conserve que les champs légers.
|
||||||
|
* Cache TTL commun, vidé au switch de profil.
|
||||||
|
* @returns {Promise<Array<{name: string, id: string, version: number}>>}
|
||||||
|
*/
|
||||||
|
async function fetchApplications() {
|
||||||
|
const cached = getCachedApplications();
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Même déduplication et même garde de génération, sur sa propre clé (D27).
|
||||||
|
return singleFlight.run('applications', loadApplications, (applications) => {
|
||||||
|
applicationsCache = applications;
|
||||||
|
applicationsTimestamp = Date.now();
|
||||||
|
console.error(`[Workflow] Cached ${applications.length} application(s)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargement réel de la liste d'applications (D27). N'écrit rien en cache.
|
||||||
|
*/
|
||||||
|
async function loadApplications() {
|
||||||
|
console.error('[Workflow] Fetching application list (Application/GetAll)...');
|
||||||
|
const response = await apiService.post('/Application/GetAll', null, true);
|
||||||
|
const entities = requireEntities(response, 'Application/GetAll');
|
||||||
|
|
||||||
|
return entities.map(a => ({
|
||||||
|
name: a.name || a.Name,
|
||||||
|
id: a.id || a.Id,
|
||||||
|
version: a.version ?? a.Version,
|
||||||
|
})).filter(a => a.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des applications déjà en cache, ou null si le cache est vide/expiré.
|
||||||
|
* Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse
|
||||||
|
* de recherche sans jamais précharger une application non demandée (D26).
|
||||||
|
* @returns {Array<{name: string, id: string, version: number}>|null}
|
||||||
|
*/
|
||||||
|
function getCachedApplications() {
|
||||||
|
if (applicationsCache && applicationsTimestamp &&
|
||||||
|
Date.now() - applicationsTimestamp < CACHE_TTL) {
|
||||||
|
return applicationsCache;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application
|
||||||
|
* sur les neuf déclarées, et rien dans la réponse ne le disait : une session
|
||||||
|
* cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à
|
||||||
|
* tort qu'il n'y en avait aucun (25/08/2026).
|
||||||
|
*
|
||||||
|
* Les autres applications sont nommées depuis la liste allégée **déjà en
|
||||||
|
* cache** ; sans elle, le hint reste générique et renvoie vers
|
||||||
|
* `list_workflow_categories` — jamais de fetch pour construire un hint.
|
||||||
|
*
|
||||||
|
* @param {string} application - application effectivement interrogée
|
||||||
|
* @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…)
|
||||||
|
*/
|
||||||
|
function buildOtherApplicationsHint(application, sujet) {
|
||||||
|
const cached = getCachedApplications();
|
||||||
|
const others = (cached || []).map(a => a.name).filter(n => n !== application);
|
||||||
|
|
||||||
|
const liste = others.length
|
||||||
|
? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.`
|
||||||
|
: `Appelez list_workflow_categories pour lister les autres applications déclarées.`;
|
||||||
|
|
||||||
|
const custom = application.toLowerCase() === 'customapp'
|
||||||
|
? ''
|
||||||
|
: ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`;
|
||||||
|
|
||||||
|
return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` +
|
||||||
|
`les autres ne le sont jamais implicitement.${custom} ${liste}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search workflows by query string.
|
* Search workflows by query string.
|
||||||
* Real AD keys (lowercase, cf. D5): id, name, version, applicationName,
|
* Real AD keys (lowercase, cf. D5): id, name, version, applicationName,
|
||||||
@@ -99,9 +218,10 @@ async function fetchAllWorkflows() {
|
|||||||
* @param {string|null} category - Optional applicationName filter (the only
|
* @param {string|null} category - Optional applicationName filter (the only
|
||||||
* grouping the AD API provides)
|
* grouping the AD API provides)
|
||||||
* @param {number} limit - Maximum results to return
|
* @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) {
|
async function searchWorkflows(query, category = null, limit = 50, application) {
|
||||||
const workflows = await fetchAllWorkflows();
|
const workflows = await fetchAllWorkflows(application);
|
||||||
|
|
||||||
let results = workflows;
|
let results = workflows;
|
||||||
|
|
||||||
@@ -130,99 +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 distinct applicationName values.
|
* Get workflow statistics for one application
|
||||||
* Workflows have no category field — applicationName is the only grouping the
|
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
||||||
* AD API provides, and every workflow of the active application carries the
|
|
||||||
* same value (e.g. "EasyWMS").
|
|
||||||
*/
|
*/
|
||||||
async function listWorkflowCategories() {
|
async function getWorkflowStats(application) {
|
||||||
const workflows = await fetchAllWorkflows();
|
const app = resolveApplication(application);
|
||||||
|
const workflows = await fetchAllWorkflows(app);
|
||||||
const categories = new Set();
|
|
||||||
workflows.forEach(w => {
|
|
||||||
const applicationName = w.applicationName || w.ApplicationName;
|
|
||||||
if (applicationName) {
|
|
||||||
categories.add(applicationName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort alphabetically
|
|
||||||
return Array.from(categories).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get workflow statistics
|
|
||||||
*/
|
|
||||||
async function getWorkflowStats() {
|
|
||||||
const workflows = await fetchAllWorkflows();
|
|
||||||
const categories = await listWorkflowCategories();
|
|
||||||
|
|
||||||
// Count workflows per applicationName (the only grouping in the data)
|
|
||||||
const categoryCounts = {};
|
|
||||||
workflows.forEach(w => {
|
|
||||||
const cat = w.applicationName || w.ApplicationName || '(unknown)';
|
|
||||||
categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
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,38 +153,37 @@ 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: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
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)
|
||||||
}]
|
}]
|
||||||
|
|||||||
+67
-18
@@ -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: [
|
success: true,
|
||||||
{
|
entityType: entity_type,
|
||||||
type: 'text',
|
resolvedTableName: tableName,
|
||||||
text: JSON.stringify(
|
...(warning ? { warning } : {}),
|
||||||
{
|
...(queryType !== 0 ? { queryType } : {}),
|
||||||
success: true,
|
|
||||||
entityType: entity_type,
|
|
||||||
result,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Garde de taille (D24) : une SEULE ligne Writing faisait 95 288 caractères
|
||||||
|
// — le modèle Writing sérialise l'agrégat complet. On écarte des lignes
|
||||||
|
// entières ; sous le plafond, la réponse est strictement celle d'avant.
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ ...head, result }, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildText = (kept) => {
|
||||||
|
const payload = { ...head };
|
||||||
|
if (kept < result.length) {
|
||||||
|
// Cet outil ne porte pas de champ de total : on l'ajoute (D24).
|
||||||
|
payload.totalRows = result.length;
|
||||||
|
Object.assign(payload, truncationSignal({
|
||||||
|
returned: kept,
|
||||||
|
total: result.length,
|
||||||
|
queryType,
|
||||||
|
unit: 'ligne',
|
||||||
|
feminine: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
payload.result = result.slice(0, kept);
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { text } = fitToCap(result.length, buildText);
|
||||||
|
return { content: [{ type: 'text', text }] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -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)));
|
||||||
|
|
||||||
|
// Pagination (L3.1) : sans elle la sortie sans filtre atteint ~70 000
|
||||||
|
// caractères et se fait rejeter par les clients MCP. totalParameters est
|
||||||
|
// le total correspondant aux filtres, AVANT pagination — le signal
|
||||||
|
// truncated se vérifie donc depuis la réponse : offset + returned < total.
|
||||||
|
const matched = rows.length;
|
||||||
|
const page = rows.slice(offset, offset + limit);
|
||||||
|
const truncated = offset + page.length < matched;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
success: true,
|
||||||
|
warehouse: warehouse || '(none — effective value = default)',
|
||||||
|
filters: { param_class: param_class || null, search: search || null, only_overridden },
|
||||||
|
totalParameters: matched,
|
||||||
|
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
|
||||||
|
returned: page.length,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
if (truncated) {
|
||||||
|
payload.truncated = true;
|
||||||
|
payload.hint = `Showing parameters ${offset + 1}-${offset + page.length} of ${matched}. Call again with offset=${offset + page.length} for the next page, or narrow the result with param_class / search.`;
|
||||||
|
}
|
||||||
|
payload.parameters = page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify({
|
text: JSON.stringify(payload, null, 2),
|
||||||
success: true,
|
|
||||||
warehouse: warehouse || '(none — effective value = default)',
|
|
||||||
filters: { param_class: param_class || null, search: search || null, only_overridden },
|
|
||||||
totalParameters: Array.isArray(parameters) ? parameters.length : 0,
|
|
||||||
totalOverrides: Array.isArray(paramValues) ? paramValues.length : 0,
|
|
||||||
returned: rows.length,
|
|
||||||
parameters: rows,
|
|
||||||
}, null, 2),
|
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+44
-16
@@ -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);
|
||||||
|
|
||||||
|
// Garde-fou de taille (L3.1) : max_results borne le nombre de résultats,
|
||||||
|
// pas le volume — les context_lines multiplient la taille (55 954 chars
|
||||||
|
// mesurés avec les seuls défauts, rejetés par le client MCP). Au-delà du
|
||||||
|
// plafond on écarte des résultats ENTIERS (jamais coupés au milieu de
|
||||||
|
// leur contexte) et on le signale : truncated + omitted + hint.
|
||||||
|
const cap = parseInt(process.env.MAX_LOG_SEARCH_CHARS) || 25000;
|
||||||
|
|
||||||
|
const buildText = (kept) => {
|
||||||
|
const omitted = result.results.length - kept.length;
|
||||||
|
const payload = {
|
||||||
|
success: true,
|
||||||
|
keyword: result.keyword,
|
||||||
|
totalResults: result.totalResults,
|
||||||
|
returned: kept.length,
|
||||||
|
};
|
||||||
|
if (omitted > 0) {
|
||||||
|
payload.truncated = true;
|
||||||
|
payload.omitted = omitted;
|
||||||
|
payload.hint = `Plafond de taille de réponse atteint (${cap} caractères) : ${kept.length} résultat(s) renvoyé(s) sur ${result.totalResults}, ${omitted} écarté(s). Affinez le keyword, réduisez context_lines ou baissez max_results.`;
|
||||||
|
}
|
||||||
|
payload.results = kept;
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
let kept = result.results.slice();
|
||||||
|
let text = buildText(kept);
|
||||||
|
while (text.length > cap && kept.length > 0) {
|
||||||
|
kept.pop();
|
||||||
|
text = buildText(kept);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [{ type: 'text', text }],
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
keyword: result.keyword,
|
|
||||||
totalResults: result.totalResults,
|
|
||||||
results: result.results,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
} 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),
|
||||||
}],
|
}],
|
||||||
|
|||||||
+102
-27
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const { data, ...head } = result;
|
||||||
content: [{
|
|
||||||
type: 'text',
|
// Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes.
|
||||||
text: JSON.stringify({
|
if (!Array.isArray(data)) {
|
||||||
success: true,
|
return {
|
||||||
...result
|
content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }]
|
||||||
}, null, 2)
|
};
|
||||||
}]
|
}
|
||||||
|
|
||||||
|
// `count` (dans head) porte déjà le total avant la coupe — c'est le total
|
||||||
|
// exigé par D24, inutile d'en ajouter un second.
|
||||||
|
const buildText = (kept) => {
|
||||||
|
const payload = { success: true, ...head };
|
||||||
|
if (kept < data.length) {
|
||||||
|
Object.assign(payload, truncationSignal({
|
||||||
|
returned: kept,
|
||||||
|
total: data.length,
|
||||||
|
queryType: query_type,
|
||||||
|
unit: 'ligne',
|
||||||
|
feminine: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
payload.data = data.slice(0, kept);
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { text } = fitToCap(data.length, buildText);
|
||||||
|
return { content: [{ type: 'text', text }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 = {
|
||||||
|
|||||||
+134
-24
@@ -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,9 +18,10 @@ 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',
|
||||||
@@ -22,25 +29,45 @@ function listTools() {
|
|||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Filter by applicationName — the only grouping the AD API provides (workflows have no category field). All workflows of the active application share the same value (e.g. "EasyWMS").',
|
description: 'Filter by the applicationName field of the returned workflows (workflows have no category field). Since `application` selects which application is fetched, all its workflows share the same applicationName — prefer `application` to change scope; `category` only narrows within the fetched set.',
|
||||||
},
|
},
|
||||||
limit: {
|
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 workflow groupings by applicationName. Workflows have no category field in the AD API — applicationName is the only grouping available, and all workflows of the active application share the same value.',
|
description: 'List the AD applications declared on the tenant (Application/GetAll) with their workflow counts where already loaded. Workflows have no category field — the application is the only grouping. Use the `application` parameter of the workflow/AD tools to query a specific one (e.g. "CustomApp" for client-specific CST_* workflows).',
|
||||||
inputSchema: {
|
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,18 +128,29 @@ 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,
|
||||||
|
...(hint ? { hint } : {}),
|
||||||
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
|
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
|
||||||
// applicationName, commonInfo. Pas de code/category/description.
|
// applicationName, commonInfo. Pas de code/category/description.
|
||||||
workflows: results.map(w => {
|
workflows: results.map(w => {
|
||||||
@@ -126,23 +170,74 @@ async function searchWorkflows(args) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de
|
||||||
|
* paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau.
|
||||||
|
*/
|
||||||
|
function assertWindowValue(value, fallback, paramName) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
if (!Number.isInteger(value) || value < 0) {
|
||||||
|
const attendu = paramName === 'max_data_chars'
|
||||||
|
? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules`
|
||||||
|
: 'offset de départ dans le blob data, défaut 0';
|
||||||
|
throw new Error(
|
||||||
|
`${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool: get_workflow_details
|
* Tool: get_workflow_details
|
||||||
|
*
|
||||||
|
* La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères
|
||||||
|
* sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse
|
||||||
|
* complète dépassait le seuil de rejet du client MCP (D24). On renvoie une
|
||||||
|
* TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées
|
||||||
|
* restent complètes, et concaténer les tranches dans l'ordre des offsets
|
||||||
|
* reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce
|
||||||
|
* blob pour n'en renvoyer que des morceaux jugés utiles.
|
||||||
*/
|
*/
|
||||||
async function getWorkflowDetails(args) {
|
async function getWorkflowDetails(args) {
|
||||||
const { workflow_id } = 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)
|
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -151,22 +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,
|
||||||
note: 'Workflows have no category field in the AD API — these are the distinct applicationName values, the only grouping available. All workflows of the active application share the same value.',
|
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.',
|
||||||
totalCategories: categories.length,
|
totalApplications: applications.length,
|
||||||
categories,
|
applications: enriched,
|
||||||
stats: {
|
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