/** * Delivery Report — auto-generated project completion report for TeamClaw sessions. * * When all tasks in a controller session finish, this module aggregates * task results, deliverables, previews, and timeline into a self-contained * HTML page that can be shared via URL or pushed to notification channels. */ import type { TeamState, TaskInfo, ControllerRunInfo, WorkerTaskResultDeliverable, } from "../types.js"; // ── Report data model ──────────────────────────────────────────────── export type DeliveryReportPhase = { taskId: string; title: string; role: string; status: string; durationMs: number; summary: string; keyPoints: string[]; error?: string; }; export type DeliveryReportDeliverable = { taskId: string; kind: string; path: string; summary: string; artifactType?: string; previewUrl?: string; previewError?: string; }; export type DeliveryReport = { id: string; sessionKey: string; generatedAt: number; // Header projectName: string; requirementSummary: string; status: "completed" | "partial" | "failed"; totalDurationMs: number; // Pipeline phases: DeliveryReportPhase[]; // Deliverables deliverables: DeliveryReportDeliverable[]; // Highlights keyPoints: string[]; blockers: string[]; followUps: string[]; notes: string; // Meta runCount: number; taskCount: number; rolesUsed: string[]; }; // ── Report generation ──────────────────────────────────────────────── export function generateDeliveryReport( sessionKey: string, state: TeamState, normalizeSessionKey: (key: unknown) => string, ): DeliveryReport | null { const normalizedKey = normalizeSessionKey(sessionKey); // Collect all controller runs for this session const runs = Object.values(state.controllerRuns) .filter((r) => normalizeSessionKey(r.sessionKey) === normalizedKey) .sort((a, b) => a.createdAt - b.createdAt); if (runs.length === 0) return null; // Collect all task IDs across all runs const allTaskIds = Array.from(new Set(runs.flatMap((r) => r.createdTaskIds))); const tasks = allTaskIds .map((id) => state.tasks[id]) .filter((t): t is TaskInfo => !!t) .sort((a, b) => a.createdAt - b.createdAt); if (tasks.length === 0) return null; // Determine overall status const failedTasks = tasks.filter((t) => t.status === "failed"); const completedTasks = tasks.filter((t) => t.status === "completed"); const activeTasks = tasks.filter((t) => t.status === "in_progress" || t.status === "pending" || t.status === "assigned", ); let status: DeliveryReport["status"] = "completed"; if (failedTasks.length > 0 && completedTasks.length === 0) status = "failed"; else if (activeTasks.length > 0 || failedTasks.length > 0) status = "partial"; // Find the best project name and summary const latestManifest = [...runs].reverse().find((r) => r.manifest)?.manifest; const firstManifest = runs.find((r) => r.manifest)?.manifest; const projectName = latestManifest?.projectName || firstManifest?.projectName || runs[0].projectDir || "Untitled Project"; const requirementSummary = firstManifest?.requirementSummary || runs[0].request || ""; // Timeline const sessionStart = runs[0].createdAt; const lastCompletion = Math.max(...tasks.map((t) => t.completedAt ?? t.updatedAt)); const totalDurationMs = lastCompletion - sessionStart; // Build phases const phases: DeliveryReportPhase[] = tasks.map((task) => ({ taskId: task.id, title: task.title, role: task.assignedRole ?? "unknown", status: task.status, durationMs: (task.completedAt ?? task.updatedAt) - task.createdAt, summary: task.resultContract?.summary ?? task.result?.slice(0, 200) ?? "", keyPoints: task.resultContract?.keyPoints ?? [], error: task.error, })); // Collect deliverables const deliverables: DeliveryReportDeliverable[] = []; for (const task of tasks) { if (!task.resultContract?.deliverables) continue; for (const [di, d] of task.resultContract.deliverables.entries()) { const preview = resolvePreviewInfo(d, task.id, di, state); deliverables.push({ taskId: task.id, kind: d.kind, path: d.value, summary: d.summary ?? "", artifactType: d.artifactType, previewUrl: preview.url, previewError: preview.error, }); } } // Aggregate highlights const keyPoints = tasks.flatMap((t) => t.resultContract?.keyPoints ?? []); const blockers = tasks.flatMap((t) => t.resultContract?.blockers ?? []); const followUps = tasks.flatMap((t) => (t.resultContract?.followUps ?? []).map( (f) => `${f.type}${f.targetRole ? ` (${f.targetRole})` : ""}: ${f.reason}`, ), ); const notes = latestManifest?.notes ?? ""; const rolesUsed = Array.from(new Set(tasks.map((t) => t.assignedRole).filter(Boolean) as string[])); return { id: `report-${normalizedKey}`, sessionKey: normalizedKey, generatedAt: Date.now(), projectName, requirementSummary, status, totalDurationMs, phases, deliverables, keyPoints, blockers, followUps, notes, runCount: runs.length, taskCount: tasks.length, rolesUsed, }; } function isPreviewableArtifactType(d: WorkerTaskResultDeliverable): boolean { return d.artifactType === "web-app" || d.artifactType === "static-site" || d.artifactType === "rest-api"; } function resolvePreviewInfo( deliverable: WorkerTaskResultDeliverable, taskId: string, deliverableIndex: number, state: TeamState, ): { url?: string; error?: string } { if (deliverable.liveUrl) return { url: deliverable.liveUrl }; // Non-previewable deliverables (plain files, notes, commands) should never // show preview errors — they don't have previews to fail. if (!isPreviewableArtifactType(deliverable)) return {}; // Find preview record for this specific deliverable const previewId = `preview-${taskId}-${deliverableIndex}`; const exact = (state.previews ?? {})[previewId]; if (exact) { if (exact.status === "healthy") { const url = deliverable.artifactType === "rest-api" && exact.previewReadyPath && exact.previewReadyPath !== "/" ? exact.liveUrl.replace(/\/$/, "") + exact.previewReadyPath : exact.liveUrl; return { url }; } if (exact.status === "failed") return { error: exact.lastError ?? "Preview failed" }; if (exact.status === "stopped") return { error: "Preview stopped" }; return { error: "Preview is still starting…" }; } // Fallback: find any healthy preview for this task const previews = Object.values(state.previews ?? {}); const match = previews.find((p) => p.taskId === taskId && p.status === "healthy"); if (match) { const url = deliverable.artifactType === "rest-api" && match.previewReadyPath && match.previewReadyPath !== "/" ? match.liveUrl.replace(/\/$/, "") + match.previewReadyPath : match.liveUrl; return { url }; } const failed = previews.find((p) => p.taskId === taskId && p.status === "failed"); if (failed) return { error: failed.lastError ?? "Preview failed" }; return {}; } // ── Session completion detection ───────────────────────────────────── export function isSessionComplete( sessionKey: string, state: TeamState, normalizeSessionKey: (key: unknown) => string, ): boolean { const normalizedKey = normalizeSessionKey(sessionKey); const runs = Object.values(state.controllerRuns) .filter((r) => normalizeSessionKey(r.sessionKey) === normalizedKey) .sort((a, b) => b.createdAt - a.createdAt); if (runs.length === 0) return false; // Collect all tasks for this session const taskIds = new Set(runs.flatMap((r) => r.createdTaskIds)); if (taskIds.size === 0) return false; // Check if any task is still active for (const taskId of taskIds) { const task = state.tasks[taskId]; if (!task) continue; if (task.status !== "completed" && task.status !== "failed") { return false; } } // Check if the latest run still has active deferred tasks const latestWithManifest = runs.find((r) => r.manifest); if (latestWithManifest?.manifest?.deferredTasks?.length) { // There are deferred tasks — a follow-up run should advance them. // Only consider complete if the latest run also says requirementFullyComplete. if (!latestWithManifest.manifest.requirementFullyComplete) { return false; } } // Check if any run is still actively executing const activeRun = runs.find((r) => r.status === "pending" || r.status === "running"); if (activeRun) return false; return true; } // ── HTML rendering ─────────────────────────────────────────────────── function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"]); function isImagePath(filePath: string): boolean { const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); return IMAGE_EXTENSIONS.has(ext); } function formatDuration(ms: number): string { if (ms < 1000) return "<1s"; const totalSec = Math.floor(ms / 1000); if (totalSec < 60) return `${totalSec}s`; const min = Math.floor(totalSec / 60); const sec = totalSec % 60; if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`; const hr = Math.floor(min / 60); const remMin = min % 60; return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`; } const STATUS_EMOJI: Record = { completed: "✅", failed: "❌", partial: "⚠️", in_progress: "🔄", pending: "⏳", assigned: "📋", blocked: "🚫", }; export function renderReportHtml(report: DeliveryReport): string { const statusEmoji = STATUS_EMOJI[report.status] ?? "❓"; const statusLabel = report.status === "completed" ? "Completed" : report.status === "failed" ? "Failed" : "Partial"; const phasesHtml = report.phases .map((phase) => { const emoji = STATUS_EMOJI[phase.status] ?? "❓"; const duration = formatDuration(phase.durationMs); const errorHtml = phase.error ? `
Error: ${escapeHtml(phase.error.slice(0, 200))}
` : ""; const keyPointsHtml = phase.keyPoints.length > 0 ? `` : ""; return `
${emoji} ${escapeHtml(phase.title)} ${escapeHtml(phase.role)} ${duration}
${escapeHtml(phase.summary)}
${errorHtml} ${keyPointsHtml}
`; }) .join("\n"); const deliverablesHtml = report.deliverables .map((d) => { const kindIcon = d.artifactType === "rest-api" ? "🔌" : d.artifactType === "web-app" || d.artifactType === "static-site" ? "🌐" : d.artifactType === "document" ? "📝" : d.kind === "directory" ? "📁" : d.kind === "command" ? "💻" : isImagePath(d.path) ? "🖼️" : "📄"; let previewHtml = ""; if (d.previewUrl) { const previewLabel = d.artifactType === "rest-api" ? "📖 Open API Documentation" : "🔗 Open Live Preview"; previewHtml = `
${previewLabel}
`; } else if (d.previewError) { previewHtml = `
⚠️ ${escapeHtml(d.previewError)}
`; } else if (d.kind === "command") { previewHtml = `
💡 Run: ${escapeHtml(d.path)}
`; } else if (d.artifactType === "document") { previewHtml = `
📖 Document — view in workspace at ${escapeHtml(d.path)}
`; } else if (isImagePath(d.path)) { // Serve image via workspace file endpoint if available previewHtml = `
🖼️ Image file — open from workspace
`; } return `
${kindIcon} ${escapeHtml(d.path)}
${escapeHtml(d.summary)}
${previewHtml}
`; }) .join("\n"); const keyPointsHtml = report.keyPoints.length > 0 ? `

💡 Key Points

` : ""; const blockersHtml = report.blockers.length > 0 ? `

🚧 Blockers

` : ""; const followUpsHtml = report.followUps.length > 0 ? `

📌 Follow-ups

` : ""; const notesHtml = report.notes ? `

📝 Notes

${escapeHtml(report.notes)}

` : ""; return ` TeamClaw Report — ${escapeHtml(report.projectName)}

${statusEmoji} ${escapeHtml(report.projectName)}

Status: ${statusLabel} ⏱️ ${formatDuration(report.totalDurationMs)} 📋 ${report.taskCount} task${report.taskCount !== 1 ? "s" : ""} 👥 ${report.rolesUsed.join(", ") || "—"}
${escapeHtml(report.requirementSummary)}

📋 Task Pipeline

${phasesHtml}
${report.deliverables.length > 0 ? `

📦 Deliverables

${deliverablesHtml}
` : ""} ${keyPointsHtml} ${blockersHtml} ${followUpsHtml} ${notesHtml} `; }