L4.2 : paramètre application sur les outils AD et workflow (D26)

L'application venait de WMS_APPLICATION (partagée par tous les profils) : le
MCP n'interrogeait que EasyWMS, alors que CustomApp porte le spécifique
client (153 workflows CST_ sur ce tenant) et que 9 applications sont
déclarées par Application/GetAll.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Arthur Ria
2026-08-25 11:24:12 +02:00
parent 706e628715
commit 92de85cf53
7 changed files with 354 additions and 244 deletions
+82 -55
View File
@@ -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>} 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;
+105 -77
View File
@@ -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<Array<{name: string, id: string, version: number}>>}
*/
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