/** * Ayudantes de pure presentation para la dynamic-workflows UI: los small, * side-effect-free formatters que convierten workflow data en display strings * (workflow list, progress counts, dashboard hint, short name, elapsed time). * * Sibling a profundidad uno bajo extensions/pandi-dynamic-workflows; bundled en index.ts * (jiti at runtime, esbuild en tests). El único coupling de vuelta a index.ts es * TYPE-only (WorkflowDefinition, WorkflowLogEntry) via `import type`, que se borra * at build time, así no hay runtime import cycle. * * NOTE: formatRunSummary vive en lib/run-summary.ts; los getRun* run-state helpers * viven en lib/run-state.ts (reexportados por runtime/index.ts para call sites legacy). */ import type { WorkflowDefinition, WorkflowLogEntry } from "../types.js"; import { stringify } from "./format.js"; export function compactInline(value: unknown, maxChars = 160): string { return stringify(value, maxChars).replace(/\s+/g, " ").trim(); } export function formatWorkflowList(files: WorkflowDefinition[]): string { if (files.length === 0) { return "No workflows found. Create one with `/workflow new ` or dynamic_workflow action=write."; } return files .map((file) => { const source = file.origin === "scaffold" ? " · scaffold canónico, solo lectura" : ""; return `- ${file.name} (${file.scope}) — ${file.relativePath}${source}`; }) .join("\n"); } /** El slice de un run record que el draft usage index necesita (mantenido minimal para purity). */ export interface DraftUsageRun { workflow?: string; state?: string; startedAt?: string; } /** * Renderiza el draft-workflows usage index: una markdown table row por draft * workflow con run counts (ok/failed), last run timestamp y last state, * ordenados por recency (never-run drafts últimos, alfabéticamente). Toma los SHORT * draft names (el llamador es dueño de la "which files are drafts" location logic); * runs matchean por short name o por la `drafts/` invocation form. * Pure así el contract se pinea en el cheapest test level; el comando `/workflow index` * escribe el resultado a .pi/workflows/drafts/INDEX.md. */ export function formatDraftUsageIndex(draftNames: string[], runs: DraftUsageRun[]): string { if (draftNames.length === 0) return "No draft workflows found."; const startedMs = (iso: string | undefined): number => { const t = Date.parse(iso ?? ""); return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY; }; const rows = draftNames.map((shortName) => { const own = runs.filter((run) => run.workflow === shortName || run.workflow === `drafts/${shortName}`); const last = own.reduce( (best, run) => (best === undefined || startedMs(run.startedAt) > startedMs(best.startedAt) ? run : best), undefined, ); return { shortName, runs: own.length, ok: own.filter((run) => run.state === "completed").length, failed: own.filter((run) => run.state === "failed").length, lastAt: last?.startedAt ?? "", lastState: last?.state ?? "", lastMs: startedMs(last?.startedAt), }; }); rows.sort((a, b) => b.lastMs - a.lastMs || a.shortName.localeCompare(b.shortName)); // Draft names son file paths, pero escape table-breaking characters defensively. const cell = (value: string) => value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|") || "—"; return [ "# Draft workflows — usage index", "", "Generated by `/workflow index` from the runs store. Do not edit by hand.", "", "| draft | runs | ok | failed | last run | last state |", "| ----- | ---- | -- | ------ | -------- | ---------- |", ...rows.map( (row) => `| ${cell(row.shortName)} | ${row.runs} | ${row.ok} | ${row.failed} | ${cell(row.lastAt)} | ${cell(row.lastState)} |`, ), ].join("\n"); } /** Auto-derived progress of the CURRENT agents() batch ("¿por dónde va?"). */ export interface WorkflowBatchProgress { label: string; done: number; started: number; total: number; } export interface WorkflowProgressCounts { agentsStarted: number; agentsDone: number; agentsRunning: number; bashDone: number; /** Present when the run's agents came from agents() (phase fields on the log details). */ batch?: WorkflowBatchProgress; } export function workflowProgress(logs: WorkflowLogEntry[]): WorkflowProgressCounts { let agentsStarted = 0; let agentsDone = 0; let bashDone = 0; // agents() threads AgentPhaseInfo per item and both `agent N start:`/`agent N end:` // log details carry {phaseId, phaseIndex, phaseTotal, phaseLabel}. Aggregate per // phaseId so the CURRENT batch (highest id) can report done/total — done over the // batch TOTAL, not over started (done/started reads "5/5" while 11 of 16 items // have not even started). const phases = new Map(); for (const logEntry of logs) { const isStart = /^agent \d+ start:/.test(logEntry.message); const isEnd = /^agent \d+ end:/.test(logEntry.message); if (isStart) agentsStarted++; if (isEnd) agentsDone++; if (logEntry.message.startsWith("bash end:")) bashDone++; if (!isStart && !isEnd) continue; const details = logEntry.details as Record | undefined; const phaseId = typeof details?.phaseId === "number" ? details.phaseId : undefined; const phaseTotal = typeof details?.phaseTotal === "number" ? details.phaseTotal : undefined; if (phaseId === undefined || phaseTotal === undefined || phaseTotal <= 0) continue; const entry = phases.get(phaseId) ?? { label: typeof details?.phaseLabel === "string" && details.phaseLabel.trim() ? details.phaseLabel.trim() : `agents-${phaseId}`, done: 0, started: 0, total: phaseTotal, }; if (isStart) entry.started++; else entry.done++; entry.total = phaseTotal; phases.set(phaseId, entry); } let batch: WorkflowBatchProgress | undefined; if (phases.size > 0) { const currentId = Math.max(...phases.keys()); const current = phases.get(currentId); if (current) batch = { ...current }; } return { agentsStarted, agentsDone, agentsRunning: Math.max(0, agentsStarted - agentsDone), bashDone, ...(batch ? { batch } : {}), }; } /** * Human text for the status line / monitor header: prefers the semantic batch * ("Review 5/16") over the legacy done/started fallback ("1/2"); "" when idle. */ export function workflowProgressLabel(progress: WorkflowProgressCounts): string { if (progress.batch) return `${progress.batch.label} ${progress.batch.done}/${progress.batch.total}`; return progress.agentsStarted > 0 ? `${progress.agentsDone}/${progress.agentsStarted}` : ""; } export function workflowDashboardHint(): string { return "/workflows ↓ monitor ← sessions Ctrl+Alt+W"; } export function shortWorkflowName(name: string): string { return name.length <= 36 ? name : `${name.slice(0, 33)}…`; } export function formatElapsedMs(ms: number): string { const seconds = Math.max(0, Math.round(ms / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; if (minutes < 60) return `${minutes}m${remainder.toString().padStart(2, "0")}s`; const hours = Math.floor(minutes / 60); return `${hours}h${(minutes % 60).toString().padStart(2, "0")}m`; }