Troisieme workflow structurant, et le seul sans tests : c est lui qui decide ce que la batterie a le droit de prendre, donc lui qui plafonnait le parc a 93 %, et il n etait meme pas suivi dans le depot avant ce matin. Vingt scenarios sur le patron de la suite hysteresis, avec le vrai code du node dans une sandbox et un snapshot capture contre Home Assistant plutot qu invente — la lecon du 24/08, quand un fixture portait les valeurs de repli du code au lieu des seuils de production et masquait un bug de bande. Ils suivent l ordre reel des branches et enferment chaque paire dans son contrefactuel : hiver sombre contre hiver ensoleille, ete secours contre ete repos, rattrapage de midi contre midi ou le soleil revient, override actif contre override expire, et le takeover demande alors que le contacteur est ouvert. Le releve reel du maintien a 2 A de cette nuit y figure, ainsi que le topoff a 52,4 V pose ce matin. Cinq mutations, chacune tombant sur un scenario different. Suite cablee au push, bloquante comme les trois autres. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
154 lines
6.2 KiB
JavaScript
154 lines
6.2 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 isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json';
|
|
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'
|
|
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json'
|
|
|| path.basename(INPUT) === 'evcs-solar-forecast-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' :
|
|
isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/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})`);
|