From 92de85cf53e2426c3b6754c8169c4e82db09e28a Mon Sep 17 00:00:00 2001 From: Arthur Ria Date: Tue, 25 Aug 2026 11:24:12 +0200 Subject: [PATCH] =?UTF-8?q?L4.2=20:=20param=C3=A8tre=20application=20sur?= =?UTF-8?q?=20les=20outils=20AD=20et=20workflow=20(D26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'application venait de WMS_APPLICATION (partagée par tous les profils) : le MCP n'interrogeait que EasyWMS, alors que CustomApp porte le spécifique client (153 workflows CST_ sur ce tenant) et que 9 applications sont déclarées par Application/GetAll. Paramètre application (défaut : l'application du profil, comportement inchangé sans lui) sur get_ad_elements, search_ad_elements, get_ad_element_details, search_workflows, get_workflow_details, list_workflow_categories. Clés de cache : ad-service passe par (application, type), workflow-service par application — sans quoi un appel CustomApp polluerait le cache EasyWMS. L'invalidation reste l'abonnement onSwitch (D8), le chargement reste paresseux (aucun préchargement des 9 applications, D10). list_workflow_categories s'adosse à Application/GetAll (liste allégée en cache : le blob data de chaque application pèse ~100 Ko) ; get_application_summary regroupe par application et ne détaille que les entrées en cache (D24), workflows compris. Acté en D26 ; CLAUDE.md mis à jour (Caches, AD), L4.2 retiré de la ROADMAP. Vérifications rejouées via le protocole (LIMAGRAIN / LIMAGRAI2512), requêtes séquentielles : - search_workflows(CST_, application:CustomApp) -> 5 objets peuplés dont CST_SendRejectContainersToPK ([Workflow] Successfully cached 153 workflows for "CustomApp"). - get_ad_elements(Workflow, application:CustomApp) -> count 153, éléments CST_* ([AD] Successfully cached 153 CustomApp::Workflow). - Séquence EasyWMS -> CustomApp -> EasyWMS sur search_workflows : 4012 vs 153, retour en cache hit ([Workflow] Using cached data for "EasyWMS"), aucune pollution ; get_application_summary montre les deux caches (workflowCachesByApplication EasyWMS 4012 / CustomApp 153). - Sans paramètre application -> comportement inchangé (W1 = W3). - switch_wms_profile EUROTRAFIC puis retour -> [Workflow] Cache cleared, [AD] All caches invalidated, [EntityResolver] Cache cleared ; summary vide. - list_workflow_categories -> 9 applications, comptes réels des applications chargées. Baseline : tools/list 23 (les 23 noms répondent), resources 6, rejet D23 d'un paramètre inconnu OK (search_workflows/applikation), npm test 4/4 exit 0. Anomalie hors périmètre consignée dans ROADMAP.md : get_workflow_details peut dépasser le seuil de rejet client (~101 800 caractères mesurés sur CST_SendRejectContainersToPK), comportement antérieur au lot. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 25 +++-- DECISIONS.md | 49 +++++++++ ROADMAP.md | 59 ++-------- src/services/ad-service.js | 137 +++++++++++++---------- src/services/workflow-service.js | 182 ++++++++++++++++++------------- src/tools/ad-tools.js | 77 +++++++------ src/tools/workflow-tools.js | 69 ++++++++---- 7 files changed, 354 insertions(+), 244 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1647f3..dd767ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,13 +170,18 @@ disponibles : c'est ainsi que Claude sait appeler `switch_wms_profile`. ## Caches -Deux caches, TTL commun `WORKFLOW_CACHE_TTL` (3 600 000 ms), chargement -paresseux, vidés à chaque bascule de profil (D10). +TTL commun `WORKFLOW_CACHE_TTL` (3 600 000 ms), chargement paresseux, vidés à +chaque bascule de profil (D10). Les outils AD et workflow acceptent un +paramètre **`application`** (défaut : l'application du profil) — les clés de +cache incluent l'application pour éviter toute pollution croisée (D26). | Cache | Granularité | Pagination | |---|---|---| -| `workflow-service` | global (~3 700 workflows) | `WORKFLOW_PAGE_SIZE`, 5000 | -| `ad-service` | **un par type** (20 types) | `AD_ELEMENT_TYPES` : `View` 200, `Workflow` 5000, `Resource` 15000, autres 100000 | +| `workflow-service` | **un par application** (~4 000 EasyWMS, 153 CustomApp) + liste allégée d'`Application/GetAll` | `WORKFLOW_PAGE_SIZE`, 5000 | +| `ad-service` | **un par (application, type)** (20 types) | `AD_ELEMENT_TYPES` : `View` 200, `Workflow` 5000, `Resource` 15000, autres 100000 | + +**Ne préchargez jamais les 9 applications** : seule l'application demandée est +chargée (D26). Les tailles de page par type viennent de l'observation des timeouts serveur — ne les augmentez pas à l'aveugle. @@ -235,11 +240,13 @@ pluriel), `Tasks`, `Stocks`, `ProductLocations`, `InboundOrders`, `Receptions`, confondues) s'obtient par `get_entity_metadata` (API Metadata) — le catalogue de la resource `wms://entities` est un raccourci de confort, pas la référence. -**Application Dictionary** : 20 types, ~38 800 éléments. `Resource` (29 374) est -de loin le plus lourd ; 3 types sont valides mais vides (`Dashboard`, -`TimelineTemplate`, `Toggle`). `WorkflowAction` et `WritingModel` ont été -retirés — 404 (D17). Détail : -[docs/ad-api-validation.md](docs/ad-api-validation.md). +**Application Dictionary** : 20 types, ~38 800 éléments (sur `EasyWMS`). +`Resource` (29 374) est de loin le plus lourd ; 3 types sont valides mais vides +(`Dashboard`, `TimelineTemplate`, `Toggle`). `WorkflowAction` et `WritingModel` +ont été retirés — 404 (D17). Détail : +[docs/ad-api-validation.md](docs/ad-api-validation.md). 9 applications AD sont +déclarées ; **`CustomApp` porte le spécifique client** (workflows `CST_*`) et +s'interroge via le paramètre `application` des outils AD et workflow (D26). **Paramètres système** : pas d'entité `CommandParameterData`. La configuration se lit dans `Parameter` (+ `DefaultValue`) et `ParamValue` (surcharges par diff --git a/DECISIONS.md b/DECISIONS.md index beceace..992893c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -519,3 +519,52 @@ Modalités : 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. diff --git a/ROADMAP.md b/ROADMAP.md index 37a519f..9775183 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,58 +25,6 @@ exploration à part — c'est probablement là que vivent les données agrégée produites par les jobs `MetricGatherer`. Livrable : un rapport, pas du code (même phase d'investigation que L4.4). -### L4.2 — Une seule application sur neuf est visible - -`Application` vient de `WMS_APPLICATION` dans `.env`, **partagé par tous les -profils**, sans surcharge par appel ni paramètre d'outil. Le MCP n'interroge donc -jamais que `EasyWMS`. - -`POST /AD/api/Application/GetAll` en déclare **9** : - -| Application | Workflows | Queries | Entities | -|---|---:|---:|---:| -| EasyWMS | 4012 | 2239 | 338 | -| **CustomApp** | **153** | **54** | **11** | -| AGV | 71 | 14 | 5 | -| Notifications | 26 | 35 | 24 | -| GalileoFaults | 9 | 20 | 24 | -| Common | 1 | 7 | 25 | -| SmartUI, User, WarehouseWebDesigner | 0 | 0–8 | 0 | - -**CustomApp porte le spécifique client** — ses workflows sont préfixés `CST_` -(`CST_SendRejectContainersToPK`, `CST_Task`, `CST_Container`…). C'est -précisément ce qu'on cherche en debug, et c'est aujourd'hui invisible. Au total -**260 workflows et ~130 queries** hors périmètre. - -Deux chantiers de difficulté très différentes : - -**API AD — simple.** L'application est un champ du payload -(`[application, tenant, pageSize, offset]`). Vérifié : `["CustomApp", tenant, -5, 0]` sur `/Workflow/GetByApplication` renvoie bien les workflows `CST_`. Il -suffit d'un paramètre `application` sur les outils AD et workflow, avec une clé -de cache incluant l'application (sinon un cache pollué mélange les -applications). - -**QueryExecute — tranché : le champ `Application` ne partitionne rien.** -`Context.AgvTasks` (entité de l'application AGV) répond aussi bien avec -`Application: "AGV"` qu'avec `Application: "EasyWMS"`. Le contexte de lecture est -**commun au tenant** : toutes les applications y déversent leurs entités. - -Conséquence — traitée : la table de résolution (D21) **agrège le Metadata de -toutes les applications** déployées, et non le seul `EasyWMS`. Inutile en -revanche d'ajouter un paramètre `application` à `QueryExecute` : il ne changerait -rien. - -**Les entités `CustomApp` ne sont interrogeables dans aucun contexte.** Les 11 -entités `CST_` ont été testées sous les quatre `QueryType`, au singulier et au -pluriel : échec partout, et `Metadata/Entities` comme `Metadata/EntitiesAll` -renvoient **0 entité** pour `CustomApp`. Aucune n'est marquée -`isDataWarehouse`. Ce sont des définitions EasyBuilder (`FromMetadata: false`) -sans projection dans un contexte requêtable. - -**L'API AD reste donc le seul accès au spécifique client** — ce qui rend le -paramètre `application` sur les outils AD et workflow d'autant plus utile. - ### L4.3 — Identifier le MCP dans les logs du WMS Les requêtes du MCP apparaissent dans les logs du WMS sous @@ -170,6 +118,13 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et début de chaque appel). - **`select_expression`** : les projections via le paramètre `Select` provoquent des erreurs de compilation côté serveur (D13). Irritant principal restant. +- **`get_workflow_details` peut dépasser le seuil de rejet client** (constaté + le 25/08/2026, livraison du lot 4). La définition complète de + `CST_SendRejectContainersToPK` (application `CustomApp`) fait ~101 800 + caractères via le protocole — au-delà du seuil de rejet mesuré en D24 + (~70 000). Comportement antérieur au lot 4 (les grosses définitions + `EasyWMS` sont dans le même cas) : à borner et signaler (`truncated`/`hint`, + D24) dans un lot futur. - **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH reste à faire. - **Historique des shipment templates** : hors de portée, les logs concernés diff --git a/src/services/ad-service.js b/src/services/ad-service.js index bf46f52..66a843c 100644 --- a/src/services/ad-service.js +++ b/src/services/ad-service.js @@ -7,11 +7,26 @@ const apiService = require('./api-service').getInstance(); const profileManager = require('../config/profile-manager'); -// Cache state - one cache per element type +// Cache state - one cache per (application, element type) (D26) const cache = {}; const cacheTimestamps = {}; const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour +/** + * Application effective : celle demandée, sinon celle du profil actif. + */ +function resolveApplication(application) { + return (application && application.trim()) || profileManager.getCurrent().application; +} + +/** + * Clé de cache composite (D26) — sans elle, un appel CustomApp polluerait le + * cache EasyWMS du même type. + */ +function cacheKey(application, elementType) { + return `${application}::${elementType}`; +} + // Invalidate all caches when profile changes — AD elements are per-tenant. profileManager.onSwitch(() => invalidateCache()); @@ -44,51 +59,55 @@ const AD_ELEMENT_TYPES = { }; /** - * Check if cache is valid for a given element type + * Check if cache is valid for a given (application, element type) */ -function isCacheValid(elementType) { - if (!cache[elementType] || !cacheTimestamps[elementType]) { +function isCacheValid(application, elementType) { + const key = cacheKey(application, elementType); + if (!cache[key] || !cacheTimestamps[key]) { return false; } const now = Date.now(); - const age = now - cacheTimestamps[elementType]; + const age = now - cacheTimestamps[key]; return age < CACHE_TTL; } /** * Get all elements of a specific type from AD API - * Implements lazy loading with caching and pagination + * Implements lazy loading with caching and pagination. + * Lazy par application (D26) : seule l'application demandée est chargée. * * @param {string} elementType - Type of element (Command, Query, Dialog, etc.) + * @param {string} [application] - Application AD (défaut : profil actif) * @returns {Promise} Array of elements */ -async function getElements(elementType) { +async function getElements(elementType, application) { // Validate element type if (!AD_ELEMENT_TYPES[elementType]) { throw new Error(`Unknown element type: ${elementType}. Valid types: ${Object.keys(AD_ELEMENT_TYPES).join(', ')}`); } + const app = resolveApplication(application); + const key = cacheKey(app, elementType); + // Check cache - if (isCacheValid(elementType)) { - console.error(`[AD] Cache hit: ${elementType} (${cache[elementType].length} elements)`); - return cache[elementType]; + if (isCacheValid(app, elementType)) { + console.error(`[AD] Cache hit: ${key} (${cache[key].length} elements)`); + return cache[key]; } - console.error(`[AD] Cache expired or empty, fetching ${elementType}...`); + console.error(`[AD] Cache expired or empty, fetching ${key}...`); try { let allElements = []; let offset = 0; const pageSize = AD_ELEMENT_TYPES[elementType]; - const profile = profileManager.getCurrent(); - const application = profile.application; - const tenant = profile.tenant; + const tenant = profileManager.getCurrent().tenant; while (true) { - const body = [application, tenant, pageSize, offset]; + const body = [app, tenant, pageSize, offset]; - console.error(`[AD] Fetching ${elementType}: offset=${offset}, pageSize=${pageSize}`); + console.error(`[AD] Fetching ${key}: offset=${offset}, pageSize=${pageSize}`); // Use AD API (useAdApi=true) const response = await apiService.post(`/${elementType}/GetByApplication`, body, true); @@ -115,14 +134,14 @@ async function getElements(elementType) { } // Update cache - cache[elementType] = allElements; - cacheTimestamps[elementType] = Date.now(); + cache[key] = allElements; + cacheTimestamps[key] = Date.now(); - console.error(`[AD] Successfully cached ${allElements.length} ${elementType}`); + console.error(`[AD] Successfully cached ${allElements.length} ${key}`); return allElements; } catch (error) { - console.error(`[AD] Error fetching ${elementType}:`, error.message); - throw new Error(`Failed to fetch ${elementType}: ${error.message}`); + console.error(`[AD] Error fetching ${key}:`, error.message); + throw new Error(`Failed to fetch ${elementType} for application "${app}": ${error.message}`); } } @@ -131,9 +150,10 @@ async function getElements(elementType) { * @param {string} elementType - Type of element * @param {string} query - Search query (matches name, description, etc.) * @param {number} limit - Maximum results to return + * @param {string} [application] - Application AD (défaut : profil actif) */ -async function searchElements(elementType, query, limit = 50) { - const elements = await getElements(elementType); +async function searchElements(elementType, query, limit = 50, application) { + const elements = await getElements(elementType, application); if (!query) { return elements.slice(0, limit); @@ -157,9 +177,10 @@ async function searchElements(elementType, query, limit = 50) { * Get element details by ID or name * @param {string} elementType - Type of element * @param {string|number} elementId - Element ID or name + * @param {string} [application] - Application AD (défaut : profil actif) */ -async function getElementDetails(elementType, elementId) { - const elements = await getElements(elementType); +async function getElementDetails(elementType, elementId, application) { + const elements = await getElements(elementType, application); // Try to find by Id, id, Code, code, Name, or name const element = elements.find(e => @@ -174,45 +195,49 @@ async function getElementDetails(elementType, elementId) { ); if (!element) { - throw new Error(`${elementType} not found: ${elementId}`); + const app = resolveApplication(application); + throw new Error( + `${elementType} not found: ${elementId} (application "${app}"). ` + + `Utilisez search_ad_elements — pensez au paramètre application ` + + `(ex: "CustomApp" pour le spécifique client).` + ); } return element; } /** - * Get application summary (count of each element type) - * Only loads types that are already cached to avoid long wait times + * Get application summary — état des caches par (application, type) (D26). + * Seules les entrées effectivement en cache sont détaillées, pour rester + * borné quel que soit le nombre d'applications interrogées (D24). + * @returns {Object} application -> type -> { count, cacheAge } */ function getApplicationSummary() { - const summary = {}; + const byApplication = {}; - Object.keys(AD_ELEMENT_TYPES).forEach(type => { - if (cache[type]) { - summary[type] = { - count: cache[type].length, - cached: true, - cacheAge: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null - }; - } else { - summary[type] = { - count: 0, - cached: false, - cacheAge: null - }; - } + Object.keys(cache).forEach(key => { + const [app, type] = key.split('::'); + if (!byApplication[app]) byApplication[app] = {}; + byApplication[app][type] = { + count: cache[key].length, + cacheAge: cacheTimestamps[key] ? Math.floor((Date.now() - cacheTimestamps[key]) / 1000) : null + }; }); - return summary; + return byApplication; } /** - * Invalidate cache for a specific type or all types + * Invalidate cache for a specific type (across all applications) or all types */ function invalidateCache(elementType = null) { if (elementType) { - delete cache[elementType]; - delete cacheTimestamps[elementType]; + Object.keys(cache) + .filter(k => k.endsWith(`::${elementType}`)) + .forEach(k => { + delete cache[k]; + delete cacheTimestamps[k]; + }); console.error(`[AD] Cache invalidated: ${elementType}`); } else { Object.keys(cache).forEach(k => { @@ -224,17 +249,19 @@ function invalidateCache(elementType = null) { } /** - * Get cache status + * Get cache status, par application puis type (D26) */ function getCacheStatus() { const status = {}; - Object.keys(AD_ELEMENT_TYPES).forEach(type => { - status[type] = { - cached: !!cache[type], - count: cache[type] ? cache[type].length : 0, - timestamp: cacheTimestamps[type], - age: cacheTimestamps[type] ? Math.floor((Date.now() - cacheTimestamps[type]) / 1000) : null, - valid: isCacheValid(type) + Object.keys(cache).forEach(key => { + const [app, type] = key.split('::'); + if (!status[app]) status[app] = {}; + status[app][type] = { + cached: true, + count: cache[key].length, + timestamp: cacheTimestamps[key], + age: cacheTimestamps[key] ? Math.floor((Date.now() - cacheTimestamps[key]) / 1000) : null, + valid: isCacheValid(app, type) }; }); return status; diff --git a/src/services/workflow-service.js b/src/services/workflow-service.js index bfb3149..c159371 100644 --- a/src/services/workflow-service.js +++ b/src/services/workflow-service.js @@ -2,14 +2,19 @@ * Workflow Service * Handles workflow fetching with lazy loading and caching * Workflows are only loaded when first requested (not at startup) + * + * Un cache par application (D26) : le paramètre `application` des outils + * sélectionne l'application AD interrogée (défaut : celle du profil actif). */ const apiService = require('./api-service').getInstance(); const profileManager = require('../config/profile-manager'); -// Cache state -let workflowCache = null; -let cacheTimestamp = null; +// Cache state — un cache de workflows par application (D26) +let workflowCaches = {}; // application -> workflows[] +let cacheTimestamps = {}; // application -> timestamp +let applicationsCache = null; // liste allégée de POST /Application/GetAll +let applicationsTimestamp = null; const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds // Clear cache when profile changes — workflows are per-tenant, so the previous @@ -17,43 +22,52 @@ const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour profileManager.onSwitch(() => clearCache()); /** - * Check if cache is still valid + * Application effective : celle demandée, sinon celle du profil actif. */ -function isCacheValid() { - if (!workflowCache || !cacheTimestamp) { +function resolveApplication(application) { + return (application && application.trim()) || profileManager.getCurrent().application; +} + +/** + * Check if cache is still valid for an application + */ +function isCacheValid(application) { + if (!workflowCaches[application] || !cacheTimestamps[application]) { return false; } - const now = Date.now(); - const age = now - cacheTimestamp; + const age = Date.now() - cacheTimestamps[application]; return age < CACHE_TTL; } /** - * Fetch all workflows from API with pagination - * Uses high page size (5000) to minimize API calls + * Fetch all workflows of an application from API with pagination. + * Uses high page size (5000) to minimize API calls. + * Lazy : seule l'application effectivement demandée est chargée (D26) — ne + * jamais précharger les 9 applications. + * @param {string} [application] - Application AD (défaut : profil actif) */ -async function fetchAllWorkflows() { +async function fetchAllWorkflows(application) { + const app = resolveApplication(application); + // Check cache validity - if (isCacheValid()) { - console.error('[Workflow] Using cached data'); - return workflowCache; + if (isCacheValid(app)) { + console.error(`[Workflow] Using cached data for "${app}"`); + return workflowCaches[app]; } - console.error('[Workflow] Cache expired or empty, fetching from API...'); + console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`); try { let allWorkflows = []; let offset = 0; const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000; - const profile = profileManager.getCurrent(); - const application = profile.application; - const tenant = profile.tenant; + const tenant = profileManager.getCurrent().tenant; while (true) { - const body = [application, tenant, pageSize, offset]; + const body = [app, tenant, pageSize, offset]; - console.error(`[Workflow] Fetching page: offset=${offset}, pageSize=${pageSize}`); + console.error(`[Workflow] Fetching page: application=${app}, offset=${offset}, pageSize=${pageSize}`); // Use AD API (useAdApi=true) const response = await apiService.post('/Workflow/GetByApplication', body, true); @@ -80,17 +94,45 @@ async function fetchAllWorkflows() { } // Update cache - workflowCache = allWorkflows; - cacheTimestamp = Date.now(); + workflowCaches[app] = allWorkflows; + cacheTimestamps[app] = Date.now(); - console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows`); + console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows for "${app}"`); return allWorkflows; } catch (error) { - console.error('[Workflow] Error fetching workflows:', error.message); - throw new Error(`Failed to fetch workflows: ${error.message}`); + console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message); + throw new Error(`Failed to fetch workflows for application "${app}": ${error.message}`); } } +/** + * Liste les applications déclarées (POST /Application/GetAll, payload null). + * La réponse est une enveloppe { entities: [...] } (D4) dont chaque élément + * porte un blob `data` volumineux — on ne conserve que les champs légers. + * Cache TTL commun, vidé au switch de profil. + * @returns {Promise>} + */ +async function fetchApplications() { + if (applicationsCache && applicationsTimestamp && + Date.now() - applicationsTimestamp < CACHE_TTL) { + return applicationsCache; + } + + console.error('[Workflow] Fetching application list (Application/GetAll)...'); + const response = await apiService.post('/Application/GetAll', null, true); + const entities = response?.entities || []; + + applicationsCache = entities.map(a => ({ + name: a.name || a.Name, + id: a.id || a.Id, + version: a.version ?? a.Version, + })).filter(a => a.name); + applicationsTimestamp = Date.now(); + + console.error(`[Workflow] Cached ${applicationsCache.length} application(s)`); + return applicationsCache; +} + /** * Search workflows by query string. * Real AD keys (lowercase, cf. D5): id, name, version, applicationName, @@ -99,9 +141,10 @@ async function fetchAllWorkflows() { * @param {string|null} category - Optional applicationName filter (the only * grouping the AD API provides) * @param {number} limit - Maximum results to return + * @param {string} [application] - Application AD interrogée (défaut : profil) */ -async function searchWorkflows(query, category = null, limit = 50) { - const workflows = await fetchAllWorkflows(); +async function searchWorkflows(query, category = null, limit = 50, application) { + const workflows = await fetchAllWorkflows(application); let results = workflows; @@ -130,15 +173,17 @@ async function searchWorkflows(query, category = null, limit = 50) { /** * Get workflow details by ID * @param {string|number} workflowId - Workflow ID + * @param {string} [application] - Application AD interrogée (défaut : profil) */ -async function getWorkflowDetails(workflowId) { +async function getWorkflowDetails(workflowId, application) { // Garde d'entrée : sans elle, un workflow_id absent matchait le premier // workflow du cache (undefined === undefined sur les clés mortes ci-dessous). if (workflowId == null || workflowId === '') { throw new Error('workflow_id est requis (id ou nom exact du workflow). Utilisez search_workflows pour le trouver.'); } - const workflows = await fetchAllWorkflows(); + const app = resolveApplication(application); + const workflows = await fetchAllWorkflows(app); // Clés réelles de l'API AD (minuscules, D5) : id, name. Les variantes // Id/Code/Name n'existent pas sur ces objets — les comparer faisait matcher @@ -149,82 +194,65 @@ async function getWorkflowDetails(workflowId) { ); if (!workflow) { - throw new Error(`Workflow not found: ${workflowId}. Utilisez search_workflows pour trouver l'id ou le nom exact.`); + throw new Error( + `Workflow not found: ${workflowId} (application "${app}"). ` + + `Utilisez search_workflows pour trouver l'id ou le nom exact — ` + + `pensez au paramètre application (ex: "CustomApp" pour le spécifique client).` + ); } return workflow; } /** - * List distinct applicationName values. - * Workflows have no category field — applicationName is the only grouping the - * AD API provides, and every workflow of the active application carries the - * same value (e.g. "EasyWMS"). + * Get workflow statistics for one application + * @param {string} [application] - Application AD interrogée (défaut : profil) */ -async function listWorkflowCategories() { - const workflows = await fetchAllWorkflows(); - - const categories = new Set(); - workflows.forEach(w => { - const applicationName = w.applicationName || w.ApplicationName; - if (applicationName) { - categories.add(applicationName); - } - }); - - // Sort alphabetically - return Array.from(categories).sort(); -} - -/** - * Get workflow statistics - */ -async function getWorkflowStats() { - const workflows = await fetchAllWorkflows(); - const categories = await listWorkflowCategories(); - - // Count workflows per applicationName (the only grouping in the data) - const categoryCounts = {}; - workflows.forEach(w => { - const cat = w.applicationName || w.ApplicationName || '(unknown)'; - categoryCounts[cat] = (categoryCounts[cat] || 0) + 1; - }); +async function getWorkflowStats(application) { + const app = resolveApplication(application); + const workflows = await fetchAllWorkflows(app); return { + application: app, total: workflows.length, - categories: categories.length, - categoryCounts, - cacheAge: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null + cacheAge: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null }; } /** - * Clear workflow cache (force refresh on next request) + * Clear workflow caches (force refresh on next request) — toutes applications. */ function clearCache() { - workflowCache = null; - cacheTimestamp = null; + workflowCaches = {}; + cacheTimestamps = {}; + applicationsCache = null; + applicationsTimestamp = null; console.error('[Workflow] Cache cleared'); } /** - * Get cache status + * Get cache status, per application (D26) + * @returns {Object} application -> { cached, count, timestamp, age, valid } */ function getCacheStatus() { - return { - cached: workflowCache !== null, - count: workflowCache ? workflowCache.length : 0, - timestamp: cacheTimestamp, - age: cacheTimestamp ? Math.floor((Date.now() - cacheTimestamp) / 1000) : null, - valid: isCacheValid() - }; + const status = {}; + Object.keys(workflowCaches).forEach(app => { + status[app] = { + cached: true, + count: workflowCaches[app].length, + timestamp: cacheTimestamps[app], + age: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null, + valid: isCacheValid(app) + }; + }); + return status; } module.exports = { fetchAllWorkflows, + fetchApplications, searchWorkflows, getWorkflowDetails, - listWorkflowCategories, getWorkflowStats, clearCache, getCacheStatus diff --git a/src/tools/ad-tools.js b/src/tools/ad-tools.js index 8383296..fa300a2 100644 --- a/src/tools/ad-tools.js +++ b/src/tools/ad-tools.js @@ -4,6 +4,7 @@ */ const adService = require('../services/ad-service'); +const workflowService = require('../services/workflow-service'); /** * List available AD tools @@ -12,7 +13,7 @@ function listTools() { return [ { name: 'get_application_summary', - description: 'Get summary of Application Dictionary elements. Shows count of cached elements per type (Commands, Queries, Dialogs, Views, etc.). Only counts already-loaded types to avoid long waits.', + description: 'Get summary of Application Dictionary caches, grouped by application then element type (D26), plus the per-application workflow caches. Only already-loaded entries are detailed to avoid long waits.', inputSchema: { type: 'object', additionalProperties: false, @@ -21,7 +22,7 @@ function listTools() { }, { name: 'get_ad_elements', - description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour.', + description: 'Get all elements of a specific type from Application Dictionary. Supports: Command, Query, Dialog, View, Entity, Event, Hook, Report, Dashboard, and 11 other types (20 total). Elements are lazy-loaded and cached for 1 hour, per (application, type).', inputSchema: { type: 'object', additionalProperties: false, @@ -35,6 +36,10 @@ function listTools() { description: 'Maximum number of elements to return (default: 100, max: 1000)', default: 100, }, + application: { + type: 'string', + description: 'AD application to query (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix). Full list via list_workflow_categories.', + }, }, required: ['element_type'], }, @@ -59,6 +64,10 @@ function listTools() { description: 'Maximum results (default: 50)', default: 50, }, + application: { + type: 'string', + description: 'AD application to search in (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix).', + }, }, required: ['element_type', 'query'], }, @@ -78,6 +87,10 @@ function listTools() { type: 'string', description: 'Element ID or name', }, + application: { + type: 'string', + description: 'AD application the element belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific elements live in "CustomApp" (CST_* prefix).', + }, }, required: ['element_type', 'element_id'], }, @@ -140,38 +153,37 @@ async function executeTool(name, args) { async function getApplicationSummaryTool(args) { console.error('[ADTools] Getting application summary'); - const summary = adService.getApplicationSummary(); + // État par (application, type) — seules les entrées en cache sont + // détaillées, la sortie reste bornée quel que soit le nombre + // d'applications interrogées (D24, D26). + const adByApplication = adService.getApplicationSummary(); + const workflowsByApplication = workflowService.getCacheStatus(); - // Calculate totals - let totalCached = 0; + let cachedEntries = 0; let totalElements = 0; - const cachedTypes = []; - const uncachedTypes = []; - - Object.entries(summary).forEach(([type, info]) => { - if (info.cached) { - totalCached++; + Object.values(adByApplication).forEach(types => { + Object.values(types).forEach(info => { + cachedEntries++; totalElements += info.count; - cachedTypes.push(type); - } else { - uncachedTypes.push(type); - } + }); }); + const availableTypes = adService.getAvailableTypes(); + return { content: [{ type: 'text', text: JSON.stringify({ success: true, summary: { - totalTypes: Object.keys(summary).length, - cachedTypes: totalCached, - uncachedTypes: uncachedTypes.length, - totalElements: totalElements + availableTypes: availableTypes.length, + cachedEntries, + totalElements, + applications: Object.keys(adByApplication) }, - elementCounts: summary, - cached: cachedTypes, - notCached: uncachedTypes + adElementsByApplication: adByApplication, + workflowCachesByApplication: workflowsByApplication, + note: 'Caches AD par (application, type) et caches workflows par application — chargés paresseusement à la première demande. Types valides via list_ad_types.' }, null, 2) }] }; @@ -181,11 +193,11 @@ async function getApplicationSummaryTool(args) { * Tool: get_ad_elements */ async function getADElementsTool(args) { - const { element_type, limit = 100 } = args; + const { element_type, limit = 100, application } = args; - console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit})`); + console.error(`[ADTools] Getting ${element_type} elements (limit: ${limit}, application: ${application || '(profil)'})`); - const elements = await adService.getElements(element_type); + const elements = await adService.getElements(element_type, application); // Limit results const limitedElements = elements.slice(0, Math.min(limit, 1000)); @@ -208,6 +220,7 @@ async function getADElementsTool(args) { text: JSON.stringify({ success: true, elementType: element_type, + ...(application ? { application } : {}), count: elements.length, returned: mappedElements.length, elements: mappedElements @@ -220,11 +233,11 @@ async function getADElementsTool(args) { * Tool: search_ad_elements */ async function searchADElementsTool(args) { - const { element_type, query, limit = 50 } = args; + const { element_type, query, limit = 50, application } = args; - console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}`); + console.error(`[ADTools] Searching ${element_type}: query="${query}", limit=${limit}, application=${application || '(profil)'}`); - const results = await adService.searchElements(element_type, query, limit); + const results = await adService.searchElements(element_type, query, limit, application); // Map to simplified format const mappedResults = results.map(e => ({ @@ -240,6 +253,7 @@ async function searchADElementsTool(args) { text: JSON.stringify({ success: true, elementType: element_type, + ...(application ? { application } : {}), query, count: mappedResults.length, elements: mappedResults @@ -252,11 +266,11 @@ async function searchADElementsTool(args) { * Tool: get_ad_element_details */ async function getADElementDetailsTool(args) { - const { element_type, element_id } = args; + const { element_type, element_id, application } = args; - console.error(`[ADTools] Getting ${element_type} details: ${element_id}`); + console.error(`[ADTools] Getting ${element_type} details: ${element_id} (application: ${application || '(profil)'})`); - const element = await adService.getElementDetails(element_type, element_id); + const element = await adService.getElementDetails(element_type, element_id, application); return { content: [{ @@ -264,6 +278,7 @@ async function getADElementDetailsTool(args) { text: JSON.stringify({ success: true, elementType: element_type, + ...(application ? { application } : {}), element }, null, 2) }] diff --git a/src/tools/workflow-tools.js b/src/tools/workflow-tools.js index 3e82fcc..278558a 100644 --- a/src/tools/workflow-tools.js +++ b/src/tools/workflow-tools.js @@ -12,7 +12,7 @@ function listTools() { return [ { name: 'search_workflows', - description: 'Search workflows by name, description, or code. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour.', + description: 'Search workflows by name. Returns matching workflows with metadata. Workflows are lazy-loaded from API on first request and cached for 1 hour, per application. Client-specific workflows (CST_* prefix) live in the "CustomApp" application — pass application: "CustomApp" to search them.', inputSchema: { type: 'object', additionalProperties: false, @@ -23,26 +23,34 @@ function listTools() { }, category: { type: 'string', - description: 'Filter by applicationName — the only grouping the AD API provides (workflows have no category field). All workflows of the active application share the same value (e.g. "EasyWMS").', + description: 'Filter by the applicationName field of the returned workflows (workflows have no category field). Since `application` selects which application is fetched, all its workflows share the same applicationName — prefer `application` to change scope; `category` only narrows within the fetched set.', }, limit: { type: 'number', description: 'Maximum results to return (default: 50)', default: 50, }, + application: { + type: 'string', + description: 'AD application whose workflows are searched (default: the active profile\'s application, usually EasyWMS). Client-specific workflows live in "CustomApp". Full list via list_workflow_categories.', + }, }, }, }, { name: 'get_workflow_details', - description: 'Get full details of a specific workflow by ID or code', + description: 'Get full details of a specific workflow by ID or name', inputSchema: { type: 'object', additionalProperties: false, properties: { workflow_id: { type: 'string', - description: 'Workflow ID or code', + description: 'Workflow ID or exact name', + }, + application: { + type: 'string', + description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".', }, }, required: ['workflow_id'], @@ -50,11 +58,16 @@ function listTools() { }, { name: 'list_workflow_categories', - description: 'List workflow groupings by applicationName. Workflows have no category field in the AD API — applicationName is the only grouping available, and all workflows of the active application share the same value.', + description: 'List the AD applications declared on the tenant (Application/GetAll) with their workflow counts where already loaded. Workflows have no category field — the application is the only grouping. Use the `application` parameter of the workflow/AD tools to query a specific one (e.g. "CustomApp" for client-specific CST_* workflows).', inputSchema: { type: 'object', additionalProperties: false, - properties: {}, + properties: { + application: { + type: 'string', + description: 'Load and count the workflows of this application (default: the active profile\'s application). Other applications are listed without loading them.', + }, + }, }, }, ]; @@ -98,17 +111,18 @@ async function executeTool(name, args) { * Tool: search_workflows */ async function searchWorkflows(args) { - const { query, category, limit = 50 } = args; + const { query, category, limit = 50, application } = args; - console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}`); + console.error(`[WorkflowTools] Searching workflows: query="${query}", category="${category}", limit=${limit}, application=${application || '(profil)'}`); - const results = await workflowService.searchWorkflows(query, category, limit); + const results = await workflowService.searchWorkflows(query, category, limit, application); return { content: [{ type: 'text', text: JSON.stringify({ success: true, + ...(application ? { application } : {}), count: results.length, // Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version, // applicationName, commonInfo. Pas de code/category/description. @@ -133,11 +147,11 @@ async function searchWorkflows(args) { * Tool: get_workflow_details */ async function getWorkflowDetails(args) { - const { workflow_id } = args; + const { workflow_id, application } = args; - console.error(`[WorkflowTools] Getting workflow details: ${workflow_id}`); + console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'})`); - const workflow = await workflowService.getWorkflowDetails(workflow_id); + const workflow = await workflowService.getWorkflowDetails(workflow_id, application); return { content: [{ @@ -154,22 +168,37 @@ async function getWorkflowDetails(args) { * Tool: list_workflow_categories */ async function listWorkflowCategories(args) { - console.error('[WorkflowTools] Listing workflow categories'); + const { application } = args || {}; - const categories = await workflowService.listWorkflowCategories(); - const stats = await workflowService.getWorkflowStats(); + console.error(`[WorkflowTools] Listing applications (workflow groupings), application=${application || '(profil)'}`); + + // La liste vient d'Application/GetAll (9 applications sur le tenant mesuré), + // pas des applicationName du seul cache actif (D26). Seule l'application + // demandée (ou celle du profil) est chargée — pas de préchargement des + // autres (D10) : leurs comptes n'apparaissent que si déjà en cache. + const applications = await workflowService.fetchApplications(); + const stats = await workflowService.getWorkflowStats(application); + const cacheStatus = workflowService.getCacheStatus(); + + const enriched = applications.map(a => ({ + name: a.name, + version: a.version, + ...(cacheStatus[a.name] + ? { workflowCount: cacheStatus[a.name].count, cacheAge: cacheStatus[a.name].age } + : { workflowCount: null }), + })); return { content: [{ type: 'text', text: JSON.stringify({ success: true, - note: 'Workflows have no category field in the AD API — these are the distinct applicationName values, the only grouping available. All workflows of the active application share the same value.', - totalCategories: categories.length, - categories, - stats: { + note: 'Workflows have no category field in the AD API — the application is the only grouping. workflowCount is only known for applications already loaded (lazy loading); pass application to search_workflows/get_ad_elements to load one.', + totalApplications: applications.length, + applications: enriched, + loaded: { + application: stats.application, totalWorkflows: stats.total, - categoryCounts: stats.categoryCounts, cacheAge: stats.cacheAge } }, null, 2)