5c — BALANCE ne repondait qu'a "combien jusqu'a ce que le solaire prenne le relais". A 15 h la reponse est zero : exacte mais inutile, le deficit qui approche etant celui de la nuit. Ajout de la prochaine traversee deficitaire (heure de debut, energie, SOC a atteindre avant). Ce SOC n'est PAS ecrete a 100 % : au-dela, meme une batterie pleine ne passe pas, et le manque est publie comme storage_shortfall_kwh — c'est le chiffre du dimensionnement. 5b — Workflow "EVCS Energy Profiles Daily V1" (N6UaB4ZozsbRPrFb), actif, 03:30 : lit /api/history/period sur 30 jours glissants et republie les quatre CSV (conso p50 et p80, enveloppe de production, taux de censure). CONSUMPTION_PROFILE devient DAILY_PROFILES et sait desormais decoder la reponse brute de l'API en plus des series deja pretes. Piege trouve : sans end_time, /api/history/period ne renvoie qu'UNE journee, silencieusement. Mes premieres mesures "sur 3 jours" n'en couvraient qu'une. Validation croisee : profils calcules depuis l'API REST (etats bruts, 11 j) contre profils calcules depuis les statistiques long terme (28 j) — 0,20 kWh/h d'ecart absolu moyen, par deux chemins de donnees independants. BUILD_HA_CALLS_PROFILES refuse d'ecraser les profils en place si l'historique ne couvre pas les 24 heures. Meme garantie de perimetre que le bilan. Suite a 27 scenarios. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
151 lines
5.9 KiB
JavaScript
151 lines
5.9 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 isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'
|
|
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-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' :
|
|
isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/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})`);
|