- TaskItem.ParentId, in der Datei als parent, in der DB als ParentId. Die
Elternaufgabe ist der Zweck ("wofuer"), blocked_by die Voraussetzung
("wann").
- GoalChain: Eltern bis zu acht Stufen, Zyklen brechen den Gang ab und
werden beim Setzen abgelehnt. Oben steht das Ziel der Einheit - der
obersten Aufgabe, die zu einer Einheit gehoert (ein Ziel, das ein Mensch
auf der obersten Ebene setzt, gehoert keiner), sonst der naechsten Einheit
darueber mit Ziel.
- Der Lauf einer Aufgabe bekommt den Absatz "Why this task exists" und die
Bitte, zu melden, wenn die Aufgabe ihrem Zweck nicht mehr dient.
- Taskboard-Tool: parent bei task_create/task_update, geprueft auf Existenz,
Sichtbarkeit (#684) und Zyklen; task_list zeigt "Serves".
- WebUI: Feld im Aufgabendialog, Anzeige auf der Karte; die Verwaltung
prueft wie das Tool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
428 lines
15 KiB
JavaScript
428 lines
15 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;
|
|
|
|
/** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */
|
|
let brettFilter = "";
|
|
|
|
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 offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
|
|
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
|
|
container.replaceChildren();
|
|
|
|
// #684: With units, every task is on a unit's board — pick one to see only that.
|
|
const bretter = brettAuswahl(alle);
|
|
if (bretter) container.append(bretter);
|
|
|
|
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 })
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it
|
|
* shows only boards that hold something; absent when no task is on a unit's board.
|
|
*/
|
|
function brettAuswahl(tasks) {
|
|
const bretter = new Map();
|
|
for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board);
|
|
if (bretter.size === 0) return null;
|
|
|
|
const auswahl = erstelleElement("select", {
|
|
"aria-label": t("tasks.board"),
|
|
onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); }
|
|
},
|
|
erstelleElement("option", { value: "", text: t("tasks.boardAll") }),
|
|
erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }),
|
|
...[...bretter.entries()]
|
|
.sort((a, b) => a[1].localeCompare(b[1]))
|
|
.map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad })));
|
|
auswahl.value = brettFilter;
|
|
|
|
return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" },
|
|
erstelleElement("span", { text: t("tasks.board") }), auswahl);
|
|
}
|
|
|
|
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 || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}${task.parentId ? ` · ↑ ${t("tasks.serves", { id: task.parentId })}` : ""}`
|
|
})
|
|
)
|
|
);
|
|
|
|
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 || "";
|
|
|
|
// #714: The task this one serves — the run is told the chain up to the unit's goal.
|
|
const eltern = erstelleElement("input", { type: "text", value: task?.parentId || "", placeholder: "t-…" });
|
|
|
|
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("label", { text: t("tasks.parent") + ":" }), eltern,
|
|
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,
|
|
parentId: eltern.value.trim()
|
|
};
|
|
|
|
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();
|
|
}
|