Files
kxkm_clown/node-engine-store.js
T
Codex Local cee246a6a9 Refonte globale P0→P9 : sécurité, V2 monorepo TypeScript, tests, tooling
- P0: Correctifs sécurité critiques (injection bash, path traversal, SSRF, rate limiting)
- P1: Audit logging, session pruning, WS validation, CSRF protection
- P2: Ollama SDK migration, admin API deduplication, attachment hardening
- P3: Node Engine store complet (graphs, runs, models, deployments)
- P4: Admin UI Node Engine (éditeur visuel, runs, queue viewer)
- P5: Persona pipeline (sources, feedback, Pharmacius, editorial)
- P6: V2 packages TypeScript (core, auth, chat-domain, persona-domain, node-engine, storage, tui)
- P7: V2 apps (api + worker + web React), ops TUI, migration/rollback scripts
- P8: Training adapters (TRL/Unsloth), sandboxing (subprocess/container), Turborepo, CI
- P9: Tests (102 backend node:test + 33 React Vitest/RTL), code quality review

135 tests, 0 failures. 8 packages + 3 apps V2. 42 modules V1 validés.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 06:15:04 +01:00

638 lines
18 KiB
JavaScript

const fs = require("fs");
const path = require("path");
function cleanText(value, maxLength = 400) {
return String(value || "").trim().slice(0, maxLength);
}
function randomId(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function createNodeEngineStore({
dataDir,
registry,
}) {
const projectRoot = path.dirname(dataDir);
const rootDir = path.join(dataDir, "node-engine");
const graphsDir = path.join(rootDir, "graphs");
const runsDir = path.join(rootDir, "runs");
const artifactsDir = path.join(rootDir, "artifacts");
const cacheDir = path.join(rootDir, "cache");
const modelsRoot = path.join(projectRoot, "models");
const modelFamilies = {
base: path.join(modelsRoot, "base_models"),
finetuned: path.join(modelsRoot, "finetuned"),
lora: path.join(modelsRoot, "lora"),
};
const modelIndexFile = path.join(modelsRoot, "registry.json");
for (const dir of [rootDir, graphsDir, runsDir, artifactsDir, cacheDir, modelsRoot, ...Object.values(modelFamilies)]) {
fs.mkdirSync(dir, { recursive: true });
}
function graphPath(id) {
return path.join(graphsDir, `${id}.json`);
}
function runPath(id) {
return path.join(runsDir, `${id}.json`);
}
function runArtifactDir(runId) {
return path.join(artifactsDir, runId);
}
function stepArtifactDir(runId, stepId) {
return path.join(runArtifactDir(runId), stepId);
}
function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return fallback;
}
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
}
function loadModelIndex() {
return readJson(modelIndexFile, { updatedAt: null, models: [] }) || { updatedAt: null, models: [] };
}
function saveModelIndex(index) {
writeJson(modelIndexFile, {
updatedAt: new Date().toISOString(),
models: index.models || [],
});
}
function normalizeNode(node, index) {
return {
id: cleanText(node?.id, 80) || `node_${index + 1}`,
type: cleanText(node?.type, 80),
title: cleanText(node?.title, 120) || cleanText(node?.type, 80) || `Node ${index + 1}`,
params: isObject(node?.params) ? clone(node.params) : {},
runtime: cleanText(node?.runtime, 80) || "local_cpu",
};
}
function normalizeEdge(edge) {
return {
from: {
node: cleanText(edge?.from?.node, 80),
output: cleanText(edge?.from?.output, 80),
},
to: {
node: cleanText(edge?.to?.node, 80),
input: cleanText(edge?.to?.input, 80),
},
};
}
function normalizeGraph(input = {}) {
const now = new Date().toISOString();
return {
id: cleanText(input.id, 80) || randomId("graph"),
name: cleanText(input.name, 120) || "Untitled Graph",
description: cleanText(input.description, 600),
runtime: cleanText(input.runtime, 80) || "local_cpu",
tags: Array.isArray(input.tags)
? input.tags.map((tag) => cleanText(tag, 40)).filter(Boolean).slice(0, 12)
: [],
version: Number.isFinite(Number(input.version)) ? Number(input.version) : 1,
status: cleanText(input.status, 40) || "draft",
nodes: Array.isArray(input.nodes) ? input.nodes.map(normalizeNode) : [],
edges: Array.isArray(input.edges) ? input.edges.map(normalizeEdge) : [],
createdAt: cleanText(input.createdAt, 80) || now,
updatedAt: now,
};
}
function summarizeGraph(graph) {
return {
id: graph.id,
name: graph.name,
description: graph.description,
runtime: graph.runtime,
status: graph.status,
version: graph.version,
tags: graph.tags,
nodeCount: graph.nodes.length,
edgeCount: graph.edges.length,
createdAt: graph.createdAt,
updatedAt: graph.updatedAt,
};
}
function validateGraph(graph) {
if (!graph.nodes.length) {
const error = new Error("Graph vide: au moins un node est requis");
error.statusCode = 400;
throw error;
}
const nodeIds = new Set();
for (const node of graph.nodes) {
const nodeType = registry.getNodeType(node.type);
if (!node.type || !nodeType) {
const error = new Error(`Type de node inconnu: ${node.type || "(vide)"}`);
error.statusCode = 400;
throw error;
}
if (!nodeType.runtimes.includes(node.runtime)) {
const error = new Error(`Runtime ${node.runtime} non supporté pour ${node.type}`);
error.statusCode = 400;
throw error;
}
if (nodeIds.has(node.id)) {
const error = new Error(`Node dupliqué: ${node.id}`);
error.statusCode = 400;
throw error;
}
nodeIds.add(node.id);
}
for (const edge of graph.edges) {
if (!nodeIds.has(edge.from.node) || !nodeIds.has(edge.to.node)) {
const error = new Error("Edge invalide: node source ou destination introuvable");
error.statusCode = 400;
throw error;
}
}
}
let seedGraphsEnsured = false;
function ensureSeedGraphs() {
if (seedGraphsEnsured) return;
seedGraphsEnsured = true;
const seedGraphs = typeof registry.listSeedGraphs === "function"
? registry.listSeedGraphs()
: [registry.buildSeedGraphTemplate()];
for (const template of seedGraphs) {
const graph = normalizeGraph(template);
const file = graphPath(graph.id);
if (!fs.existsSync(file)) {
writeJson(file, graph);
}
}
}
function listGraphs() {
ensureSeedGraphs();
return fs.readdirSync(graphsDir)
.filter((entry) => entry.endsWith(".json"))
.map((entry) => readJson(path.join(graphsDir, entry), null))
.filter(Boolean)
.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""))
.map(summarizeGraph);
}
function getGraph(id) {
ensureSeedGraphs();
const graph = readJson(graphPath(id), null);
if (!graph) {
const error = new Error(`Graph introuvable: ${id}`);
error.statusCode = 404;
throw error;
}
return graph;
}
function saveGraph(id, input) {
const existing = fs.existsSync(graphPath(id)) ? getGraph(id) : null;
const next = normalizeGraph({
...(existing || {}),
...(input || {}),
id,
version: existing ? existing.version + 1 : 1,
createdAt: existing?.createdAt,
});
validateGraph(next);
writeJson(graphPath(id), next);
return next;
}
function createGraph(input = {}) {
const graph = normalizeGraph({
...input,
id: cleanText(input.id, 80) || randomId("graph"),
});
validateGraph(graph);
writeJson(graphPath(graph.id), graph);
return graph;
}
function createRun({
graph,
actor = "admin",
runtimes = [],
}) {
const runId = randomId("run");
const createdAt = new Date().toISOString();
const run = {
id: runId,
graphId: graph.id,
graphName: graph.name,
graphVersion: graph.version,
graphSnapshot: clone(graph),
actor: cleanText(actor, 80) || "admin",
status: "queued",
createdAt,
queuedAt: createdAt,
startedAt: null,
finishedAt: null,
runtime: graph.runtime,
stepCount: graph.nodes.length,
runtimes,
cancelRequestedAt: null,
recoveredAt: null,
recoveryCount: 0,
steps: graph.nodes.map((node, index) => ({
id: node.id,
type: node.type,
title: node.title,
order: index + 1,
runtime: node.runtime,
status: "queued",
outputs: [],
error: null,
details: null,
startedAt: null,
finishedAt: null,
})),
artifactSummary: {},
};
writeJson(runPath(runId), run);
fs.mkdirSync(runArtifactDir(runId), { recursive: true });
return run;
}
function getRun(id) {
const run = readJson(runPath(id), null);
if (!run) {
const error = new Error(`Run introuvable: ${id}`);
error.statusCode = 404;
throw error;
}
return run;
}
function saveRun(run) {
writeJson(runPath(run.id), run);
return run;
}
function listRuns(limit = 20) {
return fs.readdirSync(runsDir)
.filter((entry) => entry.endsWith(".json"))
.map((entry) => readJson(path.join(runsDir, entry), null))
.filter(Boolean)
.sort((a, b) => (
(b.finishedAt || b.startedAt || b.queuedAt || b.createdAt || "")
.localeCompare(a.finishedAt || a.startedAt || a.queuedAt || a.createdAt || "")
))
.slice(0, limit);
}
function listRunsByStatus(statuses = [], limit = 100) {
const wanted = new Set((statuses || []).map((status) => cleanText(status, 40)).filter(Boolean));
return fs.readdirSync(runsDir)
.filter((entry) => entry.endsWith(".json"))
.map((entry) => readJson(path.join(runsDir, entry), null))
.filter((run) => run && (!wanted.size || wanted.has(run.status)))
.sort((a, b) => (
(a.queuedAt || a.createdAt || a.startedAt || "")
.localeCompare(b.queuedAt || b.createdAt || b.startedAt || "")
))
.slice(0, limit);
}
function updateRun(runId, patch = {}) {
const run = getRun(runId);
Object.assign(run, patch || {});
writeJson(runPath(run.id), run);
return run;
}
function markRunStep(runId, stepId, patch) {
const run = getRun(runId);
const step = run.steps.find((entry) => entry.id === stepId);
if (!step) {
const error = new Error(`Étape introuvable: ${stepId}`);
error.statusCode = 404;
throw error;
}
Object.assign(step, patch || {});
if (step.status === "running") run.status = "running";
return saveRun(run);
}
function ensureRunStepDir(runId, stepId) {
const dir = stepArtifactDir(runId, stepId);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function writeStepArtifact(runId, stepId, outputName, value) {
const dir = ensureRunStepDir(runId, stepId);
writeJson(path.join(dir, `${outputName}.json`), value);
const run = getRun(runId);
const step = run.steps.find((entry) => entry.id === stepId);
if (step) {
if (!step.outputs.includes(outputName)) step.outputs.push(outputName);
saveRun(run);
}
}
function readStepOutputs(runId, stepId) {
const dir = stepArtifactDir(runId, stepId);
if (!fs.existsSync(dir)) return {};
const outputs = {};
for (const entry of fs.readdirSync(dir).sort()) {
if (!entry.endsWith(".json")) continue;
outputs[entry.replace(/\.json$/, "")] = readJson(path.join(dir, entry), null);
}
return outputs;
}
function getArtifacts(runId) {
const root = runArtifactDir(runId);
if (!fs.existsSync(root)) {
const error = new Error(`Artifacts introuvables pour ${runId}`);
error.statusCode = 404;
throw error;
}
const artifacts = [];
for (const stepId of fs.readdirSync(root).sort()) {
const stepDir = path.join(root, stepId);
if (!fs.statSync(stepDir).isDirectory()) continue;
for (const entry of fs.readdirSync(stepDir).sort()) {
if (!entry.endsWith(".json")) continue;
artifacts.push({
stepId,
name: entry.replace(/\.json$/, ""),
file: path.join(stepDir, entry),
relativePath: path.relative(root, path.join(stepDir, entry)),
payload: readJson(path.join(stepDir, entry), null),
});
}
}
return artifacts;
}
function finishRun(runId, status) {
const run = getRun(runId);
run.status = cleanText(status, 40) || "completed";
if (!run.startedAt && run.status !== "queued") {
run.startedAt = run.queuedAt || run.createdAt || new Date().toISOString();
}
run.finishedAt = new Date().toISOString();
const root = runArtifactDir(runId);
const stepArtifacts = {};
let totalArtifacts = 0;
if (fs.existsSync(root)) {
for (const stepId of fs.readdirSync(root)) {
const stepDir = path.join(root, stepId);
try {
if (!fs.statSync(stepDir).isDirectory()) continue;
} catch { continue; }
const count = fs.readdirSync(stepDir).filter((f) => f.endsWith(".json")).length;
if (count) {
stepArtifacts[stepId] = count;
totalArtifacts += count;
}
}
}
run.artifactSummary = { totalArtifacts, stepArtifacts };
saveRun(run);
writeJson(path.join(runArtifactDir(runId), "summary.json"), {
runId,
status: run.status,
artifactSummary: run.artifactSummary,
generatedAt: run.finishedAt,
});
return run;
}
function requestRunCancel(runId) {
const run = getRun(runId);
if (["completed", "failed", "cancelled", "blocked", "not_configured"].includes(run.status)) {
return run;
}
if (run.status === "queued") {
return finishRun(runId, "cancelled");
}
run.cancelRequestedAt = new Date().toISOString();
return saveRun(run);
}
function recoverRunnableRuns() {
const recoveredAt = new Date().toISOString();
const candidates = listRunsByStatus(["queued", "running"], 500);
const recovered = [];
for (const run of candidates) {
let touched = false;
if (run.status === "running") {
run.status = "queued";
run.recoveredAt = recoveredAt;
run.recoveryCount = Number(run.recoveryCount || 0) + 1;
touched = true;
}
for (const step of run.steps || []) {
if (step.status === "running") {
step.status = "queued";
step.startedAt = null;
step.finishedAt = null;
step.error = null;
step.details = {
...(isObject(step.details) ? step.details : {}),
recoveredAt,
recoveredFrom: "running",
};
touched = true;
}
}
if (touched) saveRun(run);
recovered.push(run);
}
return recovered;
}
function resolveModelFamily(family) {
if (family === "lora") return "lora";
if (family === "finetuned") return "finetuned";
return "base";
}
function registerModel({
alias,
modelName,
family = "base",
sourceRunId,
sourceStepId,
evaluation = null,
metadata = {},
}) {
const resolvedFamily = resolveModelFamily(family);
const index = loadModelIndex();
const baseAlias = cleanText(alias, 80) || "candidate";
const previousVersions = index.models.filter((entry) => entry.alias === baseAlias);
const version = previousVersions.length + 1;
const id = `${baseAlias}_v${version}`;
const modelDir = path.join(modelFamilies[resolvedFamily], id);
fs.mkdirSync(modelDir, { recursive: true });
const entry = {
id,
alias: baseAlias,
version,
family: resolvedFamily,
modelName: cleanText(modelName, 120),
sourceRunId: cleanText(sourceRunId, 80),
sourceStepId: cleanText(sourceStepId, 80),
evaluation: evaluation || null,
metadata: isObject(metadata) ? clone(metadata) : {},
deployedTargets: [],
createdAt: new Date().toISOString(),
modelDir: path.relative(projectRoot, modelDir),
};
writeJson(path.join(modelDir, "metadata.json"), entry);
index.models = index.models.filter((item) => item.id !== id).concat(entry);
saveModelIndex(index);
return entry;
}
function listModels(limit = 50) {
return loadModelIndex().models
.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""))
.slice(0, limit);
}
function getModel(id) {
const model = loadModelIndex().models.find((entry) => entry.id === id) || null;
if (!model) {
const error = new Error(`Modèle introuvable: ${id}`);
error.statusCode = 404;
throw error;
}
return model;
}
function registerDeployment({
runId,
stepId,
target,
registeredModelId,
alias,
modelName,
}) {
const deployment = {
id: randomId("deploy"),
target: cleanText(target, 40) || "local",
registeredModelId: cleanText(registeredModelId, 80),
alias: cleanText(alias, 80),
modelName: cleanText(modelName, 120),
createdAt: new Date().toISOString(),
mode: "manifest_only",
};
const index = loadModelIndex();
const model = index.models.find((entry) => entry.id === deployment.registeredModelId);
if (!model) {
const error = new Error(`Modèle introuvable: ${deployment.registeredModelId}`);
error.statusCode = 404;
throw error;
}
const nextModel = {
...model,
deployedTargets: [...new Set([...(model.deployedTargets || []), deployment.target])],
};
writeJson(path.join(runArtifactDir(runId), `${stepId}_deployment.json`), deployment);
index.models = index.models.map((entry) => entry.id === nextModel.id ? nextModel : entry);
saveModelIndex(index);
writeJson(path.join(modelFamilies[nextModel.family], nextModel.id, "metadata.json"), nextModel);
return deployment;
}
function getOverview() {
const graphs = listGraphs();
const runs = listRuns(10);
return {
families: registry.listFamilies(),
nodeTypes: registry.listNodeTypes(),
graphs,
runs,
models: listModels(10),
runtimes: [],
storage: {
rootDir,
graphsDir,
runsDir,
artifactsDir,
cacheDir,
modelsRoot,
},
};
}
ensureSeedGraphs();
return {
listGraphs,
getGraph,
saveGraph,
createGraph,
createRun,
getRun,
listRuns,
listRunsByStatus,
updateRun,
markRunStep,
writeStepArtifact,
readStepOutputs,
ensureRunStepDir,
finishRun,
requestRunCancel,
recoverRunnableRuns,
getArtifacts,
registerModel,
listModels,
getModel,
registerDeployment,
getOverview,
};
}
module.exports = {
createNodeEngineStore,
};