282 lines
9.4 KiB
TypeScript
282 lines
9.4 KiB
TypeScript
import { Router } from "@oak/oak";
|
||
import { ObjectId } from "mongodb";
|
||
import { getDB } from "../db/mongo.ts";
|
||
import { authMiddleware } from "../middleware/auth.ts";
|
||
import { getNextNumber } from "./tasks.ts";
|
||
|
||
export const agentTasksRouter = new Router({ prefix: "/api/agent-tasks" });
|
||
|
||
interface AgentTask {
|
||
_id: ObjectId;
|
||
number?: number;
|
||
message: string;
|
||
projectId?: string;
|
||
projectName?: string;
|
||
agentId?: string;
|
||
agentName?: string;
|
||
linkedTaskIds: string[];
|
||
linkedTaskTitles: string[];
|
||
status: "pending" | "in_progress" | "done" | "rejected";
|
||
createdBy: string;
|
||
createdByName: string;
|
||
result?: string;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}
|
||
|
||
// Neuen Auftrag erstellen
|
||
agentTasksRouter.post("/", authMiddleware, async (ctx) => {
|
||
const db = await getDB();
|
||
const body = await ctx.request.body.json();
|
||
const { message, projectId, projectName, agentId, agentName, linkedTaskIds, linkedTaskTitles } = body;
|
||
|
||
if (!message || !message.trim()) {
|
||
ctx.response.status = 400;
|
||
ctx.response.body = { error: "Nachricht ist erforderlich" };
|
||
return;
|
||
}
|
||
|
||
const now = new Date();
|
||
const number = await getNextNumber("agent_task_number");
|
||
const doc: Omit<AgentTask, "_id"> = {
|
||
number,
|
||
message: message.trim(),
|
||
projectId: projectId || undefined,
|
||
projectName: projectName || undefined,
|
||
agentId: agentId || undefined,
|
||
agentName: agentName || undefined,
|
||
linkedTaskIds: linkedTaskIds || [],
|
||
linkedTaskTitles: linkedTaskTitles || [],
|
||
status: "pending",
|
||
createdBy: ctx.state.user.id,
|
||
createdByName: ctx.state.user.username,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
const result = await db.collection<AgentTask>("agent_tasks").insertOne(doc as AgentTask);
|
||
|
||
ctx.response.status = 201;
|
||
ctx.response.body = {
|
||
message: "Auftrag erstellt",
|
||
id: result.insertedId.toString(),
|
||
};
|
||
});
|
||
|
||
// Alle Aufträge laden (mit optionalen Filtern)
|
||
agentTasksRouter.get("/", authMiddleware, async (ctx) => {
|
||
const db = await getDB();
|
||
const url = ctx.request.url;
|
||
|
||
const filter: Record<string, unknown> = {};
|
||
|
||
const status = url.searchParams.get("status");
|
||
if (status) filter.status = status;
|
||
|
||
const agentId = url.searchParams.get("agentId");
|
||
if (agentId) filter.agentId = agentId;
|
||
|
||
const tasks = await db.collection<AgentTask>("agent_tasks")
|
||
.find(filter)
|
||
.sort({ createdAt: -1 })
|
||
.limit(100)
|
||
.toArray();
|
||
|
||
ctx.response.body = { tasks };
|
||
});
|
||
|
||
// Pending Aufträge für Agenten (Kurzform)
|
||
agentTasksRouter.get("/pending", authMiddleware, async (ctx) => {
|
||
const db = await getDB();
|
||
|
||
const tasks = await db.collection<AgentTask>("agent_tasks")
|
||
.find({ status: "pending" })
|
||
.sort({ createdAt: 1 })
|
||
.toArray();
|
||
|
||
ctx.response.body = { tasks };
|
||
});
|
||
|
||
// Auftrag nach Nummer laden
|
||
agentTasksRouter.get("/by-number/:number", authMiddleware, async (ctx) => {
|
||
const db = await getDB();
|
||
const num = parseInt(ctx.params.number);
|
||
if (isNaN(num)) {
|
||
ctx.response.status = 400;
|
||
ctx.response.body = { error: "Ungültige Nummer" };
|
||
return;
|
||
}
|
||
const task = await db.collection<AgentTask>("agent_tasks").findOne({ number: num });
|
||
if (!task) {
|
||
ctx.response.status = 404;
|
||
ctx.response.body = { error: "Auftrag nicht gefunden" };
|
||
return;
|
||
}
|
||
ctx.response.body = { task };
|
||
});
|
||
|
||
// Einzelnen Auftrag laden
|
||
agentTasksRouter.get("/:id", authMiddleware, async (ctx) => {
|
||
const db = await getDB();
|
||
const id = ctx.params.id;
|
||
|
||
try {
|
||
const task = await db.collection<AgentTask>("agent_tasks")
|
||
.findOne({ _id: new ObjectId(id) });
|
||
|
||
if (!task) {
|
||
ctx.response.status = 404;
|
||
ctx.response.body = { error: "Auftrag nicht gefunden" };
|
||
return;
|
||
}
|
||
|
||
ctx.response.body = { task };
|
||
} catch {
|
||
ctx.response.status = 400;
|
||
ctx.response.body = { error: "Ungültige ID" };
|
||
}
|
||
});
|
||
|
||
// OpenClaw Agent-Proxy — startet isolierten Agent-Run über WireGuard VPN (hooks/agent endpoint)
|
||
agentTasksRouter.post("/wake", authMiddleware, async (ctx) => {
|
||
const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL"); // e.g. http://10.10.0.3:18789/hooks/wake
|
||
const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN");
|
||
|
||
if (!openclawBaseUrl || !openclawToken) {
|
||
ctx.response.status = 503;
|
||
ctx.response.body = { error: "OpenClaw Wake nicht konfiguriert" };
|
||
return;
|
||
}
|
||
|
||
// Pending Tasks laden für den Prompt
|
||
const db = await getDB();
|
||
const pendingTasks = await db.collection<AgentTask>("agent_tasks")
|
||
.find({ status: "pending" })
|
||
.sort({ createdAt: 1 })
|
||
.toArray();
|
||
|
||
if (pendingTasks.length === 0) {
|
||
ctx.response.body = { message: "Keine pending Tasks" };
|
||
return;
|
||
}
|
||
|
||
// Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen
|
||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||
const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS);
|
||
await db.collection<AgentTask>("agent_tasks").updateMany(
|
||
{ status: "in_progress", updatedAt: { $lt: timeoutThreshold } },
|
||
{ $set: { status: "pending", updatedAt: new Date() } }
|
||
);
|
||
|
||
// Lock: Nur ein Task gleichzeitig pro Agent
|
||
const inProgressCount = await db.collection<AgentTask>("agent_tasks")
|
||
.countDocuments({ status: "in_progress" });
|
||
|
||
if (inProgressCount > 0) {
|
||
ctx.response.body = { message: "Agent arbeitet bereits an einem Task", blocked: true };
|
||
return;
|
||
}
|
||
|
||
// Prompt mit Task-Details bauen
|
||
const taskList = pendingTasks.map((t, i) =>
|
||
`${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || "–"}, von: ${t.createdByName})`
|
||
).join("\n");
|
||
|
||
const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || "";
|
||
// Nur den ERSTEN pending Task bearbeiten (1 Task pro Run)
|
||
const firstTask = pendingTasks[0];
|
||
const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran!
|
||
|
||
WICHTIG: KEINE Sub-Agenten spawnen (sessions_spawn)! Du bearbeitest Tasks SELBST, einzeln, nacheinander. Nur 1 Task pro Run!
|
||
|
||
Dein Agent-Task:
|
||
[${firstTask._id}] "${firstTask.message}" (Projekt: ${firstTask.projectName || "–"}, von: ${firstTask.createdByName})
|
||
|
||
Bearbeite diesen Task nach der CHECKLIST.md:
|
||
1. Task im AMS auf "in_progress" setzen: curl -X PUT https://api.ams.agentenbude.de/api/tasks/<LINKED_TASK_ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"in_progress"}'
|
||
2. Feature-Branch erstellen (feature/beschreibung)
|
||
3. Backend ZUERST implementieren (bei Full-Stack)
|
||
4. Kein \`any\`, kein \`console.log\`, keine Secrets im Code
|
||
5. Dark Theme beachten! Alle UI-Elemente müssen zum bestehenden Dark-Mode-Design passen (Farben: #1a1a2e Hintergrund, rgba(255,255,255,0.05-0.15) für Karten/Borders, helle Textfarben)
|
||
6. Self-Review: npm run build (FE) / deno check (BE)
|
||
7. Conventional Commits auf DEUTSCH
|
||
8. MR erstellen und mergen
|
||
9. Tag + Deploy
|
||
10. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/<ID> -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":"<Zusammenfassung>"}'
|
||
11. Task-Kommentar mit Zusammenfassung + MR + Version
|
||
12. Melde dem Chef das Ergebnis.
|
||
|
||
API-Auth: Bearer ${serviceKey}
|
||
AMS-API: https://api.ams.agentenbude.de/api`;
|
||
|
||
// /hooks/agent statt /hooks/wake aufrufen
|
||
const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent");
|
||
|
||
try {
|
||
const response = await fetch(agentUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": `Bearer ${openclawToken}`,
|
||
},
|
||
body: JSON.stringify({
|
||
message: prompt,
|
||
name: "AMS-Task",
|
||
sessionKey: "hook:ams-task",
|
||
wakeMode: "now",
|
||
deliver: true,
|
||
channel: "telegram",
|
||
to: "7150608398",
|
||
}),
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
|
||
if (response.ok) {
|
||
ctx.response.body = { message: "Agent gestartet", tasks: pendingTasks.length };
|
||
} else {
|
||
ctx.response.status = 502;
|
||
ctx.response.body = { error: "Agent-Start fehlgeschlagen", status: response.status };
|
||
}
|
||
} catch {
|
||
ctx.response.status = 502;
|
||
ctx.response.body = { error: "OpenClaw nicht erreichbar" };
|
||
}
|
||
});
|
||
|
||
// Auftrag-Status aktualisieren (Agent nimmt an / erledigt / lehnt ab)
|
||
agentTasksRouter.put("/:id", authMiddleware, async (ctx) => {
|
||
const db = await getDB();
|
||
const id = ctx.params.id;
|
||
const body = await ctx.request.body.json();
|
||
const { status, result } = body;
|
||
|
||
const validStatuses = ["pending", "in_progress", "done", "rejected"];
|
||
if (status && !validStatuses.includes(status)) {
|
||
ctx.response.status = 400;
|
||
ctx.response.body = { error: `Ungültiger Status. Erlaubt: ${validStatuses.join(", ")}` };
|
||
return;
|
||
}
|
||
|
||
const updateFields: Record<string, unknown> = { updatedAt: new Date() };
|
||
if (status) updateFields.status = status;
|
||
if (result !== undefined) updateFields.result = result;
|
||
|
||
try {
|
||
const res = await db.collection<AgentTask>("agent_tasks").updateOne(
|
||
{ _id: new ObjectId(id) },
|
||
{ $set: updateFields }
|
||
);
|
||
|
||
if (res.matchedCount === 0) {
|
||
ctx.response.status = 404;
|
||
ctx.response.body = { error: "Auftrag nicht gefunden" };
|
||
return;
|
||
}
|
||
|
||
ctx.response.body = { message: "Auftrag aktualisiert" };
|
||
} catch {
|
||
ctx.response.status = 400;
|
||
ctx.response.body = { error: "Ungültige ID" };
|
||
}
|
||
});
|