From 88a4f15ce1310f8ee8693835be14aa5280233f17 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 5 Feb 2026 23:42:48 +0000 Subject: Add directive-first chain system redesign Redesigns the chain system with a directive-first architecture where Directive is the top-level entity (the "why/what") and Chains are generated execution plans (the "how") that can be dynamically modified. Backend: - Add database migration for directive system tables - Add Directive, DirectiveChain, ChainStep, DirectiveEvent models - Add DirectiveVerifier and DirectiveApproval models - Add orchestration module with engine, planner, and verifier - Add comprehensive API handlers for directives - Add daemon CLI commands for directive management - Add directive skill documentation - Integrate contract completion with directive engine - Add SSE endpoint for real-time directive events Frontend: - Add directives route with split-view layout - Add 6-tab detail view (Overview, Chain, Events, Evaluations, Approvals, Verifiers) - Add React Flow DAG visualization for chain steps - Add SSE subscription hook for real-time event updates - Add useDirectives and useDirectiveEventSubscription hooks - Add directive types and API functions Fixes: - Fix test failures in ws/protocol, task_output, completion_gate, patch - Fix word boundary matching in looks_like_task() - Fix parse_last() to find actual last completion gate - Fix create_export_patch when merge-base equals HEAD - Clean up clippy warnings in new code Co-Authored-By: Claude Opus 4.5 --- makima/frontend/src/routes/directives.tsx | 1254 +++++++++++++++++++++++++++++ 1 file changed, 1254 insertions(+) create mode 100644 makima/frontend/src/routes/directives.tsx (limited to 'makima/frontend/src/routes') diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx new file mode 100644 index 0000000..51fd57a --- /dev/null +++ b/makima/frontend/src/routes/directives.tsx @@ -0,0 +1,1254 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { useParams, useNavigate } from "react-router"; +import { + ReactFlow, + Edge, + Controls, + Background, + Handle, + Position, + BackgroundVariant, + MarkerType, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { Masthead } from "../components/Masthead"; +import { useDirectives, useDirectiveEventSubscription } from "../hooks/useDirectives"; +import { useAuth } from "../contexts/AuthContext"; +import type { + DirectiveSummary, + DirectiveWithProgress, + DirectiveGraphResponse, + DirectiveGraphNode, + CreateDirectiveRequest, + RepositoryHistoryEntry, + AutonomyLevel, + StepStatus, + ConfidenceLevel, +} from "../lib/api"; +import { getRepositorySuggestions } from "../lib/api"; + +export default function DirectivesPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( +
+ +
+

Loading...

+
+
+ ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return ; +} + +function DirectivesPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + directives, + loading, + error, + createNewDirective, + archiveExistingDirective, + getDirectiveById, + getGraph, + start, + pause, + resume, + stop, + } = useDirectives(); + + const [directiveDetail, setDirectiveDetail] = useState(null); + const [directiveGraph, setDirectiveGraph] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + // Load directive detail when ID changes + useEffect(() => { + if (id) { + setDetailLoading(true); + Promise.all([getDirectiveById(id), getGraph(id).catch(() => null)]).then(([directive, graph]) => { + setDirectiveDetail(directive); + setDirectiveGraph(graph); + setDetailLoading(false); + }); + } else { + setDirectiveDetail(null); + setDirectiveGraph(null); + } + }, [id, getDirectiveById, getGraph]); + + const handleSelect = useCallback( + (directiveId: string) => { + navigate(`/directives/${directiveId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/directives"); + }, [navigate]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + }, []); + + const handleCreateSubmit = useCallback( + async (goal: string, repositoryUrl: string | undefined, autonomyLevel: AutonomyLevel) => { + const data: CreateDirectiveRequest = { + goal: goal.trim(), + repositoryUrl: repositoryUrl?.trim() || undefined, + autonomyLevel, + }; + + try { + const result = await createNewDirective(data); + if (result) { + setIsCreating(false); + navigate(`/directives/${result.id}`); + } + } catch (err) { + console.error("Failed to create directive:", err); + } + }, + [createNewDirective, navigate] + ); + + const handleCreateCancel = useCallback(() => { + setIsCreating(false); + }, []); + + const handleArchive = useCallback( + async (directive: DirectiveSummary) => { + if (confirm(`Are you sure you want to archive this directive?`)) { + const success = await archiveExistingDirective(directive.id); + if (success && directive.id === id) { + navigate("/directives"); + } + } + }, + [archiveExistingDirective, id, navigate] + ); + + const handleRefresh = useCallback(async () => { + if (id) { + const [directive, graph] = await Promise.all([ + getDirectiveById(id), + getGraph(id).catch(() => null), + ]); + setDirectiveDetail(directive); + setDirectiveGraph(graph); + } + }, [id, getDirectiveById, getGraph]); + + const handleStart = useCallback(async () => { + if (id) { + await start(id); + handleRefresh(); + } + }, [id, start, handleRefresh]); + + const handlePause = useCallback(async () => { + if (id) { + await pause(id); + handleRefresh(); + } + }, [id, pause, handleRefresh]); + + const handleResume = useCallback(async () => { + if (id) { + await resume(id); + handleRefresh(); + } + }, [id, resume, handleRefresh]); + + const handleStop = useCallback(async () => { + if (id) { + await stop(id); + handleRefresh(); + } + }, [id, stop, handleRefresh]); + + return ( +
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Create directive modal */} + {isCreating && ( + + )} + +
+ {/* Directive list */} + + + {/* Directive detail or empty state */} + {directiveDetail ? ( + + ) : ( +
+
+

+ Select a directive or create a new one +

+ +
+
+ )} +
+
+
+ ); +} + +// ============================================================================= +// Directive List Component +// ============================================================================= + +interface DirectiveListProps { + directives: DirectiveSummary[]; + loading: boolean; + onSelect: (id: string) => void; + onCreate: () => void; + selectedId?: string; + onArchive: (directive: DirectiveSummary) => void; +} + +function DirectiveList({ + directives, + loading, + onSelect, + onCreate, + selectedId, + onArchive, +}: DirectiveListProps) { + const [filter, setFilter] = useState<"all" | "active" | "completed" | "failed">("all"); + + const filteredDirectives = directives.filter((d) => { + if (filter === "all") return true; + if (filter === "active") return ["draft", "planning", "active", "paused"].includes(d.status); + if (filter === "completed") return d.status === "completed"; + if (filter === "failed") return d.status === "failed"; + return true; + }); + + return ( +
+
+

Directives

+ +
+ + {/* Filters */} +
+ {(["all", "active", "completed", "failed"] as const).map((f) => ( + + ))} +
+ + {/* List */} +
+ {loading ? ( +
+

Loading...

+
+ ) : filteredDirectives.length === 0 ? ( +
+

No directives found

+
+ ) : ( + filteredDirectives.map((d) => ( + onSelect(d.id)} + onArchive={() => onArchive(d)} + /> + )) + )} +
+
+ ); +} + +interface DirectiveListItemProps { + directive: DirectiveSummary; + selected: boolean; + onClick: () => void; + onArchive: () => void; +} + +function DirectiveListItem({ directive, selected, onClick, onArchive }: DirectiveListItemProps) { + const progress = directive.totalSteps > 0 + ? Math.round((directive.completedSteps / directive.totalSteps) * 100) + : 0; + + const statusColor = { + draft: "text-[#556677]", + planning: "text-yellow-400", + active: "text-green-400", + paused: "text-yellow-400", + completed: "text-[#75aafc]", + archived: "text-[#556677]", + failed: "text-red-400", + }[directive.status] || "text-[#556677]"; + + const confidenceColor = { + green: "bg-green-500", + yellow: "bg-yellow-500", + red: "bg-red-500", + }[directive.currentConfidence !== null && directive.currentConfidence >= 0.8 + ? "green" + : directive.currentConfidence !== null && directive.currentConfidence >= 0.5 + ? "yellow" + : "red"] || "bg-[#556677]"; + + return ( +
+
+
+
+ {directive.title || directive.goal.slice(0, 50)} +
+
+ + {directive.status} + + + {directive.completedSteps}/{directive.totalSteps} steps + +
+
+
+ {directive.currentConfidence !== null && ( +
+ )} + +
+
+ + {/* Progress bar */} + {directive.totalSteps > 0 && ( +
+
+
+ )} +
+ ); +} + +// ============================================================================= +// Directive Detail Component +// ============================================================================= + +interface DirectiveDetailProps { + directive: DirectiveWithProgress; + graph: DirectiveGraphResponse | null; + loading: boolean; + onBack: () => void; + onRefresh: () => void; + onStart: () => void; + onPause: () => void; + onResume: () => void; + onStop: () => void; +} + +function DirectiveDetail({ + directive, + graph, + loading, + onBack, + onRefresh, + onStart, + onPause, + onResume, + onStop, +}: DirectiveDetailProps) { + const [activeTab, setActiveTab] = useState<"overview" | "chain" | "events" | "evaluations" | "approvals" | "verifiers">("overview"); + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + const statusColor = { + draft: "text-[#556677] bg-[#556677]/10 border-[#556677]/30", + planning: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", + active: "text-green-400 bg-green-400/10 border-green-400/30", + paused: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30", + completed: "text-[#75aafc] bg-[#75aafc]/10 border-[#75aafc]/30", + archived: "text-[#556677] bg-[#556677]/10 border-[#556677]/30", + failed: "text-red-400 bg-red-400/10 border-red-400/30", + }[directive.status] || "text-[#556677] bg-[#556677]/10 border-[#556677]/30"; + + return ( +
+ {/* Header */} +
+
+
+ +

+ {directive.title || directive.goal.slice(0, 50)} +

+ + {directive.status} + +
+
+ {directive.status === "draft" && ( + + )} + {directive.status === "active" && ( + + )} + {directive.status === "paused" && ( + + )} + {["active", "paused"].includes(directive.status) && ( + + )} + +
+
+
+ + {/* Tabs */} +
+ {(["overview", "chain", "events", "evaluations", "approvals", "verifiers"] as const).map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === "overview" && ( + + )} + {activeTab === "chain" && ( + + )} + {activeTab === "events" && ( + + )} + {activeTab === "evaluations" && ( + + )} + {activeTab === "approvals" && ( + + )} + {activeTab === "verifiers" && ( + + )} +
+
+ ); +} + +// ============================================================================= +// Tab Components +// ============================================================================= + +function OverviewTab({ directive }: { directive: DirectiveWithProgress }) { + return ( +
+ {/* Goal */} +
+

Goal

+

+ {directive.goal} +

+
+ + {/* Progress */} +
+

Progress

+
+
+
+ {directive.chain?.completedSteps || 0} +
+
Completed Steps
+
+
+
+ {directive.chain?.totalSteps || 0} +
+
Total Steps
+
+
+
+ {directive.chain?.currentConfidence != null + ? `${Math.round((directive.chain?.currentConfidence ?? 0) * 100)}%` + : "-"} +
+
Confidence
+
+
+
+ + {/* Configuration */} +
+

Configuration

+
+
+ Autonomy Level + {directive.autonomyLevel} +
+
+ Max Rework Cycles + {directive.maxReworkCycles} +
+
+ Green Threshold + {directive.confidenceThresholdGreen} +
+
+ Yellow Threshold + {directive.confidenceThresholdYellow} +
+
+
+ + {/* Repository */} + {directive.repositoryUrl && ( +
+

Repository

+

{directive.repositoryUrl}

+
+ )} +
+ ); +} + +// Step status colors for both list and DAG views +const stepStatusStyles: Record = { + pending: { border: "#556677", bg: "#556677", text: "#556677" }, + ready: { border: "#3b82f6", bg: "#3b82f6", text: "#3b82f6" }, + running: { border: "#22c55e", bg: "#22c55e", text: "#22c55e" }, + evaluating: { border: "#eab308", bg: "#eab308", text: "#eab308" }, + passed: { border: "#75aafc", bg: "#75aafc", text: "#75aafc" }, + failed: { border: "#ef4444", bg: "#ef4444", text: "#ef4444" }, + rework: { border: "#f97316", bg: "#f97316", text: "#f97316" }, + skipped: { border: "#556677", bg: "#556677", text: "#556677" }, + blocked: { border: "#ef4444", bg: "#ef4444", text: "#ef4444" }, +}; + +// Confidence level colors +const confidenceColors: Record = { + green: "#22c55e", + yellow: "#eab308", + red: "#ef4444", +}; + +// Node dimensions +const NODE_WIDTH = 180; +const NODE_HEIGHT = 70; + +// Custom node component for steps +function StepNodeComponent({ data }: { data: DirectiveGraphNode & { selected?: boolean } }) { + const styles = stepStatusStyles[data.status] || stepStatusStyles.pending; + const isRunning = data.status === "running" || data.status === "evaluating"; + + return ( +
+ + + {/* Status indicator bar */} +
+ + {/* Content */} +
+
+ {data.name} + {data.confidenceScore !== null && data.confidenceLevel && ( +
+ )} +
+
+ + {data.status} + + {data.stepType} +
+
+ + +
+ ); +} + +// Node types for React Flow +const nodeTypes = { + step: StepNodeComponent, +}; + +function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; graph: DirectiveGraphResponse | null }) { + const [viewMode, setViewMode] = useState<"dag" | "list">("dag"); + + // Convert graph to React Flow nodes and edges + const { nodes, edges } = useMemo(() => { + if (!graph || !graph.nodes.length) { + // Fallback: generate positions from directive.steps + const stepNodes = directive.steps.map((step, index) => ({ + id: step.id, + type: "step" as const, + position: { + x: (index % 3) * 220 + 50, + y: Math.floor(index / 3) * 120 + 50, + }, + data: { + id: step.id, + name: step.name, + stepType: step.stepType, + status: step.status, + confidenceScore: step.confidenceScore, + confidenceLevel: step.confidenceLevel, + contractId: step.contractId, + editorX: null, + editorY: null, + }, + })); + + // Build edges from dependencies + const stepEdges: Edge[] = []; + directive.steps.forEach((step) => { + step.dependsOn.forEach((depName) => { + const depStep = directive.steps.find((s) => s.name === depName); + if (depStep) { + stepEdges.push({ + id: `${depStep.id}-${step.id}`, + source: depStep.id, + target: step.id, + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color: "#556677" }, + style: { stroke: "#556677", strokeWidth: 2 }, + }); + } + }); + }); + + return { nodes: stepNodes, edges: stepEdges }; + } + + // Use graph data + const graphNodes = graph.nodes.map((node) => ({ + id: node.id, + type: "step" as const, + position: { + x: node.editorX ?? 50, + y: node.editorY ?? 50, + }, + data: { ...node }, + })); + + const graphEdges: Edge[] = graph.edges.map((edge) => ({ + id: `${edge.source}-${edge.target}`, + source: edge.source, + target: edge.target, + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color: "#556677" }, + style: { stroke: "#556677", strokeWidth: 2 }, + })); + + return { nodes: graphNodes, edges: graphEdges }; + }, [graph, directive.steps]); + + if (!directive.chain) { + return ( +
+

+ No chain generated yet. Start the directive to generate a chain. +

+
+ ); + } + + return ( +
+ {/* Chain info header */} +
+
+
{directive.chain.name}
+
Generation {directive.chain.generation}
+
+
+
+ {directive.chain.completedSteps}/{directive.chain.totalSteps} steps +
+ {/* View toggle */} +
+ + +
+
+
+ + {viewMode === "dag" ? ( + /* DAG visualization */ +
+ {directive.steps.length === 0 ? ( +
+

No steps in chain

+
+ ) : ( + + + + + )} +
+ ) : ( + /* List view */ +
+

Steps

+ {directive.steps.length === 0 ? ( +

No steps in chain

+ ) : ( + directive.steps.map((step) => { + const styles = stepStatusStyles[step.status] || stepStatusStyles.pending; + + return ( +
+
+
+ {step.name} + + {step.status} + + {step.confidenceScore !== null && ( + + ({Math.round(step.confidenceScore * 100)}%) + + )} +
+
{step.stepType}
+
+ {step.description && ( +

{step.description}

+ )} + {step.dependsOn.length > 0 && ( +
+ Depends on: {step.dependsOn.join(", ")} +
+ )} +
+ ); + }) + )} +
+ )} +
+ ); +} + +function EventsTab({ directive }: { directive: DirectiveWithProgress }) { + // Subscribe to real-time events via SSE + const { events: streamEvents, isConnected, error: sseError } = useDirectiveEventSubscription(directive.id); + + // Combine initial events with streamed events (avoiding duplicates) + const allEvents = useMemo(() => { + const eventMap = new Map(); + // Add initial events first + directive.recentEvents.forEach((e) => eventMap.set(e.id, e)); + // Add streamed events (will override any duplicates) + streamEvents.forEach((e) => eventMap.set(e.id, e)); + // Sort by created_at descending (most recent first) + return Array.from(eventMap.values()).sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, [directive.recentEvents, streamEvents]); + + return ( +
+ {/* Connection status */} +
+
+ + {isConnected ? "● Live" : "○ Connecting..."} + + {sseError && {sseError}} +
+ {allEvents.length} events +
+ + {/* Event list */} + {allEvents.length === 0 ? ( +
+

No events yet

+
+ ) : ( +
+ {allEvents.map((event) => { + const severityColors: Record = { + info: "text-[#75aafc]", + warning: "text-yellow-400", + error: "text-red-400", + critical: "text-red-600", + }; + const severityColor = severityColors[event.severity] || "text-[#556677]"; + + return ( +
+
+
+ {event.eventType} + {event.actorType} +
+ + {new Date(event.createdAt).toLocaleString()} + +
+ {event.eventData != null && ( +
+                    {JSON.stringify(event.eventData, null, 2)}
+                  
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function EvaluationsTab({ directive: _directive }: { directive: DirectiveWithProgress }) { + // TODO: Fetch evaluations separately + return ( +
+

+ Evaluations will be shown here after steps are evaluated +

+
+ ); +} + +function ApprovalsTab({ directive, onRefresh }: { directive: DirectiveWithProgress; onRefresh: () => void }) { + if (directive.pendingApprovals.length === 0) { + return ( +
+

No pending approvals

+
+ ); + } + + const handleApprove = async (approvalId: string) => { + try { + const { approveDirectiveRequest } = await import("../lib/api"); + await approveDirectiveRequest(directive.id, approvalId); + onRefresh(); + } catch (err) { + console.error("Failed to approve:", err); + } + }; + + const handleDeny = async (approvalId: string) => { + try { + const { denyDirectiveRequest } = await import("../lib/api"); + await denyDirectiveRequest(directive.id, approvalId); + onRefresh(); + } catch (err) { + console.error("Failed to deny:", err); + } + }; + + return ( +
+ {directive.pendingApprovals.map((approval) => { + const urgencyColor = { + low: "text-[#556677]", + normal: "text-[#75aafc]", + high: "text-yellow-400", + critical: "text-red-400", + }[approval.urgency] || "text-[#556677]"; + + return ( +
+
+
+
+ {approval.approvalType} + + {approval.urgency} + +
+

{approval.description}

+
+
+ + +
+
+
+ ); + })} +
+ ); +} + +function VerifiersTab({ directive: _directive }: { directive: DirectiveWithProgress }) { + // TODO: Fetch verifiers separately + return ( +
+

+ Verifiers will be shown here. Use auto-detect to find available verifiers. +

+
+ ); +} + +// ============================================================================= +// Create Directive Modal +// ============================================================================= + +interface CreateDirectiveModalProps { + onSubmit: (goal: string, repositoryUrl: string | undefined, autonomyLevel: AutonomyLevel) => void; + onCancel: () => void; +} + +function CreateDirectiveModal({ onSubmit, onCancel }: CreateDirectiveModalProps) { + const [goal, setGoal] = useState(""); + const [repositoryUrl, setRepositoryUrl] = useState(""); + const [autonomyLevel, setAutonomyLevel] = useState("guardrails"); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + + // Load suggestions + useEffect(() => { + getRepositorySuggestions("remote", undefined, 5) + .then((res) => { + setSuggestions(res.entries); + }) + .catch(() => { + setSuggestions([]); + }); + }, []); + + const handleSubmit = () => { + if (goal.trim()) { + onSubmit(goal.trim(), repositoryUrl.trim() || undefined, autonomyLevel); + } + }; + + return ( +
+
+

+ Create Directive +

+ +
+ {/* Goal */} +
+ +