import { activeShellTransitionIds } from "../domain/refinement.ts"; import type { Arc, PlanCommitment, Transition, WorkflowState } from "../domain/types.ts"; import { visibleColumns } from "../ui/text-viewport.ts"; export interface MermaidProjection { stack: string[]; full: string; causal: string; local: string; } const LABEL_WIDTH = 18; const LABEL_LINES = 6; function wrapLabel(value: string): string { const normalized = value.replace(/\s+/g, " ").trim(); const lines: string[] = []; let remaining = normalized; while (remaining && lines.length < LABEL_LINES) { if (visibleColumns(remaining) <= LABEL_WIDTH) { lines.push(remaining); remaining = ""; break; } let width = 0; let cut = 0; let lastSpace = -1; for (const [index, character] of [...remaining].entries()) { const nextWidth = visibleColumns(character); if (width + nextWidth > LABEL_WIDTH) break; width += nextWidth; cut = index + 1; if (/\s/.test(character)) lastSpace = cut; } const characters = [...remaining]; if (lastSpace >= Math.floor(cut / 2)) cut = lastSpace; lines.push(characters.slice(0, Math.max(1, cut)).join("").trimEnd()); remaining = characters.slice(Math.max(1, cut)).join("").trimStart(); } if (remaining && lines.length > 0) { const final = lines.length - 1; let finalLine = lines[final] ?? ""; while (visibleColumns(`${finalLine}…`) > LABEL_WIDTH) finalLine = [...finalLine].slice(0, -1).join(""); lines[final] = `${finalLine}…`; } return lines.join("
"); } function escapeLabel(value: string): string { return wrapLabel( value .replaceAll("\\", "\\\\") .replaceAll('"', "'") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .slice(0, 240), ); } function typeLabel(value: string): string { return value.replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase(); } function transitionMarker(transition: Transition): string { if (transition.status === "active") return "▶ ACTIVE"; if (transition.status === "completed") return "✓ DONE"; if (transition.status === "failed") return "✕ FAILED"; if (transition.status === "superseded") return "↪ SUPERSEDED"; if (transition.status === "retired") return "⊘ RETIRED"; const marker: Record = { committed: "◆ COMMITTED", provisional: "⧖ PROVISIONAL", directional: "◇ DIRECTIONAL", }; return marker[transition.planning.commitment]; } function nodeLine(state: WorkflowState, id: string): string | undefined { const place = state.places[id]; if (place) { const token = state.token.placeId === id ? "● CURRENT · " : ""; const stateSuffix = place.type === "Gate" ? ` · ${place.gateState ?? "closed"}` : ""; const label = escapeLabel(`${token}${typeLabel(place.type)}${stateSuffix} ${place.label}`); return place.type === "Gate" ? ` ${id}{"${label}"}` : ` ${id}(["${label}"])`; } const transition = state.transitions[id]; if (!transition) return undefined; const label = escapeLabel(`${transitionMarker(transition)} · ${typeLabel(transition.type)} ${transition.intent}`); return ` ${id}["${label}"]`; } function edgeLine(arc: Arc): string { const labels: Record = { flow: "", read: "condition", success: "success", failure: "failure", }; const label = labels[arc.kind]; return label ? ` ${arc.fromId} -->|${label}| ${arc.toId}` : ` ${arc.fromId} --> ${arc.toId}`; } function allNodeIds(state: WorkflowState): Set { return new Set([...Object.keys(state.places), ...Object.keys(state.transitions)]); } function causalNodeIds(state: WorkflowState): Set { const selected = new Set([state.token.placeId]); for (const transition of Object.values(state.transitions)) { const include = transition.status === "active" || transition.status === "completed" || transition.status === "failed" || (transition.status === "planned" && transition.planning.commitment !== "directional"); if (!include) continue; selected.add(transition.id); for (const arc of Object.values(state.arcs)) { if (arc.fromId === transition.id || arc.toId === transition.id) { selected.add(arc.fromId); selected.add(arc.toId); } } } return selected; } function localNodeIds(state: WorkflowState): Set { const adjacency = new Map>(); for (const arc of Object.values(state.arcs)) { if (!adjacency.has(arc.fromId)) adjacency.set(arc.fromId, new Set()); if (!adjacency.has(arc.toId)) adjacency.set(arc.toId, new Set()); adjacency.get(arc.fromId)!.add(arc.toId); adjacency.get(arc.toId)!.add(arc.fromId); } const start = new Set([ state.token.placeId, ...(state.reservation ? [state.reservation.transitionId] : []), ...activeShellTransitionIds(state), ]); const selected = new Set(); const queue = [...start].map((id) => ({ id, depth: 0 })); while (queue.length > 0 && selected.size < 24) { const current = queue.shift()!; if (selected.has(current.id) || current.depth > 4) continue; selected.add(current.id); const neighbours = [...(adjacency.get(current.id) ?? [])].sort((a, b) => { const aTransition = state.transitions[a]; const bTransition = state.transitions[b]; const rank = (candidate: Transition | undefined) => { if (!candidate) return 3; if (candidate.status === "active") return 0; if (candidate.status === "planned" && candidate.planning.commitment === "committed") return 1; if (candidate.status === "planned" && candidate.planning.commitment === "provisional") return 2; return 4; }; return rank(aTransition) - rank(bTransition) || a.localeCompare(b); }); for (const neighbour of neighbours) queue.push({ id: neighbour, depth: current.depth + 1 }); } return selected; } function renderGraph(state: WorkflowState, selected: Set): string { const lines = ["graph TD"]; for (const id of [...selected].sort((a, b) => a.localeCompare(b))) { const line = nodeLine(state, id); if (line) lines.push(line); } for (const arc of Object.values(state.arcs).sort((a, b) => a.id.localeCompare(b.id))) { if (selected.has(arc.fromId) && selected.has(arc.toId)) lines.push(edgeLine(arc)); } return `${lines.join("\n")}\n`; } export function createMermaidProjection(state: WorkflowState): MermaidProjection { const shellIds = activeShellTransitionIds(state); const leafId = state.reservation?.transitionId; const stack = [ ...(state.rootIntent ? [state.rootIntent] : []), ...shellIds.map((id) => state.transitions[id]?.intent ?? id), ...(leafId ? [state.transitions[leafId]?.intent ?? leafId] : []), ]; return { stack, full: renderGraph(state, allNodeIds(state)), causal: renderGraph(state, causalNodeIds(state)), local: renderGraph(state, localNodeIds(state)), }; }