// Project: pi-teams import fs from "node:fs"; import path from "node:path"; import { runHook } from "./hooks"; import { withLock } from "./lock"; import type { TaskFile } from "./models"; import { sanitizeName, taskDir } from "./paths"; import { teamExists } from "./teams"; export function getTaskId(teamName: string): string { const dir = taskDir(teamName); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); const ids = files.map((f) => parseInt(path.parse(f).name, 10)).filter((id) => !Number.isNaN(id)); return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1"; } function getTaskPath(teamName: string, taskId: string): string { const dir = taskDir(teamName); const safeTaskId = sanitizeName(taskId); return path.join(dir, `${safeTaskId}.json`); } export async function createTask( teamName: string, subject: string, description: string, activeForm = "", metadata?: Record, ): Promise { if (!subject || !subject.trim()) throw new Error("Task subject must not be empty"); if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`); const dir = taskDir(teamName); const lockPath = dir; return await withLock(lockPath, async () => { const id = getTaskId(teamName); const task: TaskFile = { id, subject, description, activeForm, status: "pending", blocks: [], blockedBy: [], metadata, }; fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(task, null, 2)); return task; }); } export async function updateTask( teamName: string, taskId: string, updates: Partial, retries?: number, ): Promise { const p = getTaskPath(teamName, taskId); return await withLock( p, async () => { if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); const updated = { ...task, ...updates }; if (updates.status === "deleted") { fs.unlinkSync(p); return updated; } fs.writeFileSync(p, JSON.stringify(updated, null, 2)); if (updates.status === "completed") { await runHook(teamName, "task_completed", updated); } return updated; }, retries, ); } /** * Submits a plan for a task, updating its status to "planning". * @param teamName The name of the team * @param taskId The ID of the task * @param plan The content of the plan * @returns The updated task */ export async function submitPlan(teamName: string, taskId: string, plan: string): Promise { if (!plan || !plan.trim()) throw new Error("Plan must not be empty"); return await updateTask(teamName, taskId, { status: "planning", plan }); } /** * Evaluates a submitted plan for a task. * @param teamName The name of the team * @param taskId The ID of the task * @param action The evaluation action: "approve" or "reject" * @param feedback Optional feedback for the evaluation (required for rejection) * @param retries Number of times to retry acquiring the lock * @returns The updated task */ export async function evaluatePlan( teamName: string, taskId: string, action: "approve" | "reject", feedback?: string, retries?: number, ): Promise { const p = getTaskPath(teamName, taskId); return await withLock( p, async () => { if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); // 1. Validate state: Only "planning" tasks can be evaluated if (task.status !== "planning") { throw new Error( `Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` + `Tasks must be in 'planning' status to be evaluated.`, ); } // 2. Validate plan presence if (!task.plan || !task.plan.trim()) { throw new Error(`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`); } // 3. Require feedback for rejections if (action === "reject" && (!feedback || !feedback.trim())) { throw new Error("Feedback is required when rejecting a plan."); } // 4. Perform update const updates: Partial = action === "approve" ? { status: "in_progress", planFeedback: "" } : { status: "planning", planFeedback: feedback }; const updated = { ...task, ...updates }; fs.writeFileSync(p, JSON.stringify(updated, null, 2)); return updated; }, retries, ); } export async function readTask(teamName: string, taskId: string, retries?: number): Promise { const p = getTaskPath(teamName, taskId); if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`); return await withLock( p, async () => { return JSON.parse(fs.readFileSync(p, "utf-8")); }, retries, ); } export async function listTasks(teamName: string): Promise { const dir = taskDir(teamName); return await withLock(dir, async () => { const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); const tasks: TaskFile[] = files .map((f) => { const id = parseInt(path.parse(f).name, 10); if (Number.isNaN(id)) return null; return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); }) .filter((t) => t !== null); return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); }); } export async function resetOwnerTasks(teamName: string, agentName: string) { const dir = taskDir(teamName); const lockPath = dir; await withLock(lockPath, async () => { const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); for (const f of files) { const p = path.join(dir, f); const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8")); if (task.owner === agentName) { task.owner = undefined; if (task.status !== "completed") { task.status = "pending"; } fs.writeFileSync(p, JSON.stringify(task, null, 2)); } } }); }