Automatisierungsvorschlaege: Agenten schlagen n8n-Workflows vor, der Mensch importiert von Hand (#726)

- Core/Automation/WorkflowReview: free check of an n8n export - every node
  with its effect and target, code/command/SSH nodes only with a stated
  reason, embedded secrets refused (by name or by look), a short summary of
  what the workflow does.
- AutomationProposals: measured saving from the replaced tasks' receipts
  (28 days), at most 3 open per agent, the same purpose not twice, a
  rejected proposal must be answered; the human gets a task and decides in
  the approvals view. Accepting records it - importing stays a human's step.
- Candidates: recurring tasks whose runs repeat the same calls (>= 75 % of
  >= 8 runs in 14 days) are put to their agent once a month.
- Escalation tool: automation_find, automation_propose.
- Approvals view: proposals with nodes, targets, warnings, saving, JSON
  download, accept/reject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 18:58:36 +02:00
co-authored by Claude Opus 5.5
parent 64e09b82d5
commit 067ff62317
13 changed files with 992 additions and 4 deletions
@@ -698,6 +698,20 @@
"staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
"automation.title": "Automatisierungsvorschläge",
"automation.hint": "Agenten schlagen n8n-Workflows für Routine vor, die sie immer wieder gleich erledigen. Lies, was der Workflow tut — jeden Knoten und jede Adresse, die er erreicht. Nimmst du an, importierst du das JSON von Hand in n8n, testest es und gibst es als MCP-Werkzeug frei; einen Knopf, der das für dich tut, gibt es mit Absicht nicht. Eine Ablehnung behält ihren Grund.",
"automation.none": "Kein Vorschlag wartet auf dich.",
"automation.trigger": "Auslöser",
"automation.savings": "Gemessen über {days} Tage: {runs} Lauf/Läufe, {cost} USD — etwa {runsMonth} Läufe und {month} USD im Monat.",
"automation.does": "Was er tut",
"automation.replaces": "Ersetzt Aufgaben",
"automation.services": "Braucht",
"automation.risks": "Risiken",
"automation.testGuide": "Testanleitung",
"automation.download": "JSON herunterladen",
"automation.reason": "Grund (Pflicht beim Ablehnen)",
"automation.accept": "Annehmen",
"automation.reject": "Ablehnen",
"staging.voteRunning": "Abstimmung #{id} läuft bis {until}: {yes} ja, {no} nein, {missing} ausstehend. Du kannst vorher entscheiden.",
"staging.voteDone": "Abstimmung #{id}: {outcome} — {yes} ja, {no} nein, {missing} fehlen.",
"staging.voteRule": "Angenommen ab {approve} Ja, abgelehnt bei höchstens {reject}, mindestens {quorum} Stimmen",
@@ -698,6 +698,20 @@
"staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}",
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
"automation.title": "Automation proposals",
"automation.hint": "Agents propose n8n workflows for routine they do the same way again and again. Read what the workflow does — every node and every address it reaches. If you accept, import the JSON into n8n by hand, test it, and release it as an MCP tool; there is deliberately no button that does it for you. A rejection keeps its reason.",
"automation.none": "No proposal waits for you.",
"automation.trigger": "trigger",
"automation.savings": "Measured over {days} days: {runs} run(s), {cost} USD — about {runsMonth} runs and {month} USD a month.",
"automation.does": "What it does",
"automation.replaces": "Replaces tasks",
"automation.services": "Needs",
"automation.risks": "Risks",
"automation.testGuide": "Test guide",
"automation.download": "Download JSON",
"automation.reason": "Reason (required to reject)",
"automation.accept": "Accept",
"automation.reject": "Reject",
"staging.voteRunning": "Vote #{id} runs until {until}: {yes} yes, {no} no, {missing} outstanding. You can decide it first.",
"staging.voteDone": "Vote #{id}: {outcome} — {yes} yes, {no} no, {missing} missing.",
"staging.voteRule": "Approved from {approve} yes, rejected at {reject} or fewer, at least {quorum} votes",
@@ -77,7 +77,8 @@ export async function rendereStaging() {
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich);
// #726: workflows agents propose — decided here, imported by hand.
layout.append(kopfZeile, listeBereich, erstelleAutomationsKarte(), dauerBereich, leitungBereich);
container.append(layout);
await ladeFreigaben();
@@ -420,3 +421,76 @@ function oeffneAblehnenDialog(id) {
modal.oeffnen();
}
/**
* #726: Workflows agents propose. The human reads what the workflow does (the review lists every
* node and every address outside), decides, and on acceptance imports the JSON into n8n by hand —
* deliberately no "apply" button: a workflow runs outside every check here.
*/
export function erstelleAutomationsKarte() {
const box = erstelleElement("div", { klasse: "automationen" });
async function lade() {
let liste = [];
try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; }
if (!liste.length) {
box.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.none") }));
return;
}
box.replaceChildren(...liste.map(vorschlag));
}
function vorschlag(p) {
const grund = erstelleElement("input", { type: "text", placeholder: t("automation.reason") });
const entscheiden = async (annehmen) => {
try {
const r = await api(`/staging/automations/${p.id}/decide`, { method: "POST", body: { accept: annehmen, reason: grund.value } });
zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 8000);
await lade();
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
}
};
const herunterladen = async () => {
try {
const json = await api(`/staging/automations/${p.id}/workflow`);
const blob = new Blob([typeof json === "string" ? json : JSON.stringify(json, null, 2)], { type: "application/json" });
const link = erstelleElement("a", { href: URL.createObjectURL(blob) });
link.download = `automation-${p.id}.n8n.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
}
};
return erstelleElement("div", { klasse: "automation" },
erstelleElement("strong", { text: `#${p.id} ${p.purpose}` }),
erstelleElement("div", { klasse: "gedaempft text-klein", text: `${p.agentId} · ${formatiereDatumZeit(p.createdAt)} · ${t("automation.trigger")}: ${p.trigger}` }),
erstelleElement("p", { klasse: "text-klein",
text: t("automation.savings", { days: p.days, runs: p.runs, cost: p.costUsd.toFixed(2), month: p.costPerMonthUsd.toFixed(2), runsMonth: p.runsPerMonth }) }),
erstelleElement("p", { klasse: "text-klein", text: `${t("automation.does")}: ${p.summary}` }),
erstelleElement("table", { klasse: "daten-tabelle" },
erstelleElement("tbody", {}, ...p.nodes.map((n) => erstelleElement("tr", { klasse: n.dangerous ? "org-ausreisser" : "" },
erstelleElement("td", { text: n.name }),
erstelleElement("td", { text: n.effect }),
erstelleElement("td", { klasse: "gedaempft", text: n.target ?? "" }))))),
...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })),
erstelleElement("dl", { klasse: "datenliste" },
erstelleElement("dt", { text: t("automation.replaces") }), erstelleElement("dd", { text: p.taskIds.join(", ") }),
erstelleElement("dt", { text: t("automation.services") }), erstelleElement("dd", { text: p.services.join(", ") || "—" }),
erstelleElement("dt", { text: t("automation.risks") }), erstelleElement("dd", { text: p.risks }),
erstelleElement("dt", { text: t("automation.testGuide") }), erstelleElement("dd", { text: p.testGuide })),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.download"), onclick: herunterladen }),
grund,
erstelleElement("button", { type: "button", klasse: "primaer klein", text: t("automation.accept"), onclick: () => entscheiden(true) }),
erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.reject"), onclick: () => entscheiden(false) })));
}
lade();
return erstelleKarte(t("automation.title"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.hint") }),
box
]);
}