Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
T
RichardandClaude Opus 5.5 5f3a85595b Budgetende, Stufe 1: Ausfall vermerken, Regel je Agent, mitnehmen statt anrennen (#716)
Bisher teilte sich eine Budgetablehnung den Ausgang NichtGelaufen mit dem
Herunterfahren. Einmalige Aufgaben wurden den Rest des Tages alle 30 Minuten
erneut abgelehnt (jedes Mal ein Beleg) und liefen nach Mitternacht still zu
Lasten des neuen Tages. Wiederkehrende verfielen still.

- TaskLauf.KeinBudget als eigener Ausgang; der Dispatcher bildet
  BudgetExceeded darauf ab.
- Vier Felder an der Aufgabe: BudgetMissCount, LastBudgetMissAt,
  DeferredUntil, CatchUpPending (Spalten werden fuer bestehende Datenbanken
  nachgeruestet).
- Einmalige Aufgaben werden mitgenommen: Der Termin bleibt erhalten, faellig
  ist die Aufgabe wieder ab der naechsten lokalen Mitternacht, nicht vorher.
- Wiederkehrende Aufgaben folgen der Regel des Agenten
  (budget.whenExhausted): expire vermerkt den Ausfall und laesst den Termin
  verfallen; catchUpOnce fasst alle verpassten Termine zu einem Nachholauf am
  naechsten Tag zusammen. Ein regulaer faelliger Termin deckt das Nachholen
  mit ab, es bleibt bei einem Lauf.
- Der neue Tag hat Vorfahrt: Mitgenommenes und Nachholaeufe kommen nach allem
  regulaer Faelligen, unabhaengig von der Prioritaet.
- Beim ersten Takt eines neuen Tages eine Zusammenfassung je Agent im Log.
- Sichtbar: Die Aufgabenkarte der WebUI zeigt Ausfaelle, Mitnahme und
  Nachholen; die Regel ist im Desktop je Agent einstellbar. Nebenbei sind
  "Next:"/"Last:" auf der Karte ins Sprachpaket gewandert.

Stufen 2 bis 4 (Reservierung, Spielraum statt pauschalem Anteil, Vorbeugen)
folgen.

Tests: BudgetMissTests (9 neu). 1.238 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:03:15 +02:00

388 lines
13 KiB
JavaScript

// ClawdDotNet — Aufgaben (Taskboard)
//
// Central taskboard view for scheduled and manual tasks.
//
// Since #594 the only place for scheduled work. The former tab "Scheduled runs (Jobs)"
// edited the legacy config scheduler/toolJobs; after the first migration into the taskboard
// no change made there had any effect. Schedule, next and last run, pause and run-now
// now live on the task itself.
import { api } from "../modules/api.js";
import { t } from "../modules/i18n.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
formatiereKosten,
zeigeMeldung
} from "../modules/dom.js";
function holeStatusText(status) {
switch (status) {
case "Backlog": return t("status.paused");
case "Todo": return t("status.ready");
case "InProgress": return t("status.running");
case "InReview": return t("status.inReview");
case "Done": return t("status.done");
case "Canceled": return t("status.canceled");
case "Blocked": return t("status.blocked");
case "Archived": return t("status.archived");
default: return status || t("common.unknown");
}
}
const STATUS_ART = {
Backlog: "warnung",
Todo: "neutral",
InProgress: "laufend",
Done: "erfolg",
Blocked: "warnung"
};
const LAUF_ART = {
Completed: "erfolg",
Truncated: "warnung",
Incomplete: "warnung",
BudgetExceeded: "warnung",
EmptyResponse: "gefahr",
RepeatedFailure: "gefahr",
LoopLimitExceeded: "gefahr",
Failed: "gefahr"
};
const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"];
let zeigeAbgeschlossene = false;
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
const abgeschlossenSchalter = erstelleElement(
"label",
{ klasse: "gruppe-horizontal text-klein" },
erstelleElement("input", {
type: "checkbox",
checked: zeigeAbgeschlossene,
onchange: (e) => {
zeigeAbgeschlossene = e.target.checked;
ladeTaskboard();
}
}),
erstelleElement("span", { text: t("tasks.showCompleted") })
);
const neuladen = erstelleElement(
"button",
{ klasse: "schaltflaeche flach", title: t("common.refresh"), onclick: () => ladeTaskboard() },
erstelleIcon(PFADE.aktualisieren, 16)
);
const neu = erstelleElement(
"button",
{ klasse: "schaltflaeche primaer", onclick: () => oeffneAufgabenDialog(null) },
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " " + t("tasks.newTask") })
);
kopf.append(
erstelleElement("h1", { text: t("tasks.title") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, abgeschlossenSchalter, neuladen, neu)
);
layout.append(kopf, erstelleElement("div", { id: "aufgaben-bereich-inhalt" }));
container.append(layout);
await ladeTaskboard();
}
async function ladeTaskboard() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") })
);
try {
const alle = (await api("/tasks?limit=200")) || [];
const tasks = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
container.replaceChildren();
if (tasks.length === 0) {
container.append(
erstelleKarte(t("tasks.empty"), [
erstelleElement("p", {
klasse: "gedaempft",
text: "..."
})
])
);
return;
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) raster.append(erstelleAufgabenKarte(task));
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard Error", [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
])
);
}
}
function terminText(task) {
if (!task.when) return t("tasks.scheduleNone");
const art = task.when.kind;
return `${art} ${task.when.value} (${task.when.timeZone || "UTC"})`;
}
function erstelleAufgabenKarte(task) {
const zeilen = [];
zeilen.push(
erstelleElement(
"div",
{ klasse: "task-status-zeile gruppe-horizontal" },
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}` })
)
);
zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `${t("tasks.schedule")}: ${terminText(task)}` }));
zeilen.push(
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: `${t("tasks.nextRun")}: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
})
);
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, `${t("tasks.lastRun")}: `);
if (task.lastRun) {
letzter.append(
`${formatiereDatumZeit(task.lastRun.occurredAt)} `,
erstellePlakette(task.lastRun.status, LAUF_ART[task.lastRun.status] || "neutral"),
` ${task.lastRun.costIsKnown ? formatiereKosten(task.lastRun.costUsd) : "—"}`
);
if (task.lastRun.resultRef) letzter.title = task.lastRun.resultRef;
} else {
letzter.append("—");
}
zeilen.push(letzter);
if (task.hinweis) {
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
}
// #716: What the budget refused, and what happens next.
const ausfall = task.budgetMiss;
if (ausfall) {
const teile = [t("tasks.budgetMissed", { n: ausfall.count })];
if (ausfall.lastAt) teile.push(t("tasks.budgetMissedLast", { time: formatiereDatumZeit(ausfall.lastAt) }));
if (ausfall.carriedOver && ausfall.deferredUntil) {
teile.push(t("tasks.budgetCarriedOver", { time: formatiereDatumZeit(ausfall.deferredUntil) }));
} else if (ausfall.catchUpPending) {
teile.push(t("tasks.budgetCatchUp", { time: ausfall.deferredUntil ? formatiereDatumZeit(ausfall.deferredUntil) : "—" }));
}
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${teile.join(" · ")}` }));
}
const aktionen = erstelleElement("div", {
klasse: "gruppe-horizontal",
stil: "margin-top: 8px; justify-content: flex-end;"
});
if (task.assignee !== "@human" && task.status === "Todo") {
aktionen.append(
erstelleElement(
"button",
{ klasse: "schaltflaeche flach klein", title: t("tasks.runNow"), onclick: () => fuehreAus(task) },
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " " + t("tasks.runNow") })
)
);
}
if (task.status === "Todo") {
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: t("tasks.pause"),
onclick: () => setzeAktiv(task, false)
})
);
} else if (["Backlog", "Done", "Canceled"].includes(task.status)) {
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: t("tasks.resume"),
onclick: () => setzeAktiv(task, true)
})
);
}
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: t("common.edit"),
onclick: () => oeffneAufgabenDialog(task)
})
);
zeilen.push(aktionen);
return erstelleKarte(task.title || `Task ${task.id}`, zeilen);
}
async function fuehreAus(task) {
try {
const res = await api(`/tasks/${encodeURIComponent(task.id)}/run`, { method: "POST" });
zeigeMeldung(res?.message || t("tasks.runTriggered"), "erfolg");
setTimeout(ladeTaskboard, 1500);
} catch (err) {
zeigeMeldung(`Error: ${err.message}`, "gefahr");
}
}
async function setzeAktiv(task, aktiv) {
try {
const res = await api(`/tasks/${encodeURIComponent(task.id)}/enabled?enabled=${aktiv}`, { method: "PUT" });
zeigeMeldung(res?.message || (aktiv ? t("tasks.resume") : t("tasks.pause")), "info");
await ladeTaskboard();
} catch (err) {
zeigeMeldung(`Error: ${err.message}`, "gefahr");
}
}
async function oeffneAufgabenDialog(task) {
let agenten = [];
try {
agenten = (await api("/agents")) || [];
} catch {
//
}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const titel = erstelleElement("input", { type: "text", required: true, value: task?.title || "" });
const beschreibung = erstelleElement("textarea", { rows: "5", placeholder: "..." });
beschreibung.value = task?.body || "";
const zuordnung = erstelleElement("select", { required: true });
for (const a of agenten) {
zuordnung.append(
erstelleElement("option", { value: `@new:${a.agentId}`, text: `${a.displayName} (New Run)` }),
erstelleElement("option", { value: `@${a.agentId}`, text: `${a.displayName} (Chat History)` })
);
}
zuordnung.append(erstelleElement("option", { value: "@human", text: "Human (No Agent)" }));
if (task?.assignee && ![...zuordnung.options].some((o) => o.value === task.assignee)) {
zuordnung.append(erstelleElement("option", { value: task.assignee, text: task.assignee }));
}
zuordnung.value = task?.assignee || (agenten[0] ? `@new:${agenten[0].agentId}` : "@human");
const terminArt = erstelleElement("select");
terminArt.append(
erstelleElement("option", { value: "", text: t("tasks.scheduleNone") }),
erstelleElement("option", { value: "cron", text: "Cron" }),
erstelleElement("option", { value: "every", text: t("tasks.scheduleInterval") }),
erstelleElement("option", { value: "at", text: t("tasks.scheduleAt") })
);
terminArt.value = task?.when?.kind || "";
const terminWert = erstelleElement("input", { type: "text", value: task?.when?.value || "" });
const standardZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Berlin";
const zeitzone = erstelleElement("input", { type: "text", value: task?.when?.timeZone || standardZone });
const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "..." });
abnahme.value = task?.acceptance || "";
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
const aktualisiere = () => {
const wiederkehrend = terminArt.value === "cron" || terminArt.value === "every";
const imVerlauf = zuordnung.value.startsWith("@") && !zuordnung.value.startsWith("@new") && zuordnung.value !== "@human";
hinweis.hidden = !(wiederkehrend && imVerlauf);
hinweis.textContent = "⚠ Notice: Running recurring tasks in chat history increases context and costs.";
const ohneTermin = terminArt.value === "";
terminWert.disabled = ohneTermin;
zeitzone.disabled = ohneTermin;
terminWert.placeholder =
{ cron: "e.g. 0 7-22/3 * * *", every: "e.g. 30m, 2h, 1d", at: "e.g. 2026-09-15T08:00" }[terminArt.value] || "";
};
zuordnung.addEventListener("change", aktualisiere);
terminArt.addEventListener("change", aktualisiere);
aktualisiere();
formular.append(
erstelleElement("label", { text: t("common.name") + ":" }), titel,
erstelleElement("label", { text: t("tasks.taskMessage") + ":" }), beschreibung,
erstelleElement("label", { text: t("tasks.agent") + ":" }), zuordnung,
erstelleElement("label", { text: t("tasks.scheduleType") + ":" }), terminArt,
erstelleElement("label", { text: "Value:" }), terminWert,
erstelleElement("label", { text: "Timezone:" }), zeitzone,
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
erstelleElement("span"), hinweis
);
const abbrechen = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
text: t("common.cancel"),
onclick: () => modal.schliessen()
});
const speichern = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: task ? t("common.save") : t("tasks.newTask")
});
const modal = erstelleModal(task ? t("common.edit") : t("tasks.createTitle"), formular, [abbrechen, speichern]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichern.disabled = true;
const body = {
title: titel.value.trim(),
body: beschreibung.value.trim(),
assignee: zuordnung.value,
whenKind: terminArt.value || null,
whenValue: terminArt.value ? terminWert.value.trim() : null,
timeZone: terminArt.value ? zeitzone.value.trim() : null,
acceptance: abnahme.value.trim(),
priority: task?.priority ?? 3
};
try {
const res = await api(task ? `/tasks/${encodeURIComponent(task.id)}` : "/tasks", {
method: task ? "PUT" : "POST",
body
});
modal.schliessen();
const text = res?.message || t("tasks.saved");
zeigeMeldung(text, "erfolg");
await ladeTaskboard();
} catch (err) {
zeigeMeldung(`Error: ${err.message}`, "gefahr");
} finally {
speichern.disabled = false;
}
});
modal.oeffnen();
}