Files
EVCS/tools/n8n-push.mjs
Tallagrand Thierry e68fa160b6 Fix G: coupure EDF générique quand l'override EVCS disparaît mi-session
Incident nuit 21-22/08 : la fenêtre HC nuit se ferme pendant que le véhicule
est encore en session active (pas encore flag "charge terminée"). L'EVCS
relâche correctement son override, mais émet un état transitoire générique
(stop_source_lost/idle_wait_source) qu'aucune règle de coupure immédiate du
workflow Grid Hysteresis (Fix C/E2) ne reconnaît. EDF restait alors maintenu
des heures par la boucle générique jusqu'au seuil statique gridSocOff,
plafonnant le MPPT Victron à ~100W et privant la maison de solaire.

Nouvelle règle générique (Compute Grid Hysteresis) basée sur evcsGridOverride
plutôt que sur une liste blanche de noms de surplus_state — couvre donc aussi
tout futur état non énuméré. Garde anti-glitch sur lecture HA invalide.
Nouvelle suite tests/golden-hysteresis/ (7 scénarios, première pour ce
workflow), câblée bloquante dans tools/n8n-push.mjs. Déployé et validé
(golden-hysteresis 7/7, golden EVCS 39/39 inchangé).

Écarte le diagnostic incomplet laissé par un autre agent dans HANDOFF.md
(edf_general_ok_off côté EVCS aurait créé un deuxième écrivain non coordonné
sur le même switch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:56:06 +02:00

148 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
// Usage: node tools/n8n-push.mjs <inputFile> [workflowId]
// PUT un workflow n8n depuis un fichier JSON local (méthode validée 04/08/2026 :
// body minimal {name, nodes, connections, settings, staticData}, settings épuré).
// Fait automatiquement un backup GET avant le PUT dans modified/.
// Clé API lue depuis ~/.config/opencode/opencode.json (mcp.n8n-mcp.environment).
import fs from 'fs';
import os from 'os';
import path from 'path';
const INPUT = process.argv[2];
if (!INPUT) {
console.error('Usage: node tools/n8n-push.mjs <inputFile> [workflowId]');
process.exit(1);
}
// R2 : src/ = SOURCE DE VÉRITÉ pour les workflows enregistrés dans le node-map.
// Le build injecte src/**/*.js dans le JSON AVANT toute vérification.
const { findWorkflowByFile } = await import('./lib-node-map.mjs');
const registered = findWorkflowByFile(INPUT);
if (registered) {
console.log(`Build ${registered.srcDir}/ → JSON (source de vérité : src/)...`);
const { spawnSync } = await import('child_process');
const b = spawnSync('node', ['tools/n8n-build.mjs', INPUT], { stdio: 'inherit' });
if (b.status !== 0) {
console.error('ABANDON: échec du build src → JSON.');
process.exit(1);
}
}
const local = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const WORKFLOW_ID = process.argv[3] || local.id;
if (!WORKFLOW_ID) {
console.error('ABANDON: aucun id de workflow (ni argument, ni champ id dans le JSON).');
process.exit(1);
}
function loadApiConfig() {
if (process.env.N8N_API_KEY) {
return {
url: process.env.N8N_URL || 'https://n8n.airtria.pro',
key: process.env.N8N_API_KEY
};
}
const cfgPath = path.join(os.homedir(), '.config/opencode/opencode.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
const env = cfg?.mcp?.['n8n-mcp']?.environment || {};
if (!env.N8N_API_KEY) {
console.error('Clé API n8n introuvable (env N8N_API_KEY ou opencode.json)');
process.exit(1);
}
return {
url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''),
key: env.N8N_API_KEY
};
}
const { url, key } = loadApiConfig();
const headers = { 'X-N8N-API-KEY': key, 'Content-Type': 'application/json' };
// 1. Backup automatique de l'état serveur AVANT push
const getRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, { headers });
if (!getRes.ok) {
console.error(`GET backup échoué: ${getRes.status} ${getRes.statusText}`);
process.exit(1);
}
const remote = await getRes.json();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `modified/prepush-${WORKFLOW_ID}-${stamp}.json`;
fs.mkdirSync('modified', { recursive: true });
fs.writeFileSync(backupPath, JSON.stringify(remote, null, 2) + '\n');
console.log(`Backup serveur avant push: ${backupPath}`);
// 2. Body minimal (n8n API v1 rejette les propriétés additionnelles — erreur 1003)
const body = {
name: local.name,
nodes: local.nodes,
connections: local.connections,
settings: {
executionOrder: local.settings?.executionOrder || 'v1',
callerPolicy: local.settings?.callerPolicy || 'workflowsFromSameOwner'
},
staticData: local.staticData ?? null
};
// 3. Sanity check : node --check sur chaque jsCode
let syntaxErrors = 0;
for (const node of body.nodes || []) {
const js = node.parameters?.jsCode;
if (!js) continue;
const tmp = path.join(os.tmpdir(), `n8n-check-${node.id || node.name}.js`.replace(/[^\w.-]/g, '_'));
fs.writeFileSync(tmp, js);
const { spawnSync } = await import('child_process');
const r = spawnSync('node', ['--check', tmp]);
if (r.status !== 0) {
syntaxErrors++;
console.error(`Syntaxe KO dans node "${node.name}":\n${r.stderr.toString().slice(0, 500)}`);
}
fs.unlinkSync(tmp);
}
if (syntaxErrors) {
console.error(`ABANDON: ${syntaxErrors} node(s) avec erreur de syntaxe. Rien n'a été poussé.`);
process.exit(1);
}
console.log('Syntaxe OK sur tous les nodes jsCode.');
// 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
const skipGolden = process.argv.includes('--skip-golden');
const testSuite =
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' :
isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' :
null;
if (testSuite) {
if (skipGolden) {
console.log('⚠️ Suite de tests SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.');
} else {
console.log(`Exécution de la suite de tests (${testSuite})...`);
const { spawnSync } = await import('child_process');
const g = spawnSync('node', [testSuite], { stdio: 'inherit' });
if (g.status !== 0) {
console.error('ABANDON: la suite de tests échoue. Rien n\'a été poussé.');
console.error('(Évolution volontaire du comportement ? Mettre à jour les attendus');
console.error(' de la suite dans le même commit, puis re-pousser.)');
process.exit(1);
}
}
}
// 4. PUT
const putRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, {
method: 'PUT',
headers,
body: JSON.stringify(body)
});
const text = await putRes.text();
if (!putRes.ok) {
console.error(`PUT REFUSÉ: ${putRes.status} ${putRes.statusText}`);
console.error(text.slice(0, 1000));
process.exit(1);
}
const updated = JSON.parse(text);
console.log(`PUT OK: "${updated.name}" (updatedAt: ${updated.updatedAt}, active=${updated.active})`);